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"],
]);
});
});