diff --git a/docs/testing.md b/docs/testing.md index 911dfeec..c2d269e2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1123,7 +1123,7 @@ Dependency inversion gives every layer a seam for its fakes: | Layer | Seam | Technique | |---|---|---| -| Domain | none needed | Pure functions; inputs (including `now: Date`) passed as parameters. Plain assertions. What runs over a whole deck or log is timed at two sizes in its own process's CPU time: eight times the input must take under four times as long as an eighth of it done eight times (a linear pass gives about one, work growing with the input about eight). The growth tests (`growth` in `src/testing/growth.ts`, `growthAsync` for work that awaits, `growthAfterSetup` for work that changes what it works on, set up afresh before each run and not timed) time `queryCards` over 5,000 cards, `lapseIndex`, `chainIndexOf` and `statisticsOf` over years of answers, `instanceGraphOf` over an instance's cards and answers and over a document thousands of folders deep, a course's outline (`courseOutlineFromRecords`) over its chapters and steps, the learner's progress in it (`courseProgress`) over a chapter's steps, the release check's course rules (`courseProblems`), a draft's outline (`draftOutline`) over the chapters and steps, a draft's cards with their wrong options, as the Studio's outline and its step, chapter and question pages show them (`draftCardOf`), over many cards and many wrong options of one card, and the changes to a card's many wrong options (`distractorChanges`), the deck health's cards that say the same (`duplicateCardsOf`) over many cards alike, a JSON-LD copy (`jsonLdOf` in `solid`) over many values of one property, the search of a Turtle text for its base and prefix declarations (`writeTurtle` in `solid`), the shape check's report on a pod document and its check of a write (`createShaclShapeValidator` in `solid`, over an engine that answers at once) over a catalog's many links that aren't members and many dates it can't read, the notes on a release's making (`makingNotesOf`) over many in one language, a merge of drafts (`mergedDraft`) over many statements of one subject, the blank nodes a removal takes along (`withOnlyTheirBlankNodes` in `solid`) over many of one subject and many subjects naming one, a catalog's decks (`toDecks` in `solid`) and a deck file's refusal (`readDeckFile`) over many decks and agents, a catalog written over many licenses it no longer states (`withCatalog` in `solid`), a deck's cards added from a release, removed and brought to this app's format, one card's many distractors added and changed, a deck removed from a cards document another app wrote in, catalog entries and review states brought to this app's format, review states removed, a day's answers removed, many month summaries dropped from the digest, and many review states' snapshots dropped, decks removed and one deck's unreadable dates dropped by the instance check's repair, and many registrations of an instance renamed and removed in a type index, against a pod in memory (`importDeck`, `applyCardChanges`, `removeDeck`, `upgradeCards`, `upgradeDecks`, `upgradeReviewStates`, `applyReviewChanges`, `removeDay`, `updateDigest`, `applyRepairs`, `renameInstanceRegistrations` and `removeInstanceRegistrations` in `solid`), the repairs planned from a check of many subjects (`planRepair`), a subject's many values set or removed in one call (`setIriAll`, `setStringAll` and `removeLiteralAll`, in the client modules' contract tests), the Studio's graph layout and routes over a deck's cards, and its cut of a label (`drawnLabel`), which must not grow with the label at all, over one millions of characters long. | +| Domain | none needed | Pure functions; inputs (including `now: Date`) passed as parameters. Plain assertions. What runs over a whole deck or log is timed at two sizes in its own process's CPU time: eight times the input must take under four times as long as an eighth of it done eight times (a linear pass gives about one, work growing with the input about eight). The growth tests (`growth` in `src/testing/growth.ts`, `growthAsync` for work that awaits, `growthAfterSetup` for work that changes what it works on, set up afresh before each run and not timed) time `queryCards` over 5,000 cards, `lapseIndex`, `chainIndexOf` and `statisticsOf` over years of answers, `instanceGraphOf` over an instance's cards and answers and over a document thousands of folders deep, a course's outline (`courseOutlineFromRecords`) over its chapters and steps, the learner's progress in it (`courseProgress`) over a chapter's steps, the release check's course rules (`courseProblems`), a draft's outline (`draftOutline`) over the chapters and steps, a draft's cards with their wrong options, as the Studio's outline and its step, chapter and question pages show them (`draftCardOf`), over many cards and many wrong options of one card, and the changes to a card's many wrong options (`distractorChanges`), the deck health's cards that say the same (`duplicateCardsOf`) over many cards alike, a JSON-LD copy (`jsonLdOf` in `solid`) over many values of one property, the search of a Turtle text for its base and prefix declarations (`writeTurtle` in `solid`), the shape check's report on a pod document and its check of a write (`createShaclShapeValidator` in `solid`, over an engine that answers at once) over a catalog's many links that aren't members and many dates it can't read, the notes on a release's making (`makingNotesOf`) over many in one language, a draft's listing (`draftLibraryDeck`) and its release diff (`releaseDiff` and `simulateLearnerUpgrade`) over many sources, a merge of drafts (`mergedDraft`) over many statements of one subject, the blank nodes a removal takes along (`withOnlyTheirBlankNodes` in `solid`) over many of one subject and many subjects naming one, a catalog's decks (`toDecks` in `solid`) and a deck file's refusal (`readDeckFile`) over many decks and agents, a catalog written over many licenses it no longer states (`withCatalog` in `solid`), a deck's cards added from a release, removed and brought to this app's format, one card's many distractors added and changed, a deck removed from a cards document another app wrote in, catalog entries and review states brought to this app's format, review states removed, a day's answers removed, many month summaries dropped from the digest, and many review states' snapshots dropped, decks removed and one deck's unreadable dates dropped by the instance check's repair, and many registrations of an instance renamed and removed in a type index, against a pod in memory (`importDeck`, `applyCardChanges`, `removeDeck`, `upgradeCards`, `upgradeDecks`, `upgradeReviewStates`, `applyReviewChanges`, `removeDay`, `updateDigest`, `applyRepairs`, `renameInstanceRegistrations` and `removeInstanceRegistrations` in `solid`), the repairs planned from a check of many subjects (`planRepair`), a subject's many values set or removed in one call (`setIriAll`, `setStringAll` and `removeLiteralAll`, in the client modules' contract tests), the Studio's graph layout and routes over a deck's cards, and its cut of a label (`drawnLabel`), which must not grow with the label at all, over one millions of characters long. | | Application | ports | Fake ports are injected (`vi.fn` per method): the fixtures and fake ports in [fakePorts.ts](../packages/application/src/testing/fakePorts.ts), and `NO_PORTS` ([noPorts.ts](../packages/application/src/testing/noPorts.ts)), which other packages' tests spread too, for the ports a test doesn't use. Each slice is tested in its own `.test.ts`, the stores the slices share in theirs. No module mocking. | | UI | `UseCases` prop | Screens render with a fake `UseCases`: `makeUseCasesFake` ([useCasesFake.ts](../packages/ui/src/test/useCasesFake.ts)) makes every slice's fake ([test/fakes/](../packages/ui/src/test/fakes/)) in one object, each calling the others through it. A screen that takes a slice (the trial's) can be given that slice's fake alone. Assertions use testing-library queries. Query-dependent components get a fresh `QueryClient` (retries off). | | Infrastructure mappers | none needed | Pure `Dataset`/`Subject` → domain functions, fed in-memory datasets built with `datasetFrom`/`buildThing` ([datasets.ts](../packages/solid/src/testing/datasets.ts)). | diff --git a/packages/application/src/releaseDrafts.ts b/packages/application/src/releaseDrafts.ts index e4824271..b0b05c40 100644 --- a/packages/application/src/releaseDrafts.ts +++ b/packages/application/src/releaseDrafts.ts @@ -31,6 +31,7 @@ import { unsupportedIdOf, } from "@solid-memo/domain/release/releaseDraft"; import { continuityProblems } from "@solid-memo/domain/release/continuityRules"; +import { draftLibraryDeck } from "@solid-memo/domain/release/draftListing"; import { releaseDiff, simulateLearnerUpgrade, @@ -599,11 +600,12 @@ export function createReleaseDraftUseCases({ const { prev } = draft.root; if (prev === undefined) return null; const [previous, before] = await Promise.all([release(prev), releaseModel(prev)]); + const listed = draftLibraryDeck(draft); return { previous, - diff: releaseDiff(previous, draft), + diff: releaseDiff(previous, draft, listed), problems: continuityProblems(before, draftReleaseModel(draft)), - upgrade: simulateLearnerUpgrade(previous, draft), + upgrade: simulateLearnerUpgrade(previous, draft, listed), }; }, }; diff --git a/packages/domain/src/release/draftListing.test.ts b/packages/domain/src/release/draftListing.test.ts index 586457a8..c341ff64 100644 --- a/packages/domain/src/release/draftListing.test.ts +++ b/packages/domain/src/release/draftListing.test.ts @@ -4,11 +4,30 @@ import { describe, expect, it } from "vitest"; import { SM } from "@solid-memo/vocab/vocab.generated"; +import { growth } from "../testing/growth"; import { card, courseDraft, deckDraft, DRAFT, NOW, of } from "../testing/releaseDraft"; +import type { ReleaseDraft } from "./releaseDraft"; import { draftLibraryContent, draftLibraryDeck } from "./draftListing"; const DCTERMS = "http://purl.org/dc/terms/"; +/** The deck fixture drawn from `count` more sources, each with a title. */ +function withSources(count: number, draft: ReleaseDraft = deckDraft()): ReleaseDraft { + const urls = Array.from({ length: count }, (_, n) => `https://many.example/${n}`); + return { + ...draft, + root: { ...draft.root, wasDerivedFrom: [...draft.root.wasDerivedFrom, ...urls] }, + triples: [ + ...draft.triples, + ...urls.map((url, n) => ({ + subject: url, + predicate: `${DCTERMS}title`, + object: { kind: "literal" as const, value: `Source ${n}`, language: "", datatype: "" }, + })), + ], + }; +} + describe("draftLibraryDeck", () => { it("lists a course as the library would: one release, its cards in use, its sources", () => { const draft = courseDraft(); @@ -53,7 +72,7 @@ describe("draftLibraryDeck", () => { theme: ["https://theme.example/"], keyword: { en: ["word"] }, language: ["http://publications.europa.eu/resource/authority/language/SWE"], - wasDerivedFrom: ["https://source.example/"], + wasDerivedFrom: ["https://source.example/", "https://unsaid.example/"], }, agents: [{ id: "anna", data: { name: "Anna" } }], triples: [ @@ -95,6 +114,7 @@ describe("draftLibraryDeck", () => { modifiedAt: NOW, sources: [ { url: "https://source.example/", authors: ["Ben"], license: "https://licence.example/" }, + { url: "https://unsaid.example/", authors: [] }, ], }); expect(listed.isCourse).toBeUndefined(); @@ -111,6 +131,40 @@ describe("draftLibraryDeck", () => { expect(listed.title).toEqual({}); expect(listed.createdAt).toBeUndefined(); }); + + it("lists a source the draft names twice once", () => { + const draft = withSources(1); + const listed = draftLibraryDeck({ + ...draft, + root: { + ...draft.root, + wasDerivedFrom: [...draft.root.wasDerivedFrom, "https://many.example/0"], + }, + }); + expect(listed.sources).toEqual(draftLibraryDeck(draft).sources); + }); + + it("lists in time that grows with how many sources the draft has", () => { + const listed = draftLibraryDeck(withSources(100)); + expect(listed.sources).toHaveLength(100); + expect(listed.sources.at(-1)).toEqual({ + url: "https://many.example/99", + title: "Source 99", + authors: [], + }); + // Eight times the sources take about as long as an eighth of them + // eight times over; going over every statement for each source would + // take eight times as long. + const ratio = growth( + (count) => { + const many = withSources(count); + return () => draftLibraryDeck(many); + }, + 1_000, + 8, + ); + expect(ratio).toBeLessThan(4); + }); }); describe("draftLibraryContent", () => { diff --git a/packages/domain/src/release/draftListing.ts b/packages/domain/src/release/draftListing.ts index ed2a4c4a..83b3a5c8 100644 --- a/packages/domain/src/release/draftListing.ts +++ b/packages/domain/src/release/draftListing.ts @@ -8,7 +8,7 @@ import { CARD_FORMAT_VERSION, type Distractor } from "../deck.ts"; import { distractorFromRecord, libraryCardFromRecord } from "../deckRecord.ts"; import { copyKeywords } from "../keywords.ts"; import type { LibraryCard, LibraryDeck, LibraryDeckContent, LibrarySource } from "../library.ts"; -import { iriIn, type ReleaseDraft } from "./releaseDraft.ts"; +import { iriIn, type ReleaseDraft, triplesBySubject } from "./releaseDraft.ts"; import { DCTERMS_NS } from "./releaseModel.ts"; /** @@ -16,14 +16,18 @@ import { DCTERMS_NS } from "./releaseModel.ts"; * The listing preview): the LibraryDeck its learners would see, built * from the draft alone. It is the only release of its series here, its * cards those in use, its authors named by the agents the draft - * describes, and its sources by what the draft says of them. + * describes, and its sources by what the draft says of them, gathered + * in one pass over its statements. */ export function draftLibraryDeck(draft: ReleaseDraft): LibraryDeck { const { root } = draft; const names = new Map(draft.agents.map((agent) => [iriIn(draft, agent.id), agent.data.name])); + // A source named twice is listed once, as the release's statements would name it. + const sources = new Set(root.wasDerivedFrom); + const saying = triplesBySubject(draft.triples, sources); const said = (subject: string, predicate: string) => - draft.triples - .filter((triple) => triple.subject === subject && triple.predicate === predicate) + (saying.get(subject) ?? []) + .filter((triple) => triple.predicate === predicate) .map((triple) => triple.object); const version = root.version ?? "1"; return { @@ -51,7 +55,7 @@ export function draftLibraryDeck(draft: ReleaseDraft): LibraryDeck { direction: directionOfConcept(root.studyDirection)!, ...(root.created === undefined ? {} : { createdAt: root.created }), ...(root.modified === undefined ? {} : { modifiedAt: root.modified }), - sources: root.wasDerivedFrom.map((url): LibrarySource => { + sources: [...sources].map((url): LibrarySource => { const title = said(url, `${DCTERMS_NS}title`).find((term) => term.kind === "literal"); const license = said(url, `${DCTERMS_NS}license`).find((term) => term.kind === "iri"); return { @@ -73,8 +77,12 @@ export function draftLibraryDeck(draft: ReleaseDraft): LibraryDeck { * shows it (draftLibraryDeck), and every card, retired ones too, each * with its wrong options. A card or a wrong option without text is left * out, as a reader leaves out one that does not fit its shape. + * `listed` is the draft's listing, when already made. */ -export function draftLibraryContent(draft: ReleaseDraft): LibraryDeckContent { +export function draftLibraryContent( + draft: ReleaseDraft, + listed: LibraryDeck = draftLibraryDeck(draft), +): LibraryDeckContent { const { url, releases: _releases, @@ -84,7 +92,7 @@ export function draftLibraryContent(draft: ReleaseDraft): LibraryDeckContent { createdAt: _createdAt, isCourse, ...about - } = draftLibraryDeck(draft); + } = listed; const options = new Map(draft.distractors.map((node) => [iriIn(draft, node.id), node.data])); const distractorsOf = (iris: readonly string[]): Distractor[] => iris diff --git a/packages/domain/src/release/releaseDiff.test.ts b/packages/domain/src/release/releaseDiff.test.ts index e450fd42..0f0a0005 100644 --- a/packages/domain/src/release/releaseDiff.test.ts +++ b/packages/domain/src/release/releaseDiff.test.ts @@ -4,8 +4,10 @@ import { describe, expect, it } from "vitest"; import { SM } from "@solid-memo/vocab/vocab.generated"; +import { growth } from "../testing/growth.ts"; import { card, courseDraft, deckDraft, DRAFT } from "../testing/releaseDraft.ts"; import type { DraftNode, ReleaseDraft } from "./releaseDraft.ts"; +import { draftLibraryDeck } from "./draftListing.ts"; import { releaseDiff, simulateLearnerUpgrade } from "./releaseDiff.ts"; import { rebaseDraft } from "./releaseToDraft.ts"; import { nextVersionDraft } from "./releaseVersion.ts"; @@ -15,6 +17,24 @@ const V1 = "https://pod.example/solid-memo/main/releases/solid/v1.ttl"; /** The course fixture as release 1, at V1. */ const v1 = (draft: ReleaseDraft = courseDraft()): ReleaseDraft => rebaseDraft(draft, V1); +/** The course fixture as release 1, drawn from `count` more sources, each with a title. */ +function withSources(count: number): ReleaseDraft { + const draft = courseDraft(); + const urls = Array.from({ length: count }, (_, n) => `https://many.example/${n}`); + return v1({ + ...draft, + root: { ...draft.root, wasDerivedFrom: [...draft.root.wasDerivedFrom, ...urls] }, + triples: [ + ...draft.triples, + ...urls.map((url, n) => ({ + subject: url, + predicate: "http://purl.org/dc/terms/title", + object: { kind: "literal" as const, value: `Source ${n}`, language: "", datatype: "" }, + })), + ], + }); +} + /** The node of a list with its record changed by `change`; the others as they are. */ function edit( nodes: readonly DraftNode[], @@ -281,4 +301,34 @@ describe("simulateLearnerUpgrade", () => { lost: { cards: ["q-loose"], chapters: [] }, }); }); + + it("compares with the draft's listing when it is already made", () => { + const release = withSources(3); + const next = nextVersionDraft(release, DRAFT); + const listed = draftLibraryDeck(next); + expect(releaseDiff(release, next, listed)).toEqual(releaseDiff(release, next)); + expect(simulateLearnerUpgrade(release, next, listed)).toEqual( + simulateLearnerUpgrade(release, next), + ); + }); + + it("compares in time that grows with how many sources the release has", () => { + const release = withSources(100); + const next = nextVersionDraft(release, DRAFT); + expect(releaseDiff(release, next).about).toEqual([]); + expect(simulateLearnerUpgrade(release, next).plan).toBeNull(); + // Eight times the sources take about as long as an eighth of them + // eight times over; going over every statement for each source would + // take eight times as long. + const ratio = growth( + (count) => { + const many = withSources(count); + const draft = nextVersionDraft(many, DRAFT); + return () => [releaseDiff(many, draft), simulateLearnerUpgrade(many, draft)]; + }, + 1_000, + 8, + ); + expect(ratio).toBeLessThan(4); + }); }); diff --git a/packages/domain/src/release/releaseDiff.ts b/packages/domain/src/release/releaseDiff.ts index f75710bb..7cf36461 100644 --- a/packages/domain/src/release/releaseDiff.ts +++ b/packages/domain/src/release/releaseDiff.ts @@ -4,6 +4,7 @@ import { LATEST_VERSION, type CardV7 } from "@solid-memo/vocab/types.generated"; import type { Card, Deck } from "../deck.ts"; +import type { LibraryDeck } from "../library.ts"; import { cardContentFromRecord } from "../deckRecord.ts"; import { sameKeywords } from "../keywords.ts"; import { sameText } from "../langText.ts"; @@ -148,9 +149,14 @@ function changeOf( /** * What the draft changes of the release it follows (`previous`): what * it says of itself, and each chapter, step, card and wrong option it - * adds, changes, retires or restores. + * adds, changes, retires or restores. `listed` is the draft's listing + * (draftLibraryDeck), when already made. */ -export function releaseDiff(previous: ReleaseDraft, draft: ReleaseDraft): ReleaseDiff { +export function releaseDiff( + previous: ReleaseDraft, + draft: ReleaseDraft, + listed: LibraryDeck = draftLibraryDeck(draft), +): ReleaseDiff { // The release's subjects at the draft's address, so the links between them compare. const before = rebaseDraft(previous, draft.url); const subjects: SubjectChange[] = []; @@ -165,7 +171,7 @@ export function releaseDiff(previous: ReleaseDraft, draft: ReleaseDraft): Releas else subjects.push(change); } } - const [a, b] = [draftLibraryDeck(before), draftLibraryDeck(draft)]; + const [a, b] = [draftLibraryDeck(before), listed]; const same: Record = { course: before.course === draft.course, title: sameText(a.title, b.title), @@ -205,14 +211,16 @@ const LEARNER = "https://learner.solid-memo.invalid/solid-memo/"; * import makes it, the learner having changed nothing, every card * there (a course's learner having reached every question) and, of a * course, every chapter completed. Nothing is written: the plan is what - * learners' decks would get. + * learners' decks would get. `listed` is the draft's listing + * (draftLibraryDeck), when already made. */ export function simulateLearnerUpgrade( previous: ReleaseDraft, draft: ReleaseDraft, + listed: LibraryDeck = draftLibraryDeck(draft), ): LearnerUpgrade { const from = draftLibraryContent(previous); - const to = draftLibraryContent(draft); + const to = draftLibraryContent(draft, listed); const newer = Number(to.version) > Number(from.version); const course = previous.course || draft.course; const cardsDocumentUrl = `${LEARNER}decks/learner.ttl`; @@ -241,7 +249,7 @@ export function simulateLearnerUpgrade( cards, from, to, - releases: draftLibraryDeck(draft).releases, + releases: listed.releases, course, }) : null;