t16-freshness.mjs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. /**
  2. * T16 数据新鲜度与过期状态 纯逻辑检查(票 16)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t16-freshness.mjs
  6. *
  7. * 七段(每条对着票 16 的验收原句):
  8. * A 验收 1:显示最近一次成功更新时间(缺失显示占位符,**不是** 00:00:00)
  9. * B 验收 2:新鲜 / 过期 / 不可用 / 部分失败**四态可区分**,文案各不相同
  10. * C 验收 3:**连续两个**配置周期未成功更新 → 过期或不可用(连续,不是累计)
  11. * D 验收 4:过期数据**保留**最后已知值并带过期标识(不伪装成实时)
  12. * E 验收 5:缺失指标显示占位符,**不显示虚假零值**(0 是有效值,null 才是缺失)
  13. * F 验收 6:刷新周期(前端轮询 + WS 断线也算失败周期)真的按周期跑、能停干净
  14. * G 静态守卫:组件/store 不自己拼文案与色键;domain 纯函数;周期来自配置
  15. *
  16. * 口径来源(维护者 2026-09-24 定):前端轮询 + WS 断线也算失败周期;
  17. * 「过期/不可用」复用票 05 的分区状态词汇;四态演示用注入式时钟 + mock 开关。
  18. */
  19. import { readFileSync } from 'node:fs'
  20. import { createPinia, setActivePinia } from 'pinia'
  21. import {
  22. EXPIRED_AFTER_FAILURES,
  23. FRESHNESS_PLACEHOLDER,
  24. FRESHNESS_VIEW,
  25. clockText,
  26. emptyFreshness,
  27. freshnessOf,
  28. freshnessText,
  29. freshnessView,
  30. isRetained,
  31. markFailure,
  32. markSuccess,
  33. overallFreshness,
  34. overallFreshnessText,
  35. sectionStateOf,
  36. sectionStateOfFreshnessView,
  37. sinceSuccessText,
  38. toMillis,
  39. viewFromBackendFreshness,
  40. viewFromSectionState,
  41. } from '../../../src/modules/cockpit/domain/freshnessModel.js'
  42. import { EXPIRED_MISSED_CYCLES, FRESHNESS, dataAgeText, isExpired } from '../../../src/modules/cockpit/domain/freshness.js'
  43. import {
  44. DEFAULT_REFRESH_SECONDS,
  45. getFreshnessState,
  46. isPolling,
  47. resetFreshness,
  48. runCycle,
  49. startPolling,
  50. stopPolling,
  51. } from '../../../src/modules/cockpit/services/freshnessService.js'
  52. import { useRuntimeStore } from '../../../src/modules/cockpit/stores/runtimeStore.js'
  53. import { normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js'
  54. const checks = []
  55. function check(name, pass, evidence) {
  56. checks.push({ name, pass })
  57. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  58. }
  59. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  60. const read = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  61. const code = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  62. const readCode = (relative) => code(read(relative))
  63. const codeVue = (text) => code(text.replace(/<!--[\s\S]*?-->/g, ''))
  64. const readVueCode = (relative) => codeVue(read(relative))
  65. setActivePinia(createPinia())
  66. /** 假定时器:把「两个周期」变成同步可控,不真的等 60 秒。 */
  67. function fakeTimers() {
  68. const tasks = []
  69. return {
  70. setTimeout: (fn, ms) => {
  71. const id = { fn, ms }
  72. tasks.push(id)
  73. return id
  74. },
  75. clearTimeout: (id) => {
  76. const index = tasks.indexOf(id)
  77. if (index >= 0) tasks.splice(index, 1)
  78. },
  79. async step() {
  80. const next = tasks.shift()
  81. if (!next) return false
  82. next.fn()
  83. await Promise.resolve()
  84. return true
  85. },
  86. pending: () => tasks.length,
  87. }
  88. }
  89. const T0 = Date.parse('2026-09-24T10:00:00+08:00')
  90. // ================================================================ A 最近成功更新时间
  91. {
  92. const fresh = markSuccess(emptyFreshness(), T0)
  93. const view = freshnessView(fresh, T0 + 5000)
  94. check(
  95. 'A1-shows-last-success-clock',
  96. /^\d{2}:\d{2}:\d{2}$/.test(view.lastSuccessClock) && view.sinceSuccess === '5 秒前',
  97. `最近一次成功更新 ${view.lastSuccessClock}(${view.sinceSuccess})`,
  98. )
  99. // 缺失必须是占位符,**不是** 00:00:00 —— 后者看起来像一个真实时刻。
  100. const none = freshnessView(emptyFreshness(), T0)
  101. check(
  102. 'A2-missing-time-is-placeholder',
  103. none.lastSuccessClock === FRESHNESS_PLACEHOLDER &&
  104. none.sinceSuccess === FRESHNESS_PLACEHOLDER &&
  105. clockText(null) === FRESHNESS_PLACEHOLDER &&
  106. clockText('not-a-time') === FRESHNESS_PLACEHOLDER &&
  107. clockText('') === FRESHNESS_PLACEHOLDER,
  108. `没有成功过时显示「${none.lastSuccessClock}」(不编一个 00:00:00)`,
  109. )
  110. check(
  111. 'A3-accepts-iso-and-epoch',
  112. clockText(T0) === clockText(new Date(T0).toISOString()) && toMillis('2026-09-24T10:00:00+08:00') === T0,
  113. '毫秒时间戳与 ISO 串(后端 updatedAt 的形状)都认,解析不出回 null',
  114. )
  115. check(
  116. 'A4-age-text-scales',
  117. sinceSuccessText(markSuccess(emptyFreshness(), T0), T0 + 90_000) === '2 分钟前' &&
  118. sinceSuccessText(markSuccess(emptyFreshness(), T0), T0 + 7200_000) === '2 小时前',
  119. '「多久之前」随量级换单位(秒/分钟/小时)',
  120. )
  121. }
  122. // ================================================================ B 四态可区分
  123. {
  124. const never = emptyFreshness()
  125. const current = markSuccess(never, T0)
  126. let stale = current
  127. stale = markFailure(stale, T0 + 1000, '取数失败')
  128. stale = markFailure(stale, T0 + 2000, '取数失败')
  129. let unavailable = never
  130. unavailable = markFailure(unavailable, T0 + 1000, '取数失败')
  131. const views = [freshnessOf(current), freshnessOf(stale), freshnessOf(unavailable), freshnessOf(never)]
  132. check(
  133. 'B1-four-states-distinct',
  134. freshnessOf(current) === FRESHNESS_VIEW.CURRENT &&
  135. freshnessOf(stale) === FRESHNESS_VIEW.STALE &&
  136. freshnessOf(unavailable) === FRESHNESS_VIEW.UNAVAILABLE &&
  137. freshnessOf(never) === FRESHNESS_VIEW.EMPTY,
  138. `四态:${views.join(' / ')}(新鲜 / 过期 / 不可用 / 尚未取数)`,
  139. )
  140. const texts = views.map((view) => freshnessText(view))
  141. check(
  142. 'B2-four-texts-distinct',
  143. new Set(texts).size === 4 && freshnessText(FRESHNESS_VIEW.STALE).includes('过期') && !freshnessText(FRESHNESS_VIEW.STALE).includes('不可用'),
  144. `四句文案互不相同,且过期与不可用**说得不一样**:${texts.join(' | ')}`,
  145. )
  146. check(
  147. 'B3-partial-is-its-own-state',
  148. overallFreshness([FRESHNESS_VIEW.CURRENT, FRESHNESS_VIEW.UNAVAILABLE]) === 'partial' &&
  149. overallFreshness([FRESHNESS_VIEW.UNAVAILABLE, FRESHNESS_VIEW.UNAVAILABLE]) === FRESHNESS_VIEW.UNAVAILABLE &&
  150. /部分/.test(overallFreshnessText('partial')),
  151. `部分失败是独立状态:「${overallFreshnessText('partial')}」(一对一全坏才算整体不可用)`,
  152. )
  153. check(
  154. 'B4-maps-to-section-vocabulary',
  155. sectionStateOfFreshnessView(FRESHNESS_VIEW.STALE) === 'stale' &&
  156. sectionStateOfFreshnessView(FRESHNESS_VIEW.UNAVAILABLE) === 'unavailable' &&
  157. sectionStateOfFreshnessView(FRESHNESS_VIEW.CURRENT) === 'success',
  158. '四态映射到票 05 的分区状态词汇(不新造第二套状态词)',
  159. )
  160. check(
  161. 'B5-bridges-backend-conclusion',
  162. viewFromBackendFreshness(FRESHNESS.EXPIRED) === FRESHNESS_VIEW.STALE &&
  163. viewFromBackendFreshness(FRESHNESS.EMPTY) === FRESHNESS_VIEW.EMPTY &&
  164. viewFromBackendFreshness('WHATEVER') === '',
  165. '后端给了结论时能桥接过来(认不出的回空串,不猜)',
  166. )
  167. }
  168. // ================================================================ C 连续两个周期
  169. {
  170. check('C1-two-cycles-is-the-bar', EXPIRED_AFTER_FAILURES === 2 && EXPIRED_MISSED_CYCLES === 2, `判定阈值 = ${EXPIRED_AFTER_FAILURES}(与票 05 的 EXPIRED_MISSED_CYCLES 一致)`)
  171. // 一个周期失败:还不算过期(这正是"连续两个"的意思)。
  172. let state = markSuccess(emptyFreshness(), T0)
  173. state = markFailure(state, T0 + 1000)
  174. check(
  175. 'C2-one-failure-not-yet-stale',
  176. freshnessOf(state) === FRESHNESS_VIEW.CURRENT && state.consecutiveFailures === 1,
  177. `第 1 个周期失败:连续失败 ${state.consecutiveFailures},仍是「${freshnessText(freshnessOf(state))}」`,
  178. )
  179. // 第二个周期失败:过期。
  180. state = markFailure(state, T0 + 2000)
  181. check(
  182. 'C3-two-consecutive-failures-stale',
  183. freshnessOf(state) === FRESHNESS_VIEW.STALE,
  184. `第 2 个周期失败:连续失败 ${state.consecutiveFailures} → 「${freshnessText(freshnessOf(state))}」`,
  185. )
  186. // **连续性**是关键:中间成功一次就清零,不能累计。
  187. let resumed = markFailure(markSuccess(emptyFreshness(), T0), T0 + 1000)
  188. resumed = markSuccess(resumed, T0 + 2000)
  189. resumed = markFailure(resumed, T0 + 3000)
  190. check(
  191. 'C4-success-resets-streak',
  192. freshnessOf(resumed) === FRESHNESS_VIEW.CURRENT && resumed.consecutiveFailures === 1,
  193. `失败→成功→失败:连续失败回到 ${resumed.consecutiveFailures}(不是累计 2),仍是「${freshnessText(freshnessOf(resumed))}」`,
  194. )
  195. // 从未成功过 + 两个周期失败 → 不可用(没有旧值可留,与过期分开)。
  196. let never = emptyFreshness()
  197. never = markFailure(never, T0 + 1000)
  198. never = markFailure(never, T0 + 2000)
  199. check(
  200. 'C5-never-succeeded-is-unavailable',
  201. freshnessOf(never) === FRESHNESS_VIEW.UNAVAILABLE,
  202. `从未取到值又连续失败 ${never.consecutiveFailures} 次 → 「${freshnessText(freshnessOf(never))}」(与"过期"分开:没有旧值可留)`,
  203. )
  204. }
  205. // ================================================================ D 过期保留旧值
  206. {
  207. const succeeded = markSuccess(emptyFreshness(), T0)
  208. let stale = markFailure(markFailure(succeeded, T0 + 1000), T0 + 2000)
  209. check(
  210. 'D1-stale-keeps-last-success',
  211. stale.lastSuccessAt === T0 && isRetained(stale) === true,
  212. `过期后 lastSuccessAt 仍是第一次成功那一刻(${clockText(stale.lastSuccessAt)})—— 保留最后已知值,界面上带过期标识`,
  213. )
  214. check(
  215. 'D2-failure-does-not-erase-success-time',
  216. markFailure(succeeded, T0 + 1000).lastSuccessAt === T0 && stale.successes === 1,
  217. '失败**不覆盖**"最后一次成功是什么时候"(否则值班人员就查不到旧值有多旧)',
  218. )
  219. check(
  220. 'D3-retained-only-for-stale',
  221. isRetained(succeeded) === false && isRetained(emptyFreshness()) === false,
  222. '只有「过期」带保留标识;新鲜与尚未取数都不带',
  223. )
  224. check(
  225. 'D4-view-carries-retained-flag',
  226. freshnessView(stale, T0 + 3000).retained === true && freshnessView(stale, T0 + 3000).failures === 2,
  227. '视图对象把 retained / failures 一并交给界面(组件不自己算)',
  228. )
  229. }
  230. // ================================================================ E 不显示虚假零值
  231. {
  232. const missing = { dataAgeSeconds: null, updatedAt: null }
  233. check(
  234. 'E1-age-missing-is-placeholder',
  235. dataAgeText(missing) === '—' && dataAgeText({ dataAgeSeconds: undefined }) === '—',
  236. `年龄缺失显示「${dataAgeText(missing)}」,**不是**「0 秒前」(那会被读成刚更新过)`,
  237. )
  238. check(
  239. 'E2-real-zero-is-valid',
  240. dataAgeText({ dataAgeSeconds: 0 }) === '0 秒前' && clockText(T0) !== FRESHNESS_PLACEHOLDER,
  241. `后端真给 0 是有效数据:${dataAgeText({ dataAgeSeconds: 0 })}(照实显示,不当缺失)`,
  242. )
  243. check(
  244. 'E3-missed-cycles-drives-expiry',
  245. isExpired({ freshness: '', missedCycles: 2 }) === true &&
  246. isExpired({ freshness: '', missedCycles: 1 }) === false &&
  247. isExpired({ freshness: FRESHNESS.CURRENT, missedCycles: 5 }) === false,
  248. '后端只给 missedCycles 时按 >= 2 兜底;给了 freshness 结论则以它为准(前端不推翻后端)',
  249. )
  250. check(
  251. 'E4-no-zero-fabrication-on-empty',
  252. freshnessView(emptyFreshness(), T0).lastSuccessAt === null,
  253. '从未成功时不编一个时间戳出来(字段保持 null,界面显示占位符)',
  254. )
  255. // 变异测试暴露的缺口:`sinceSuccessText` 的"缺失回占位符"此前没有断言,
  256. // 把占位符改成「0 秒前」时闸门仍全绿(假的通过)。这里补上,两个入口都要守。
  257. check(
  258. 'E5-since-success-missing-is-placeholder',
  259. sinceSuccessText(emptyFreshness(), T0) === FRESHNESS_PLACEHOLDER &&
  260. sinceSuccessText({ lastSuccessAt: null }, T0) === FRESHNESS_PLACEHOLDER &&
  261. sinceSuccessText({ lastSuccessAt: 'garbage' }, T0) === FRESHNESS_PLACEHOLDER &&
  262. sinceSuccessText({ lastSuccessAt: T0 }, NaN) === FRESHNESS_PLACEHOLDER &&
  263. // 真给 0(刚刚更新)照实说 0 秒前,不混淆成缺失。
  264. sinceSuccessText({ lastSuccessAt: T0 }, T0) === '0 秒前',
  265. `两个年龄入口在缺失时都给「${FRESHNESS_PLACEHOLDER}」(不编「0 秒前」),而真的 0 秒照实显示`,
  266. )
  267. }
  268. // ================================================================ F 刷新周期(轮询)
  269. {
  270. // 周期真的按配置跑;注入时钟让"两个周期"同步可测。
  271. resetFreshness()
  272. const timers = fakeTimers()
  273. let clock = T0
  274. let pushLive = true
  275. let ticks = 0
  276. const states = []
  277. startPolling({
  278. refreshSeconds: 10,
  279. tick: () => {
  280. ticks += 1
  281. return { ok: true }
  282. },
  283. isPushLive: () => pushLive,
  284. now: () => clock,
  285. setTimeout: timers.setTimeout,
  286. clearTimeout: timers.clearTimeout,
  287. onState: (state) => states.push(freshnessOf(state)),
  288. })
  289. check(
  290. 'F1-first-cycle-runs-immediately',
  291. ticks === 1 && isPolling() && timers.pending() === 1,
  292. '启动即跑一轮(否则首屏要空等一个周期),并排好下一次',
  293. )
  294. check('F2-not-polling-before-start', DEFAULT_REFRESH_SECONDS === 30, `默认周期 ${DEFAULT_REFRESH_SECONDS} 秒(配置 runtime.refreshSeconds 可覆盖)`)
  295. // 推进两个周期(每周期取数成功)→ 一直新鲜。
  296. clock += 10_000
  297. await timers.step()
  298. clock += 10_000
  299. await timers.step()
  300. check(
  301. 'F3-successful-cycles-stay-current',
  302. ticks === 3 && freshnessOf(getFreshnessState()) === FRESHNESS_VIEW.CURRENT,
  303. `${ticks} 个周期都成功 → 仍是「${freshnessText(freshnessOf(getFreshnessState()))}」`,
  304. )
  305. // WS 断线:**也算这一周期失败**(维护者口径),取数成功也不改变结论。
  306. pushLive = false
  307. clock += 10_000
  308. await timers.step()
  309. const afterOne = getFreshnessState()
  310. clock += 10_000
  311. await timers.step()
  312. const afterTwo = getFreshnessState()
  313. check(
  314. 'F4-push-down-counts-as-failure',
  315. afterOne.consecutiveFailures === 1 &&
  316. freshnessOf(afterOne) === FRESHNESS_VIEW.CURRENT &&
  317. freshnessOf(afterTwo) === FRESHNESS_VIEW.STALE &&
  318. /状态推送未连接/.test(afterTwo.lastFailureReason),
  319. `WS 断线两个周期(REST 仍取数成功)→ 第 1 个周期仍是新鲜、第 2 个转「${freshnessText(freshnessOf(afterTwo))}」,原因是「${afterTwo.lastFailureReason}」`,
  320. )
  321. // 恢复推送 + 取数成功 → 回到新鲜(失败清零)。
  322. pushLive = true
  323. clock += 10_000
  324. await timers.step()
  325. check(
  326. 'F5-recovery-clears-failures',
  327. freshnessOf(getFreshnessState()) === FRESHNESS_VIEW.CURRENT && getFreshnessState().consecutiveFailures === 0,
  328. `链路恢复后回到「${freshnessText(freshnessOf(getFreshnessState()))}」(连续失败清零)`,
  329. )
  330. check(
  331. 'F6-tick-failure-counts',
  332. await (async () => {
  333. resetFreshness()
  334. const t2 = fakeTimers()
  335. let now2 = T0
  336. startPolling({
  337. refreshSeconds: 5,
  338. tick: () => false,
  339. isPushLive: () => true,
  340. now: () => now2,
  341. setTimeout: t2.setTimeout,
  342. clearTimeout: t2.clearTimeout,
  343. })
  344. // 启动即跑一轮(失败),再推进一个周期 → 连续 2 次失败。
  345. now2 += 5000
  346. await t2.step()
  347. const state = getFreshnessState()
  348. stopPolling()
  349. return state.consecutiveFailures === 2
  350. })(),
  351. '`tick` 返回 false 也算失败周期(取数失败与 WS 断线两条路都进同一本账)',
  352. )
  353. // 停止必须清干净(票 15 刚在"漏取消定时器"上栽过)。
  354. stopPolling()
  355. check(
  356. 'F7-stop-clears-timer',
  357. isPolling() === false && timers.pending() === 0,
  358. `stopPolling 后定时器剩 ${timers.pending()} 个、isPolling=${isPolling()}`,
  359. )
  360. check(
  361. 'F8-tick-throw-is-failure',
  362. (() => {
  363. resetFreshness()
  364. const t3 = fakeTimers()
  365. startPolling({
  366. refreshSeconds: 5,
  367. tick: () => {
  368. throw new Error('boom')
  369. },
  370. isPushLive: () => true,
  371. now: () => T0,
  372. setTimeout: t3.setTimeout,
  373. clearTimeout: t3.clearTimeout,
  374. })
  375. const state = getFreshnessState()
  376. stopPolling()
  377. return state.consecutiveFailures === 1 && /boom/.test(state.lastFailureReason)
  378. })(),
  379. '`tick` 抛错也收成失败周期(不把异常冒成页面级报错)',
  380. )
  381. resetFreshness()
  382. }
  383. // ================================================================ F9 store 接线(推送到达 = 链路活着)
  384. {
  385. const runtimeStore = useRuntimeStore()
  386. runtimeStore.reset()
  387. runtimeStore.startFreshness({
  388. refreshSeconds: 10,
  389. tick: () => ({ ok: true }),
  390. now: () => T0,
  391. })
  392. runtimeStore.stopFreshness()
  393. // 没有连接时 WS 不可用 → 启动那一轮**算失败**(这正是维护者口径:断线也算失败周期)。
  394. const beforeFailures = runtimeStore.freshnessState.consecutiveFailures
  395. const beforeSuccesses = runtimeStore.freshnessState.successes
  396. // 推送到达 → 连续失败清零并记一次成功(否则安静的推送周期会被误判成过期)。
  397. runtimeStore.ingest({ deviceCode: 'EQ-1', monitoringPointCode: 'MP-1', status: 'ALARM', serverUpdatedAt: '2026-09-24T10:00:05+08:00' })
  398. check(
  399. 'F9-push-arrival-counts-as-live',
  400. beforeFailures >= 1 &&
  401. runtimeStore.freshnessState.consecutiveFailures === 0 &&
  402. runtimeStore.freshnessState.successes > beforeSuccesses,
  403. `WS 未连时启动那轮先记失败(${beforeFailures} 次);推送到达后连续失败清零、成功数 ${beforeSuccesses} → ${runtimeStore.freshnessState.successes}`,
  404. )
  405. check(
  406. 'F10-store-exposes-view-for-ui',
  407. typeof runtimeStore.freshness.view === 'string' &&
  408. typeof runtimeStore.freshness.text === 'string' &&
  409. typeof runtimeStore.retained === 'boolean',
  410. `store 给出视图对象(view=${runtimeStore.freshness.view}、text=「${runtimeStore.freshness.text}」)供组件直接消费`,
  411. )
  412. runtimeStore.reset()
  413. }
  414. // ================================================================ G 静态守卫
  415. {
  416. const shellCode = readVueCode('CockpitShell.vue')
  417. check(
  418. 'G1-shell-shows-freshness',
  419. /data-freshness/.test(shellCode) && /runtimeStore\.freshness/.test(shellCode),
  420. '外壳把新鲜度画出来(data-freshness / data-freshness-at 是走查断言面)',
  421. )
  422. check(
  423. 'G2-shell-does-not-own-wording',
  424. !/数据已过期|数据不可用(尚未/.test(shellCode),
  425. '外壳不自己拼状态文案(文案只在 domain/freshnessModel.js 一份)',
  426. )
  427. check(
  428. 'G3-shell-stops-polling-on-unmount',
  429. /stopFreshness\(\)/.test(readVueCode('CockpitShell.vue')),
  430. '离开外壳必须停轮询(定时器不跟着页面跑下去)',
  431. )
  432. check(
  433. 'G4-domain-pure-and-no-timer',
  434. !/\bfetch\s*\(|XMLHttpRequest|setTimeout|setInterval/.test(read('domain/freshnessModel.js')) &&
  435. !/from '\.\.\/services\//.test(read('domain/freshnessModel.js')),
  436. 'domain/freshnessModel.js 是纯函数(不发请求、不 import service、不自己起定时器)',
  437. )
  438. check(
  439. 'G5-service-timers-are-injectable',
  440. /config\.setTimeout \|\|/.test(readCode('services/freshnessService.js')) &&
  441. /config\.clearTimeout \|\|/.test(readCode('services/freshnessService.js')),
  442. '轮询服务的定时器可注入(单测与走查用假定时器演示两个周期)',
  443. )
  444. check(
  445. 'G6-period-comes-from-config',
  446. (() => {
  447. const { config, issues } = normalizeConfig({ runtime: { refreshSeconds: 15 } })
  448. const bad = normalizeConfig({ runtime: { refreshSeconds: 0 } })
  449. return config.runtime.refreshSeconds === 15 && bad.config.runtime.refreshSeconds === 30 && bad.issues.length > 0
  450. })(),
  451. '周期来自配置 runtime.refreshSeconds;0/负数被拒并退回默认(否则"两个周期"永远不成立)',
  452. )
  453. check(
  454. 'G7-no-credential-in-freshness-modules',
  455. !/Authorization|Bearer|api\.token|ws\.token/.test(read('domain/freshnessModel.js')) &&
  456. !/Authorization|Bearer/.test(readCode('services/freshnessService.js')),
  457. '新鲜度两层都不碰凭据',
  458. )
  459. check(
  460. 'G8-store-owns-the-accounting',
  461. /recordExternal/.test(readCode('stores/runtimeStore.js')) && /isPushLive: \(\) => live\.value/.test(readCode('stores/runtimeStore.js')),
  462. 'WS 可用性取自 store 的连接状态(方向不反:service 不认识 store)',
  463. )
  464. // 「部分失败」必须真的被界面消费 —— 变异测试暴露过:纯函数写了却没人用(死代码),
  465. // 验收第 2 条会因此名存实亡。
  466. check(
  467. 'G9-partial-state-is-consumed-by-ui',
  468. /overallFreshness\(/.test(shellCode) &&
  469. /overallFreshnessText\(/.test(shellCode) &&
  470. /data-freshness-partial/.test(shellCode),
  471. '「部分失败」被外壳真的画出来(data-freshness-partial),不是只写在 domain 里',
  472. )
  473. check(
  474. 'G10-bridges-section-to-freshness',
  475. viewFromSectionState('stale') === FRESHNESS_VIEW.STALE &&
  476. viewFromSectionState('failed') === FRESHNESS_VIEW.UNAVAILABLE &&
  477. viewFromSectionState('') === '' &&
  478. sectionStateOf({ state: 'success' }) === 'success' &&
  479. sectionStateOf(null) === '',
  480. '分区状态与新鲜度四态之间有唯一一处显式翻译(界面不自己数数)',
  481. )
  482. }
  483. const failed = checks.filter((item) => !item.pass)
  484. console.log('')
  485. console.log(`T16 数据新鲜度检查:${checks.length - failed.length}/${checks.length} 项通过。`)
  486. if (failed.length) {
  487. console.log('未通过:')
  488. for (const item of failed) console.log(` · ${item.name}`)
  489. process.exit(1)
  490. }
  491. console.log('T16 数据新鲜度全部通过。')