/** * 契约漂移检查(任务 A1.7)。 * * 三件事: * 1. 事件与 HTTP 样例必须符合 contracts/schemas 下的 JSON Schema * 2. 事件类型在 schema、TS 镜像、协议文档三处必须完全一致 * 3. 每种事件类型都要有样例,避免新增类型忘了配样例 */ import assert from 'node:assert/strict'; import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import Ajv2020 from 'ajv/dist/2020.js'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const SCHEMA_DIR = join(ROOT, 'contracts', 'schemas'); const EVENTS_SCHEMA_ID = 'https://aps.local/contracts/events.schema.json'; const readText = (rel) => readFile(join(ROOT, rel), 'utf8'); async function buildAjv() { const ajv = new Ajv2020({ allErrors: true, strict: false }); for (const name of await readdir(SCHEMA_DIR)) { if (!name.endsWith('.json')) continue; ajv.addSchema(JSON.parse(await readFile(join(SCHEMA_DIR, name), 'utf8'))); } return ajv; } const formatErrors = (errors) => (errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`).join('; '); test('事件样例符合 events.schema.json', async () => { const ajv = await buildAjv(); const validate = ajv.getSchema(EVENTS_SCHEMA_ID); assert.ok(validate, '未注册 events.schema.json'); const lines = (await readText('contracts/fixtures/events.jsonl')).split(/\r?\n/).filter((l) => l.trim()); assert.ok(lines.length >= 11, `样例太少:${lines.length}`); for (const line of lines) { const event = JSON.parse(line); assert.ok(validate(event), `${event.type} 样例不符合 schema:${formatErrors(validate.errors)}`); } }); test('HTTP 样例符合各自 schema', async () => { const ajv = await buildAjv(); const cases = JSON.parse(await readText('contracts/fixtures/http.json')); for (const [name, testCase] of Object.entries(cases)) { const schemaId = `https://aps.local/contracts/${testCase.schema}`; const validate = ajv.getSchema(schemaId); assert.ok(validate, `${name}:未注册 ${testCase.schema}`); assert.ok(validate(testCase.value), `${name} 样例不符合 ${testCase.schema}:${formatErrors(validate.errors)}`); } }); test('事件类型在 schema、TS 与协议文档三处一致', async () => { const schemaTypes = new Set( [...(await readText('contracts/schemas/events.schema.json')).matchAll(/"const":\s*"([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*)"/g)].map((m) => m[1]), ); const tsTypes = new Set([...(await readText('contracts/events.ts')).matchAll(/EventEnvelope<'([^']+)'/g)].map((m) => m[1])); const docTypes = new Set([...(await readText('docs/spec/protocol.md')).matchAll(/^\| `([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*)` \|/gm)].map((m) => m[1])); assert.ok(schemaTypes.size >= 11, `schema 里事件类型太少:${schemaTypes.size}`); const diff = (a, b, label) => [...a].filter((t) => !b.has(t)).map((t) => `${label}:${t}`); const problems = [ ...diff(schemaTypes, tsTypes, '只在 schema'), ...diff(tsTypes, schemaTypes, '只在 TS'), ...diff(schemaTypes, docTypes, '只在 schema(文档缺)'), ...diff(docTypes, schemaTypes, '只在文档'), ]; assert.deepEqual(problems, [], `事件类型不一致:\n${problems.join('\n')}`); }); test('每种事件类型都有样例', async () => { const fixtureTypes = new Set( (await readText('contracts/fixtures/events.jsonl')) .split(/\r?\n/) .filter((l) => l.trim()) .map((l) => JSON.parse(l).type), ); const schemaTypes = new Set( [...(await readText('contracts/schemas/events.schema.json')).matchAll(/"const":\s*"([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*)"/g)].map((m) => m[1]), ); const missing = [...schemaTypes].filter((t) => !fixtureTypes.has(t)); assert.deepEqual(missing, [], `以下事件类型缺样例:${missing.join('、')}`); });