m/paliad#122. atlas's #96 Slice A added per-card 'Überspringen' but no un-skip path — hidden cards just disappeared from the timeline. This adds the missing return path: - CalcOptions.IncludeHidden (default false) tells the calculator to re-surface skipRules entries as faded rows instead of dropping them. When true, the rule renders with UIDeadline.IsHidden=true and the descendant-suppression cascade is bypassed so children compute their dates off the un-suppressed parent. - UIResponse.HiddenCount always reflects the projection's hide count (gate-passed rules whose submission_code is in skipRules) so the "Ausgeblendete (N)" badge stays accurate regardless of toggle state. - /tools/verfahrensablauf gets a "Ausgeblendete anzeigen" checkbox next to the perspective + appellant selectors. URL-driven (?show_hidden=1) so the state is shareable and survives reload. The row hides itself on projections with zero hidden cards. - Hidden cards render via .timeline-item--hidden / .fr-col-item--hidden (opacity 0.55 + dotted border, mirroring the existing --skipped fade) and carry an inline "Wieder einblenden" chip. Clicking the chip removes the skip choice via the page's existing attachEventCardChoices remove callback (URL state + recalc included) and runs through a new delegated handler in event-card-choices.ts. - 3 new i18n keys (DE+EN): choices.show_hidden.label, choices.show_hidden.count, choices.unhide.chip. The skip-choice storage shape (paliad.project_event_choices, atlas's table) is unchanged — un-hide is just a delete of the skip row. Tests: 3 new bun-test cases pin the chip contract (emits on isHidden= true with submission_code, suppressed otherwise); go test ./internal/... + bun run build clean.
274 lines
12 KiB
TypeScript
274 lines
12 KiB
TypeScript
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 `<input type="date">`. 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> = {}): 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"],
|
|
]);
|
|
});
|
|
});
|