From b92ccd66712f6c28e3beb5ac4b5c4f4df687bcef Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Wed, 4 Nov 2026 14:00:10 +0100 Subject: [PATCH 1/3] test(solid): split the deck repository's growth tests into four files Vitest runs a file's tests one after another, so the fourteen growth tests of solidDeckRepository.growth.test.ts ran in a row on one worker while the others sat idle, and that one file took most of the solid package's test task, the longest Turbo task of the check job. They are now four files, by what they write: a deck's cards (releases added or refused, distractors changed, cards removed), whole decks (removed and changed in a catalog), older formats brought up to date, and a learner's study (review states, answers, chapters). The setup they share moves to src/testing/deckWrites.ts. Every test keeps its name and assertions; growth() compares the CPU time of the test's own process, so running the files on separate workers doesn't change what a test measures. Refs: #562 --- docs/testing.md | 6 + .../solidDeckRepository.cards.growth.test.ts | 99 ++++ .../solidDeckRepository.decks.growth.test.ts | 89 ++++ ...solidDeckRepository.formats.growth.test.ts | 67 +++ .../src/solidDeckRepository.growth.test.ts | 432 ------------------ ...olidDeckRepository.progress.growth.test.ts | 119 +++++ packages/solid/src/testing/deckWrites.ts | 169 +++++++ 7 files changed, 549 insertions(+), 432 deletions(-) create mode 100644 packages/solid/src/solidDeckRepository.cards.growth.test.ts create mode 100644 packages/solid/src/solidDeckRepository.decks.growth.test.ts create mode 100644 packages/solid/src/solidDeckRepository.formats.growth.test.ts delete mode 100644 packages/solid/src/solidDeckRepository.growth.test.ts create mode 100644 packages/solid/src/solidDeckRepository.progress.growth.test.ts create mode 100644 packages/solid/src/testing/deckWrites.ts diff --git a/docs/testing.md b/docs/testing.md index 0e5d29f9f..a10f15a4c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1209,6 +1209,12 @@ 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`. - 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/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 }; +} From bf2caa26ce348ef7c9f052066994fd09a9b3481c Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Wed, 4 Nov 2026 14:02:38 +0100 Subject: [PATCH 2/3] test(solid): split the round trip of the library's releases into four files releaseDrafts.roundtrip.test.ts took every release of the library in one file, so test:roundtrip ran them one after another on one worker, in the check job and in solid-memo-client's. The releases are now dealt into four files by the size of their Turtle, the largest first, each to the smallest file so far, so the large taxonomy and Solid Fundamentals versions spread over the files. The dealing is deterministic and lives in src/testing/releaseShards.ts, whose test holds that every release is in exactly one shard and that there is a file for each shard and no other, so no release is dropped or run twice. Refs: #562 --- docs/testing.md | 8 ++- ...t.ts => releaseDrafts.1.roundtrip.test.ts} | 12 ++-- .../src/releaseDrafts.2.roundtrip.test.ts | 17 ++++++ .../src/releaseDrafts.3.roundtrip.test.ts | 17 ++++++ .../src/releaseDrafts.4.roundtrip.test.ts | 17 ++++++ .../solid/src/testing/releaseShards.test.ts | 58 +++++++++++++++++++ packages/solid/src/testing/releaseShards.ts | 57 ++++++++++++++++++ packages/solid/vitest.roundtrip.config.ts | 2 +- 8 files changed, 176 insertions(+), 12 deletions(-) rename packages/solid/src/{releaseDrafts.roundtrip.test.ts => releaseDrafts.1.roundtrip.test.ts} (58%) create mode 100644 packages/solid/src/releaseDrafts.2.roundtrip.test.ts create mode 100644 packages/solid/src/releaseDrafts.3.roundtrip.test.ts create mode 100644 packages/solid/src/releaseDrafts.4.roundtrip.test.ts create mode 100644 packages/solid/src/testing/releaseShards.test.ts create mode 100644 packages/solid/src/testing/releaseShards.ts diff --git a/docs/testing.md b/docs/testing.md index a10f15a4c..f1b001c1a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1183,7 +1183,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)). | @@ -1212,9 +1212,11 @@ graph LR - 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 + in `src/testing/`. The deck repository's growth tests are `solidDeckRepository..growth.test.ts`, over - `packages/solid/src/testing/deckWrites.ts`. + `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/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/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"] }, From 49ca8a2c269073a66d573c76c05cfd1d55535762 Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Wed, 4 Nov 2026 14:03:43 +0100 Subject: [PATCH 3/3] ci: run check:ci's parts as steps of their own The check job ran npm run check:ci as one step, so the jobs API, which anyone can read, gave one time for the lints, every Turbo task and the repository's checks together; finding the slow part took a local run. check:ci's parts are now scripts of their own, check:lints, check:tasks and check:repository, which npm run check and check:ci both call, so a local run and CI run the same things. The check job runs each as its own step, the later ones also when an earlier one failed, so a run still shows every failure, and coverage:summary still reports after the tests. Closes: #562 --- .github/workflows/ci.yml | 30 +++++++++++++++++++++--------- docs/testing.md | 9 ++++++--- package.json | 6 ++++-- 3 files changed, 31 insertions(+), 14 deletions(-) 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 f1b001c1a..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/): 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))\" --",