/// import assert from "node:assert/strict"; import { test } from "node:test"; // Excluded from tsc (tsconfig.json) — never bundled/emitted, run natively via // `node --test`, which requires the literal `.ts` extension (Node's ESM // resolver does not remap `.js` specifiers to `.ts` files at runtime). import type { StatusModelEntry } from "./legal-api.ts"; import { declinedTotal, formatSyncRunSummary, MAX_SUMMARY_LENGTH, newSyncRunCounters, recordDecline, SYNC_RUN_SUMMARY_PREFIX, type SyncRunCounters, syncRunSummaryTags, } from "./sync-run-summary.ts"; import { pickSyncTargetIssue, resolveIssueStatus, type SyncCandidate, } from "./sync-target.ts"; test("AC2: שלוש המחרוזות (ריק, נדחה, נכשל) שונות זו מזו", () => { const empty = newSyncRunCounters(); const emptyLine = formatSyncRunSummary(empty, { outcome: "ok" }); const declined = newSyncRunCounters(); declined.scanned = 12; // matched נשאר 0 — 12 התיקים נדחו ב-`no_writable_root`, כלומר // `pickSyncTargetIssue` לא בחר להם יעד כלל. מצב עקבי עם האינvariant. for (let i = 0; i < 12; i++) recordDecline(declined, "no_writable_root"); const declinedLine = formatSyncRunSummary(declined, { outcome: "ok" }); const failed = newSyncRunCounters(); failed.scanned = 3; const failedLine = formatSyncRunSummary(failed, { outcome: "failed", error: "boom", }); assert.notEqual(emptyLine, declinedLine); assert.notEqual(emptyLine, failedLine); assert.notEqual(declinedLine, failedLine); // כל השלוש כתובות=0 assert.match(emptyLine, /written=0/); assert.match(declinedLine, /written=0/); assert.match(failedLine, /written=0/); assert.match(emptyLine, /run=ok scanned=0 matched=0 written=0 declined=0/); assert.match( declinedLine, /run=ok scanned=12 matched=0 written=0 declined=12 \[no_linked_issues=0 no_writable_root=12 ambiguous_writable_roots=0 unknown_status=0 already_matching=0\]/, ); assert.match(failedLine, /run=failed scanned=3.*error=boom/); }); test("מיפוי SyncTargetReason → דלי: כל reason מעלה את הדלי הנכון בלבד", () => { const noLinked = newSyncRunCounters(); recordDecline(noLinked, "no_linked_issues"); assert.equal(noLinked.declined.no_linked_issues, 1); assert.equal(declinedTotal(noLinked), 1); const noRoot = newSyncRunCounters(); recordDecline(noRoot, "no_writable_root"); assert.equal(noRoot.declined.no_writable_root, 1); assert.equal(declinedTotal(noRoot), 1); const ambiguous = newSyncRunCounters(); recordDecline(ambiguous, "ambiguous_writable_roots"); assert.equal(ambiguous.declined.ambiguous_writable_roots, 1); assert.equal(declinedTotal(ambiguous), 1); // כל אחד מהשלושה לא נגע בדליים האחרים. for (const c of [noLinked, noRoot, ambiguous]) { const total = c.declined.no_linked_issues + c.declined.no_writable_root + c.declined.ambiguous_writable_roots + c.declined.unknown_status + c.declined.already_matching; assert.equal(total, 1); } }); /** * פיקסצ'ר מצומצם של `/api/status-model` — מספיק כדי ש-`resolveIssueStatus` * יחזיר todo/in_progress/done/null. */ const STATUS_MODEL: StatusModelEntry[] = [ { key: "new", label: "חדש", description: "", phase: "intake", selectable: true, terminal: false, on_enter: null, }, { key: "drafted", label: "טיוטה מוכנה", description: "", phase: "drafting", selectable: true, terminal: false, on_enter: null, }, { key: "final", label: "סופי", description: "", phase: "final", selectable: true, terminal: true, on_enter: null, }, ]; interface SimulatedCase { case_number: string; status: string; candidates: readonly SyncCandidate[]; } /** * מחקה את לולאת `worker.ts` (sync-case-status) — אותה סדרת-החלטות בדיוק, * באמצעות `pickSyncTargetIssue`/`resolveIssueStatus` האמיתיים. יש תקדים * לחיקוי-לולאה כזה ב-`sync-target.test.ts` (`legacyTargets`). */ function simulateSyncRun(cases: readonly SimulatedCase[]): SyncRunCounters { const counters = newSyncRunCounters(); counters.scanned = cases.length; for (const legalCase of cases) { const targetStatus = resolveIssueStatus(STATUS_MODEL, legalCase.status); if (targetStatus === null) { recordDecline(counters, "unknown_status"); continue; } const { target, reason } = pickSyncTargetIssue(legalCase.candidates); if (!target) { // reason כאן הוא SyncTargetReason שאינו "ok" (target===null) — // תת-קבוצה מובטחת-מהדר של SyncDeclineReason (ראה sync-run-summary.ts). recordDecline(counters, reason as Exclude); continue; } counters.matched++; if (target.status === targetStatus) { recordDecline(counters, "already_matching"); continue; } counters.written++; } return counters; } test("אינvariant: matched === written + declined.already_matching, ו-scanned מתפרק במלואו", () => { const cases: SimulatedCase[] = [ // scanned, unknown_status { case_number: "c-unknown", status: "totally_unknown", candidates: [] }, // no_linked_issues { case_number: "c-nolink", status: "new", candidates: [] }, // no_writable_root { case_number: "c-noroot", status: "new", candidates: [ { id: "i-1", status: "blocked", parentId: null, companyId: "c1" }, ], }, // ambiguous_writable_roots { case_number: "c-ambiguous", status: "new", candidates: [ { id: "i-2", status: "todo", parentId: null, companyId: "c1" }, { id: "i-3", status: "in_progress", parentId: null, companyId: "c1" }, ], }, // matched + already_matching (target.status === targetStatus === todo) { case_number: "c-matching", status: "new", candidates: [ { id: "i-4", status: "todo", parentId: null, companyId: "c1" }, ], }, // matched + written (target.status !== targetStatus) { case_number: "c-written", status: "final", candidates: [ { id: "i-5", status: "in_progress", parentId: null, companyId: "c1" }, ], }, ]; const counters = simulateSyncRun(cases); assert.equal(counters.scanned, 6); assert.equal(counters.matched, 2); // c-matching + c-written assert.equal(counters.written, 1); // c-written בלבד assert.equal(counters.declined.already_matching, 1); // c-matching assert.equal( counters.matched, counters.written + counters.declined.already_matching, ); assert.equal( counters.scanned, counters.matched + counters.declined.no_linked_issues + counters.declined.no_writable_root + counters.declined.ambiguous_writable_roots + counters.declined.unknown_status, ); assert.equal(counters.declined.unknown_status, 1); assert.equal(counters.declined.no_linked_issues, 1); assert.equal(counters.declined.no_writable_root, 1); assert.equal(counters.declined.ambiguous_writable_roots, 1); }); test("חסם-אורך: שגיאה בת 5000 תווים לא שוברת את מבנה השורה", () => { const counters = newSyncRunCounters(); counters.scanned = 42; counters.matched = 3; counters.written = 1; recordDecline(counters, "already_matching"); recordDecline(counters, "already_matching"); const longError = "x".repeat(5000); const line = formatSyncRunSummary(counters, { outcome: "failed", error: longError, }); assert.ok(line.length <= MAX_SUMMARY_LENGTH); assert.match(line, /scanned=42/); assert.match(line, /written=1/); assert.match(line, /already_matching=2/); assert.ok(line.endsWith("…")); }); test("tags: כל הערכים מחרוזות, וכל תשעת המונים + outcome נוכחים", () => { const counters = newSyncRunCounters(); counters.scanned = 5; counters.matched = 2; counters.written = 1; recordDecline(counters, "no_linked_issues"); const tags = syncRunSummaryTags(counters, { outcome: "ok" }); for (const value of Object.values(tags)) { assert.equal(typeof value, "string"); } const expectedKeys = [ "outcome", "scanned", "matched", "written", "declined", "no_linked_issues", "no_writable_root", "ambiguous_writable_roots", "unknown_status", "already_matching", ]; for (const key of expectedKeys) { assert.ok(key in tags, `missing tag key: ${key}`); } assert.equal(Object.keys(tags).length, expectedKeys.length); }); test("קידומת יציבה: כל שורה מתחילה ב-SYNC_RUN_SUMMARY_PREFIX", () => { const counters = newSyncRunCounters(); const okLine = formatSyncRunSummary(counters, { outcome: "ok" }); const failedLine = formatSyncRunSummary(counters, { outcome: "failed", error: "x", }); assert.ok(okLine.startsWith(`${SYNC_RUN_SUMMARY_PREFIX} `)); assert.ok(failedLine.startsWith(`${SYNC_RUN_SUMMARY_PREFIX} `)); });