244 lines
8.7 KiB
TypeScript
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">')
|
|
})
|
|
})
|