This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
{
|
||||
"schema_version": "conusai.ocr.document/v1",
|
||||
"engine": {
|
||||
"name": "fixture-engine",
|
||||
"version": "1",
|
||||
"detection_model": null,
|
||||
"recognition_model": null
|
||||
},
|
||||
"page_count": 3,
|
||||
"pages": [
|
||||
{
|
||||
"index": 0,
|
||||
"width": 1000,
|
||||
"height": 1400,
|
||||
"unit": "px",
|
||||
"dpi": 150,
|
||||
"source": "text_layer",
|
||||
"rotation": 0,
|
||||
"text": "Quarterly report\nReference: QR-2026-04\nIssued on: 2026-04-01\nThis line has no label",
|
||||
"lines": [
|
||||
{
|
||||
"text": "Quarterly report",
|
||||
"bbox": [
|
||||
100,
|
||||
80,
|
||||
500,
|
||||
120
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.99,
|
||||
"order": 0,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "Reference: QR-2026-04",
|
||||
"bbox": [
|
||||
100,
|
||||
200,
|
||||
600,
|
||||
240
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.97,
|
||||
"order": 1,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "Issued on: 2026-04-01",
|
||||
"bbox": [
|
||||
100,
|
||||
260,
|
||||
600,
|
||||
300
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.95,
|
||||
"order": 2,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "This line has no label",
|
||||
"bbox": [
|
||||
100,
|
||||
320,
|
||||
700,
|
||||
360
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 3,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
}
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"id": "p1-b1",
|
||||
"bbox": [
|
||||
100,
|
||||
80,
|
||||
500,
|
||||
120
|
||||
],
|
||||
"order": 0,
|
||||
"lines": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p1-b2",
|
||||
"bbox": [
|
||||
100,
|
||||
200,
|
||||
600,
|
||||
240
|
||||
],
|
||||
"order": 1,
|
||||
"lines": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p1-b3",
|
||||
"bbox": [
|
||||
100,
|
||||
260,
|
||||
600,
|
||||
300
|
||||
],
|
||||
"order": 2,
|
||||
"lines": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p1-b4",
|
||||
"bbox": [
|
||||
100,
|
||||
320,
|
||||
700,
|
||||
360
|
||||
],
|
||||
"order": 3,
|
||||
"lines": [
|
||||
3
|
||||
]
|
||||
}
|
||||
],
|
||||
"class": "text"
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"width": 1000,
|
||||
"height": 1400,
|
||||
"unit": "px",
|
||||
"dpi": 150,
|
||||
"source": "ocr",
|
||||
"rotation": 0,
|
||||
"text": "Total amount: 1 250,00 EUR\nSigned by: A. Example\nA sentence that happens to contain a colon: and then runs on for far too many words to be a label at all",
|
||||
"lines": [
|
||||
{
|
||||
"text": "Total amount: 1 250,00 EUR",
|
||||
"bbox": [
|
||||
100,
|
||||
100,
|
||||
700,
|
||||
140
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.82,
|
||||
"order": 0,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "Signed by: A. Example",
|
||||
"bbox": [
|
||||
100,
|
||||
160,
|
||||
600,
|
||||
200
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.74,
|
||||
"order": 1,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "A sentence that happens to contain a colon: and then runs on for far too many words to be a label at all",
|
||||
"bbox": [
|
||||
100,
|
||||
220,
|
||||
900,
|
||||
260
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 2,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
}
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"id": "p2-b1",
|
||||
"bbox": [
|
||||
100,
|
||||
100,
|
||||
700,
|
||||
140
|
||||
],
|
||||
"order": 0,
|
||||
"lines": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p2-b2",
|
||||
"bbox": [
|
||||
100,
|
||||
160,
|
||||
600,
|
||||
200
|
||||
],
|
||||
"order": 1,
|
||||
"lines": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p2-b3",
|
||||
"bbox": [
|
||||
100,
|
||||
220,
|
||||
900,
|
||||
260
|
||||
],
|
||||
"order": 2,
|
||||
"lines": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"class": "scan"
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"width": 1000,
|
||||
"height": 1400,
|
||||
"unit": "px",
|
||||
"dpi": 150,
|
||||
"source": "text_layer",
|
||||
"rotation": 0,
|
||||
"class": "drawing",
|
||||
"text": "A-101 2400\nA-102 2410\nA-103 2420\nA-104 2430\nA-105 2440\nA-106 2450\nA-107 2460\nA-108 2470\nA-109 2480\nA-110 2490\nA-111 2500\nA-112 2510",
|
||||
"lines": [
|
||||
{
|
||||
"text": "A-101 2400",
|
||||
"bbox": [
|
||||
100,
|
||||
100,
|
||||
500,
|
||||
130
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 0,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "A-102 2410",
|
||||
"bbox": [
|
||||
100,
|
||||
140,
|
||||
500,
|
||||
170
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 1,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "A-103 2420",
|
||||
"bbox": [
|
||||
100,
|
||||
180,
|
||||
500,
|
||||
210
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 2,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "A-104 2430",
|
||||
"bbox": [
|
||||
100,
|
||||
220,
|
||||
500,
|
||||
250
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 3,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "A-105 2440",
|
||||
"bbox": [
|
||||
100,
|
||||
260,
|
||||
500,
|
||||
290
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 4,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "A-106 2450",
|
||||
"bbox": [
|
||||
100,
|
||||
300,
|
||||
500,
|
||||
330
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 5,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "A-107 2460",
|
||||
"bbox": [
|
||||
100,
|
||||
340,
|
||||
500,
|
||||
370
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 6,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "A-108 2470",
|
||||
"bbox": [
|
||||
100,
|
||||
380,
|
||||
500,
|
||||
410
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 7,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "A-109 2480",
|
||||
"bbox": [
|
||||
100,
|
||||
420,
|
||||
500,
|
||||
450
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 8,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "A-110 2490",
|
||||
"bbox": [
|
||||
100,
|
||||
460,
|
||||
500,
|
||||
490
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 9,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "A-111 2500",
|
||||
"bbox": [
|
||||
100,
|
||||
500,
|
||||
500,
|
||||
530
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 10,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
},
|
||||
{
|
||||
"text": "A-112 2510",
|
||||
"bbox": [
|
||||
100,
|
||||
540,
|
||||
500,
|
||||
570
|
||||
],
|
||||
"polygon": null,
|
||||
"confidence": 0.9,
|
||||
"order": 11,
|
||||
"words": [],
|
||||
"repeated": false
|
||||
}
|
||||
],
|
||||
"blocks": [
|
||||
{
|
||||
"id": "p3-b1",
|
||||
"bbox": [
|
||||
100,
|
||||
100,
|
||||
500,
|
||||
130
|
||||
],
|
||||
"order": 0,
|
||||
"lines": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p3-b2",
|
||||
"bbox": [
|
||||
100,
|
||||
140,
|
||||
500,
|
||||
170
|
||||
],
|
||||
"order": 1,
|
||||
"lines": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p3-b3",
|
||||
"bbox": [
|
||||
100,
|
||||
180,
|
||||
500,
|
||||
210
|
||||
],
|
||||
"order": 2,
|
||||
"lines": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p3-b4",
|
||||
"bbox": [
|
||||
100,
|
||||
220,
|
||||
500,
|
||||
250
|
||||
],
|
||||
"order": 3,
|
||||
"lines": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p3-b5",
|
||||
"bbox": [
|
||||
100,
|
||||
260,
|
||||
500,
|
||||
290
|
||||
],
|
||||
"order": 4,
|
||||
"lines": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p3-b6",
|
||||
"bbox": [
|
||||
100,
|
||||
300,
|
||||
500,
|
||||
330
|
||||
],
|
||||
"order": 5,
|
||||
"lines": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p3-b7",
|
||||
"bbox": [
|
||||
100,
|
||||
340,
|
||||
500,
|
||||
370
|
||||
],
|
||||
"order": 6,
|
||||
"lines": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p3-b8",
|
||||
"bbox": [
|
||||
100,
|
||||
380,
|
||||
500,
|
||||
410
|
||||
],
|
||||
"order": 7,
|
||||
"lines": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p3-b9",
|
||||
"bbox": [
|
||||
100,
|
||||
420,
|
||||
500,
|
||||
450
|
||||
],
|
||||
"order": 8,
|
||||
"lines": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p3-b10",
|
||||
"bbox": [
|
||||
100,
|
||||
460,
|
||||
500,
|
||||
490
|
||||
],
|
||||
"order": 9,
|
||||
"lines": [
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p3-b11",
|
||||
"bbox": [
|
||||
100,
|
||||
500,
|
||||
500,
|
||||
530
|
||||
],
|
||||
"order": 10,
|
||||
"lines": [
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p3-b12",
|
||||
"bbox": [
|
||||
100,
|
||||
540,
|
||||
500,
|
||||
570
|
||||
],
|
||||
"order": 11,
|
||||
"lines": [
|
||||
11
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Every string a page of the module shows is a message in English and in Lithuanian (spec §10.4), the
|
||||
// Lithuanian plural forms are real ones, and no component of the module carries a string of its own.
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createI18n, lintUiStrings } from "@conusai-kit/i18n"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import { PAGE_CLASSES } from "../contract/constants.js"
|
||||
import { exportLabels, listLabels, reviewLabels, uploadLabels } from "../ui/labels.js"
|
||||
|
||||
// The messages as the app has them: from the manifest, flattened and merged by the kit.
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const en = catalogues.en?.documents ?? {}
|
||||
const lt = catalogues.lt?.documents ?? {}
|
||||
|
||||
/** The keys a builder asks for, recorded by running it with a lookup that notes each one. */
|
||||
function keysUsed(): string[] {
|
||||
const asked: string[] = []
|
||||
const t = (key: string) => {
|
||||
asked.push(key)
|
||||
return key
|
||||
}
|
||||
uploadLabels(t, "failed")
|
||||
listLabels(t)
|
||||
reviewLabels(t, { failed: "failed", aiDisclosure: "ai", pageWord: "page" })
|
||||
exportLabels(t, "failed")
|
||||
return asked
|
||||
}
|
||||
|
||||
describe("the documents catalogues", () => {
|
||||
it("hold the same keys in English and in Lithuanian", () => {
|
||||
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
|
||||
})
|
||||
|
||||
it("hold no empty message", () => {
|
||||
for (const [key, text] of [...Object.entries(en), ...Object.entries(lt)]) expect(text.trim(), key).not.toBe("")
|
||||
})
|
||||
|
||||
it("hold every message the components' labels ask for, in both languages", () => {
|
||||
const asked = keysUsed()
|
||||
expect(asked.length).toBeGreaterThan(30)
|
||||
for (const key of asked) {
|
||||
expect(en[key as keyof typeof en], `en is missing documents.${key}`).toBeTruthy()
|
||||
expect(lt[key as keyof typeof lt], `lt is missing documents.${key}`).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it("hold no message nothing asks for, so a renamed label does not leave a dead string behind", () => {
|
||||
// The pages ask for these directly (a heading, a sentence), not through a label builder.
|
||||
const direct = new Set([
|
||||
"title",
|
||||
"intro",
|
||||
"review.title",
|
||||
"review.back",
|
||||
"review.waiting",
|
||||
"review.pendingCount",
|
||||
"review.approvedCount",
|
||||
"review.skippedTitle",
|
||||
"review.skippedPage",
|
||||
...PAGE_CLASSES.map((pageClass) => `review.pageClass.${pageClass}`),
|
||||
])
|
||||
const asked = new Set([...keysUsed(), ...direct])
|
||||
expect(Object.keys(en).filter((key) => !asked.has(key))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("the Lithuanian plural forms", () => {
|
||||
const count = (locale: "en" | "lt", n: number) =>
|
||||
createI18n(locale, { en: { documents: en }, lt: { documents: lt } }).message("documents.review.pendingCount", {
|
||||
count: n,
|
||||
})
|
||||
|
||||
it("select one, few and other the way Lithuanian counts", () => {
|
||||
expect(count("lt", 1)).toBe("1 eilutė peržiūrai")
|
||||
expect(count("lt", 2)).toBe("2 eilutės peržiūrai")
|
||||
expect(count("lt", 9)).toBe("9 eilutės peržiūrai")
|
||||
expect(count("lt", 10)).toBe("10 eilučių peržiūrai")
|
||||
expect(count("lt", 21)).toBe("21 eilutė peržiūrai")
|
||||
})
|
||||
|
||||
it("keep English to its two forms", () => {
|
||||
expect(count("en", 1)).toBe("1 line to review")
|
||||
expect(count("en", 2)).toBe("2 lines to review")
|
||||
expect(lt["review.pendingCount"]).toContain("few {")
|
||||
expect(en["review.pendingCount"]).not.toContain("few {")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the components", () => {
|
||||
const dir = new URL("../ui/", import.meta.url).pathname
|
||||
const sources = readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".tsx"))
|
||||
.map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }))
|
||||
|
||||
it("carry no hard-coded user-facing string", () => {
|
||||
expect(sources.length).toBeGreaterThanOrEqual(4)
|
||||
for (const { name, text } of sources) expect(lintUiStrings(text, name), name).toEqual([])
|
||||
})
|
||||
|
||||
it("carry no colour literal: the brand tokens are the only source of colour", () => {
|
||||
for (const { name, text } of sources)
|
||||
expect(text, name).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\brgb\(|\bhsl\(|\boklch\(/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
// The extractor over a scripted ModelGateway: the mapping of the gateway's failures to the codes a
|
||||
// document records, and the retry each one gets. The whole path through the real gateway client is
|
||||
// proved by test/documents-extraction.test.ts.
|
||||
|
||||
import { AiError, type GenerateObjectOptions, type ModelGateway, ModelGatewayError } from "@conusai-kit/platform"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { modelLineExtractor } from "../adapters/model-extractor.js"
|
||||
import { ExtractionFailed, type ExtractionInput, type ReadDocument } from "../core/ports.js"
|
||||
import { noCache, type StageCache } from "../core/stage-cache.js"
|
||||
|
||||
const document: ReadDocument = {
|
||||
engine: { name: "fixture", version: "1" },
|
||||
page_count: 1,
|
||||
pages: [
|
||||
{
|
||||
index: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: "text_layer",
|
||||
text: "Reference: QR-1",
|
||||
class: "text",
|
||||
lines: [{ text: "Reference: QR-1", bbox: [10, 10, 60, 20], confidence: 0.9, order: 0, repeated: false }],
|
||||
blocks: [{ id: "p1-b1", bbox: [10, 10, 60, 20], order: 0, lines: [0] }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const input = (cache: StageCache = noCache): ExtractionInput => ({ document, pageImage: async () => undefined, cache })
|
||||
|
||||
/** A gateway that answers from a script, one entry per call: a value is the reply, an Error is thrown. */
|
||||
function scripted(...script: Array<unknown>): { gateway: ModelGateway; options: GenerateObjectOptions[] } {
|
||||
const options: GenerateObjectOptions[] = []
|
||||
const gateway: ModelGateway = {
|
||||
async generateObject(_schema, _messages, call) {
|
||||
options.push(call ?? {})
|
||||
const next = script.shift()
|
||||
if (next instanceof Error) throw next
|
||||
return next as never
|
||||
},
|
||||
}
|
||||
return { gateway, options }
|
||||
}
|
||||
|
||||
const good = { lines: [{ label: "Reference", value: "QR-1", quote: "Reference: QR-1" }] }
|
||||
const extractorOf = (gateway: ModelGateway) => modelLineExtractor({ gateway, model: "m" })
|
||||
|
||||
describe("the model extractor", () => {
|
||||
it("names the model and the prompt version, which every proposal records", () => {
|
||||
const extractor = extractorOf(scripted().gateway)
|
||||
expect(extractor.name).toBe("m")
|
||||
expect(extractor.version).toMatch(/^[a-z-]+\.v\d+$/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
["refusal", "extraction_refused", 2],
|
||||
["truncated", "extraction_truncated", 2],
|
||||
["empty", "extraction_invalid", 2],
|
||||
// a schema failure was already asked again by the gateway: the extractor does not ask a third time
|
||||
["schema", "extraction_invalid", 1],
|
||||
] as const)("a %s is asked again as the rules say, then ends as %s", async (kind, code, calls) => {
|
||||
const failure = () => new ModelGatewayError(kind, "x")
|
||||
const { gateway, options } = scripted(failure(), failure())
|
||||
await expect(extractorOf(gateway).extract(input())).rejects.toMatchObject({ code })
|
||||
expect(options).toHaveLength(calls)
|
||||
})
|
||||
|
||||
it("answers when the second attempt is good, giving a truncated reply twice the room", async () => {
|
||||
const { gateway, options } = scripted(new ModelGatewayError("truncated", "x"), good)
|
||||
const { lines } = await extractorOf(gateway).extract(input())
|
||||
expect(lines.map((line) => line.value)).toEqual(["QR-1"])
|
||||
expect(options.map((call) => call.maxTokens)).toEqual([4096, 8192])
|
||||
})
|
||||
|
||||
it("treats a request the gateway will never accept as the document's failure", async () => {
|
||||
const bad = () => new AiError("bad image", { status: 400 })
|
||||
const { gateway } = scripted(bad(), bad())
|
||||
await expect(extractorOf(gateway).extract(input())).rejects.toMatchObject({
|
||||
code: "extraction_invalid",
|
||||
name: ExtractionFailed.name,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
["a network error", new AiError("down", { code: "network_error" })],
|
||||
["a 503", new AiError("busy", { status: 503 })],
|
||||
["a missing token scope", new AiError("no", { status: 403 })],
|
||||
["a missing model", new ModelGatewayError("no_model", "x")],
|
||||
])("leaves %s to the queue, which retries the job", async (_name, error) => {
|
||||
const { gateway, options } = scripted(error)
|
||||
await expect(extractorOf(gateway).extract(input())).rejects.toBe(error)
|
||||
expect(options).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("reads a cached reply without asking the model", async () => {
|
||||
const stored = new Map<string, unknown>()
|
||||
const cache: StageCache = {
|
||||
async get(stage, key, schema) {
|
||||
const value = stored.get(`${stage}/${key}`)
|
||||
return value === undefined ? undefined : schema.parse(value)
|
||||
},
|
||||
async put(stage, key, value) {
|
||||
stored.set(`${stage}/${key}`, value)
|
||||
},
|
||||
}
|
||||
const { gateway, options } = scripted(good)
|
||||
await extractorOf(gateway).extract(input(cache))
|
||||
await extractorOf(gateway).extract(input(cache))
|
||||
expect(options).toHaveLength(1)
|
||||
// another model is another key
|
||||
const other = scripted(good)
|
||||
await modelLineExtractor({ gateway: other.gateway, model: "other" }).extract(input(cache))
|
||||
expect(other.options).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
// What the extraction does with a page by its class (ocr-service OCR3.15): a drawing and an empty page
|
||||
// are never asked about and are reported; a scan and a table are sent as image and text; a text page as
|
||||
// text; the lines that repeat on most pages are not in the prompt; and a verified quote names the
|
||||
// reader's block. The model is a recording gateway: what it was sent is what is asserted.
|
||||
|
||||
import type { ChatMessage, GenerateObjectOptions, ModelGateway } from "@conusai-kit/platform"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { LOW_TRUST_NOTE, modelLineExtractor, PAGE_TEXT_CLOSE, PAGE_TEXT_OPEN } from "../adapters/model-extractor.js"
|
||||
import type { PageClass } from "../contract/index.js"
|
||||
import { planPage, promptTextOf } from "../core/page-plan.js"
|
||||
import type { ExtractionInput, PageImage, ReadDocument, ReadPage } from "../core/ports.js"
|
||||
import { noCache } from "../core/stage-cache.js"
|
||||
|
||||
/** A row long enough that a page of it is not weak for want of text (MIN_PAGE_TEXT_CHARS). */
|
||||
const SHEET_ROW = "Reference: QR-2026-0417 (rev 2)"
|
||||
|
||||
const png: PageImage = { bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]), mediaType: "image/png" }
|
||||
|
||||
function page(index: number, pageClass: PageClass, rows: readonly string[], over: Partial<ReadPage> = {}): ReadPage {
|
||||
return {
|
||||
index,
|
||||
width: 1000,
|
||||
height: 1400,
|
||||
class: pageClass,
|
||||
source: pageClass === "text" ? "text_layer" : "ocr",
|
||||
text: rows.join("\n"),
|
||||
lines: rows.map((text, order) => ({
|
||||
text,
|
||||
bbox: [100, 100 + order * 40, 600, 130 + order * 40] as const,
|
||||
confidence: 0.97,
|
||||
order,
|
||||
repeated: false,
|
||||
})),
|
||||
blocks: rows.map((_, order) => ({
|
||||
id: `p${index + 1}-b${order + 1}`,
|
||||
bbox: [90, 90 + order * 40, 700, 140 + order * 40] as const,
|
||||
order,
|
||||
lines: [order],
|
||||
})),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const documentOf = (...pages: ReadPage[]): ReadDocument => ({
|
||||
engine: { name: "fixture", version: "1" },
|
||||
page_count: pages.length,
|
||||
pages,
|
||||
})
|
||||
|
||||
interface Sent {
|
||||
messages: ChatMessage[]
|
||||
options: GenerateObjectOptions
|
||||
}
|
||||
|
||||
/** A gateway that records every request and answers with the lines it is given for the page it sees. */
|
||||
function recording(answer: (text: string) => Array<{ label: string; value: string; quote: string }>) {
|
||||
const sent: Sent[] = []
|
||||
const gateway: ModelGateway = {
|
||||
async generateObject(_schema, messages, options) {
|
||||
sent.push({ messages: [...messages], options: options ?? {} })
|
||||
const user = messages.find((message) => message.role === "user")
|
||||
const parts = Array.isArray(user?.content) ? user.content : []
|
||||
const text = parts.map((part) => ("text" in part ? part.text : "")).join("\n")
|
||||
const from = text.indexOf(PAGE_TEXT_OPEN) + PAGE_TEXT_OPEN.length + 1
|
||||
const to = text.lastIndexOf(PAGE_TEXT_CLOSE) - 1
|
||||
return { lines: answer(text.slice(from, to)) } as never
|
||||
},
|
||||
}
|
||||
return { gateway, sent }
|
||||
}
|
||||
|
||||
const partsOf = (sent: Sent): Array<{ type: string; text?: string }> => {
|
||||
const user = sent.messages.find((message) => message.role === "user")
|
||||
return (Array.isArray(user?.content) ? user.content : []) as Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
const input = (document: ReadDocument, pageImage: ExtractionInput["pageImage"]): ExtractionInput => ({
|
||||
document,
|
||||
pageImage,
|
||||
cache: noCache,
|
||||
})
|
||||
|
||||
const reference = (text: string) =>
|
||||
text
|
||||
.split("\n")
|
||||
.filter((row) => row.includes(":"))
|
||||
.map((row) => ({ label: row.split(":")[0] as string, value: (row.split(":")[1] as string).trim(), quote: row }))
|
||||
|
||||
describe("the plan for a page", () => {
|
||||
const by = (pageClass: PageClass, over: Partial<ReadPage> = {}) =>
|
||||
planPage(page(0, pageClass, [SHEET_ROW], over), ["drawing", "empty"])
|
||||
|
||||
it("leaves out the classes it is told to, whatever else is true of the page", () => {
|
||||
expect(by("drawing")).toEqual({ kind: "skip" })
|
||||
expect(by("empty")).toEqual({ kind: "skip" })
|
||||
expect(planPage(page(0, "scan", [SHEET_ROW]), ["scan"])).toEqual({ kind: "skip" })
|
||||
})
|
||||
|
||||
it("shows a scan its image and says its text is a recognition; a table gets image and text", () => {
|
||||
expect(by("scan")).toEqual({ kind: "read", image: true, lowTrustText: true })
|
||||
expect(by("table")).toMatchObject({ kind: "read", image: true })
|
||||
})
|
||||
|
||||
it("reads a text page as text, with the image only when the reader was unsure of it", () => {
|
||||
expect(by("text")).toEqual({ kind: "read", image: false, lowTrustText: false })
|
||||
const unsure = page(0, "text", [SHEET_ROW])
|
||||
const weak = { ...unsure, lines: unsure.lines.map((line) => ({ ...line, confidence: 0.4 })) }
|
||||
expect(planPage(weak, ["drawing", "empty"])).toMatchObject({ kind: "read", image: true })
|
||||
})
|
||||
|
||||
it("reads a drawing when the deployment lets it, with its image", () => {
|
||||
expect(planPage(page(0, "drawing", ["A-101"]), [])).toMatchObject({ kind: "read", image: true })
|
||||
})
|
||||
|
||||
it("drops the lines flagged as repeating from the text the model is shown", () => {
|
||||
const rows = ["ACME Specification", "Reference: QR-1", "Page footer"]
|
||||
const shown = page(0, "text", rows)
|
||||
const lines = shown.lines.map((line, at) => ({ ...line, repeated: at !== 1 }))
|
||||
expect(promptTextOf({ ...shown, lines })).toBe("Reference: QR-1")
|
||||
expect(promptTextOf({ ...shown, lines: [] })).toBe(rows.join("\n"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("the extractor and the page classes", () => {
|
||||
it("asks nothing about a drawing or an empty page and reports them as skipped, in page order", async () => {
|
||||
const { gateway, sent } = recording(reference)
|
||||
const document = documentOf(
|
||||
page(0, "text", ["Reference: QR-1"]),
|
||||
page(
|
||||
1,
|
||||
"drawing",
|
||||
Array.from({ length: 400 }, (_, at) => `A-${at} 2400`),
|
||||
),
|
||||
page(2, "empty", []),
|
||||
)
|
||||
const result = await modelLineExtractor({ gateway, model: "m" }).extract(input(document, async () => png))
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(result.lines.map((line) => [line.page, line.value])).toEqual([[1, "QR-1"]])
|
||||
expect(result.skipped).toEqual([
|
||||
{ page: 2, pageClass: "drawing" },
|
||||
{ page: 3, pageClass: "empty" },
|
||||
])
|
||||
})
|
||||
|
||||
it("lets a deployment read drawings: the list of excluded classes is the option's", async () => {
|
||||
const { gateway, sent } = recording(reference)
|
||||
const document = documentOf(page(0, "drawing", ["Scale: 1:50"]))
|
||||
const result = await modelLineExtractor({ gateway, model: "m", excludedPageClasses: [] }).extract(
|
||||
input(document, async () => png),
|
||||
)
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(result.skipped).toEqual([])
|
||||
})
|
||||
|
||||
it("sends a scan as text and image, the text marked low trust; a text page as text alone", async () => {
|
||||
const { gateway, sent } = recording(reference)
|
||||
const asked: number[] = []
|
||||
const document = documentOf(page(0, "scan", ["Reference: QR-1"]), page(1, "text", [SHEET_ROW]))
|
||||
await modelLineExtractor({ gateway, model: "m" }).extract(
|
||||
input(document, async (index) => {
|
||||
asked.push(index)
|
||||
return png
|
||||
}),
|
||||
)
|
||||
expect(asked).toEqual([0])
|
||||
const kinds = sent.map((one) => partsOf(one).map((part) => part.type))
|
||||
expect(kinds).toContainEqual(["text", "image_url"])
|
||||
expect(kinds).toContainEqual(["text"])
|
||||
const scanText = partsOf(sent.find((one) => partsOf(one).length === 2) as Sent)[0]?.text
|
||||
expect(scanText).toContain(LOW_TRUST_NOTE)
|
||||
const textText = partsOf(sent.find((one) => partsOf(one).length === 1) as Sent)[0]?.text
|
||||
expect(textText).not.toContain(LOW_TRUST_NOTE)
|
||||
})
|
||||
|
||||
it("leaves a header that repeats on most pages out of the prompt, and still verifies against the page", async () => {
|
||||
const { gateway, sent } = recording(reference)
|
||||
const rows = ["ACME: Specification", "Reference: QR-1"]
|
||||
const withHeader = page(0, "text", rows)
|
||||
const document = documentOf({
|
||||
...withHeader,
|
||||
lines: withHeader.lines.map((line, at) => ({ ...line, repeated: at === 0 })),
|
||||
})
|
||||
const result = await modelLineExtractor({ gateway, model: "m" }).extract(input(document, async () => undefined))
|
||||
const text = partsOf(sent[0] as Sent)[0]?.text ?? ""
|
||||
expect(text).toContain("Reference: QR-1")
|
||||
expect(text).not.toContain("ACME")
|
||||
expect(result.lines.map((line) => [line.label, line.verification])).toEqual([["Reference", "verified"]])
|
||||
})
|
||||
|
||||
it("gives a verified line the block it sits in, and that block's box in place of the lines'", async () => {
|
||||
const { gateway } = recording(reference)
|
||||
const document = documentOf(page(0, "text", ["Reference: QR-1", "Issued: 2026-04-01"]))
|
||||
const { lines } = await modelLineExtractor({ gateway, model: "m" }).extract(input(document, async () => undefined))
|
||||
const second = lines.find((line) => line.label === "Issued")
|
||||
expect(second?.blockId).toBe("p1-b2")
|
||||
// block p1-b2 is [90, 130, 700, 180] on a 1000 x 1400 page, not the line's [100, 140, 600, 170]
|
||||
expect(second?.box).toEqual({ x: 0.09, y: 130 / 1400, width: 0.61, height: 50 / 1400 })
|
||||
})
|
||||
|
||||
it("marks a quote the model read off the image, and the text does not hold, as a scanned quote to check", async () => {
|
||||
const { gateway } = recording(() => [{ label: "Qty", value: "12", quote: "Qty 12 pcs" }])
|
||||
const document = documentOf(page(0, "scan", ["Delivery note"]), page(1, "text", ["Delivery note"]))
|
||||
const { lines } = await modelLineExtractor({ gateway, model: "m" }).extract(
|
||||
input(document, async (index) => (index === 0 ? png : undefined)),
|
||||
)
|
||||
const [scan, text] = lines
|
||||
expect(scan).toMatchObject({ page: 1, verification: "needs_review", note: "scanned_quote", confidence: 0 })
|
||||
// the same unfound quote on a page the model saw no image of carries no such note
|
||||
expect(text).toMatchObject({ page: 2, verification: "needs_review" })
|
||||
expect(text?.note).toBeUndefined()
|
||||
})
|
||||
|
||||
it("keys the cache by the image: another image of the same page is asked again", async () => {
|
||||
const stored = new Map<string, unknown>()
|
||||
const cache = {
|
||||
async get(stage: string, key: string) {
|
||||
return stored.get(`${stage}/${key}`) as never
|
||||
},
|
||||
async put(stage: string, key: string, value: unknown) {
|
||||
stored.set(`${stage}/${key}`, value)
|
||||
},
|
||||
}
|
||||
const { gateway, sent } = recording(reference)
|
||||
const document = documentOf(page(0, "scan", ["Reference: QR-1"]))
|
||||
const extractor = modelLineExtractor({ gateway, model: "m" })
|
||||
const run = (image: PageImage) => extractor.extract({ document, pageImage: async () => image, cache })
|
||||
await run(png)
|
||||
await run(png)
|
||||
expect(sent).toHaveLength(1)
|
||||
await run({ ...png, bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 9, 9, 9]) })
|
||||
expect(sent).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
// Reads a ZIP the way a spreadsheet application does: from the central directory at the end. It is
|
||||
// the test's independent reader of what `buildWorkbook` wrote, so the writer is not checked by itself.
|
||||
|
||||
import { inflateRawSync } from "node:zlib"
|
||||
|
||||
export function unzip(bytes: Uint8Array): Map<string, string> {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
let eocd = bytes.length - 22
|
||||
while (eocd >= 0 && view.getUint32(eocd, true) !== 0x06054b50) eocd -= 1
|
||||
if (eocd < 0) throw new Error("no end-of-central-directory record")
|
||||
const entries = view.getUint16(eocd + 10, true)
|
||||
let at = view.getUint32(eocd + 16, true)
|
||||
const files = new Map<string, string>()
|
||||
for (let i = 0; i < entries; i += 1) {
|
||||
if (view.getUint32(at, true) !== 0x02014b50) throw new Error("a bad central directory entry")
|
||||
const method = view.getUint16(at + 10, true)
|
||||
const size = view.getUint32(at + 20, true)
|
||||
const nameLength = view.getUint16(at + 28, true)
|
||||
const extraLength = view.getUint16(at + 30, true)
|
||||
const commentLength = view.getUint16(at + 32, true)
|
||||
const local = view.getUint32(at + 42, true)
|
||||
const name = new TextDecoder().decode(bytes.subarray(at + 46, at + 46 + nameLength))
|
||||
const dataAt = local + 30 + view.getUint16(local + 26, true) + view.getUint16(local + 28, true)
|
||||
const raw = bytes.subarray(dataAt, dataAt + size)
|
||||
files.set(name, new TextDecoder().decode(method === 8 ? inflateRawSync(raw) : raw))
|
||||
at += 46 + nameLength + extraLength + commentLength
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/** The text and number cells of the first sheet, row by row. */
|
||||
export function sheetRows(bytes: Uint8Array): Array<Array<string | number>> {
|
||||
const sheet = unzip(bytes).get("xl/worksheets/sheet1.xml") ?? ""
|
||||
return [...sheet.matchAll(/<row [^>]*>(.*?)<\/row>/gs)].map(([, row]) =>
|
||||
[...(row as string).matchAll(/<c ([^>]*)>(.*?)<\/c>/gs)].map(([, attributes, body]) => {
|
||||
if (!(attributes as string).includes('t="inlineStr"')) return Number(/<v>(.*?)<\/v>/s.exec(body as string)?.[1])
|
||||
const text = /<t[^>]*>(.*?)<\/t>/s.exec(body as string)?.[1] ?? ""
|
||||
return text
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, ">")
|
||||
.replace(/</g, "<")
|
||||
.replace(/&/g, "&")
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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">')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { ReadDocument } from "../core/ports.js"
|
||||
import { foldForComparison, isWeakPage, locateQuote, quoteIsOnPage } from "../core/verify.js"
|
||||
|
||||
const fixture = JSON.parse(
|
||||
readFileSync(new URL("./fixtures/ocr-document.json", import.meta.url), "utf8"),
|
||||
) as ReadDocument
|
||||
const [first, second] = fixture.pages as [ReadDocument["pages"][number], ReadDocument["pages"][number]]
|
||||
|
||||
describe("the comparison of a quote with a page", () => {
|
||||
it("folds whitespace, case and diacritics, and nothing else", () => {
|
||||
expect(foldForComparison(" Šarūnas \n\t ĄČĘĖĮŠŲŪŽ ")).toBe("sarunas aceeisuuz")
|
||||
// digits, punctuation and letters that are different letters stay different
|
||||
expect(foldForComparison("1 250,00")).not.toBe(foldForComparison("1 520,00"))
|
||||
expect(foldForComparison("a-b")).not.toBe(foldForComparison("a b"))
|
||||
expect(foldForComparison("ß")).toBe("ß")
|
||||
})
|
||||
|
||||
it("finds a quote that differs from the page only in what is folded", () => {
|
||||
expect(quoteIsOnPage(first.text, "reference: qr-2026-04")).toBe(true)
|
||||
expect(quoteIsOnPage(first.text, "Issued on: 2026-04-01\nThis line has no label")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not find a quote that is not there, an empty one, or one from another page", () => {
|
||||
expect(quoteIsOnPage(first.text, "Reference: QR-2026-05")).toBe(false)
|
||||
expect(quoteIsOnPage(first.text, " ")).toBe(false)
|
||||
expect(quoteIsOnPage(first.text, "Total amount: 1 250,00 EUR")).toBe(false)
|
||||
expect(quoteIsOnPage(second.text, "Total amount: 1 250,00 EUR")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("where a quote sits", () => {
|
||||
it("is the box of the line, as fractions of the page, with that line's confidence", () => {
|
||||
const at = locateQuote(first, "REFERENCE: QR-2026-04")
|
||||
expect(at?.box).toEqual({ x: 0.1, y: 200 / 1400, width: 0.5, height: 40 / 1400 })
|
||||
expect(at?.confidence).toBe(0.97)
|
||||
})
|
||||
|
||||
it("spans the lines a quote runs over, and averages their confidence", () => {
|
||||
const at = locateQuote(first, "Reference: QR-2026-04\nIssued on: 2026-04-01")
|
||||
expect(at?.box).toEqual({ x: 0.1, y: 200 / 1400, width: 0.5, height: 100 / 1400 })
|
||||
expect(at?.confidence).toBeCloseTo(0.96)
|
||||
})
|
||||
|
||||
it("is nothing for a quote that is not on the lines, and no box for a page with no size", () => {
|
||||
expect(locateQuote(first, "Reference: QR-9")).toBeUndefined()
|
||||
expect(locateQuote({ ...first, width: 0, height: 0 }, "Reference: QR-2026-04")?.box).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("which pages a model is shown as an image", () => {
|
||||
it("is a scan, a page the reader was unsure of, or a page with next to no text", () => {
|
||||
expect(isWeakPage(first)).toBe(false)
|
||||
expect(isWeakPage(second)).toBe(true) // source: ocr
|
||||
expect(isWeakPage({ ...first, lines: first.lines.map((line) => ({ ...line, confidence: 0.5 })) })).toBe(true)
|
||||
expect(isWeakPage({ ...first, text: " Hi " })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { buildWorkbook } from "../core/xlsx.js"
|
||||
import { unzip } from "./support/unzip.js"
|
||||
|
||||
describe("the workbook writer", () => {
|
||||
const book = buildWorkbook("Approved lines", [
|
||||
["Label", "Value", "Page"],
|
||||
["Total", "12,50", 2],
|
||||
])
|
||||
const files = unzip(book)
|
||||
|
||||
it("writes the parts a spreadsheet application needs to open the file", () => {
|
||||
expect([...files.keys()].sort()).toEqual([
|
||||
"[Content_Types].xml",
|
||||
"_rels/.rels",
|
||||
"xl/_rels/workbook.xml.rels",
|
||||
"xl/styles.xml",
|
||||
"xl/workbook.xml",
|
||||
"xl/worksheets/sheet1.xml",
|
||||
])
|
||||
expect(files.get("xl/workbook.xml")).toContain('<sheet name="Approved lines" sheetId="1" r:id="rId1"/>')
|
||||
})
|
||||
|
||||
it("keeps text as text and a number as a number, in the cell it was given", () => {
|
||||
const sheet = files.get("xl/worksheets/sheet1.xml") ?? ""
|
||||
expect(sheet).toContain('<c r="A1" t="inlineStr" s="1"><is><t xml:space="preserve">Label</t></is></c>')
|
||||
expect(sheet).toContain('<c r="B2" t="inlineStr"><is><t xml:space="preserve">12,50</t></is></c>')
|
||||
expect(sheet).toContain('<c r="C2"><v>2</v></c>')
|
||||
})
|
||||
|
||||
it("writes a formula-looking value as inert text, never as a formula", () => {
|
||||
const sheet =
|
||||
unzip(buildWorkbook("S", [['=HYPERLINK("http://x")', "+1+1", "@SUM(A1)", "-2+3"]])).get(
|
||||
"xl/worksheets/sheet1.xml",
|
||||
) ?? ""
|
||||
expect(sheet).not.toContain("<f>")
|
||||
expect(sheet.match(/t="inlineStr"/g)).toHaveLength(4)
|
||||
expect(sheet).toContain("=HYPERLINK("http://x")")
|
||||
})
|
||||
|
||||
it("escapes markup and drops characters XML cannot carry", () => {
|
||||
const sheet = unzip(buildWorkbook("S", [["a<b>&\u0000c\u0007d"]])).get("xl/worksheets/sheet1.xml") ?? ""
|
||||
expect(sheet).toContain("a<b>&cd")
|
||||
expect([...sheet].some((char) => char.charCodeAt(0) < 9)).toBe(false)
|
||||
})
|
||||
|
||||
it("refuses a sheet name Excel would reject", () => {
|
||||
for (const name of ["", "a/b", "x".repeat(32), "[a]", "a:b"]) {
|
||||
expect(() => buildWorkbook(name, [])).toThrow(/sheet name/)
|
||||
}
|
||||
})
|
||||
|
||||
it("is byte-for-byte reproducible, so an export of the same lines is the same file", () => {
|
||||
expect(Buffer.from(buildWorkbook("S", [["a"]])).equals(Buffer.from(buildWorkbook("S", [["a"]])))).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user