209 lines
8.7 KiB
TypeScript
209 lines
8.7 KiB
TypeScript
// The knowledge page on F10: its loader reads through the caller and returns plain data with every label
|
|
// translated, and its view renders what reached the browser. The service's health, the indexing indicator
|
|
// and the search stay three separate things: "indexing" is not "down".
|
|
|
|
import { AppError, buildRegistry, Container, notFound, type TransportCaller } from "@conusai-kit/core"
|
|
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
|
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
|
|
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
|
import { renderToStaticMarkup as renderMarkup } from "react-dom/server"
|
|
import { describe, expect, it, vi } from "vitest"
|
|
import { appModules } from "../../index.js"
|
|
import { modulePages } from "../../pages.js"
|
|
import { knowledgePage } from "../ui/pages.js"
|
|
|
|
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
|
|
const renderToStaticMarkup = (node: React.ReactNode) =>
|
|
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
|
|
|
|
const catalogues = buildRegistry([...appModules]).catalogues
|
|
const i18n = createI18n("en", {
|
|
en: { knowledge: catalogues.en?.knowledge ?? {}, app: { failed: "That did not work: {detail}" } },
|
|
})
|
|
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
|
|
|
const callerOf = (answers: Record<string, unknown>, asked: unknown[] = []): TransportCaller =>
|
|
({
|
|
call: async (contract: { name: string }, input: unknown) => {
|
|
asked.push([contract.name, input])
|
|
const answer = answers[contract.name]
|
|
if (answer instanceof Error) throw answer
|
|
return answer
|
|
},
|
|
}) as unknown as TransportCaller
|
|
|
|
const load = (segments: string[], answers: Record<string, unknown>, asked?: unknown[]) =>
|
|
knowledgePage.load({
|
|
slugs,
|
|
segments,
|
|
search: {},
|
|
caller: callerOf(answers, asked),
|
|
i18n,
|
|
container: new Container(),
|
|
})
|
|
|
|
/** The page as the shell renders it: the loader's data through JSON, as the browser receives it, then the view. */
|
|
const render = async (answers: Record<string, unknown>) => {
|
|
const data = await load([], answers)
|
|
expect(JSON.parse(JSON.stringify(data))).toStrictEqual(data)
|
|
const { View } = knowledgePage
|
|
return renderToStaticMarkup(
|
|
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
|
<View slugs={slugs} segments={[]} locale="en" data={JSON.parse(JSON.stringify(data))} />
|
|
</DataProvider>,
|
|
)
|
|
}
|
|
|
|
const document = (documentId: string, status: string, error: string | null) => ({
|
|
documentId,
|
|
collectionName: "contracts",
|
|
fileId: `file-${documentId}`,
|
|
filename: `${documentId}.pdf`,
|
|
status,
|
|
error,
|
|
indexedAt: null,
|
|
requestedBy: "u1",
|
|
})
|
|
|
|
const status = (enabled: boolean, state: string, reasons: readonly string[] = [], refusal?: string) => ({
|
|
enabled,
|
|
service: { state, reasons, ...(refusal === undefined ? {} : { refusal }) },
|
|
documents: { queued: 1, indexing: 0, indexed: 2, failed: 0, cancelled: 0 },
|
|
})
|
|
|
|
const answers = {
|
|
"knowledge.collection.list": {
|
|
collections: [{ name: "contracts", documentCount: 2, indexedCount: 1, createdBy: "u1" }],
|
|
},
|
|
"knowledge.status": status(true, "degraded", ["the search index is behind"]),
|
|
"knowledge.document.list": {
|
|
documents: [document("d1", "indexed", null), document("d2", "failed", "the file could not be read")],
|
|
},
|
|
"knowledge.autoindex.get": { collectionName: null },
|
|
}
|
|
|
|
describe("the knowledge page", () => {
|
|
it("is listed for the shell", async () => {
|
|
await expect(modulePages.knowledge?.()).resolves.toBe(knowledgePage)
|
|
})
|
|
|
|
it("shows the service's health, the collection and its documents in words", async () => {
|
|
const html = await render(answers)
|
|
expect(html).toContain("<h1 ")
|
|
expect(html).toContain("<li>the search index is behind</li>")
|
|
expect(html).toContain(">contracts</h2>")
|
|
expect(html).toContain("d1.pdf")
|
|
expect(html).toContain("the file could not be read")
|
|
expect(html).toContain('role="grid"')
|
|
expect(html.match(/role="columnheader"/g)).toHaveLength(3)
|
|
})
|
|
|
|
// core-11: the project's automatic PDF indexing, off or naming its collection, with the project's slugs.
|
|
it("offers automatic PDF indexing into one of the collections, showing the current choice", async () => {
|
|
const asked: unknown[] = []
|
|
const off = await load([], answers, asked)
|
|
expect(asked).toContainEqual(["knowledge.autoindex.get", slugs])
|
|
expect(off.autoIndex.current).toBeNull()
|
|
const html = await render(answers)
|
|
expect(html).toContain("Index uploaded PDFs automatically")
|
|
const on = await load([], { ...answers, "knowledge.autoindex.get": { collectionName: "contracts" } })
|
|
expect(on.autoIndex.current).toBe("contracts")
|
|
})
|
|
|
|
it("does not offer automatic indexing while the module is off", async () => {
|
|
const html = await render({ ...answers, "knowledge.status": status(false, "disabled") })
|
|
expect(html).not.toContain("Index uploaded PDFs automatically")
|
|
})
|
|
|
|
it("reads each collection's documents with the project's slugs", async () => {
|
|
const asked: unknown[] = []
|
|
await load([], answers, asked)
|
|
expect(asked).toContainEqual(["knowledge.document.list", { ...slugs, collectionName: "contracts" }])
|
|
})
|
|
|
|
it("says the module is off in place of its collections", async () => {
|
|
const html = await render({ ...answers, "knowledge.status": status(false, "disabled") })
|
|
expect(html).toContain('role="status"')
|
|
expect(html).not.toContain('role="grid"')
|
|
expect(html).not.toContain("<dl>")
|
|
})
|
|
|
|
it.each([
|
|
["plan_required", "The organization's plan does not include document search."],
|
|
["feature_disabled", "Document search is switched off for this organization by platform policy."],
|
|
["quota_exhausted", "Today's indexing allowance is spent. Try again tomorrow."],
|
|
["budget_exhausted", "The organization's AI budget is spent. An administrator can raise it on the platform."],
|
|
[
|
|
"scope_missing",
|
|
"This deployment's token does not hold knowledge.collections:use. Deploy the app again, then have an owner or admin approve it.",
|
|
],
|
|
["indexing", "The service is building its index. Try again in a few minutes."],
|
|
["approval_required", "An administrator must approve this on the platform first."],
|
|
["runtime_unavailable", "The knowledge service did not answer. Try again later."],
|
|
])("shows the refusal %s as the page's own state", async (refusal, text) => {
|
|
const html = await render({
|
|
...answers,
|
|
"knowledge.status": status(true, "unavailable", [], refusal),
|
|
})
|
|
expect(html).toContain(`data-state="${refusal}"`)
|
|
expect(html).toContain('role="status"')
|
|
expect(html).toContain(text.replaceAll("'", "'"))
|
|
})
|
|
|
|
it("shows a refusal in Lithuanian to a Lithuanian reader", async () => {
|
|
const lt = createI18n("lt", {
|
|
lt: { knowledge: catalogues.lt?.knowledge ?? {}, app: { failed: "Nepavyko: {detail}" } },
|
|
})
|
|
const data = await knowledgePage.load({
|
|
slugs,
|
|
segments: [],
|
|
search: {},
|
|
caller: callerOf({ ...answers, "knowledge.status": status(true, "disabled", [], "plan_required") }),
|
|
i18n: lt,
|
|
container: new Container(),
|
|
})
|
|
expect(data.status.refusal).toEqual({
|
|
code: "plan_required",
|
|
text: "Organizacijos planas dokumentų paieškos neapima.",
|
|
})
|
|
})
|
|
|
|
it("says a document's failure in words, and keeps a code it does not know", async () => {
|
|
const html = await render({
|
|
...answers,
|
|
"knowledge.document.list": {
|
|
documents: [document("d1", "failed", "quota_exhausted"), document("d2", "failed", "something_new")],
|
|
},
|
|
})
|
|
// Worded in the browser from the failure labels the loader handed over: the list is live.
|
|
expect(html).toContain(">Today's indexing allowance is spent. Try again tomorrow.<")
|
|
expect(html).toContain(">something_new<")
|
|
})
|
|
|
|
it("answers a path it does not know as not found, without reading", async () => {
|
|
const call = vi.fn()
|
|
await expect(
|
|
knowledgePage.load({
|
|
slugs,
|
|
segments: ["extra"],
|
|
search: {},
|
|
caller: { call } as unknown as TransportCaller,
|
|
i18n,
|
|
container: new Container(),
|
|
}),
|
|
).rejects.toMatchObject({ status: 404 })
|
|
expect(call).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("hands a procedure's refusal on, as the AppError the shell answers", async () => {
|
|
const refused = load([], { ...answers, "knowledge.collection.list": notFound("no such project") })
|
|
await expect(refused).rejects.toBeInstanceOf(AppError)
|
|
await expect(refused).rejects.toMatchObject({ status: 404 })
|
|
})
|
|
})
|
|
|
|
// Explicit synthetic admission: module-local fixtures exercise the selected module.
|
|
vi.mock("../../inclusion.json", () => ({
|
|
default: { schemaVersion: 1, selected: ["knowledge"], everAdopted: ["knowledge"] },
|
|
}))
|