173 lines
5.8 KiB
TypeScript
173 lines
5.8 KiB
TypeScript
|
|
/**
|
||
|
|
* HTML citation card renderer tests (CE-WP-0004-T03).
|
||
|
|
*
|
||
|
|
* Snapshots lock the output format defined in
|
||
|
|
* `docs/decisions/ADR-0007-citation-card-format.md`. Class names are
|
||
|
|
* part of the public contract — renaming any of them requires updating
|
||
|
|
* both this test and the ADR.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, expect, it } from "vitest";
|
||
|
|
|
||
|
|
import type { Annotation } from "@shared/annotation";
|
||
|
|
import type { Document } from "@shared/document";
|
||
|
|
import type { EvidenceItem } from "@shared/evidence";
|
||
|
|
import type {
|
||
|
|
AnnotationId,
|
||
|
|
DocumentId,
|
||
|
|
EvidenceItemId,
|
||
|
|
RepresentationId,
|
||
|
|
} from "@shared/ids";
|
||
|
|
|
||
|
|
import { renderCitationCardHtml } from "./html";
|
||
|
|
|
||
|
|
const DOC_ID = "doc_2024-order" as DocumentId;
|
||
|
|
const REP_ID = "rep_2024-order" as RepresentationId;
|
||
|
|
const ANN_ID = "ann_para3" as AnnotationId;
|
||
|
|
const EV_ID = "ev_para3" as EvidenceItemId;
|
||
|
|
|
||
|
|
function makeDoc(overrides: Partial<Document> = {}): Document {
|
||
|
|
return {
|
||
|
|
id: DOC_ID,
|
||
|
|
title: "Order from 14 Mar 2024",
|
||
|
|
mediaType: "application/pdf",
|
||
|
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||
|
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeAnn(overrides: Partial<Annotation> = {}): Annotation {
|
||
|
|
return {
|
||
|
|
id: ANN_ID,
|
||
|
|
documentId: DOC_ID,
|
||
|
|
representationId: REP_ID,
|
||
|
|
selectors: [],
|
||
|
|
quote: "Die Frist endet am 31. März 2024.",
|
||
|
|
normalizeVersion: 1,
|
||
|
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||
|
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeEv(overrides: Partial<EvidenceItem> = {}): EvidenceItem {
|
||
|
|
return {
|
||
|
|
id: EV_ID,
|
||
|
|
annotationIds: [ANN_ID],
|
||
|
|
status: "candidate",
|
||
|
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||
|
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("renderCitationCardHtml()", () => {
|
||
|
|
it("renders the full aside with quote, attribution, and commentary", () => {
|
||
|
|
const out = renderCitationCardHtml({
|
||
|
|
evidenceItem: makeEv({ commentary: "Deadline clause for the buyer." }),
|
||
|
|
document: makeDoc(),
|
||
|
|
annotation: makeAnn(),
|
||
|
|
});
|
||
|
|
expect(out).toMatchInlineSnapshot(`
|
||
|
|
"<aside class="citation-card">
|
||
|
|
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
|
||
|
|
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite> · <a class="citation-card__link" href="/viewer?document=doc_2024-order&annotation=ann_para3">Open source</a></p>
|
||
|
|
<div class="citation-card__commentary">Deadline clause for the buyer.</div>
|
||
|
|
</aside>
|
||
|
|
"
|
||
|
|
`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("omits the commentary div when none is set", () => {
|
||
|
|
const out = renderCitationCardHtml({
|
||
|
|
evidenceItem: makeEv(),
|
||
|
|
document: makeDoc(),
|
||
|
|
annotation: makeAnn(),
|
||
|
|
});
|
||
|
|
expect(out).toMatchInlineSnapshot(`
|
||
|
|
"<aside class="citation-card">
|
||
|
|
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
|
||
|
|
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite> · <a class="citation-card__link" href="/viewer?document=doc_2024-order&annotation=ann_para3">Open source</a></p>
|
||
|
|
</aside>
|
||
|
|
"
|
||
|
|
`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("converts \\n inside the quote to <br> for rich-text paste", () => {
|
||
|
|
const out = renderCitationCardHtml({
|
||
|
|
evidenceItem: makeEv(),
|
||
|
|
document: makeDoc(),
|
||
|
|
annotation: makeAnn({ quote: "Line one.\nLine two." }),
|
||
|
|
});
|
||
|
|
expect(out).toContain(
|
||
|
|
'<blockquote class="citation-card__quote">Line one.<br>Line two.</blockquote>',
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("HTML-escapes &, <, >, \", and ' in user-supplied text", () => {
|
||
|
|
const out = renderCitationCardHtml({
|
||
|
|
evidenceItem: makeEv({
|
||
|
|
commentary: `Notes: <script>alert("xss")</script> & 'untrusted'`,
|
||
|
|
}),
|
||
|
|
document: makeDoc({ title: `Order "draft" & more` }),
|
||
|
|
annotation: makeAnn({ quote: "<<value>> & 'inner'" }),
|
||
|
|
});
|
||
|
|
// Quote escaping
|
||
|
|
expect(out).toContain(
|
||
|
|
"<blockquote class=\"citation-card__quote\"><<value>> & 'inner'</blockquote>",
|
||
|
|
);
|
||
|
|
// Source label escaping
|
||
|
|
expect(out).toContain(
|
||
|
|
"<cite class=\"citation-card__source\">Order "draft" & more</cite>",
|
||
|
|
);
|
||
|
|
// Commentary escaping — and especially: no raw <script>
|
||
|
|
expect(out).not.toContain("<script>");
|
||
|
|
expect(out).toContain(
|
||
|
|
"<div class=\"citation-card__commentary\">Notes: <script>alert("xss")</script> & 'untrusted'</div>",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("falls back to metadata.filename when document.title is missing", () => {
|
||
|
|
const out = renderCitationCardHtml({
|
||
|
|
evidenceItem: makeEv(),
|
||
|
|
document: {
|
||
|
|
id: DOC_ID,
|
||
|
|
mediaType: "application/pdf",
|
||
|
|
createdAt: "2026-05-25T00:00:00.000Z",
|
||
|
|
updatedAt: "2026-05-25T00:00:00.000Z",
|
||
|
|
metadata: { filename: "order-2024.pdf" },
|
||
|
|
},
|
||
|
|
annotation: makeAnn(),
|
||
|
|
});
|
||
|
|
expect(out).toContain(
|
||
|
|
'<cite class="citation-card__source">order-2024.pdf</cite>',
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("drops the · separator and link when openContextUrlOverride is empty", () => {
|
||
|
|
const out = renderCitationCardHtml({
|
||
|
|
evidenceItem: makeEv(),
|
||
|
|
document: makeDoc(),
|
||
|
|
annotation: makeAnn(),
|
||
|
|
openContextUrlOverride: "",
|
||
|
|
});
|
||
|
|
expect(out).toMatchInlineSnapshot(`
|
||
|
|
"<aside class="citation-card">
|
||
|
|
<blockquote class="citation-card__quote">Die Frist endet am 31. März 2024.</blockquote>
|
||
|
|
<p class="citation-card__attribution"><cite class="citation-card__source">Order from 14 Mar 2024</cite></p>
|
||
|
|
</aside>
|
||
|
|
"
|
||
|
|
`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not emit inline style attributes (host page controls CSS)", () => {
|
||
|
|
const out = renderCitationCardHtml({
|
||
|
|
evidenceItem: makeEv({ commentary: "Some commentary." }),
|
||
|
|
document: makeDoc(),
|
||
|
|
annotation: makeAnn(),
|
||
|
|
});
|
||
|
|
expect(out).not.toMatch(/style=/);
|
||
|
|
});
|
||
|
|
});
|