Files
plugin-legal-ai/src/sync-run-summary.test.ts
Chaim Marcus 237d829844 feat(sync): מוני-ריצה מובחנים ל-sync-case-status (legal-ai #617)
הג'וב סיים עד כה בשורה אחת — `casesChecked` — שאינה מבחינה בין "אין מה
לעשות", "נדחה בכוונה" ו"נפל". 13,872 ריצות דיווחו בריאות ירוקה, ובפועל
היו בהן 389 כתיבות בלבד; האבחנה ב-#604 נבנתה על היעדר-ראיה.

כל ריצה פולטת מעתה: scanned · matched · written · declined{no_linked_issues,
no_writable_root, ambiguous_writable_roots, unknown_status, already_matching}.
הפליטה יושבת ב-`finally` וזורקת את השגיאה המקורית הלאה, כך שריצה שנפלה
נשארת `failed` ב-`plugin_job_runs` **וגם** נושאת את המונים שהספיקה לצבור.

שני משטחים קיימים וצורכים, שנמדדו:
- `ctx.logger.info` → stdout של pm2 — שם בוצע בפועל האבחון של #626/#637.
- `ctx.metrics.write` → `plugin_logs` (level='metric') — הטבלה שפאנל
  "Recent Logs" בדף-הפלאגין מרנדר, וניתנת לשאילתת-SQL (AC4).

המונים בתוך מחרוזת-ההודעה ולא רק ב-meta, כי שני המשטחים מתעלמים מ-meta
בפועל (הפאנל מרנדר createdAt/level/message בלבד; ומ-pino נמדד ששדה
`{error:…}` נופל). נדרשה הרשאת `metrics.write` ב-manifest — זו הדרך
היחידה לכתוב ל-`plugin_logs`.

אפס שינוי בהתנהגות-הכתיבה: `sync-target.ts` לא נגע כלל, ובכלל זה
`NON_WRITABLE_STATUSES` (הכרעת-יו"ר, #626).

Closes ezer-mishpati/legal-ai#617

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 16:51:48 +03:00

293 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// <reference types="node" />
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<typeof reason, "ok">);
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} `));
});