diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 288c30498..09e83e658 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,17 +138,29 @@ jobs: # Chromium, which site's tests draw the docs' Mermaid diagrams in # (docs/markdown.md). - uses: ./.github/actions/chromium - # Biome's formatting and lint rules (docs/style.md), markdownlint's - # and cspell's (docs/testing.md#documentation); then every - # package: typecheck, tests at 100% coverage, generated code and - # Turtle formatting in step with their sources; then the - # boundaries, the docs' index (docs/docs.ttl) and the shipped - # dependencies' maturity (docs/testing.md). npm run check without - # the deck library, which the library job checks, and compares with - # the base. - - run: npm run check:ci + # npm run check:ci, a step per part, so that the jobs API shows how + # long each takes. A later part also runs when an earlier part + # failed, so that a run shows every failure, but not when a step + # before the parts failed: the lints step runs only after every + # step above passed, and the later parts only after it ran. npm run + # check without the deck library, which the library job checks, + # and compares with the base. First Biome's formatting and lint + # rules (docs/style.md), markdownlint's, cspell's and the prose + # check's (docs/testing.md#documentation). + - id: lints + run: npm run check:lints + # Every package: typecheck, tests at 100% coverage, the round trip + # of the library's releases, generated code and Turtle formatting + # in step with their sources. + - if: ${{ !cancelled() && (steps.lints.outcome == 'success' || steps.lints.outcome == 'failure') }} + run: npm run check:tasks env: TURBO_CACHE_MAX_AGE: 14d + # The boundaries, the docs' index (docs/docs.ttl), the shipped + # dependencies' maturity and the repository's other checks + # (docs/testing.md#the-repositorys-own-checks). + - if: ${{ !cancelled() && (steps.lints.outcome == 'success' || steps.lints.outcome == 'failure') }} + run: npm run check:repository # Each package's coverage, in the run's summary; the thresholds that # gate are each package's own, in npm run check. - if: success() || failure() diff --git a/docs/testing.md b/docs/testing.md index 0e5d29f9f..59df5d89a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -20,6 +20,8 @@ branches, functions, lines), or `npm test` fails npm test # every package's tests, with coverage thresholds (turbo) npm run check # Biome's lint, markdownlint, cspell and the prose check, then npm test's tests, the round trip, typecheck, drift, Turtle formatting, the deck library, boundaries, the repository's own checks (below) npm run check:ci # npm run check without the deck library, as CI's check job runs it (its library job checks the library) +npm run check:lints # the first part of both: Biome's lint, markdownlint, cspell and the prose check +npm run check:tasks # the second: every package's Turbo tasks but the deck library's (npm run check adds it) npm run browsers # once: Chromium's headless shell, which npm run check and npm run build draw the docs' diagrams in (on Linux, if it lacks system libraries: sudo npx playwright install-deps chromium) npm run lint # Biome: the formatting and lint rules of the TypeScript, JSON and CSS (docs/style.md) npm run format # Biome: rewrite those files as it formats them @@ -664,7 +666,7 @@ jobs run side by side, each | Job | Kind | What it checks | |---|---|---| | `gate` | deterministic | what the run runs: every job, or none but `reuse` (and `release-files`), when the run reuses the one that tested its tree (`npm run ci:gate`, [releasing.md](releasing.md#what-each-stage-checks)); on a release commit, that it changes nothing but the files a release writes | -| `check` | deterministic | `npm run check:ci`: `npm run check` without the deck library (`library` checks it), Chromium's headless shell and its system libraries, for the docs' diagrams, from GitHub's cache or installed ([.github/actions/chromium](../.github/actions/chromium/action.yml), [Browsers](#browsers)). Every package's coverage goes to the run's summary (`npm run coverage:summary`). Turbo's cache is kept between runs under this job's own key, so a task whose inputs haven't changed doesn't run again. Only a run on `main` saves it, and pull requests and the merge queue start from `main`'s; the queue keeps it as its run's `turbo-cache` artifact, which `reuse` saves as `main`'s. Before all of it, `npm audit signatures` checks the registry's signatures of the installed packages (The workflows, below) | +| `check` | deterministic | `npm run check:ci`: `npm run check` without the deck library (`library` checks it), its parts each a step (`check:lints`, `check:tasks`, `check:repository`), so the jobs API shows each part's time. A part runs when a part before it failed, so a run shows every failure, but none runs when a step before the parts failed, such as `npm ci` or `npm audit signatures`. Chromium's headless shell and its system libraries, for the docs' diagrams, from GitHub's cache or installed ([.github/actions/chromium](../.github/actions/chromium/action.yml), [Browsers](#browsers)). Every package's coverage goes to the run's summary (`npm run coverage:summary`). Turbo's cache is kept between runs under this job's own key, so a task whose inputs haven't changed doesn't run again. Only a run on `main` saves it, and pull requests and the merge queue start from `main`'s; the queue keeps it as its run's `turbo-cache` artifact, which `reuse` saves as `main`'s. Before all of it, `npm audit signatures` checks the registry's signatures of the installed packages (The workflows, below) | | `site` | deterministic | `npm run build`, once a run, the docs' diagrams drawn as in `check`, kept a week as the run's `site` artifact (a `journeys` job run again still finds it), which the `journeys` jobs test and the deploy ships. Nothing in it comes from GitHub's cache, and in the merge queue and on `main` it attests the list of the site's files, which the deploy checks the artifact against ([deployment.md](deployment.md#the-build-ci-tested)) | | `reuse` | deterministic | nothing: on `main`, when the run reuses the one that tested its tree, it takes over that run's site as this run's `site` artifact and, except on a release commit, its Turbo cache, saved as `main`'s. It runs none of the repository's code | | `release-files` | deterministic | on a release commit, when the run reuses the one that tested its parent: the files a release writes, by Biome, markdownlint and cspell, as `npm run check` has them, and `publiccode.yml` by its parser | @@ -972,12 +974,13 @@ URLs are compared as strings or by parsed origin, not with patterns. ### The repository's own checks -`npm run check` starts with `npm run lint`: [Biome](https://biomejs.dev/) +`npm run check` starts with `npm run check:lints`, which runs `npm run lint`: [Biome](https://biomejs.dev/) fails on a TypeScript, JSON or CSS file it would format otherwise, or that breaks one of its lint rules ([style.md](style.md#formatting)). Then `npm run lint:markdown`, `npm run lint:spelling` and `npm run lint:prose` check the Markdown's style, spelling and prose -([Documentation](#documentation)). It ends with +([Documentation](#documentation)). Then `npm run check:tasks` runs +every package's Turbo tasks. It ends with `npm run check:repository`, checks of the repository as a whole, plain Node scripts in [scripts/](../scripts/): @@ -1183,7 +1186,7 @@ Dependency inversion gives every layer a seam for its fakes: | 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)). | | Infrastructure I/O shells | injected `fetch` + `vi.mock` | The app's client modules (`client/resources`, `client/rdf`, `client/session`) are mocked, or given a fake (`fakeSessionClient.ts` in `packages/solid/src/testing/`); the tests assert the shell's fetch → map → return. Only the client modules' own tests use the Solid client libraries: each runs its module's contract, in [src/testing/contract/](../packages/solid/src/testing/contract/), against Inrupt's implementation, and tests beside it what is the library's alone ([vendor-code.md](vendor-code.md#cost-of-swapping-a-vendor)). `solid-memo-client`'s tests run the same contract against its own. The contract holds both clients to reading no RDF-star in JSON-LD, which keeps the parser from holding annotated top-level nodes to the end, in time growing with their square; it stands in for a growth test there. | | Shapes (`packages/shacl/`, `packages/solid/src/conformance.test.ts`) | the real engine | The real `rdf-validate-shacl` over the real shapes in `ns/shapes/`: fixture documents under `packages/vocab/fixtures/` pass or fail as a table says. A record written through every descriptor, and every migration step's output, conforms (`conformance.test.ts`). Every library deck passes too, by `npm run library:check` ([deck-library.md](deck-library.md#checks)). Shape documents are read as the browser reads them, through a `fetch` that serves the repository's files at their IRIs (`shapesFetch` in [sources.ts](../packages/vocab/tooling/sources.ts), a `Response` with its `url` set). | -| The round trip (`packages/solid/src/releaseDrafts.roundtrip.test.ts`) | the real draft adapter | Every release in `decks/` becomes a draft in a pod kept in memory, as an import makes one, is read back as the Studio opens it, and is published again at its own address (`assemble`), its triples coming back apart from what publishing sets (the format versions, its time of change), a blank node compared by what it says. Its task, `test:roundtrip`, in `npm run check`, runs again only when `decks/`, `ns/` or the package change. The unit tests take three releases the same way: a course, one with blank nodes, a plain deck. | +| The round trip (`packages/solid/src/releaseDrafts..roundtrip.test.ts`) | the real draft adapter | Every release in `decks/` becomes a draft in a pod kept in memory, as an import makes one, is read back as the Studio opens it, and is published again at its own address (`assemble`), its triples coming back apart from what publishing sets (the format versions, its time of change), a blank node compared by what it says. Its task, `test:roundtrip`, in `npm run check`, runs again only when `decks/`, `ns/` or the package change. The releases are dealt into `ROUND_TRIP_FILES` files of about the same size, the largest first, each to the smallest file so far (`libraryReleaseShards` in `src/testing/releaseShards.ts`), whose test holds that each release is in exactly one, and that each file is there. The unit tests take three releases the same way: a course, one with blank nodes, a plain deck. | | The library (`packages/solid/src/libraryReleases.test.ts`) | the real library adapter | Every release in `decks/` and the index, read as the app reads them, through `createSolidDeckLibrary` over a fetch serving the repository's files at their IRIs. The index lists every deck, and each release reads with all its cards (its retired ones retired). A course's outline has every chapter and step it uses, each asking cards the app can ask (a back and at least two distractors). The library check validates the releases; this tests what the app makes of them. Its rules (`packages/domain/src/release/`) have their own tests, and a golden test, `packages/shacl/node/deckLibrary.golden.test.ts`, keeps as text what they say of published releases with faults put in. | | Markdown (`packages/markdown/`) | none needed | Pure functions over strings: every node in both profiles, the folds, the chunks of a step's theory, each rule for a release (`markdownProblems`), the limits at every entry point, and the cmark and commonmark.js pathological inputs at the length cap read in time linear in their length ([markdown.md](markdown.md#limits)). | | The docs' diagrams (`packages/site/`) | an injected `launch` | What a block must have, the settings, the keys and `checkSvg` are pure functions (`diagrams.test.ts`); `FONT_CHARS` is held to the `cmap` of the installed Fredoka file, read from its WOFF2. `drawDiagrams.test.ts` draws through a fake browser (`evaluate(fn, arg)` calls `fn(arg)` in node), a fake `mermaid` and `document.fonts`, covering the drawing page's callbacks: cache hits, a hit failing `checkSvg`, misses written under a temporary name, the font's notice in every drawing, a block Mermaid refuses at the page's line, Chromium missing when drawing is required or optional, and `DOCS_DIAGRAMS=skip`, in CI too, drawing nothing and starting no browser. One test, the only one of the real launch, draws a flowchart, a sequence diagram and a state diagram in both themes in the real Chromium with the official Mermaid (timeout 120 s); `DOCS_DIAGRAMS=skip` skips it, so the package's coverage and `npm run check` fail ([markdown.md](markdown.md#diagrams)). | @@ -1209,6 +1212,14 @@ graph LR ## Conventions - Tests are colocated: `foo.ts` ↔ `foo.test.ts`. +- Vitest runs a package's test files at once, each on a worker, but a + file's tests one after another. A file of slow tests is split into + files of about the same time, by what they test, their shared setup + in `src/testing/`. The deck repository's growth tests are + `solidDeckRepository..growth.test.ts`, over + `packages/solid/src/testing/deckWrites.ts`. The round trip of the + library's releases is `releaseDrafts..roundtrip.test.ts` + ([Strategy per layer](#strategy-per-layer)). - Test files follow the same import boundaries as their subject ([boundaries.md](boundaries.md)); `packages/shacl/src/testing/turtle.ts` parses Turtle through `src/turtleDocument.ts` for the SHACL tests. diff --git a/package.json b/package.json index 6ac7d3f63..98458b944 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,10 @@ "test": "turbo run test", "test:unit": "vitest run", "test:watch": "vitest", - "check": "npm run lint && npm run lint:markdown && npm run lint:spelling && npm run lint:prose && turbo run typecheck test test:roundtrip generate:check format:turtle:check library:check && npm run check:repository", - "check:ci": "npm run lint && npm run lint:markdown && npm run lint:spelling && npm run lint:prose && turbo run typecheck test test:roundtrip generate:check format:turtle:check && npm run check:repository", + "check": "npm run check:lints && npm run check:tasks -- library:check && npm run check:repository", + "check:ci": "npm run check:lints && npm run check:tasks && npm run check:repository", + "check:lints": "npm run lint && npm run lint:markdown && npm run lint:spelling && npm run lint:prose", + "check:tasks": "turbo run typecheck test test:roundtrip generate:check format:turtle:check", "check:repository": "npm run check:boundaries && npm run check:exports && npm run check:scripts && npm run check:docs && npm run check:dependencies && npm run check:reuse && npm run check:actions && npm run check:shell && npm run check:keys", "check:breaking": "node -e \"import('./scripts/checkBreaking.ts').then((m) => m.run(process))\" --", "check:closes": "node -e \"import('./scripts/checkClosesIssue.ts').then((m) => m.run(process))\" --", diff --git a/packages/solid/src/releaseDrafts.roundtrip.test.ts b/packages/solid/src/releaseDrafts.1.roundtrip.test.ts similarity index 58% rename from packages/solid/src/releaseDrafts.roundtrip.test.ts rename to packages/solid/src/releaseDrafts.1.roundtrip.test.ts index de1bd8f5d..4325e2af3 100644 --- a/packages/solid/src/releaseDrafts.roundtrip.test.ts +++ b/packages/solid/src/releaseDrafts.1.roundtrip.test.ts @@ -9,13 +9,9 @@ * format versions, its time of change). Its own Turbo task, `npm run * test:roundtrip -w @solid-memo/solid`, run by `npm run check`, again only * when decks/ changes; the unit tests make the trip with a few releases. + * This file takes one shard of the releases, each such file another + * (testing/releaseShards.ts), so that they run at once. */ -import { describe, expect, it } from "vitest"; -import { draftPod, libraryReleases, roundTrip } from "./testing/releaseDrafts"; +import { describeRoundTrips } from "./testing/releaseShards"; -describe.each(libraryReleases())("decks/%s", (path) => { - it("comes back whole as a draft published again", { timeout: 60_000 }, async () => { - const { release, assembled } = await roundTrip(await draftPod(), path); - expect(assembled).toEqual(release); - }); -}); +describeRoundTrips(1); diff --git a/packages/solid/src/releaseDrafts.2.roundtrip.test.ts b/packages/solid/src/releaseDrafts.2.roundtrip.test.ts new file mode 100644 index 000000000..75fccaf09 --- /dev/null +++ b/packages/solid/src/releaseDrafts.2.roundtrip.test.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +/** + * Every release of the deck library made a draft in a pod as it is, then + * published again at its own address (docs/testing.md, The round trip): + * what it says comes back whole, apart from what publishing sets (its + * format versions, its time of change). Its own Turbo task, `npm run + * test:roundtrip -w @solid-memo/solid`, run by `npm run check`, again only + * when decks/ changes; the unit tests make the trip with a few releases. + * This file takes one shard of the releases, each such file another + * (testing/releaseShards.ts), so that they run at once. + */ +import { describeRoundTrips } from "./testing/releaseShards"; + +describeRoundTrips(2); diff --git a/packages/solid/src/releaseDrafts.3.roundtrip.test.ts b/packages/solid/src/releaseDrafts.3.roundtrip.test.ts new file mode 100644 index 000000000..02c3acbd0 --- /dev/null +++ b/packages/solid/src/releaseDrafts.3.roundtrip.test.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +/** + * Every release of the deck library made a draft in a pod as it is, then + * published again at its own address (docs/testing.md, The round trip): + * what it says comes back whole, apart from what publishing sets (its + * format versions, its time of change). Its own Turbo task, `npm run + * test:roundtrip -w @solid-memo/solid`, run by `npm run check`, again only + * when decks/ changes; the unit tests make the trip with a few releases. + * This file takes one shard of the releases, each such file another + * (testing/releaseShards.ts), so that they run at once. + */ +import { describeRoundTrips } from "./testing/releaseShards"; + +describeRoundTrips(3); diff --git a/packages/solid/src/releaseDrafts.4.roundtrip.test.ts b/packages/solid/src/releaseDrafts.4.roundtrip.test.ts new file mode 100644 index 000000000..6abe09742 --- /dev/null +++ b/packages/solid/src/releaseDrafts.4.roundtrip.test.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +/** + * Every release of the deck library made a draft in a pod as it is, then + * published again at its own address (docs/testing.md, The round trip): + * what it says comes back whole, apart from what publishing sets (its + * format versions, its time of change). Its own Turbo task, `npm run + * test:roundtrip -w @solid-memo/solid`, run by `npm run check`, again only + * when decks/ changes; the unit tests make the trip with a few releases. + * This file takes one shard of the releases, each such file another + * (testing/releaseShards.ts), so that they run at once. + */ +import { describeRoundTrips } from "./testing/releaseShards"; + +describeRoundTrips(4); diff --git a/packages/solid/src/solidDeckRepository.cards.growth.test.ts b/packages/solid/src/solidDeckRepository.cards.growth.test.ts new file mode 100644 index 000000000..567fc10fd --- /dev/null +++ b/packages/solid/src/solidDeckRepository.cards.growth.test.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +/** + * A deck's cards are written in time that grows with them, not their + * square, against a pod in memory: a release added, or refused for a + * distractor its cards share, one card's many distractors written and + * changed, and the deck's cards removed. + * + * Eight times as many must take less than four times as long as an + * eighth of them done eight times over (testing.md); what puts the input + * in place is not timed (growthAfterSetup). The other writes of a deck + * are timed in the other solidDeckRepository.*.growth.test.ts files, each + * a file of its own so that they run at once. + */ +import { describe, expect, it } from "vitest"; +import { growthAfterSetup, growthAsync } from "@solid-memo/domain/testing/growth"; +import { + INSTANCE, + release, + oneCard, + sharedDistractor, + repositoryInPod, +} from "./testing/deckWrites"; + +// Each test does its work many times over, at up to thousands of cards: a +// few seconds on a laptop, and many times that in CI, coverage on and +// every package's tests run at once. What is held to is the ratio, of +// CPU time; the clock only stops a test that never ends. +describe("card writes", { timeout: 180_000 }, () => { + it("add a release in time that grows with its cards, not their square", async () => { + const ratio = await growthAsync( + (count) => { + const content = release(count); + return () => repositoryInPod().repository.importDeck(INSTANCE, content); + }, + 600, + 8, + ); + expect(ratio).toBeLessThan(4); + }); + + it("add a release of one card with many distractors in time that grows with them, not their square", async () => { + const ratio = await growthAsync( + (count) => { + const content = oneCard(count); + return () => repositoryInPod().repository.importDeck(INSTANCE, content); + }, + 500, + 8, + ); + expect(ratio).toBeLessThan(4); + }); + + it("refuse a release whose cards share a distractor in time that grows with them, not their square (GHSA-f72p-j39c-w728)", async () => { + const ratio = await growthAsync( + (count) => { + const content = sharedDistractor(count); + return () => + expect(repositoryInPod().repository.importDeck(INSTANCE, content)).rejects.toMatchObject({ + code: "idTakenInDeck", + }); + }, + 300, + 8, + ); + expect(ratio).toBeLessThan(4); + }); + + it("remove a deck's cards in time that grows with them, not their square", async () => { + const ratio = await growthAfterSetup( + (count) => async () => { + const content = release(count); + const { repository } = repositoryInPod(); + const deck = await repository.importDeck(INSTANCE, content); + const ids = content.cards.map((card) => card.id); + return () => repository.applyCardChanges(deck, { save: [], remove: ids }); + }, + 300, + 8, + ); + expect(ratio).toBeLessThan(4); + }); + + it("change every distractor of one card in time that grows with them, not their square", async () => { + const ratio = await growthAfterSetup( + (count) => async () => { + const { repository } = repositoryInPod(); + const deck = await repository.importDeck(INSTANCE, oneCard(count)); + const card = oneCard(count, count).cards[0]!; + return () => repository.applyCardChanges(deck, { save: [card], remove: [] }); + }, + 300, + 8, + ); + expect(ratio).toBeLessThan(4); + }); +}); diff --git a/packages/solid/src/solidDeckRepository.decks.growth.test.ts b/packages/solid/src/solidDeckRepository.decks.growth.test.ts new file mode 100644 index 000000000..01d00fdbd --- /dev/null +++ b/packages/solid/src/solidDeckRepository.decks.growth.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +/** + * Decks are changed and removed in time that grows with them and their + * cards, not their square, against a pod in memory: a deck removed from + * a document another app wrote in, and many decks of a catalog changed + * and removed at once. + * + * Eight times as many must take less than four times as long as an + * eighth of them done eight times over (testing.md); what puts the input + * in place is not timed (growthAfterSetup). The other writes of a deck + * are timed in the other solidDeckRepository.*.growth.test.ts files, each + * a file of its own so that they run at once. + */ +import { describe, expect, it } from "vitest"; +import { growthAfterSetup } from "@solid-memo/domain/testing/growth"; +import { + INSTANCE, + release, + repositoryInPod, + put, + read, + catalogOfDecks, +} from "./testing/deckWrites"; + +// Each test does its work many times over, at up to thousands of cards: a +// few seconds on a laptop, and many times that in CI, coverage on and +// every package's tests run at once. What is held to is the ratio, of +// CPU time; the clock only stops a test that never ends. +describe("card writes", { timeout: 180_000 }, () => { + it("remove a deck from a cards document another app wrote in, in time that grows with its cards, not their square", async () => { + const ratio = await growthAfterSetup( + (count) => async () => { + const { fetch, repository } = repositoryInPod(); + const deck = await repository.importDeck(INSTANCE, release(count)); + const text = await read(fetch, deck.cardsDocumentUrl); + const other = `<${deck.cardsDocumentUrl}#other> "kept" .`; + await put(fetch, deck.cardsDocumentUrl, `${text}\n${other}`); + return async () => { + const { keptDocuments } = await repository.removeDeck(deck); + expect(keptDocuments).toEqual([deck.cardsDocumentUrl]); + }; + }, + 300, + 8, + ); + expect(ratio).toBeLessThan(4); + }); + + it("change many decks of a catalog at once in time that grows with them, not their square", async () => { + const ratio = await growthAfterSetup( + (count) => async () => { + const { repository } = await catalogOfDecks(count); + const decks = await repository.listDecks(INSTANCE); + // Long titles, so that the change is sent as one PUT at either size, never as a PATCH. + const changed = decks.map((deck) => ({ + ...deck, + title: { en: `${deck.id} ${"x".repeat(1000)}` }, + direction: "back-to-front" as const, + })); + return async () => { + await repository.saveDecks(changed); + }; + }, + 100, + 8, + ); + expect(ratio).toBeLessThan(4); + }); + + it("remove many decks of a catalog at once, each listed by a group, in time that grows with them, not their square", async () => { + const ratio = await growthAfterSetup( + (count) => async () => { + const { fetch, repository } = await catalogOfDecks(count); + const decks = await repository.listDecks(INSTANCE); + return async () => { + expect(await repository.removeDecks(decks)).toEqual({ keptDocuments: [] }); + expect(await read(fetch, `${INSTANCE}catalog.ttl`)).not.toContain("deck-"); + expect((await fetch(`${INSTANCE}decks/deck-0.ttl`)).status).toBe(404); + }; + }, + 100, + 8, + ); + expect(ratio).toBeLessThan(4); + }); +}); diff --git a/packages/solid/src/solidDeckRepository.formats.growth.test.ts b/packages/solid/src/solidDeckRepository.formats.growth.test.ts new file mode 100644 index 000000000..0ec04383b --- /dev/null +++ b/packages/solid/src/solidDeckRepository.formats.growth.test.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +/** + * A deck's cards and a catalog's entries in an older format are + * brought to this app's in time that grows with them, not their square, + * against a pod in memory. + * + * Eight times as many must take less than four times as long as an + * eighth of them done eight times over (testing.md); what puts the input + * in place is not timed (growthAfterSetup). The other writes of a deck + * are timed in the other solidDeckRepository.*.growth.test.ts files, each + * a file of its own so that they run at once. + */ +import { describe, expect, it } from "vitest"; +import { growthAfterSetup, growthAsync } from "@solid-memo/domain/testing/growth"; +import { INSTANCE, SM, release, repositoryInPod, put } from "./testing/deckWrites"; + +// Each test does its work many times over, at up to thousands of cards: a +// few seconds on a laptop, and many times that in CI, coverage on and +// every package's tests run at once. What is held to is the ratio, of +// CPU time; the clock only stops a test that never ends. +describe("card writes", { timeout: 180_000 }, () => { + it("bring cards in an older format to this app's in time that grows with them, not their square", async () => { + const ratio = await growthAsync( + (count) => { + const turtle = Array.from( + { length: count }, + (_, i) => + `<#c${i}> a <${SM}Card> ; <${SM}front> "front ${i}" ; <${SM}back> "back ${i}" ; <${SM}formatVersion> 6 .`, + ).join("\n"); + return async () => { + const { fetch, repository } = repositoryInPod(); + const deck = await repository.importDeck(INSTANCE, release(0)); + await put(fetch, deck.cardsDocumentUrl, turtle); + expect(await repository.upgradeCards(deck)).toBe(true); + }; + }, + 1200, + 8, + ); + expect(ratio).toBeLessThan(4); + }); + + it("bring catalog entries in an older format to this app's, each listed by the catalog, in time that grows with them, not their square", async () => { + const ratio = await growthAfterSetup( + (count) => async () => { + const { fetch, repository } = repositoryInPod(); + // Entries as format 1 wrote them, none listed by the catalog yet. + const entries = Array.from( + { length: count }, + (_, i) => + `<#deck-${i}> a <${SM}Deck> ; "Deck ${i}" ; <${SM}cardsDocument> ; <${SM}reviewsDocument> .`, + ); + const catalog = "<#catalog> a ."; + await put(fetch, `${INSTANCE}catalog.ttl`, [catalog, ...entries].join("\n")); + return async () => { + expect(await repository.upgradeDecks(INSTANCE, null)).toBe(true); + }; + }, + 200, + 8, + ); + expect(ratio).toBeLessThan(4); + }); +}); diff --git a/packages/solid/src/solidDeckRepository.growth.test.ts b/packages/solid/src/solidDeckRepository.growth.test.ts deleted file mode 100644 index e9f081ba1..000000000 --- a/packages/solid/src/solidDeckRepository.growth.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors -// -// SPDX-License-Identifier: MIT - -/** - * A deck's cards, review states and answers are written and removed in - * time that grows with them, not their square, against a pod in memory: - * a release added or refused for a distractor its cards share, its - * cards removed, one card's many distractors written and changed, a - * deck removed from a document another app wrote - * in, and cards, catalog entries and review states in an older format - * brought to this app's, and a course's completed chapters changed. - * Eight times as many must take less than four - * times as long as an eighth of them done eight times over (testing.md); - * what puts the input in place is not timed (growthAfterSetup). - */ -import { describe, expect, it } from "vitest"; -import type { Answer } from "@solid-memo/domain/answer"; -import type { LibraryDeckContent } from "@solid-memo/domain/library"; -import type { ReviewState } from "@solid-memo/domain/review"; -import { growthAfterSetup, growthAsync } from "@solid-memo/domain/testing/growth"; -import { createLocalPod } from "./localPod"; -import { createMemoryResourceStore } from "./memoryResourceStore"; -import { createSolidAnswerLog } from "./solidAnswerLog"; -import { createSolidDeckRepository } from "./solidDeckRepository"; -import { createSolidReviewStateRepository } from "./solidReviewStateRepository"; - -const INSTANCE = "https://pod.example/memo/"; -const SM = "https://solid-memo.com/ns/vocab/v1.ttl#"; - -/** A release of `cards`. */ -function releaseOf(cards: LibraryDeckContent["cards"]): LibraryDeckContent { - return { - url: "https://library.example/releases/x/v1.ttl", - seriesUrl: "https://library.example/releases/x/v1.ttl#series", - title: { en: "Deck" }, - formatVersion: 6, - authors: [], - direction: "front-to-back", - version: "1", - themes: [], - keywords: {}, - cards, - }; -} - -/** A release of `count` cards, each with a distractor. */ -function release(count: number): LibraryDeckContent { - return releaseOf( - Array.from({ length: count }, (_, i) => ({ - id: `c${i}`, - front: { en: `front ${i}` }, - back: { en: `back ${i}` }, - formatVersion: 7, - distractors: [{ id: `d${i}`, text: { en: `wrong ${i}` } }], - })), - ); -} - -/** A release of one card with `count` distractors, their ids from `first` on. */ -function oneCard(count: number, first = 0): LibraryDeckContent { - return releaseOf([ - { - id: "c0", - front: { en: "front" }, - back: { en: "back" }, - formatVersion: 7, - distractors: Array.from({ length: count }, (_, i) => ({ - id: `d${first + i}`, - text: { en: `wrong ${first + i}` }, - })), - }, - ]); -} - -/** A release of `count` cards that all name one distractor, in `count` languages. */ -function sharedDistractor(count: number): LibraryDeckContent { - const distractor = { - id: "d", - text: Object.fromEntries(Array.from({ length: count }, (_, i) => [`en-x-l${i}`, "wrong"])), - }; - return releaseOf( - Array.from({ length: count }, (_, i) => ({ - id: `c${i}`, - front: { en: `front ${i}` }, - back: { en: `back ${i}` }, - formatVersion: 7, - distractors: [distractor], - })), - ); -} - -/** A pod in memory and a deck repository over it. */ -function repositoryInPod() { - let n = 0; - const fetch = createLocalPod({ - root: "https://pod.example/", - store: createMemoryResourceStore(), - newEtag: () => `"e${n++}"`, - }); - const repository = createSolidDeckRepository({ - fetch, - now: () => new Date("2026-10-30T00:00:00Z"), - randomId: () => `r${n++}`, - }); - return { fetch, repository }; -} - -/** Puts `text` (Turtle) in place of the document at `url`. */ -async function put(fetch: typeof globalThis.fetch, url: string, text: string): Promise { - const response = await fetch(url, { - method: "PUT", - headers: { "Content-Type": "text/turtle" }, - body: text, - }); - expect(response.ok).toBe(true); -} - -/** The text of the document at `url`. */ -async function read(fetch: typeof globalThis.fetch, url: string): Promise { - return (await fetch(url)).text(); -} - -/** An SM-2 review state of the card `c${i}`. */ -function stateOf(i: number): ReviewState { - return { - cardId: `c${i}`, - direction: "front-to-back", - easeFactor: 2.5, - intervalDays: 1, - repetitions: 1, - due: "2026-10-31", - firstReviewedAt: "2026-10-30T08:00:00.000Z", - lastReviewedAt: "2026-10-30T08:00:00.000Z", - formatVersion: 2, - }; -} - -/** A deck in a pod in memory, with `count` review states in its reviews document. */ -async function deckWithStates(count: number) { - const { fetch, repository } = repositoryInPod(); - const deck = await repository.importDeck(INSTANCE, release(0)); - const reviews = createSolidReviewStateRepository({ fetch, randomId: () => "id" }); - const states = Array.from({ length: count }, (_, i) => stateOf(i)); - await reviews.createReviewStates(deck, { sm2: states, fsrs: [] }); - return { fetch, deck, reviews, states }; -} - -/** - * A catalog of `count` decks in this app's format, each listed by the - * catalog and by one deck group, with its cards document, as the - * Studio's bulk actions find them. - */ -async function catalogOfDecks(count: number) { - const { fetch, repository } = repositoryInPod(); - const entries = Array.from( - { length: count }, - (_, i) => - `<#deck-${i}> a <${SM}Deck> ; "Deck ${i}" ; <${SM}cardsDocument> ; <${SM}reviewsDocument> .`, - ); - const listed = Array.from({ length: count }, (_, i) => `<#deck-${i}>`).join(", "); - const group = `<#group-1> a <${SM}DeckGroup> ; ${listed} .`; - const catalog = - "<#catalog> a ; <#group-1> ."; - await put(fetch, `${INSTANCE}catalog.ttl`, [catalog, group, ...entries].join("\n")); - expect(await repository.upgradeDecks(INSTANCE, null)).toBe(true); - // Each deck's cards document, saying it is the deck's: removed with it. - for (let i = 0; i < count; i++) { - const card = `<#c0> a <${SM}Card> ; <${SM}front> "front" ; <${SM}back> "back" ; <${SM}formatVersion> 7 .`; - const partOf = `<> <../catalog.ttl#deck-${i}> .`; - await put(fetch, `${INSTANCE}decks/deck-${i}.ttl`, `${partOf}\n${card}`); - } - return { fetch, repository }; -} - -// Each test does its work many times over, at up to thousands of cards: a -// few seconds on a laptop, and many times that in CI, coverage on and -// every package's tests run at once. What is held to is the ratio, of -// CPU time; the clock only stops a test that never ends. -describe("card writes", { timeout: 180_000 }, () => { - it("add a release in time that grows with its cards, not their square", async () => { - const ratio = await growthAsync( - (count) => { - const content = release(count); - return () => repositoryInPod().repository.importDeck(INSTANCE, content); - }, - 600, - 8, - ); - expect(ratio).toBeLessThan(4); - }); - - it("add a release of one card with many distractors in time that grows with them, not their square", async () => { - const ratio = await growthAsync( - (count) => { - const content = oneCard(count); - return () => repositoryInPod().repository.importDeck(INSTANCE, content); - }, - 500, - 8, - ); - expect(ratio).toBeLessThan(4); - }); - - it("refuse a release whose cards share a distractor in time that grows with them, not their square (GHSA-f72p-j39c-w728)", async () => { - const ratio = await growthAsync( - (count) => { - const content = sharedDistractor(count); - return () => - expect(repositoryInPod().repository.importDeck(INSTANCE, content)).rejects.toMatchObject({ - code: "idTakenInDeck", - }); - }, - 300, - 8, - ); - expect(ratio).toBeLessThan(4); - }); - - it("remove a deck's cards in time that grows with them, not their square", async () => { - const ratio = await growthAfterSetup( - (count) => async () => { - const content = release(count); - const { repository } = repositoryInPod(); - const deck = await repository.importDeck(INSTANCE, content); - const ids = content.cards.map((card) => card.id); - return () => repository.applyCardChanges(deck, { save: [], remove: ids }); - }, - 300, - 8, - ); - expect(ratio).toBeLessThan(4); - }); - - it("change every distractor of one card in time that grows with them, not their square", async () => { - const ratio = await growthAfterSetup( - (count) => async () => { - const { repository } = repositoryInPod(); - const deck = await repository.importDeck(INSTANCE, oneCard(count)); - const card = oneCard(count, count).cards[0]!; - return () => repository.applyCardChanges(deck, { save: [card], remove: [] }); - }, - 300, - 8, - ); - expect(ratio).toBeLessThan(4); - }); - - it("remove a deck from a cards document another app wrote in, in time that grows with its cards, not their square", async () => { - const ratio = await growthAfterSetup( - (count) => async () => { - const { fetch, repository } = repositoryInPod(); - const deck = await repository.importDeck(INSTANCE, release(count)); - const text = await read(fetch, deck.cardsDocumentUrl); - const other = `<${deck.cardsDocumentUrl}#other> "kept" .`; - await put(fetch, deck.cardsDocumentUrl, `${text}\n${other}`); - return async () => { - const { keptDocuments } = await repository.removeDeck(deck); - expect(keptDocuments).toEqual([deck.cardsDocumentUrl]); - }; - }, - 300, - 8, - ); - expect(ratio).toBeLessThan(4); - }); - - it("bring cards in an older format to this app's in time that grows with them, not their square", async () => { - const ratio = await growthAsync( - (count) => { - const turtle = Array.from( - { length: count }, - (_, i) => - `<#c${i}> a <${SM}Card> ; <${SM}front> "front ${i}" ; <${SM}back> "back ${i}" ; <${SM}formatVersion> 6 .`, - ).join("\n"); - return async () => { - const { fetch, repository } = repositoryInPod(); - const deck = await repository.importDeck(INSTANCE, release(0)); - await put(fetch, deck.cardsDocumentUrl, turtle); - expect(await repository.upgradeCards(deck)).toBe(true); - }; - }, - 1200, - 8, - ); - expect(ratio).toBeLessThan(4); - }); - - it("bring catalog entries in an older format to this app's, each listed by the catalog, in time that grows with them, not their square", async () => { - const ratio = await growthAfterSetup( - (count) => async () => { - const { fetch, repository } = repositoryInPod(); - // Entries as format 1 wrote them, none listed by the catalog yet. - const entries = Array.from( - { length: count }, - (_, i) => - `<#deck-${i}> a <${SM}Deck> ; "Deck ${i}" ; <${SM}cardsDocument> ; <${SM}reviewsDocument> .`, - ); - const catalog = "<#catalog> a ."; - await put(fetch, `${INSTANCE}catalog.ttl`, [catalog, ...entries].join("\n")); - return async () => { - expect(await repository.upgradeDecks(INSTANCE, null)).toBe(true); - }; - }, - 200, - 8, - ); - expect(ratio).toBeLessThan(4); - }); - - it("change many decks of a catalog at once in time that grows with them, not their square", async () => { - const ratio = await growthAfterSetup( - (count) => async () => { - const { repository } = await catalogOfDecks(count); - const decks = await repository.listDecks(INSTANCE); - // Long titles, so that the change is sent as one PUT at either size, never as a PATCH. - const changed = decks.map((deck) => ({ - ...deck, - title: { en: `${deck.id} ${"x".repeat(1000)}` }, - direction: "back-to-front" as const, - })); - return async () => { - await repository.saveDecks(changed); - }; - }, - 100, - 8, - ); - expect(ratio).toBeLessThan(4); - }); - - it("remove many decks of a catalog at once, each listed by a group, in time that grows with them, not their square", async () => { - const ratio = await growthAfterSetup( - (count) => async () => { - const { fetch, repository } = await catalogOfDecks(count); - const decks = await repository.listDecks(INSTANCE); - return async () => { - expect(await repository.removeDecks(decks)).toEqual({ keptDocuments: [] }); - expect(await read(fetch, `${INSTANCE}catalog.ttl`)).not.toContain("deck-"); - expect((await fetch(`${INSTANCE}decks/deck-0.ttl`)).status).toBe(404); - }; - }, - 100, - 8, - ); - expect(ratio).toBeLessThan(4); - }); - - it("bring review states in an older format to this app's in time that grows with them, not their square", async () => { - const ratio = await growthAfterSetup( - (count) => async () => { - const { fetch, deck, reviews } = await deckWithStates(count); - // The same states as format 1 wrote them. - const text = await read(fetch, deck.reviewsDocumentUrl); - const older = text.replaceAll(`formatVersion> "2"`, `formatVersion> "1"`); - await put(fetch, deck.reviewsDocumentUrl, older); - return async () => { - expect(await reviews.upgradeReviewStates(deck)).toBe(true); - }; - }, - 300, - 8, - ); - expect(ratio).toBeLessThan(4); - }); - - it("remove review states in time that grows with them, not their square", async () => { - const ratio = await growthAfterSetup( - (count) => async () => { - const { deck, reviews, states } = await deckWithStates(count); - const keys = states.map(({ cardId, direction }) => ({ cardId, direction })); - return () => reviews.applyReviewChanges(deck, { save: [], remove: keys, every: true }); - }, - 300, - 8, - ); - expect(ratio).toBeLessThan(4); - }); - - it("remove a day's answers in time that grows with them, not their square", async () => { - const deckUrl = `${INSTANCE}catalog.ttl#deck-1`; - const ratio = await growthAfterSetup( - (count) => async () => { - const { fetch } = repositoryInPod(); - const log = createSolidAnswerLog({ fetch }); - const answers = Array.from( - { length: count }, - (_, i): Answer => ({ - id: `answer-${i}`, - deckUrl, - cardUrl: `${INSTANCE}decks/deck-1.ttl#c${i}`, - direction: "front-to-back", - grade: 4, - answeredAt: "2026-10-30T10:00:00.000Z", - studyDay: "2026-10-30", - nextIntervalDays: 6, - }), - ); - await log.appendAll(INSTANCE, answers); - return () => log.removeDay(INSTANCE, deckUrl, "2026-10-30"); - }, - 300, - 8, - ); - expect(ratio).toBeLessThan(4); - }); - - it("change a course's completed chapters in time that grows with them, not their square", async () => { - const ratio = await growthAfterSetup( - (count) => async () => { - const { fetch, repository } = repositoryInPod(); - const deck = await repository.importDeck(INSTANCE, release(0)); - // Completions all named after one chapter, as another app may write them. - const catalog = await read(fetch, `${INSTANCE}catalog.ttl`); - const completed = Array.from( - { length: count }, - (_, i) => - `<${deck.url}> <${SM}completedChapter> .`, - ); - await put(fetch, `${INSTANCE}catalog.ttl`, [catalog, ...completed].join("\n")); - return () => - repository.setCompletedChapters(deck, { - kind: "notDone", - chapterUrl: "https://library.example/releases/x/v1.ttl#ch-1", - }); - }, - 1000, - 8, - ); - expect(ratio).toBeLessThan(4); - }); -}); diff --git a/packages/solid/src/solidDeckRepository.progress.growth.test.ts b/packages/solid/src/solidDeckRepository.progress.growth.test.ts new file mode 100644 index 000000000..0c3139a32 --- /dev/null +++ b/packages/solid/src/solidDeckRepository.progress.growth.test.ts @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +/** + * What a learner's study writes is changed in time that grows with it, + * not its square, against a pod in memory: review states in an older + * format brought to this app's and review states removed, a day's + * answers removed, and a course's completed chapters changed. + * + * Eight times as many must take less than four times as long as an + * eighth of them done eight times over (testing.md); what puts the input + * in place is not timed (growthAfterSetup). The other writes of a deck + * are timed in the other solidDeckRepository.*.growth.test.ts files, each + * a file of its own so that they run at once. + */ +import { describe, expect, it } from "vitest"; +import type { Answer } from "@solid-memo/domain/answer"; +import { growthAfterSetup } from "@solid-memo/domain/testing/growth"; +import { createSolidAnswerLog } from "./solidAnswerLog"; +import { + INSTANCE, + SM, + release, + repositoryInPod, + put, + read, + deckWithStates, +} from "./testing/deckWrites"; + +// Each test does its work many times over, at up to thousands of cards: a +// few seconds on a laptop, and many times that in CI, coverage on and +// every package's tests run at once. What is held to is the ratio, of +// CPU time; the clock only stops a test that never ends. +describe("card writes", { timeout: 180_000 }, () => { + it("bring review states in an older format to this app's in time that grows with them, not their square", async () => { + const ratio = await growthAfterSetup( + (count) => async () => { + const { fetch, deck, reviews } = await deckWithStates(count); + // The same states as format 1 wrote them. + const text = await read(fetch, deck.reviewsDocumentUrl); + const older = text.replaceAll(`formatVersion> "2"`, `formatVersion> "1"`); + await put(fetch, deck.reviewsDocumentUrl, older); + return async () => { + expect(await reviews.upgradeReviewStates(deck)).toBe(true); + }; + }, + 300, + 8, + ); + expect(ratio).toBeLessThan(4); + }); + + it("remove review states in time that grows with them, not their square", async () => { + const ratio = await growthAfterSetup( + (count) => async () => { + const { deck, reviews, states } = await deckWithStates(count); + const keys = states.map(({ cardId, direction }) => ({ cardId, direction })); + return () => reviews.applyReviewChanges(deck, { save: [], remove: keys, every: true }); + }, + 300, + 8, + ); + expect(ratio).toBeLessThan(4); + }); + + it("remove a day's answers in time that grows with them, not their square", async () => { + const deckUrl = `${INSTANCE}catalog.ttl#deck-1`; + const ratio = await growthAfterSetup( + (count) => async () => { + const { fetch } = repositoryInPod(); + const log = createSolidAnswerLog({ fetch }); + const answers = Array.from( + { length: count }, + (_, i): Answer => ({ + id: `answer-${i}`, + deckUrl, + cardUrl: `${INSTANCE}decks/deck-1.ttl#c${i}`, + direction: "front-to-back", + grade: 4, + answeredAt: "2026-10-30T10:00:00.000Z", + studyDay: "2026-10-30", + nextIntervalDays: 6, + }), + ); + await log.appendAll(INSTANCE, answers); + return () => log.removeDay(INSTANCE, deckUrl, "2026-10-30"); + }, + 300, + 8, + ); + expect(ratio).toBeLessThan(4); + }); + + it("change a course's completed chapters in time that grows with them, not their square", async () => { + const ratio = await growthAfterSetup( + (count) => async () => { + const { fetch, repository } = repositoryInPod(); + const deck = await repository.importDeck(INSTANCE, release(0)); + // Completions all named after one chapter, as another app may write them. + const catalog = await read(fetch, `${INSTANCE}catalog.ttl`); + const completed = Array.from( + { length: count }, + (_, i) => + `<${deck.url}> <${SM}completedChapter> .`, + ); + await put(fetch, `${INSTANCE}catalog.ttl`, [catalog, ...completed].join("\n")); + return () => + repository.setCompletedChapters(deck, { + kind: "notDone", + chapterUrl: "https://library.example/releases/x/v1.ttl#ch-1", + }); + }, + 1000, + 8, + ); + expect(ratio).toBeLessThan(4); + }); +}); diff --git a/packages/solid/src/testing/deckWrites.ts b/packages/solid/src/testing/deckWrites.ts new file mode 100644 index 000000000..564aa3acc --- /dev/null +++ b/packages/solid/src/testing/deckWrites.ts @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +import { expect } from "vitest"; +import type { LibraryDeckContent } from "@solid-memo/domain/library"; +import type { ReviewState } from "@solid-memo/domain/review"; +import { createLocalPod } from "../localPod"; +import { createMemoryResourceStore } from "../memoryResourceStore"; +import { createSolidDeckRepository } from "../solidDeckRepository"; +import { createSolidReviewStateRepository } from "../solidReviewStateRepository"; + +/** + * What the growth tests of a deck's writes (solidDeckRepository.*.growth.test.ts) + * share: releases of many cards or distractors, a deck repository over a + * pod in memory, and decks with many review states or a catalog of many + * decks put in place in it. + */ +export const INSTANCE = "https://pod.example/memo/"; +export const SM = "https://solid-memo.com/ns/vocab/v1.ttl#"; + +/** A release of `cards`. */ +function releaseOf(cards: LibraryDeckContent["cards"]): LibraryDeckContent { + return { + url: "https://library.example/releases/x/v1.ttl", + seriesUrl: "https://library.example/releases/x/v1.ttl#series", + title: { en: "Deck" }, + formatVersion: 6, + authors: [], + direction: "front-to-back", + version: "1", + themes: [], + keywords: {}, + cards, + }; +} + +/** A release of `count` cards, each with a distractor. */ +export function release(count: number): LibraryDeckContent { + return releaseOf( + Array.from({ length: count }, (_, i) => ({ + id: `c${i}`, + front: { en: `front ${i}` }, + back: { en: `back ${i}` }, + formatVersion: 7, + distractors: [{ id: `d${i}`, text: { en: `wrong ${i}` } }], + })), + ); +} + +/** A release of one card with `count` distractors, their ids from `first` on. */ +export function oneCard(count: number, first = 0): LibraryDeckContent { + return releaseOf([ + { + id: "c0", + front: { en: "front" }, + back: { en: "back" }, + formatVersion: 7, + distractors: Array.from({ length: count }, (_, i) => ({ + id: `d${first + i}`, + text: { en: `wrong ${first + i}` }, + })), + }, + ]); +} + +/** A release of `count` cards that all name one distractor, in `count` languages. */ +export function sharedDistractor(count: number): LibraryDeckContent { + const distractor = { + id: "d", + text: Object.fromEntries(Array.from({ length: count }, (_, i) => [`en-x-l${i}`, "wrong"])), + }; + return releaseOf( + Array.from({ length: count }, (_, i) => ({ + id: `c${i}`, + front: { en: `front ${i}` }, + back: { en: `back ${i}` }, + formatVersion: 7, + distractors: [distractor], + })), + ); +} + +/** A pod in memory and a deck repository over it. */ +export function repositoryInPod() { + let n = 0; + const fetch = createLocalPod({ + root: "https://pod.example/", + store: createMemoryResourceStore(), + newEtag: () => `"e${n++}"`, + }); + const repository = createSolidDeckRepository({ + fetch, + now: () => new Date("2026-10-30T00:00:00Z"), + randomId: () => `r${n++}`, + }); + return { fetch, repository }; +} + +/** Puts `text` (Turtle) in place of the document at `url`. */ +export async function put( + fetch: typeof globalThis.fetch, + url: string, + text: string, +): Promise { + const response = await fetch(url, { + method: "PUT", + headers: { "Content-Type": "text/turtle" }, + body: text, + }); + expect(response.ok).toBe(true); +} + +/** The text of the document at `url`. */ +export async function read(fetch: typeof globalThis.fetch, url: string): Promise { + return (await fetch(url)).text(); +} + +/** An SM-2 review state of the card `c${i}`. */ +function stateOf(i: number): ReviewState { + return { + cardId: `c${i}`, + direction: "front-to-back", + easeFactor: 2.5, + intervalDays: 1, + repetitions: 1, + due: "2026-10-31", + firstReviewedAt: "2026-10-30T08:00:00.000Z", + lastReviewedAt: "2026-10-30T08:00:00.000Z", + formatVersion: 2, + }; +} + +/** A deck in a pod in memory, with `count` review states in its reviews document. */ +export async function deckWithStates(count: number) { + const { fetch, repository } = repositoryInPod(); + const deck = await repository.importDeck(INSTANCE, release(0)); + const reviews = createSolidReviewStateRepository({ fetch, randomId: () => "id" }); + const states = Array.from({ length: count }, (_, i) => stateOf(i)); + await reviews.createReviewStates(deck, { sm2: states, fsrs: [] }); + return { fetch, deck, reviews, states }; +} + +/** + * A catalog of `count` decks in this app's format, each listed by the + * catalog and by one deck group, with its cards document, as the + * Studio's bulk actions find them. + */ +export async function catalogOfDecks(count: number) { + const { fetch, repository } = repositoryInPod(); + const entries = Array.from( + { length: count }, + (_, i) => + `<#deck-${i}> a <${SM}Deck> ; "Deck ${i}" ; <${SM}cardsDocument> ; <${SM}reviewsDocument> .`, + ); + const listed = Array.from({ length: count }, (_, i) => `<#deck-${i}>`).join(", "); + const group = `<#group-1> a <${SM}DeckGroup> ; ${listed} .`; + const catalog = + "<#catalog> a ; <#group-1> ."; + await put(fetch, `${INSTANCE}catalog.ttl`, [catalog, group, ...entries].join("\n")); + expect(await repository.upgradeDecks(INSTANCE, null)).toBe(true); + // Each deck's cards document, saying it is the deck's: removed with it. + for (let i = 0; i < count; i++) { + const card = `<#c0> a <${SM}Card> ; <${SM}front> "front" ; <${SM}back> "back" ; <${SM}formatVersion> 7 .`; + const partOf = `<> <../catalog.ttl#deck-${i}> .`; + await put(fetch, `${INSTANCE}decks/deck-${i}.ttl`, `${partOf}\n${card}`); + } + return { fetch, repository }; +} diff --git a/packages/solid/src/testing/releaseShards.test.ts b/packages/solid/src/testing/releaseShards.test.ts new file mode 100644 index 000000000..55321230b --- /dev/null +++ b/packages/solid/src/testing/releaseShards.test.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { libraryReleases } from "./releaseDrafts"; +import { balancedShards, libraryReleaseShards, ROUND_TRIP_FILES } from "./releaseShards"; + +const weights: Record = { a: 5, b: 4, c: 3, d: 3, e: 2, f: 1 }; +const weightOf = (item: string) => weights[item]!; + +describe("balancedShards", () => { + it("deals the heaviest first, each to the lightest shard so far", () => { + expect(balancedShards(["f", "e", "d", "c", "b", "a"], 2, weightOf)).toEqual([ + ["a", "d", "f"], + ["b", "c", "e"], + ]); + }); + + it("deals the same however the items come", () => { + const items = Object.keys(weights); + const once = balancedShards(items, 3, weightOf); + expect(balancedShards([...items].reverse(), 3, weightOf)).toEqual(once); + expect(once).toEqual([ + ["a", "f"], + ["b", "e"], + ["c", "d"], + ]); + }); + + it("leaves a shard empty when there are fewer items than shards", () => { + expect(balancedShards(["a"], 2, weightOf)).toEqual([["a"], []]); + }); +}); + +describe("libraryReleaseShards", () => { + it("takes every release of the library once", () => { + const shards = libraryReleaseShards(); + expect(shards).toHaveLength(ROUND_TRIP_FILES); + for (const shard of shards) expect(shard.length).toBeGreaterThan(0); + expect(shards.flat().sort()).toEqual(libraryReleases()); + }); + + it("has a round-trip file for each shard, and none more", () => { + const src = join(import.meta.dirname, ".."); + const files = readdirSync(src).filter((file) => file.includes(".roundtrip.test.")); + const expected = Array.from( + { length: ROUND_TRIP_FILES }, + (_, i) => `releaseDrafts.${i + 1}.roundtrip.test.ts`, + ); + expect(files.sort()).toEqual(expected); + for (const [i, file] of expected.entries()) { + expect(readFileSync(join(src, file), "utf8")).toContain(`describeRoundTrips(${i + 1});`); + } + }); +}); diff --git a/packages/solid/src/testing/releaseShards.ts b/packages/solid/src/testing/releaseShards.ts new file mode 100644 index 000000000..af22a8b74 --- /dev/null +++ b/packages/solid/src/testing/releaseShards.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +import { statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { DECKS_ROOT } from "@solid-memo/vocab/tooling/root"; +import { draftPod, libraryReleases, roundTrip } from "./releaseDrafts"; + +/** + * The round trip of every library release is split into this many files, + * `releaseDrafts..roundtrip.test.ts` for n from 1, so that Vitest runs + * them on as many workers: it runs a file's tests one after another. + */ +export const ROUND_TRIP_FILES = 4; + +/** + * `items` dealt into `count` shards of about the same weight: the + * heaviest first, each to the lightest shard so far (the first of + * equals), items of equal weight in their order by name. Each item is in + * exactly one shard, and each shard is sorted by name. + */ +export function balancedShards( + items: readonly string[], + count: number, + weight: (item: string) => number, +): string[][] { + const shards = Array.from({ length: count }, () => ({ items: [] as string[], weight: 0 })); + const weighed = items.map((item) => ({ item, weight: weight(item) })); + weighed.sort((a, b) => b.weight - a.weight || (a.item < b.item ? -1 : 1)); + for (const { item, weight: w } of weighed) { + const lightest = shards.reduce((best, shard) => (shard.weight < best.weight ? shard : best)); + lightest.items.push(item); + lightest.weight += w; + } + return shards.map((shard) => shard.items.sort()); +} + +/** The library's releases (libraryReleases) in ROUND_TRIP_FILES shards balanced by their files' size. */ +export function libraryReleaseShards(): string[][] { + return balancedShards( + libraryReleases(), + ROUND_TRIP_FILES, + (path) => statSync(join(DECKS_ROOT, path)).size, + ); +} + +/** The round trip of each release of shard `n` (from 1) of libraryReleaseShards. */ +export function describeRoundTrips(n: number): void { + describe.each(libraryReleaseShards()[n - 1]!)("decks/%s", (path) => { + it("comes back whole as a draft published again", { timeout: 60_000 }, async () => { + const { release, assembled } = await roundTrip(await draftPod(), path); + expect(assembled).toEqual(release); + }); + }); +} diff --git a/packages/solid/vitest.roundtrip.config.ts b/packages/solid/vitest.roundtrip.config.ts index 29452bad2..5d1768eca 100644 --- a/packages/solid/vitest.roundtrip.config.ts +++ b/packages/solid/vitest.roundtrip.config.ts @@ -4,7 +4,7 @@ import { packageConfig } from "../../vitest.shared.ts"; -/** The round trip of every library release through a draft (src/releaseDrafts.roundtrip.test.ts), a task of its own. */ +/** The round trip of every library release through a draft (src/releaseDrafts..roundtrip.test.ts), a task of its own. */ export default packageConfig({ environment: "happy-dom", test: { include: ["src/**/*.roundtrip.test.ts"] },