This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
// 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">')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user