/** * T16 数据新鲜度与过期状态 纯逻辑检查(票 16)。 * * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t16-freshness.mjs * * 七段(每条对着票 16 的验收原句): * A 验收 1:显示最近一次成功更新时间(缺失显示占位符,**不是** 00:00:00) * B 验收 2:新鲜 / 过期 / 不可用 / 部分失败**四态可区分**,文案各不相同 * C 验收 3:**连续两个**配置周期未成功更新 → 过期或不可用(连续,不是累计) * D 验收 4:过期数据**保留**最后已知值并带过期标识(不伪装成实时) * E 验收 5:缺失指标显示占位符,**不显示虚假零值**(0 是有效值,null 才是缺失) * F 验收 6:刷新周期(前端轮询 + WS 断线也算失败周期)真的按周期跑、能停干净 * G 静态守卫:组件/store 不自己拼文案与色键;domain 纯函数;周期来自配置 * * 口径来源(维护者 2026-09-24 定):前端轮询 + WS 断线也算失败周期; * 「过期/不可用」复用票 05 的分区状态词汇;四态演示用注入式时钟 + mock 开关。 */ import { readFileSync } from 'node:fs' import { createPinia, setActivePinia } from 'pinia' import { EXPIRED_AFTER_FAILURES, FRESHNESS_PLACEHOLDER, FRESHNESS_VIEW, clockText, emptyFreshness, freshnessOf, freshnessText, freshnessView, isRetained, markFailure, markSuccess, overallFreshness, overallFreshnessText, sectionStateOf, sectionStateOfFreshnessView, sinceSuccessText, toMillis, viewFromBackendFreshness, viewFromSectionState, } from '../../../src/modules/cockpit/domain/freshnessModel.js' import { EXPIRED_MISSED_CYCLES, FRESHNESS, dataAgeText, isExpired } from '../../../src/modules/cockpit/domain/freshness.js' import { DEFAULT_REFRESH_SECONDS, getFreshnessState, isPolling, resetFreshness, runCycle, startPolling, stopPolling, } from '../../../src/modules/cockpit/services/freshnessService.js' import { useRuntimeStore } from '../../../src/modules/cockpit/stores/runtimeStore.js' import { normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js' const checks = [] function check(name, pass, evidence) { checks.push({ name, pass }) console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`) } const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url) const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8') const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') const readCode = (relative) => code(read(relative)) const codeVue = (text) => code(text.replace(//g, '')) const readVueCode = (relative) => codeVue(read(relative)) setActivePinia(createPinia()) /** 假定时器:把「两个周期」变成同步可控,不真的等 60 秒。 */ function fakeTimers() { const tasks = [] return { setTimeout: (fn, ms) => { const id = { fn, ms } tasks.push(id) return id }, clearTimeout: (id) => { const index = tasks.indexOf(id) if (index >= 0) tasks.splice(index, 1) }, async step() { const next = tasks.shift() if (!next) return false next.fn() await Promise.resolve() return true }, pending: () => tasks.length, } } const T0 = Date.parse('2026-09-24T10:00:00+08:00') // ================================================================ A 最近成功更新时间 { const fresh = markSuccess(emptyFreshness(), T0) const view = freshnessView(fresh, T0 + 5000) check( 'A1-shows-last-success-clock', /^\d{2}:\d{2}:\d{2}$/.test(view.lastSuccessClock) && view.sinceSuccess === '5 秒前', `最近一次成功更新 ${view.lastSuccessClock}(${view.sinceSuccess})`, ) // 缺失必须是占位符,**不是** 00:00:00 —— 后者看起来像一个真实时刻。 const none = freshnessView(emptyFreshness(), T0) check( 'A2-missing-time-is-placeholder', none.lastSuccessClock === FRESHNESS_PLACEHOLDER && none.sinceSuccess === FRESHNESS_PLACEHOLDER && clockText(null) === FRESHNESS_PLACEHOLDER && clockText('not-a-time') === FRESHNESS_PLACEHOLDER && clockText('') === FRESHNESS_PLACEHOLDER, `没有成功过时显示「${none.lastSuccessClock}」(不编一个 00:00:00)`, ) check( 'A3-accepts-iso-and-epoch', clockText(T0) === clockText(new Date(T0).toISOString()) && toMillis('2026-09-24T10:00:00+08:00') === T0, '毫秒时间戳与 ISO 串(后端 updatedAt 的形状)都认,解析不出回 null', ) check( 'A4-age-text-scales', sinceSuccessText(markSuccess(emptyFreshness(), T0), T0 + 90_000) === '2 分钟前' && sinceSuccessText(markSuccess(emptyFreshness(), T0), T0 + 7200_000) === '2 小时前', '「多久之前」随量级换单位(秒/分钟/小时)', ) } // ================================================================ B 四态可区分 { const never = emptyFreshness() const current = markSuccess(never, T0) let stale = current stale = markFailure(stale, T0 + 1000, '取数失败') stale = markFailure(stale, T0 + 2000, '取数失败') let unavailable = never unavailable = markFailure(unavailable, T0 + 1000, '取数失败') const views = [freshnessOf(current), freshnessOf(stale), freshnessOf(unavailable), freshnessOf(never)] check( 'B1-four-states-distinct', freshnessOf(current) === FRESHNESS_VIEW.CURRENT && freshnessOf(stale) === FRESHNESS_VIEW.STALE && freshnessOf(unavailable) === FRESHNESS_VIEW.UNAVAILABLE && freshnessOf(never) === FRESHNESS_VIEW.EMPTY, `四态:${views.join(' / ')}(新鲜 / 过期 / 不可用 / 尚未取数)`, ) const texts = views.map((view) => freshnessText(view)) check( 'B2-four-texts-distinct', new Set(texts).size === 4 && freshnessText(FRESHNESS_VIEW.STALE).includes('过期') && !freshnessText(FRESHNESS_VIEW.STALE).includes('不可用'), `四句文案互不相同,且过期与不可用**说得不一样**:${texts.join(' | ')}`, ) check( 'B3-partial-is-its-own-state', overallFreshness([FRESHNESS_VIEW.CURRENT, FRESHNESS_VIEW.UNAVAILABLE]) === 'partial' && overallFreshness([FRESHNESS_VIEW.UNAVAILABLE, FRESHNESS_VIEW.UNAVAILABLE]) === FRESHNESS_VIEW.UNAVAILABLE && /部分/.test(overallFreshnessText('partial')), `部分失败是独立状态:「${overallFreshnessText('partial')}」(一对一全坏才算整体不可用)`, ) check( 'B4-maps-to-section-vocabulary', sectionStateOfFreshnessView(FRESHNESS_VIEW.STALE) === 'stale' && sectionStateOfFreshnessView(FRESHNESS_VIEW.UNAVAILABLE) === 'unavailable' && sectionStateOfFreshnessView(FRESHNESS_VIEW.CURRENT) === 'success', '四态映射到票 05 的分区状态词汇(不新造第二套状态词)', ) check( 'B5-bridges-backend-conclusion', viewFromBackendFreshness(FRESHNESS.EXPIRED) === FRESHNESS_VIEW.STALE && viewFromBackendFreshness(FRESHNESS.EMPTY) === FRESHNESS_VIEW.EMPTY && viewFromBackendFreshness('WHATEVER') === '', '后端给了结论时能桥接过来(认不出的回空串,不猜)', ) } // ================================================================ C 连续两个周期 { check('C1-two-cycles-is-the-bar', EXPIRED_AFTER_FAILURES === 2 && EXPIRED_MISSED_CYCLES === 2, `判定阈值 = ${EXPIRED_AFTER_FAILURES}(与票 05 的 EXPIRED_MISSED_CYCLES 一致)`) // 一个周期失败:还不算过期(这正是"连续两个"的意思)。 let state = markSuccess(emptyFreshness(), T0) state = markFailure(state, T0 + 1000) check( 'C2-one-failure-not-yet-stale', freshnessOf(state) === FRESHNESS_VIEW.CURRENT && state.consecutiveFailures === 1, `第 1 个周期失败:连续失败 ${state.consecutiveFailures},仍是「${freshnessText(freshnessOf(state))}」`, ) // 第二个周期失败:过期。 state = markFailure(state, T0 + 2000) check( 'C3-two-consecutive-failures-stale', freshnessOf(state) === FRESHNESS_VIEW.STALE, `第 2 个周期失败:连续失败 ${state.consecutiveFailures} → 「${freshnessText(freshnessOf(state))}」`, ) // **连续性**是关键:中间成功一次就清零,不能累计。 let resumed = markFailure(markSuccess(emptyFreshness(), T0), T0 + 1000) resumed = markSuccess(resumed, T0 + 2000) resumed = markFailure(resumed, T0 + 3000) check( 'C4-success-resets-streak', freshnessOf(resumed) === FRESHNESS_VIEW.CURRENT && resumed.consecutiveFailures === 1, `失败→成功→失败:连续失败回到 ${resumed.consecutiveFailures}(不是累计 2),仍是「${freshnessText(freshnessOf(resumed))}」`, ) // 从未成功过 + 两个周期失败 → 不可用(没有旧值可留,与过期分开)。 let never = emptyFreshness() never = markFailure(never, T0 + 1000) never = markFailure(never, T0 + 2000) check( 'C5-never-succeeded-is-unavailable', freshnessOf(never) === FRESHNESS_VIEW.UNAVAILABLE, `从未取到值又连续失败 ${never.consecutiveFailures} 次 → 「${freshnessText(freshnessOf(never))}」(与"过期"分开:没有旧值可留)`, ) } // ================================================================ D 过期保留旧值 { const succeeded = markSuccess(emptyFreshness(), T0) let stale = markFailure(markFailure(succeeded, T0 + 1000), T0 + 2000) check( 'D1-stale-keeps-last-success', stale.lastSuccessAt === T0 && isRetained(stale) === true, `过期后 lastSuccessAt 仍是第一次成功那一刻(${clockText(stale.lastSuccessAt)})—— 保留最后已知值,界面上带过期标识`, ) check( 'D2-failure-does-not-erase-success-time', markFailure(succeeded, T0 + 1000).lastSuccessAt === T0 && stale.successes === 1, '失败**不覆盖**"最后一次成功是什么时候"(否则值班人员就查不到旧值有多旧)', ) check( 'D3-retained-only-for-stale', isRetained(succeeded) === false && isRetained(emptyFreshness()) === false, '只有「过期」带保留标识;新鲜与尚未取数都不带', ) check( 'D4-view-carries-retained-flag', freshnessView(stale, T0 + 3000).retained === true && freshnessView(stale, T0 + 3000).failures === 2, '视图对象把 retained / failures 一并交给界面(组件不自己算)', ) } // ================================================================ E 不显示虚假零值 { const missing = { dataAgeSeconds: null, updatedAt: null } check( 'E1-age-missing-is-placeholder', dataAgeText(missing) === '—' && dataAgeText({ dataAgeSeconds: undefined }) === '—', `年龄缺失显示「${dataAgeText(missing)}」,**不是**「0 秒前」(那会被读成刚更新过)`, ) check( 'E2-real-zero-is-valid', dataAgeText({ dataAgeSeconds: 0 }) === '0 秒前' && clockText(T0) !== FRESHNESS_PLACEHOLDER, `后端真给 0 是有效数据:${dataAgeText({ dataAgeSeconds: 0 })}(照实显示,不当缺失)`, ) check( 'E3-missed-cycles-drives-expiry', isExpired({ freshness: '', missedCycles: 2 }) === true && isExpired({ freshness: '', missedCycles: 1 }) === false && isExpired({ freshness: FRESHNESS.CURRENT, missedCycles: 5 }) === false, '后端只给 missedCycles 时按 >= 2 兜底;给了 freshness 结论则以它为准(前端不推翻后端)', ) check( 'E4-no-zero-fabrication-on-empty', freshnessView(emptyFreshness(), T0).lastSuccessAt === null, '从未成功时不编一个时间戳出来(字段保持 null,界面显示占位符)', ) // 变异测试暴露的缺口:`sinceSuccessText` 的"缺失回占位符"此前没有断言, // 把占位符改成「0 秒前」时闸门仍全绿(假的通过)。这里补上,两个入口都要守。 check( 'E5-since-success-missing-is-placeholder', sinceSuccessText(emptyFreshness(), T0) === FRESHNESS_PLACEHOLDER && sinceSuccessText({ lastSuccessAt: null }, T0) === FRESHNESS_PLACEHOLDER && sinceSuccessText({ lastSuccessAt: 'garbage' }, T0) === FRESHNESS_PLACEHOLDER && sinceSuccessText({ lastSuccessAt: T0 }, NaN) === FRESHNESS_PLACEHOLDER && // 真给 0(刚刚更新)照实说 0 秒前,不混淆成缺失。 sinceSuccessText({ lastSuccessAt: T0 }, T0) === '0 秒前', `两个年龄入口在缺失时都给「${FRESHNESS_PLACEHOLDER}」(不编「0 秒前」),而真的 0 秒照实显示`, ) } // ================================================================ F 刷新周期(轮询) { // 周期真的按配置跑;注入时钟让"两个周期"同步可测。 resetFreshness() const timers = fakeTimers() let clock = T0 let pushLive = true let ticks = 0 const states = [] startPolling({ refreshSeconds: 10, tick: () => { ticks += 1 return { ok: true } }, isPushLive: () => pushLive, now: () => clock, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout, onState: (state) => states.push(freshnessOf(state)), }) check( 'F1-first-cycle-runs-immediately', ticks === 1 && isPolling() && timers.pending() === 1, '启动即跑一轮(否则首屏要空等一个周期),并排好下一次', ) check('F2-not-polling-before-start', DEFAULT_REFRESH_SECONDS === 30, `默认周期 ${DEFAULT_REFRESH_SECONDS} 秒(配置 runtime.refreshSeconds 可覆盖)`) // 推进两个周期(每周期取数成功)→ 一直新鲜。 clock += 10_000 await timers.step() clock += 10_000 await timers.step() check( 'F3-successful-cycles-stay-current', ticks === 3 && freshnessOf(getFreshnessState()) === FRESHNESS_VIEW.CURRENT, `${ticks} 个周期都成功 → 仍是「${freshnessText(freshnessOf(getFreshnessState()))}」`, ) // WS 断线:**也算这一周期失败**(维护者口径),取数成功也不改变结论。 pushLive = false clock += 10_000 await timers.step() const afterOne = getFreshnessState() clock += 10_000 await timers.step() const afterTwo = getFreshnessState() check( 'F4-push-down-counts-as-failure', afterOne.consecutiveFailures === 1 && freshnessOf(afterOne) === FRESHNESS_VIEW.CURRENT && freshnessOf(afterTwo) === FRESHNESS_VIEW.STALE && /状态推送未连接/.test(afterTwo.lastFailureReason), `WS 断线两个周期(REST 仍取数成功)→ 第 1 个周期仍是新鲜、第 2 个转「${freshnessText(freshnessOf(afterTwo))}」,原因是「${afterTwo.lastFailureReason}」`, ) // 恢复推送 + 取数成功 → 回到新鲜(失败清零)。 pushLive = true clock += 10_000 await timers.step() check( 'F5-recovery-clears-failures', freshnessOf(getFreshnessState()) === FRESHNESS_VIEW.CURRENT && getFreshnessState().consecutiveFailures === 0, `链路恢复后回到「${freshnessText(freshnessOf(getFreshnessState()))}」(连续失败清零)`, ) check( 'F6-tick-failure-counts', await (async () => { resetFreshness() const t2 = fakeTimers() let now2 = T0 startPolling({ refreshSeconds: 5, tick: () => false, isPushLive: () => true, now: () => now2, setTimeout: t2.setTimeout, clearTimeout: t2.clearTimeout, }) // 启动即跑一轮(失败),再推进一个周期 → 连续 2 次失败。 now2 += 5000 await t2.step() const state = getFreshnessState() stopPolling() return state.consecutiveFailures === 2 })(), '`tick` 返回 false 也算失败周期(取数失败与 WS 断线两条路都进同一本账)', ) // 停止必须清干净(票 15 刚在"漏取消定时器"上栽过)。 stopPolling() check( 'F7-stop-clears-timer', isPolling() === false && timers.pending() === 0, `stopPolling 后定时器剩 ${timers.pending()} 个、isPolling=${isPolling()}`, ) check( 'F8-tick-throw-is-failure', (() => { resetFreshness() const t3 = fakeTimers() startPolling({ refreshSeconds: 5, tick: () => { throw new Error('boom') }, isPushLive: () => true, now: () => T0, setTimeout: t3.setTimeout, clearTimeout: t3.clearTimeout, }) const state = getFreshnessState() stopPolling() return state.consecutiveFailures === 1 && /boom/.test(state.lastFailureReason) })(), '`tick` 抛错也收成失败周期(不把异常冒成页面级报错)', ) resetFreshness() } // ================================================================ F9 store 接线(推送到达 = 链路活着) { const runtimeStore = useRuntimeStore() runtimeStore.reset() runtimeStore.startFreshness({ refreshSeconds: 10, tick: () => ({ ok: true }), now: () => T0, }) runtimeStore.stopFreshness() // 没有连接时 WS 不可用 → 启动那一轮**算失败**(这正是维护者口径:断线也算失败周期)。 const beforeFailures = runtimeStore.freshnessState.consecutiveFailures const beforeSuccesses = runtimeStore.freshnessState.successes // 推送到达 → 连续失败清零并记一次成功(否则安静的推送周期会被误判成过期)。 runtimeStore.ingest({ deviceCode: 'EQ-1', monitoringPointCode: 'MP-1', status: 'ALARM', serverUpdatedAt: '2026-09-24T10:00:05+08:00' }) check( 'F9-push-arrival-counts-as-live', beforeFailures >= 1 && runtimeStore.freshnessState.consecutiveFailures === 0 && runtimeStore.freshnessState.successes > beforeSuccesses, `WS 未连时启动那轮先记失败(${beforeFailures} 次);推送到达后连续失败清零、成功数 ${beforeSuccesses} → ${runtimeStore.freshnessState.successes}`, ) check( 'F10-store-exposes-view-for-ui', typeof runtimeStore.freshness.view === 'string' && typeof runtimeStore.freshness.text === 'string' && typeof runtimeStore.retained === 'boolean', `store 给出视图对象(view=${runtimeStore.freshness.view}、text=「${runtimeStore.freshness.text}」)供组件直接消费`, ) runtimeStore.reset() } // ================================================================ G 静态守卫 { const shellCode = readVueCode('CockpitShell.vue') check( 'G1-shell-shows-freshness', /data-freshness/.test(shellCode) && /runtimeStore\.freshness/.test(shellCode), '外壳把新鲜度画出来(data-freshness / data-freshness-at 是走查断言面)', ) check( 'G2-shell-does-not-own-wording', !/数据已过期|数据不可用(尚未/.test(shellCode), '外壳不自己拼状态文案(文案只在 domain/freshnessModel.js 一份)', ) check( 'G3-shell-stops-polling-on-unmount', /stopFreshness\(\)/.test(readVueCode('CockpitShell.vue')), '离开外壳必须停轮询(定时器不跟着页面跑下去)', ) check( 'G4-domain-pure-and-no-timer', !/\bfetch\s*\(|XMLHttpRequest|setTimeout|setInterval/.test(read('domain/freshnessModel.js')) && !/from '\.\.\/services\//.test(read('domain/freshnessModel.js')), 'domain/freshnessModel.js 是纯函数(不发请求、不 import service、不自己起定时器)', ) check( 'G5-service-timers-are-injectable', /config\.setTimeout \|\|/.test(readCode('services/freshnessService.js')) && /config\.clearTimeout \|\|/.test(readCode('services/freshnessService.js')), '轮询服务的定时器可注入(单测与走查用假定时器演示两个周期)', ) check( 'G6-period-comes-from-config', (() => { const { config, issues } = normalizeConfig({ runtime: { refreshSeconds: 15 } }) const bad = normalizeConfig({ runtime: { refreshSeconds: 0 } }) return config.runtime.refreshSeconds === 15 && bad.config.runtime.refreshSeconds === 30 && bad.issues.length > 0 })(), '周期来自配置 runtime.refreshSeconds;0/负数被拒并退回默认(否则"两个周期"永远不成立)', ) check( 'G7-no-credential-in-freshness-modules', !/Authorization|Bearer|api\.token|ws\.token/.test(read('domain/freshnessModel.js')) && !/Authorization|Bearer/.test(readCode('services/freshnessService.js')), '新鲜度两层都不碰凭据', ) check( 'G8-store-owns-the-accounting', /recordExternal/.test(readCode('stores/runtimeStore.js')) && /isPushLive: \(\) => live\.value/.test(readCode('stores/runtimeStore.js')), 'WS 可用性取自 store 的连接状态(方向不反:service 不认识 store)', ) // 「部分失败」必须真的被界面消费 —— 变异测试暴露过:纯函数写了却没人用(死代码), // 验收第 2 条会因此名存实亡。 check( 'G9-partial-state-is-consumed-by-ui', /overallFreshness\(/.test(shellCode) && /overallFreshnessText\(/.test(shellCode) && /data-freshness-partial/.test(shellCode), '「部分失败」被外壳真的画出来(data-freshness-partial),不是只写在 domain 里', ) check( 'G10-bridges-section-to-freshness', viewFromSectionState('stale') === FRESHNESS_VIEW.STALE && viewFromSectionState('failed') === FRESHNESS_VIEW.UNAVAILABLE && viewFromSectionState('') === '' && sectionStateOf({ state: 'success' }) === 'success' && sectionStateOf(null) === '', '分区状态与新鲜度四态之间有唯一一处显式翻译(界面不自己数数)', ) } const failed = checks.filter((item) => !item.pass) console.log('') console.log(`T16 数据新鲜度检查:${checks.length - failed.length}/${checks.length} 项通过。`) if (failed.length) { console.log('未通过:') for (const item of failed) console.log(` · ${item.name}`) process.exit(1) } console.log('T16 数据新鲜度全部通过。')