Files
template-app/modules/documents/__tests__/ui.test.tsx
T

244 lines
8.7 KiB
TypeScript

// The components' markup, rendered on the server the way a page renders it: what a screen reader and
// a keyboard user get is in the HTML, so the accessibility basics are asserted there -- a name for
// every control, a caption and scoped headers for every table, a status that says what a colour would.
import { buildRegistry } from "@conusai-kit/core"
import { TransportProvider as KitTransport } from "@conusai-kit/ui"
import { renderToStaticMarkup } from "react-dom/server"
import { describe, expect, it } from "vitest"
import { appModules } from "../../index.js"
import type { DocumentView, LineView } from "../contract/index.js"
import {
DocumentList,
ExportButton,
exportLabels,
LineReview,
listLabels,
reviewLabels,
TransportProvider,
UploadForm,
uploadLabels,
} from "../ui/index.js"
const en = buildRegistry([...appModules]).catalogues.en?.documents ?? {}
const t = (key: string, values?: Record<string, string | number>) => {
let text = en[key as keyof typeof en] ?? `?${key}`
for (const [name, value] of Object.entries(values ?? {})) text = text.replace(`{${name}}`, String(value))
return text
}
const slugs = { org: "acme", workspace: "ws", project: "p" }
const shared = { failed: "That did not work: {detail}", aiDisclosure: "AI-generated content", pageWord: "Page" }
const line = (over: Partial<LineView> = {}): LineView => ({
lineId: "l1",
expectedVersion: 1,
status: "pending",
label: "Reference",
value: "QR-1",
page: 1,
quote: "Reference: QR-1",
box: { x: 0.1, y: 0.2, width: 0.5, height: 0.03 },
confidence: 0.97,
edited: false,
verification: "verified",
note: null,
blockId: null,
model: "fixture+gateway",
promptVersion: "extract-lines.v2",
decidedAt: null,
...over,
})
const inProvider = (node: React.ReactNode) =>
renderToStaticMarkup(
<TransportProvider call={async () => undefined} describe={() => "x"}>
{node}
</TransportProvider>,
)
const review = (lines: LineView[]) =>
inProvider(<LineReview slugs={slugs} fileId="f1" lines={lines} labels={reviewLabels(t, shared)} locale="en" />)
describe("the review table", () => {
const html = review([
line(),
line({ lineId: "l2", label: "Total", status: "approved" }),
line({ lineId: "l3", label: "Date", status: "rejected" }),
])
it("tells a reader the content is machine-proposed, in text", () => {
expect(html).toContain("AI-generated content")
expect(html).toContain("Proposed by fixture+gateway extract-lines.v2")
})
it("is a table with a caption and a scoped header for every column", () => {
expect(html).toContain("<caption>Extracted lines</caption>")
expect(html.match(/<th scope="col">/g)).toHaveLength(7)
expect(html).toContain('<th scope="row">Reference</th>')
})
it("names each action by the line it acts on, so a list of buttons is not a list of 'Approve'", () => {
for (const name of ["Approve Reference", "Edit Reference", "Reject Reference"]) expect(html).toContain(name)
})
it("offers no action on a line that has been decided, and says what was decided in words", () => {
expect(html).not.toContain("Approve Total")
expect(html).not.toContain("Reject Date")
for (const word of ["To review", "Approved", "Rejected"]) expect(html).toContain(word)
})
it("shows the source of every line, labelled with its page", () => {
expect(html).toContain('aria-label="Source, page 1"')
expect(html).toContain("Reference: QR-1")
})
it("approves every verified pending line at once, only while there is one", () => {
expect(html).toContain("Approve all verified lines")
expect(review([line({ status: "approved" })])).not.toContain("Approve all verified lines")
})
it("marks a line whose quote was not found, and does not offer it to the bulk approval", () => {
const flagged = review([line({ verification: "needs_review" })])
expect(flagged).toContain("Needs review: the quoted words were not found on the page.")
expect(flagged).not.toContain("Approve all verified lines")
expect(html).not.toContain("Needs review")
})
it("says a quote read off a scanned page must be checked against the image, instead of the plain note", () => {
const flagged = review([line({ verification: "needs_review", note: "scanned_quote" })])
expect(flagged).toContain("comes from a scanned page")
expect(flagged).toContain("Check it against the page image.")
expect(flagged).not.toContain("were not found on the page")
})
it("names the reader's block beside the source when it gave one", () => {
expect(review([line({ blockId: "p3-b2" })])).toContain("Block p3-b2")
expect(review([line()])).not.toContain("Block ")
})
it("says so when there is nothing to review", () => {
expect(review([])).toContain("No lines were proposed for this document.")
})
})
describe("the document list", () => {
const documents: DocumentView[] = [
{
fileId: "f1",
filename: "report.pdf",
status: "extracted",
failureCode: null,
pageCount: 2,
skippedPages: [],
pending: 3,
approved: 1,
uploadedAt: "2026-10-19T09:00:00.000Z",
},
{
fileId: "f2",
filename: "scan.png",
status: "failed",
failureCode: "ocr_failed",
pageCount: null,
skippedPages: [],
pending: 0,
approved: 0,
uploadedAt: "2026-10-19T09:05:00.000Z",
},
{
fileId: "f3",
filename: "new.pdf",
status: "queued",
failureCode: null,
pageCount: null,
skippedPages: [],
pending: 0,
approved: 0,
uploadedAt: "2026-10-19T09:06:00.000Z",
},
]
const html = renderToStaticMarkup(
<DocumentList
documents={documents}
hrefFor={(id) => `/d/${id}`}
downloadHrefFor={(id) => `/api/v1/files.file.download?fileId=${id}`}
labels={listLabels(t)}
formatDate={(iso) => iso.slice(0, 10)}
/>,
)
it("links each document by a name that says which one", () => {
expect(html).toContain('href="/d/f1"')
expect(html).toContain('aria-label="Review report.pdf"')
expect(html).toContain('href="/api/v1/files.file.download?fileId=f1"')
expect(html).toContain('aria-label="Download report.pdf"')
})
it("writes each state and each failure as text", () => {
expect(html).toContain("Ready for review")
expect(html).toContain("Could not be read The reader could not process this file.")
expect(html).toContain("Waiting to be read")
})
it("is a captioned table, or a sentence when there is nothing to list", () => {
expect(html).toContain("<caption>Documents</caption>")
expect(
renderToStaticMarkup(
<DocumentList
documents={[]}
hrefFor={() => ""}
downloadHrefFor={() => ""}
labels={listLabels(t)}
formatDate={() => ""}
/>,
),
).toContain("No documents yet.")
})
})
describe("the export button", () => {
const render = (approved: number) =>
inProvider(<ExportButton slugs={slugs} fileId="f1" approved={approved} labels={exportLabels(t, shared.failed)} />)
it("is disabled with nothing approved, and the reason is the text its description points at", () => {
const html = render(0)
expect(html).toMatch(/<button[^>]*disabled=""[^>]*aria-describedby="([^"]+)"/)
const id = /aria-describedby="([^"]+)"/.exec(html)?.[1] ?? ""
expect(html).toContain(`id="${id}">Approve at least one line to export.`)
})
it("is enabled once a line is approved, and says only approved lines are exported", () => {
const html = render(2)
expect(html).not.toContain('disabled=""')
expect(html).toContain("Only approved lines are exported.")
})
})
describe("the upload form", () => {
// The kit's provider, not the module's: the form uploads through the kit's `uploadFile`, which
// asks the transport the app provides for a way to send a body.
const html = renderToStaticMarkup(
<KitTransport call={async () => undefined} describe={() => "x"}>
<UploadForm slugs={slugs} accept={["application/pdf"]} labels={uploadLabels(t, shared.failed)} />
</KitTransport>,
)
it("labels its file input and describes it with the hint", () => {
// React writes an input's attributes in its own order (`type` first), so the pattern does not fix one.
const id = /<input [^>]*\bid="([^"]+)"[^>]*\bname="file"/.exec(html)?.[1]
expect(id).toBeTruthy()
expect(html).toContain(`<label for="${id}">Upload a document</label>`)
expect(html).toContain("aria-describedby=")
expect(html).toContain("PDF, PNG or JPEG, up to 50 MB.")
})
it("asks the browser for the types the module reads and requires a file", () => {
expect(html).toContain('accept="application/pdf"')
expect(html).toContain('required=""')
})
it("announces its outcome in a status region", () => {
expect(html).toContain('<p role="status">')
})
})