import { describe, expect, test } from "bun:test"; import { type CalculatedDeadline, bucketDeadlinesIntoColumns, deadlineCardHtml, } from "./verfahrensablauf-core"; // Regression tests for the editable→click-to-edit wiring on timeline date // cells (m/paliad#59). When CardOpts.editable=true the card renderer must // emit `class="… frist-date-edit"` with `data-rule-code` + `data-current- // date` on the date span. Pages then attach a delegated click handler that // resolves that selector to swap in an inline ``. If a // future refactor drops the attrs, /tools/verfahrensablauf and // /tools/fristenrechner both silently lose click-to-edit (no script error, // nothing happens on click). These tests pin the contract. // // Fixture leaves ruleRef/legalSource* empty so deadlineCardHtml stays // inside its non-DOM code paths (escHtml is DOM-backed and bun test runs // in plain Node without jsdom). const dl = (overrides: Partial = {}): CalculatedDeadline => ({ code: "upc-rop-12", name: "Klageerwiderung", nameEN: "Statement of Defence", party: "defendant", priority: "mandatory", ruleRef: "", dueDate: "2026-07-15", originalDate: "2026-07-15", wasAdjusted: false, isRootEvent: false, isCourtSet: false, ...overrides, }); describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => { test("date span carries frist-date-edit class + data-rule-code + data-current-date", () => { const html = deadlineCardHtml(dl(), { showParty: true, editable: true }); expect(html).toContain('class="timeline-date frist-date-edit"'); expect(html).toContain('data-rule-code="upc-rop-12"'); expect(html).toContain('data-current-date="2026-07-15"'); expect(html).toContain('role="button"'); expect(html).toContain('tabindex="0"'); }); test("editable=false (default) emits the date span without click-to-edit attrs", () => { const html = deadlineCardHtml(dl(), { showParty: true }); expect(html).toContain("timeline-date"); expect(html).not.toContain("data-rule-code="); expect(html).not.toContain('role="button"'); }); test("root event suppresses editable even when editable=true (root has no override semantic)", () => { const html = deadlineCardHtml(dl({ isRootEvent: true }), { showParty: true, editable: true }); expect(html).not.toContain("data-rule-code="); }); test("isCourtSet renders the court-set placeholder with click-to-edit so users can pin a real date", () => { const html = deadlineCardHtml(dl({ isCourtSet: true }), { showParty: true, editable: true }); expect(html).toContain("timeline-court-set frist-date-edit"); expect(html).toContain('data-rule-code="upc-rop-12"'); }); test("empty rule code with editable=true still suppresses click-to-edit (no anchor target)", () => { const html = deadlineCardHtml(dl({ code: "" }), { showParty: true, editable: true }); expect(html).not.toContain("data-rule-code="); }); }); // t-paliad-290 (m/paliad#122): the "Ausgeblendete anzeigen" toggle // surfaces hidden cards via UIDeadline.IsHidden=true. The renderer // must (a) emit an inline "Wieder einblenden" chip carrying the // submission_code (so the delegated handler in event-card-choices.ts // can resolve which skip to clear) and (b) NOT emit the chip when // either isHidden is false or the rule has no submission_code (no // hide target to undo). describe("deadlineCardHtml — isHidden inline 'Wieder einblenden' chip (t-paliad-290)", () => { test("isHidden=true with submission_code emits unhide chip with data-submission-code", () => { const html = deadlineCardHtml(dl({ isHidden: true }), { showParty: true }); expect(html).toContain("event-card-choices-unhide"); expect(html).toContain('data-submission-code="upc-rop-12"'); }); test("isHidden=false (default) suppresses unhide chip", () => { const html = deadlineCardHtml(dl(), { showParty: true }); expect(html).not.toContain("event-card-choices-unhide"); }); test("isHidden=true on a rule with no submission_code suppresses unhide chip", () => { const html = deadlineCardHtml(dl({ code: "", isHidden: true }), { showParty: true }); expect(html).not.toContain("event-card-choices-unhide"); }); }); // Pure column-routing behaviour. Originally pinned by m/paliad#81 // (side + appellant axes), re-framed by m/paliad#88: the column // axis is now "Unsere Seite vs Gegnerseite" ("WE always on the // left") instead of the misleading Proaktiv/Reaktiv pair. // Hits bucketDeadlinesIntoColumns directly so the assertions stay // in pure-Node territory (renderColumnsBody goes through escHtml -> // document.createElement which isn't available in plain bun test). // // Scenario fixture mirrors the UPC Appeal "both parties" case m // pasted into #81: every filing rule carries party='both' so the // legacy mirror path duplicates every row across both columns. // With ?appellant= set, the duplicate must collapse to a single // row in the appellant's column. describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81, #88)", () => { const both = (name: string, due: string): CalculatedDeadline => ({ code: name, name, nameEN: name, party: "both", priority: "mandatory", ruleRef: "", dueDate: due, originalDate: due, wasAdjusted: false, isRootEvent: false, isCourtSet: false, }); const partySpecific = (party: string, name: string, due: string): CalculatedDeadline => ({ ...both(name, due), party, }); test("default (no opts) mirrors 'both' rules into ours AND opponent — legacy behaviour preserved", () => { const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]); expect(rows).toHaveLength(1); expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]); expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]); expect(rows[0].court).toHaveLength(0); }); test("default (no side) places claimant on the left (ours) — 'we are claimant' fallback", () => { const rows = bucketDeadlinesIntoColumns([ partySpecific("claimant", "Klageschrift", "2026-01-01"), partySpecific("defendant", "Klageerwiderung", "2026-04-01"), ]); expect(rows[0].ours.map((d) => d.name)).toEqual(["Klageschrift"]); expect(rows[1].opponent.map((d) => d.name)).toEqual(["Klageerwiderung"]); }); test("appellant=claimant collapses 'both' rules into ours when side=claimant (or default)", () => { const rows = bucketDeadlinesIntoColumns( [both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")], { appellant: "claimant" }, ); expect(rows.map((r) => r.ours.map((d) => d.name))).toEqual([ ["Notice of Appeal"], ["Statement of Grounds"], ]); rows.forEach((r) => expect(r.opponent).toHaveLength(0)); }); test("appellant=defendant collapses 'both' rules into opponent when side=null/claimant", () => { const rows = bucketDeadlinesIntoColumns( [both("Notice of Appeal", "2026-07-23")], { appellant: "defendant" }, ); expect(rows[0].ours).toHaveLength(0); expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]); }); test("side=defendant flips which party owns 'ours' vs 'opponent' — WE always on the left", () => { // User is on the defendant side: defendant filings land in 'ours' // (left), claimant filings land in 'opponent' (right). Court rules // stay in court regardless of side. const rows = bucketDeadlinesIntoColumns( [ partySpecific("claimant", "Klageschrift", "2026-01-01"), partySpecific("defendant", "Klageerwiderung", "2026-04-01"), partySpecific("court", "Urteil", "2026-10-01"), ], { side: "defendant" }, ); expect(rows[0].opponent.map((d) => d.name)).toEqual(["Klageschrift"]); expect(rows[1].ours.map((d) => d.name)).toEqual(["Klageerwiderung"]); expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]); }); test("side=defendant + appellant=defendant routes 'both' into 'ours' (user's own column)", () => { // The user is the defendant AND the appellant, so the appellant's // column == the user's own column == ours after the swap. const rows = bucketDeadlinesIntoColumns( [both("Notice of Appeal", "2026-07-23")], { side: "defendant", appellant: "defendant" }, ); expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]); expect(rows[0].opponent).toHaveLength(0); }); test("side=defendant + appellant=claimant routes 'both' into opponent (claimant ≠ us)", () => { // Side flip + appellant axis combined: the claimant is the appellant // but NOT us, so the collapsed 'both' row lands in the opponent // column (right). This is the UPC Appeal "they appealed, we // respond" scenario. const rows = bucketDeadlinesIntoColumns( [both("Notice of Appeal", "2026-07-23")], { side: "defendant", appellant: "claimant" }, ); expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]); expect(rows[0].ours).toHaveLength(0); }); test("rows align across columns by dueDate so same-day events stay on one grid row", () => { const sameDate = "2026-07-23"; const rows = bucketDeadlinesIntoColumns([ partySpecific("claimant", "A", sameDate), partySpecific("defendant", "B", sameDate), partySpecific("court", "C", sameDate), ]); expect(rows).toHaveLength(1); expect(rows[0].ours.map((d) => d.name)).toEqual(["A"]); expect(rows[0].opponent.map((d) => d.name)).toEqual(["B"]); expect(rows[0].court.map((d) => d.name)).toEqual(["C"]); }); test("appellantContext overrides the page-level appellant for descendants (t-paliad-265)", () => { // A per-decision pick stamps AppellantContext on descendants of // that decision. The bucketer prefers it over the page-level // appellant: if a "both" row carries appellantContext='defendant', // it collapses to defendant's column regardless of the global // appellant opt. const dl: CalculatedDeadline = { ...both("Notice of Appeal", "2026-07-23"), appellantContext: "defendant", }; const rows = bucketDeadlinesIntoColumns([dl], { appellant: "claimant" }); expect(rows[0].ours).toHaveLength(0); expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]); }); test("appellantContext='claimant' + side='defendant' lands the row in opponent (claimant ≠ us)", () => { // The user is on the defendant side; per-card pick says the // claimant appealed. The "both" row collapses to the claimant's // column, which after the side-swap is opponent (right). const dl: CalculatedDeadline = { ...both("Notice of Appeal", "2026-07-23"), appellantContext: "claimant", }; const rows = bucketDeadlinesIntoColumns([dl], { side: "defendant", appellant: "defendant" }); expect(rows[0].ours).toHaveLength(0); expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]); }); test("appellantContext='both' or 'none' falls back to page-level mirror (t-paliad-265)", () => { // 'both' and 'none' aren't side-collapse values — they're // statements about who appealed but don't pick a column. The // bucketer treats them as no override, so the page-level // appellant (or default mirror) applies. const both1: CalculatedDeadline = { ...both("Notice of Appeal", "2026-07-23"), appellantContext: "both", }; const rowsBoth = bucketDeadlinesIntoColumns([both1]); expect(rowsBoth[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]); expect(rowsBoth[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]); }); test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => { const rows = bucketDeadlinesIntoColumns([ partySpecific("court", "Oral Hearing", ""), partySpecific("claimant", "Statement of Claim", "2026-01-01"), partySpecific("court", "Decision", ""), ]); expect(rows.map((r) => [r.ours, r.court, r.opponent].flat().map((d) => d.name))).toEqual([ ["Statement of Claim"], ["Oral Hearing"], ["Decision"], ]); }); });