/** * T07 坐标校验与无地图位置 纯逻辑检查(票 07)。 * * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs / t08-map-summary.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t07-coordinate.mjs * * 分八段: * A 唯一准入判据(只认后端 renderable;前端不纠偏、不查范围、不用默认坐标) * B 原因文案(后端各 reason 逐一命中;「缺失」与「非法」分开;认不出的不泄露原因码) * C 切分不静默丢对象(不上图 ≠ 不存在,两堆都要交出去) * D 两个口径分开(后端全量 vs 前端已加载,不混成一个数) * E 后端汇总进领域批次(票 05 遗留的漏读;且不得跨页累加) * F 契约化 mock 的三桶分法照抄后端 * G 静态守卫(domain 不重算坐标范围;界面零原因码;不上图清单由领域层供货) * H 「已上图」措辞不再把被拦下的对象算进去 * * 契约依据是**后端实现**:`CockpitCoordinateValidator`(state 三态、renderable 是结论) * 与 `CockpitMapSnapshotService`(汇总按整份快照算、三桶分法)。 * * 2026-10-12 后端裁定:取消无入口的人工校核前置,改为「结构合法 + 落在主场景区内」自动准入。 * 后端 reason 因此增为七个(新增 `OUTSIDE_PRIMARY_AREA`);`UNVERIFIED_COORDINATE` 仍在词表里, * 供历史快照与「后端未给校验段」的防御路径使用,但不再是新数据的常态结论。 */ import { readFileSync } from 'node:fs' import { createPinia, setActivePinia } from 'pinia' import { COORDINATE_REASON_TEXT, NO_MAP_LOCATION_TEXT, UNVALIDATED_TEXT, blockReason, blockedEntries, coordinateOverview, coordinateSummaryText, isRenderable, splitByCoordinate, toCoordinateSummary, } from '../../../src/modules/cockpit/domain/coordinate.js' import { mergeMapPage, summaryView, toMapBatch } from '../../../src/modules/cockpit/domain/mapSummary.js' import { fetchMapSnapshot } from '../../../src/modules/cockpit/services/mapApi.js' import { getMockTransportPaths, registerMockTransport, requestCockpitJson, resetMockTransportsForTest, } from '../../../src/modules/cockpit/services/cockpitApi.js' import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js' import { MAP_SUMMARY_PAGE_SIZE, useMapStore } from '../../../src/modules/cockpit/stores/mapStore.js' const checks = [] function check(name, pass, evidence) { checks.push({ name, pass }) console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`) } const mockConfig = { dataSource: 'mock', api: { baseUrl: '', token: '' } } /** 造一个领域地图对象(形状同 `domain/mapSummary.js`)。 */ function object({ code = 'X', kind = 'monitoring-point', name = '', longitude = 110.1, latitude = 28.4, coordinate = null } = {}) { return { kind, code, name: name || `${code} 对象`, status: '', statusReasons: [], longitude, latitude, coordinate: coordinate || { state: 'VALID', reason: '', crs: 'WGS84', renderable: true, validated: true }, missing: [], } } /** 后端校验段的取值(照抄 `CockpitCoordinateValidator`;2026-10-12 起含区外原因)。 */ const BACKEND_CASES = [ { state: 'VALID', reason: null, renderable: true }, { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', renderable: false }, { state: 'INVALID', reason: 'MISSING_LONGITUDE', renderable: false }, { state: 'INVALID', reason: 'MISSING_LATITUDE', renderable: false }, { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false }, { state: 'INVALID', reason: 'LATITUDE_OUT_OF_RANGE', renderable: false }, { state: 'INVALID', reason: 'ZERO_COORDINATE', renderable: false }, { state: 'INVALID', reason: 'OUTSIDE_PRIMARY_AREA', renderable: false }, ] /** 后端载荷里的对象 → 领域对象(走真实的适配层,不手搓 coordinate 段)。 */ function rawToObject(raw, kind = 'monitoring-point') { const payload = kind === 'monitoring-point' ? { monitoringPoints: [raw], unassociatedEquipment: [] } : { monitoringPoints: [], unassociatedEquipment: [raw] } return toMapBatch(payload).objects[0] } // ================================================================ A 唯一准入判据 { // 后端每一种结论都如实照搬,不做二次判断。 const mapped = BACKEND_CASES.map((item) => ({ expected: item.renderable, actual: isRenderable(rawToObject({ pointCode: 'MP-A', coordinateValidation: item })), state: item.state, reason: item.reason, })) check( 'A1-admission-follows-renderable', mapped.every((item) => item.actual === item.expected), JSON.stringify(mapped.map((item) => `${item.state}/${item.reason || '-'}→${item.actual}`)), ) // 关键对抗:state 说 VALID 但后端 renderable=false 时,**必须**听 renderable。 // 这一条守着「前端不另立一套判据」——否则两个答案必然有一个是错的,而错的会画到地图上。 const contradictory = rawToObject({ pointCode: 'MP-C', coordinateValidation: { state: 'VALID', reason: null, renderable: false, crs: 'WGS84' }, }) check('A2-renderable-beats-state', isRenderable(contradictory) === false, 'state=VALID 但 renderable=false → 不上图') // 反向:state 说 INVALID 但后端 renderable=true,同样听后端(前端不做"再校验一遍")。 const permissive = rawToObject({ pointCode: 'MP-P', coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: true, crs: 'WGS84' }, }) check('A3-no-frontend-recheck', isRenderable(permissive) === true, '后端说可绘制就绘制,前端不推翻') // 前端不纠偏、不用默认坐标:坐标值原样带出,不被改成"看起来合法"的数。 const outOfRange = toMapBatch({ page: 1, pageSize: 1, snapshotAt: 'S', total: 1, complete: true, unassociatedEquipment: [ { deviceCode: 'EQ-X', deviceName: '超范围设备', longitude: 181, latitude: 28.455, coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' }, }, ], }).objects[0] check( 'A4-no-correction-no-default', outOfRange.longitude === 181 && outOfRange.latitude === 28.455 && isRenderable(outOfRange) === false, `原值 ${outOfRange.longitude},${outOfRange.latitude} 未被纠正,且不上图`, ) } // ================================================================ B 原因文案 { const texts = BACKEND_CASES.filter((item) => item.reason).map((item) => blockReason( rawToObject({ pointCode: 'MP-B', coordinateValidation: { ...item, crs: 'WGS84' }, }), ), ) check( 'B1-every-reason-has-text', texts.length === BACKEND_CASES.filter((item) => item.reason).length && texts.every((item) => item.text && item.text !== NO_MAP_LOCATION_TEXT && item.text !== UNVALIDATED_TEXT), JSON.stringify(texts.map((item) => `${item.code}→${item.text}`)), ) check( 'B2-reason-dictionary-covers-backend', Object.keys(COORDINATE_REASON_TEXT).length === 7 && BACKEND_CASES.filter((item) => item.reason).every((item) => !!COORDINATE_REASON_TEXT[item.reason]), JSON.stringify(Object.keys(COORDINATE_REASON_TEXT)), ) // 后端整段没给校验段(票 05 的 validated:false)→「坐标未校验」,这是「缺失」而不是「非法」。 const missing = rawToObject({ pointCode: 'MP-N' }) const missingReason = blockReason(missing) check( 'B3-unvalidated-is-missing-not-invalid', missingReason.text === UNVALIDATED_TEXT && missingReason.code === '', JSON.stringify(missingReason), ) // 认不出的原因:只说「无地图位置」,不把原因码甩给值班人员。 const unknown = object({ code: 'MP-U', coordinate: { state: 'INVALID', reason: 'SOMETHING_NEW', crs: 'WGS84', renderable: false, validated: true } }) check('B4-unknown-reason-generic', blockReason(unknown).text === NO_MAP_LOCATION_TEXT, JSON.stringify(blockReason(unknown))) // 界面上给的是中文:六个原因里不得出现后端原因码原文。 const leaked = texts.filter((item) => /^[A-Z_]+$/.test(item.text)) check('B5-no-reason-code-as-text', leaked.length === 0, leaked.length ? JSON.stringify(leaked) : '文案全是中文,零原因码') } // ================================================================ C 不静默丢对象 { const objects = [ object({ code: 'OK-1' }), object({ code: 'BAD-1', coordinate: { state: 'INVALID', reason: 'ZERO_COORDINATE', crs: 'WGS84', renderable: false, validated: true } }), object({ code: 'OK-2' }), object({ code: 'BAD-2', coordinate: { state: 'UNVERIFIED', reason: 'UNVERIFIED_COORDINATE', crs: 'WGS84', renderable: false, validated: true } }), ] const { renderable, blocked } = splitByCoordinate(objects) check( 'C1-split-keeps-both', renderable.length === 2 && blocked.length === 2 && renderable.length + blocked.length === objects.length, `可上图 ${renderable.length} / 不上图 ${blocked.length} / 输入 ${objects.length}`, ) check( 'C2-blocked-keeps-identity', blocked.map((item) => item.code).join(',') === 'BAD-1,BAD-2', '被拦下的对象仍带编码与名称(列表要用),不是被丢掉的黑洞', ) const entries = blockedEntries(objects) check( 'C3-entries-carry-reason', entries.length === 2 && entries[0].reasonText === '零坐标(经纬度均为 0)' && entries[1].reasonText === '坐标未经校验', JSON.stringify(entries.map((item) => `${item.code}:${item.reasonText}`)), ) check('C4-empty-input-safe', splitByCoordinate(null).blocked.length === 0 && splitByCoordinate(undefined).renderable.length === 0, '空输入不抛错') } // ================================================================ D 两个口径分开 { // 后端说全量 20 个、可上图 12、非法 5、未校验 3;前端只加载了 4 个(3 好 1 坏)。 const backend = toCoordinateSummary({ crs: 'WGS84', totalObjectCount: 20, renderableCount: 12, invalidCount: 5, unverifiedCount: 3 }) const loaded = [ object({ code: 'L-1' }), object({ code: 'L-2' }), object({ code: 'L-3' }), object({ code: 'L-4', coordinate: { state: 'INVALID', reason: 'ZERO_COORDINATE', crs: 'WGS84', renderable: false, validated: true } }), ] const overview = coordinateOverview(loaded, backend) check( 'D1-two-calibers-kept-apart', overview.backend.total === 20 && overview.backend.renderable === 12 && overview.loaded.total === 4 && overview.loaded.renderable === 3 && overview.loaded.blocked === 1, `后端 ${overview.backend.renderable}/${overview.backend.total} · 已加载 ${overview.loaded.renderable}/${overview.loaded.total}`, ) check( 'D2-texts-name-their-subject', overview.backendText.includes('全量') && overview.backendText.includes('12/20') && overview.loadedText.includes('已加载'), `${overview.backendText} | ${overview.loadedText}`, ) check( 'D3-blocked-entry-listed', overview.blockedEntries.length === 1 && overview.blockedEntries[0].code === 'L-4', JSON.stringify(overview.blockedEntries), ) // 后端没给汇总时:不补 0(0 会被读成"一个坏坐标都没有"),如实说没有。 const absent = coordinateOverview(loaded, toCoordinateSummary(null)) check( 'D4-missing-summary-not-zero-faked', absent.backend.available === false && absent.backend.total === null && coordinateSummaryText(absent.backend).includes('未取到'), coordinateSummaryText(absent.backend), ) // 后端汇总字段缺失时同样不补 0。 const partial = toCoordinateSummary({ crs: 'WGS84', totalObjectCount: 7 }) check( 'D5-null-counts-stay-null', partial.available === true && partial.total === 7 && partial.renderable === null && partial.invalid === null, JSON.stringify(partial), ) } // ================================================================ E 汇总进领域批次(票 05 的漏读) { const batch = toMapBatch({ page: 1, pageSize: 2, snapshotAt: '2026-09-02T10:00:00+08:00', total: 5, complete: false, monitoringPoints: [ { pointCode: 'MP-1', pointName: '一', longitude: 110.1, latitude: 28.4, coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' } }, ], unassociatedEquipment: [ { deviceCode: 'EQ-1', deviceName: '二', longitude: 181, latitude: 28.4, coordinateValidation: { state: 'INVALID', reason: 'LONGITUDE_OUT_OF_RANGE', renderable: false, crs: 'WGS84' } }, ], coordinateValidation: { crs: 'WGS84', totalObjectCount: 5, renderableCount: 2, invalidCount: 2, unverifiedCount: 1 }, }) check( 'E1-batch-carries-backend-summary', batch.coordinateSummary.available === true && batch.coordinateSummary.total === 5 && batch.coordinateSummary.invalid === 2 && batch.coordinateSummary.unverified === 1, JSON.stringify(batch.coordinateSummary), ) // 跨页合并:汇总是全量口径,取最新一次响应,**不得累加**(累加会变成"页数 × 全量")。 const first = mergeMapPage(null, batch) const second = mergeMapPage(first, { ...batch, page: 2, coordinateSummary: batch.coordinateSummary }) check( 'E2-no-accumulation-across-pages', second.coordinateSummary.total === 5 && second.coordinateSummary.renderable === 2, `两页之后 total=${second.coordinateSummary.total}(不是 10)`, ) // 后端没给汇总的页不会把已拿到的汇总清掉。 const third = mergeMapPage(second, { page: 3, objects: [], coordinateSummary: toCoordinateSummary(null) }) check('E3-absent-summary-keeps-last', third.coordinateSummary.total === 5, `total=${third.coordinateSummary.total}`) // 空态的初值也有这个字段(界面不必判 undefined)。 check('E4-empty-state-has-field', mergeMapPage(null, { page: 1, objects: [] }).coordinateSummary.available === false, '初值 available=false') } // ================================================================ F mock 三桶分法照抄后端 { resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE }, handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest }, }) const first = await fetchMapSnapshot({ page: 1, pageSize: MAP_SUMMARY_PAGE_SIZE, config: mockConfig }) const summary = first.data.coordinateSummary // mock 的五条样例:2 个 VALID、1 个 UNVERIFIED、2 个 INVALID(超范围 + 零坐标)。 check( 'F1-mock-summary-three-buckets', summary.total === 5 && summary.renderable === 2 && summary.invalid === 2 && summary.unverified === 1 && summary.crs === 'WGS84', JSON.stringify(summary), ) // 全量口径不随页码变化(后端是按整份快照算完再切片)。 const second = await fetchMapSnapshot({ page: 2, pageSize: MAP_SUMMARY_PAGE_SIZE, snapshotAt: first.data.snapshotAt, config: mockConfig, }) check( 'F2-summary-stable-across-pages', second.data.coordinateSummary.total === summary.total && second.data.coordinateSummary.renderable === summary.renderable, `第 2 页 total=${second.data.coordinateSummary.total}`, ) // 已加载口径随翻页增长,与后端全量口径**本来就不等**——这正是要分开报的原因。 const merged = mergeMapPage(mergeMapPage(null, first.data), second.data) const overview = coordinateOverview(merged.objects, merged.coordinateSummary) check( 'F3-loaded-differs-from-backend', overview.backend.renderable === 2 && overview.loaded.total === 4 && overview.loaded.renderable === 2 && overview.loaded.blocked === 2, `后端可上图 ${overview.backend.renderable}/5 · 已加载 ${overview.loaded.renderable}/${overview.loaded.total}(无地图位置 ${overview.loaded.blocked})`, ) // 空样例:0 是真实值(不是缺失)。 // 注意这里读**原始载荷**:`empty` 在票 05 的词汇里是没有领域载荷的(`data: null`), // 所以 `fetchMapSnapshot` 看不到汇总段,得直接问统一出口要后端回的那一份。 resetMockTransportsForTest() registerCockpitMocks({ scenario: 'empty', handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest } }) const emptyRaw = await requestCockpitJson('/map-snapshot?page=1&pageSize=2', { config: mockConfig }) const emptySummary = toCoordinateSummary(emptyRaw.data && emptyRaw.data.coordinateValidation) check( 'F4-empty-scenario-zeroes', emptyRaw.ok === true && emptySummary.available === true && emptySummary.total === 0 && emptySummary.invalid === 0, JSON.stringify(emptySummary), ) // 字段缺失样例:没给 coordinateValidation 的对象落进「未校验」桶(与后端同一分法)。 resetMockTransportsForTest() registerCockpitMocks({ scenario: 'degraded', handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest } }) const degraded = await fetchMapSnapshot({ page: 1, pageSize: MAP_SUMMARY_PAGE_SIZE, config: mockConfig }) check( 'F5-degraded-counts-unverified', degraded.data.coordinateSummary.total === 1 && degraded.data.coordinateSummary.unverified === 1 && degraded.data.coordinateSummary.renderable === 0, JSON.stringify(degraded.data.coordinateSummary), ) } // ================================================================ G store 供货(组件不自己算) { setActivePinia(createPinia()) resetMockTransportsForTest() registerCockpitMocks({ scenario: 'success', mapSnapshot: { pageSize: MAP_SUMMARY_PAGE_SIZE }, handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest }, }) const mapStore = useMapStore() mapStore.reset() mapStore.setup({ config: mockConfig }) await mapStore.advance() const afterFirst = mapStore.coordinate await mapStore.advance() const afterSecond = mapStore.coordinate check( 'G1-store-supplies-overview', afterFirst.loaded.total === 2 && afterFirst.loaded.renderable === 2 && afterFirst.loaded.blocked === 0, JSON.stringify(afterFirst.loaded), ) check( 'G2-overview-grows-with-pages', afterSecond.loaded.total === 4 && afterSecond.loaded.renderable === 2 && afterSecond.loaded.blocked === 2 && afterSecond.blockedEntries.length === 2, `${afterSecond.loadedText} | ${JSON.stringify(afterSecond.blockedEntries.map((item) => item.reasonText))}`, ) check( 'G3-backend-caliber-unchanged', afterSecond.backend.total === 5 && afterSecond.backend.renderable === 2, afterSecond.backendText, ) } // ================================================================ H 「已上图」措辞不再骗人 { const blocked = object({ code: 'BAD', coordinate: { state: 'INVALID', reason: 'ZERO_COORDINATE', crs: 'WGS84', renderable: false, validated: true } }) const state = { phase: 'complete', loaded: 2, total: 2, objects: [object({ code: 'OK' }), blocked] } const view = summaryView(state) check( 'H1-complete-label-separates-blocked', view.label.includes('已上图 1 个') && view.label.includes('无地图位置 1 个'), view.label, ) // 没有对象可判时(纯文案单测的老用法)保持票 08 的原措辞,不凭空造数。 const legacy = summaryView({ phase: 'complete', loaded: 7, total: 7 }) check('H2-legacy-usage-unchanged', legacy.label.includes('已上图 7 个') && !legacy.label.includes('无地图位置'), legacy.label) // 终态措辞不得把被拦下的对象算进「已上图」。 const allBlocked = summaryView({ phase: 'complete', loaded: 2, total: 2, objects: [blocked, object({ code: 'BAD2', coordinate: { state: 'INVALID', reason: 'ZERO_COORDINATE', crs: 'WGS84', renderable: false, validated: true } })] }) check('H3-all-blocked-reports-zero', allBlocked.label.includes('已上图 0 个') && allBlocked.label.includes('无地图位置 2 个'), allBlocked.label) } // ================================================================ I 静态守卫 { const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url) /** * 读一个模块并**去掉注释**再断言。 * 守卫必须看代码而不是说明文字:本模块的注释里就写着「为什么连 `state === 'VALID'` 都不作为判据」 * 这句反例(第一版守卫正是被自己的注释判红的),拿注释当证据等于没守。 */ const stripComments = (text) => text .replace(/\/\*[\s\S]*?\*\//g, ' ') // 行注释;`://` 里的双斜杠是 URL,别误伤。 .replace(/(^|[^:])\/\/.*$/gm, '$1') const read = (relative) => stripComments(readFileSync(new URL(relative, moduleRoot), 'utf8')) // 票 07 验收第 4 条:前端不自动纠偏。落在代码上就是"没有任何地方再查一遍经纬度范围"。 // 查数值边界比较(180 / 90 与经纬度变量的比较)—— 判定只许来自后端 renderable。 const RANGE_PATTERN = /(longitude|latitude|lon|lat)\s*[<>]=?\s*-?(90|180)\b|-?(90|180)\s*[<>]=?\s*(longitude|latitude|lon|lat)\b/i const domainFiles = ['domain/coordinate.js', 'domain/mapSummary.js', 'domain/fields.js', 'domain/pointDetail.js'] const rechecked = domainFiles.filter((file) => RANGE_PATTERN.test(read(file))) check( 'I1-no-frontend-range-recheck', rechecked.length === 0, rechecked.length ? `出现前端范围重算:${rechecked.join(', ')}` : '领域层零范围重算,准入只认后端结论', ) // 准入判据只有一处:只许与 renderable 比较,不许出现 `state === 'VALID'` 当第二套判据。 const coordinateText = read('domain/coordinate.js') check( 'I2-single-admission-path', /coordinate\.renderable === true/.test(coordinateText) && !/state === ['"]VALID['"]/.test(coordinateText), '准入只看 renderable,没有第二套 state 判据', ) // 界面不得自己判坐标:只许读 store 算好的数,不许调判据函数、不许碰后端校验段。 const UI_JUDGES = /coordinateValidation|isRenderable\(|blockReason\(|splitByCoordinate\(|\.renderable\s*===/ const uiFiles = [ 'components/MapCanvas.vue', 'components/MapCoordinateBlocked.vue', 'CockpitShell.vue', ] const uiJudging = uiFiles.filter((file) => UI_JUDGES.test(read(file))) check( 'I3-ui-does-not-judge', uiJudging.length === 0, uiJudging.length ? `界面自己判坐标:${uiJudging.join(', ')}` : '三个界面文件零坐标判据,全部取自 store', ) // 内部原因码不上界面:清单组件只渲染领域层给的中文文案字段。 const listText = read('components/MapCoordinateBlocked.vue') const rawCodes = Object.keys(COORDINATE_REASON_TEXT).filter((code) => listText.includes(code)) check('I4-no-reason-code-in-list-ui', rawCodes.length === 0, rawCodes.length ? JSON.stringify(rawCodes) : '清单组件零后端原因码') // 「保留在列表里」的可达性:清单组件确实渲染了原因文案与对象名(不是只有计数)。 check( 'I5-list-renders-reason-and-name', /item\.reasonText/.test(listText) && /item\.name/.test(listText) && /blockedEntries/.test(coordinateText), '清单渲染对象名与中文原因', ) // 后端全量口径只许经领域层转换,界面与 store 不出现后端裸字段名(票 05 的 D1 同一条纪律)。 const backendField = /totalObjectCount|renderableCount|invalidCount|unverifiedCount/ const leaked = ['stores/mapStore.js', 'components/MapCanvas.vue', 'components/MapCoordinateBlocked.vue'].filter((file) => backendField.test(read(file)), ) check( 'I6-backend-summary-fields-confined', leaked.length === 0, leaked.length ? `裸字段泄漏:${leaked.join(', ')}` : '后端汇总字段只出现在 domain/coordinate.js 与 mock', ) } const failed = checks.filter((item) => !item.pass) console.log('') console.log(`T07 逻辑检查:${checks.length - failed.length}/${checks.length} 项通过。`) if (failed.length) { console.log('未通过:' + failed.map((item) => item.name).join(', ')) process.exit(1) }