|
24 | 24 | * pins do not read positions across groups, so a reorder alone cannot turn |
25 | 25 | * them red — and a dropped notice cannot hide behind a correct order. |
26 | 26 | * |
| 27 | + * ## The pairing pins (PAIR) |
| 28 | + * |
| 29 | + * Where a semantic entry declares in `conversionIds` that it judges a |
| 30 | + * conversion's applied edits, ② prints that entry's headline beside those |
| 31 | + * edits, marked review: once under each run of the conversion's edits, counting |
| 32 | + * them. It is a copy, never a move: ③ still prints the entry, and ③'s lines and |
| 33 | + * count stay the chain's. Declared links only, never prose: the pins enumerate |
| 34 | + * the links off the registry and replay each linked conversion's own fixture, |
| 35 | + * so a link the spec lane authors later is covered on the day it lands. |
| 36 | + * |
27 | 37 | * ## Why in-process, over a real chain run |
28 | 38 | * |
29 | 39 | * The inputs are not fabricated: the stack below is replayed through the real |
30 | 40 | * `applyMetaMigrations` and parsed by the real `ObjectStackDefinitionSchema`, |
31 | 41 | * exactly as the command does, and `printMigrationReport` is the function the |
32 | 42 | * command's text face calls with them. Spawning the CLI would add a process |
33 | | - * and a config load and pin nothing more about the order. |
| 43 | + * and a config load and pin nothing more about the order. One pin adds a link |
| 44 | + * to a real result, and says so: the spec allows a link to a conversion an |
| 45 | + * EARLIER step replays, and none is authored yet. |
34 | 46 | */ |
35 | 47 |
|
36 | 48 | import { stripVTControlCharacters } from 'node:util'; |
37 | 49 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
38 | | -import { ObjectStackDefinitionSchema, formatZodIssue, normalizeStackInput } from '@objectstack/spec'; |
| 50 | +import { ALL_CONVERSIONS, ObjectStackDefinitionSchema, formatZodIssue, normalizeStackInput } from '@objectstack/spec'; |
39 | 51 | import { |
40 | 52 | applyMetaMigrations, |
| 53 | + MIGRATIONS_BY_MAJOR, |
41 | 54 | MIGRATION_MAJORS, |
42 | 55 | MIGRATION_SUPPORT_FLOOR, |
43 | 56 | type MigrationChainResult, |
| 57 | + type MigrationTodo, |
44 | 58 | } from '@objectstack/spec/migrations'; |
45 | 59 | import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; |
46 | 60 | import { printMigrationReport, type MigrationReport } from './meta.js'; |
@@ -102,10 +116,19 @@ afterEach(() => { |
102 | 116 | vi.restoreAllMocks(); |
103 | 117 | }); |
104 | 118 |
|
105 | | -/** Replay the chain the way the command does, print the report, return its lines. */ |
106 | | -function run(stack: Record<string, unknown>, fromMajor: number, toMajor: number): Run { |
| 119 | +/** |
| 120 | + * Replay the chain the way the command does, print the report, return its lines. |
| 121 | + * `amend` edits the chain's result before it is printed — one pin uses it, to |
| 122 | + * add a link no registry entry declares yet. |
| 123 | + */ |
| 124 | +function run( |
| 125 | + stack: Record<string, unknown>, |
| 126 | + fromMajor: number, |
| 127 | + toMajor: number, |
| 128 | + amend: (result: MigrationChainResult) => MigrationChainResult = (r) => r, |
| 129 | +): Run { |
107 | 130 | const normalized = normalizeStackInput(stack, { convert: false }); |
108 | | - const result = applyMetaMigrations(normalized, fromMajor, toMajor); |
| 131 | + const result = amend(applyMetaMigrations(normalized, fromMajor, toMajor)); |
109 | 132 | const parsed = ObjectStackDefinitionSchema.safeParse(result.stack); |
110 | 133 | const report: MigrationReport = { |
111 | 134 | result, |
@@ -257,3 +280,216 @@ describe('no notice, edit or refusal is dropped, merged or reworded (SET)', () = |
257 | 280 | expect(lines.filter((l) => l.trim() !== '').sort()).toEqual(accounted.sort()); |
258 | 281 | }); |
259 | 282 | }); |
| 283 | + |
| 284 | +/** |
| 285 | + * Two flows, each with an edge-branched decision carrying two conditioned |
| 286 | + * out-edges and no `mode` — the shape `flow-decision-mode-inclusive-explicit` |
| 287 | + * rewrites, twice — and a dashboard `refreshInterval`, whose conversion no |
| 288 | + * semantic entry names. |
| 289 | + */ |
| 290 | +const decisionFlow = (name: string) => ({ |
| 291 | + name, |
| 292 | + label: name, |
| 293 | + type: 'autolaunched', |
| 294 | + status: 'active', |
| 295 | + nodes: [ |
| 296 | + { id: 'start', type: 'start', label: 'Start' }, |
| 297 | + { id: 'verdict', type: 'decision', label: 'Verdict?' }, |
| 298 | + { id: 'refuse', type: 'end', label: 'Refuse' }, |
| 299 | + { id: 'convert', type: 'end', label: 'Convert' }, |
| 300 | + ], |
| 301 | + edges: [ |
| 302 | + { id: 'e1', source: 'start', target: 'verdict' }, |
| 303 | + { id: 'e2', source: 'verdict', target: 'refuse', condition: "lead.status != 'confirmed'" }, |
| 304 | + { id: 'e3', source: 'verdict', target: 'convert', condition: "lead.status == 'confirmed'" }, |
| 305 | + ], |
| 306 | +}); |
| 307 | +const DECISION_STACK = { |
| 308 | + manifest: { id: 'com.example.report-pairing', name: 'Report Pairing', version: '1.0.0', type: 'app' }, |
| 309 | + objects: [{ name: 'rp_lead', label: 'Lead', fields: { status: { type: 'text', label: 'Status' } } }], |
| 310 | + flows: [decisionFlow('rp_lead_verdict'), decisionFlow('rp_lead_recheck')], |
| 311 | + dashboards: [{ name: 'rp_kpi', label: 'KPI', widgets: [], refreshInterval: 300 }], |
| 312 | +}; |
| 313 | + |
| 314 | +/** One `time` default with a `Z` — a single edit by `time-default-utc-suffix-dropped`. */ |
| 315 | +const TIME_STACK = { |
| 316 | + manifest: { id: 'com.example.report-pairing-time', name: 'Report Pairing Time', version: '1.0.0', type: 'app' }, |
| 317 | + objects: [{ |
| 318 | + name: 'rp_shift', |
| 319 | + label: 'Shift', |
| 320 | + fields: { starts_at: { type: 'time', label: 'Starts at', defaultValue: '09:00Z' } }, |
| 321 | + }], |
| 322 | +}; |
| 323 | + |
| 324 | +const DECISION_CONVERSION = 'flow-decision-mode-inclusive-explicit'; |
| 325 | +const DECISION_JUDGE = 'flow-decision-edge-branching-first-match'; |
| 326 | +const TIME_CONVERSION = 'time-default-utc-suffix-dropped'; |
| 327 | +const TIME_JUDGE = 'time-default-zone-refused'; |
| 328 | + |
| 329 | +const REVIEW_RE = /^ {6}↳ review /; |
| 330 | + |
| 331 | +/** The review line a judge prints under `edits` edits — written from the entry's data, not from the printer. */ |
| 332 | +function reviewLine(judge: MigrationTodo, edits: number): string { |
| 333 | + const subject = edits === 1 ? 'the edit above' : `the ${edits} edits above`; |
| 334 | + return ` ↳ review ${subject} against the manual change [protocol ${judge.toMajor}] ${judge.surface} → ${judge.replacement}`; |
| 335 | +} |
| 336 | + |
| 337 | +/** Every link the registry declares: a semantic entry, and a conversion id it names. */ |
| 338 | +function declaredLinks(): Array<{ toMajor: number; entry: string; conversionId: string }> { |
| 339 | + return MIGRATION_MAJORS.flatMap((m) => |
| 340 | + MIGRATIONS_BY_MAJOR[m]!.semantic.flatMap((s) => |
| 341 | + (s.conversionIds ?? []).map((conversionId) => ({ toMajor: m, entry: s.id, conversionId })), |
| 342 | + ), |
| 343 | + ); |
| 344 | +} |
| 345 | + |
| 346 | +function todoOf(result: MigrationChainResult, id: string): MigrationTodo { |
| 347 | + const todo = result.todos.find((t) => t.id === id); |
| 348 | + expect(todo, `the chain reports the semantic entry ${id}`).toBeDefined(); |
| 349 | + return todo!; |
| 350 | +} |
| 351 | + |
| 352 | +/** The index of the last line of the first run of edits by `conversionId`, and the run's length. */ |
| 353 | +function runOf(lines: string[], conversionId: string): { last: number; length: number } { |
| 354 | + const isEdit = (l: string | undefined) => l !== undefined && l.startsWith(' • ') && l.endsWith(` (${conversionId})`); |
| 355 | + const first = lines.findIndex((l) => isEdit(l)); |
| 356 | + expect(first, `an applied edit by ${conversionId} is printed`).toBeGreaterThan(-1); |
| 357 | + let last = first; |
| 358 | + while (isEdit(lines[last + 1])) last += 1; |
| 359 | + return { last, length: last - first + 1 }; |
| 360 | +} |
| 361 | + |
| 362 | +/** The review lines printed directly under line `i`. */ |
| 363 | +function reviewsUnder(lines: string[], i: number): string[] { |
| 364 | + const out: string[] = []; |
| 365 | + for (let j = i + 1; j < lines.length && REVIEW_RE.test(lines[j]!); j += 1) out.push(lines[j]!); |
| 366 | + return out; |
| 367 | +} |
| 368 | + |
| 369 | +describe('an applied edit a semantic entry judges prints that entry beside it, marked review (PAIR)', () => { |
| 370 | + it('prints the judge once under the run of edits it judges, counting them', () => { |
| 371 | + const { result, lines } = run(DECISION_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); |
| 372 | + const edits = result.applied.filter((a) => a.conversionId === DECISION_CONVERSION); |
| 373 | + expect(edits, 'anti-vacuity: both decisions were rewritten').toHaveLength(2); |
| 374 | + const { last, length } = runOf(lines, DECISION_CONVERSION); |
| 375 | + expect(length).toBe(2); |
| 376 | + expect(reviewsUnder(lines, last)).toEqual([reviewLine(todoOf(result, DECISION_JUDGE), 2)]); |
| 377 | + // Beside the edit, i.e. inside ②: after its header, before ③'s. |
| 378 | + expect(last).toBeGreaterThan(indexOf(lines, APPLIED_HEADER_RE)); |
| 379 | + expect(last + 1).toBeLessThan(indexOf(lines, SEMANTIC_HEADER_RE)); |
| 380 | + }); |
| 381 | + |
| 382 | + it('says "the edit above" under a run of one', () => { |
| 383 | + const { result, lines } = run(TIME_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); |
| 384 | + const { last, length } = runOf(lines, TIME_CONVERSION); |
| 385 | + expect(length).toBe(1); |
| 386 | + expect(reviewsUnder(lines, last)).toEqual([reviewLine(todoOf(result, TIME_JUDGE), 1)]); |
| 387 | + }); |
| 388 | + |
| 389 | + it('prints an edit no entry judges exactly as before, with no review line under it', () => { |
| 390 | + const { result, lines } = run(DECISION_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); |
| 391 | + const linked = new Set(result.todos.flatMap((t) => t.conversionIds ?? [])); |
| 392 | + const unjudged = result.applied.filter((a) => !linked.has(a.conversionId)); |
| 393 | + expect(unjudged.length, 'anti-vacuity: the dashboard edit has no judge').toBeGreaterThan(0); |
| 394 | + for (const a of unjudged) { |
| 395 | + const i = lines.indexOf(` • ${a.path}: ${a.from} → ${a.to} (${a.conversionId})`); |
| 396 | + expect(i, `unjudged edit printed: ${a.path}`).toBeGreaterThan(-1); |
| 397 | + expect(reviewsUnder(lines, i), `no review line under ${a.path}`).toEqual([]); |
| 398 | + } |
| 399 | + // ②'s own lines are the chain's, byte-identical and in order, review lines aside. |
| 400 | + expect(lines.filter((l) => l.startsWith(' • '))).toEqual(appliedLines(result)); |
| 401 | + expect(lines.filter((l) => APPLIED_HEADER_RE.test(l))).toEqual([ |
| 402 | + ` Applied ${result.applied.length} mechanical change(s):`, |
| 403 | + ]); |
| 404 | + }); |
| 405 | + |
| 406 | + it('prints no review line at all where no applied conversion is linked', () => { |
| 407 | + const { result, lines } = run(FINDINGS_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); |
| 408 | + const linked = new Set(result.todos.flatMap((t) => t.conversionIds ?? [])); |
| 409 | + expect(linked.size, 'anti-vacuity: the chain does carry links').toBeGreaterThan(0); |
| 410 | + expect(result.applied.some((a) => linked.has(a.conversionId))).toBe(false); |
| 411 | + expect(lines.filter((l) => REVIEW_RE.test(l))).toEqual([]); |
| 412 | + }); |
| 413 | + |
| 414 | + it('keeps every semantic entry in ③ — the judge included — with the chain\'s count and bytes', () => { |
| 415 | + const { result, lines } = run(DECISION_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); |
| 416 | + const header = indexOf(lines, SEMANTIC_HEADER_RE); |
| 417 | + expect(lines[header]).toBe(` ${result.todos.length} manual change(s) require your judgment:`); |
| 418 | + const expected = noticeLines(result); |
| 419 | + expect(lines.slice(header + 1, header + 1 + expected.length)).toEqual(expected); |
| 420 | + const entries = lines.slice(header + 1).filter((l) => /^ {4}⚠ \[protocol \d+\] /.test(l)); |
| 421 | + expect(entries).toHaveLength(result.todos.length); |
| 422 | + const judge = todoOf(result, DECISION_JUDGE); |
| 423 | + expect(entries).toContain(` ⚠ [protocol ${judge.toMajor}] ${judge.surface} → ${judge.replacement}`); |
| 424 | + }); |
| 425 | + |
| 426 | + it('prints nothing but the groups and the review lines the declared links call for', () => { |
| 427 | + const { report, result, lines } = run(DECISION_STACK, MIGRATION_SUPPORT_FLOOR, TERMINUS); |
| 428 | + // Written without the printer's run logic: a real chain replays one |
| 429 | + // conversion at a time, so a conversion's edits are one run and its count |
| 430 | + // is the run's length. |
| 431 | + const runs = new Map<string, number>(); |
| 432 | + for (const [i, a] of result.applied.entries()) { |
| 433 | + if (i > 0 && result.applied[i - 1]!.conversionId !== a.conversionId) { |
| 434 | + expect(runs.has(a.conversionId), `${a.conversionId} edits are contiguous`).toBe(false); |
| 435 | + } |
| 436 | + runs.set(a.conversionId, (runs.get(a.conversionId) ?? 0) + 1); |
| 437 | + } |
| 438 | + const reviews = result.todos.flatMap((t) => |
| 439 | + (t.conversionIds ?? []).filter((id) => runs.has(id)).map((id) => reviewLine(t, runs.get(id)!)), |
| 440 | + ); |
| 441 | + expect(reviews.length, 'anti-vacuity: the stack exercises a link').toBeGreaterThan(0); |
| 442 | + const accounted = [ |
| 443 | + ...lines.filter((l) => VERDICT_RE.test(l) || APPLIED_HEADER_RE.test(l) || SEMANTIC_HEADER_RE.test(l)), |
| 444 | + ...refusalLines(report), |
| 445 | + ...appliedLines(result), |
| 446 | + ...reviews, |
| 447 | + ...noticeLines(result), |
| 448 | + ].filter((l) => l.trim() !== ''); |
| 449 | + expect(lines.filter((l) => l.trim() !== '').sort()).toEqual(accounted.sort()); |
| 450 | + }); |
| 451 | + |
| 452 | + it('pairs every link the registry declares, replayed over the linked conversion\'s own fixture', () => { |
| 453 | + const links = declaredLinks(); |
| 454 | + // Anti-vacuity, and the two links authored when the printer learned to pair. |
| 455 | + expect(links).toEqual(expect.arrayContaining([ |
| 456 | + expect.objectContaining({ entry: DECISION_JUDGE, conversionId: DECISION_CONVERSION }), |
| 457 | + expect.objectContaining({ entry: TIME_JUDGE, conversionId: TIME_CONVERSION }), |
| 458 | + ])); |
| 459 | + for (const link of links) { |
| 460 | + const conversion = ALL_CONVERSIONS.find((c) => c.id === link.conversionId); |
| 461 | + expect(conversion, `${link.entry} names a registered conversion`).toBeDefined(); |
| 462 | + printed = []; |
| 463 | + const { result, lines } = run( |
| 464 | + conversion!.fixture.before as Record<string, unknown>, |
| 465 | + MIGRATION_SUPPORT_FLOOR, |
| 466 | + TERMINUS, |
| 467 | + ); |
| 468 | + const { last, length } = runOf(lines, link.conversionId); |
| 469 | + expect( |
| 470 | + reviewsUnder(lines, last), |
| 471 | + `${link.entry} is printed beside the ${link.conversionId} edits`, |
| 472 | + ).toContain(reviewLine(todoOf(result, link.entry), length)); |
| 473 | + } |
| 474 | + }); |
| 475 | + |
| 476 | + it('pairs across hops: a link to a conversion an earlier step replays', () => { |
| 477 | + // No authored link crosses a hop yet, though `SemanticMigration.conversionIds` |
| 478 | + // allows one, so this pin adds it: a protocol-18 entry made to judge a |
| 479 | + // protocol-17 conversion. Everything else is the chain's own result. |
| 480 | + const earlier = 'action-execute-to-target'; |
| 481 | + const conversion = ALL_CONVERSIONS.find((c) => c.id === earlier)!; |
| 482 | + const judgeId = DECISION_JUDGE; |
| 483 | + const { result, lines } = run( |
| 484 | + conversion.fixture.before as Record<string, unknown>, |
| 485 | + MIGRATION_SUPPORT_FLOOR, |
| 486 | + TERMINUS, |
| 487 | + (r) => ({ ...r, todos: r.todos.map((t) => (t.id === judgeId ? { ...t, conversionIds: [earlier] } : t)) }), |
| 488 | + ); |
| 489 | + const judge = todoOf(result, judgeId); |
| 490 | + const edit = result.applied.find((a) => a.conversionId === earlier); |
| 491 | + expect(edit?.toMajor, 'anti-vacuity: the edit comes from an earlier hop').toBeLessThan(judge.toMajor); |
| 492 | + const { last, length } = runOf(lines, earlier); |
| 493 | + expect(reviewsUnder(lines, last)).toEqual([reviewLine(judge, length)]); |
| 494 | + }); |
| 495 | +}); |
0 commit comments