Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions apps/studio/src/ui/draftEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,36 @@ describe("useDraftEditor", () => {
expect(useCases.editReleaseDraft).toHaveBeenCalledWith(DRAFT_URL, [rename("A"), rename("Ap")]);
});

it("writes the last of a run of keystrokes in the making notes or the contact, in its place", async () => {
const { useCases } = await renderEditor({
editReleaseDraft: vi.fn(async (_url, changes) => made(courseDraft(), changes)),
});
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const notes = (en: string): DraftChange => ({ kind: "setMakingNotes", notes: { en } });
const contact = (url: string): DraftChange => ({ kind: "setContact", url });
const typed = [
...["N", "No", "Not", "Note"].map(notes),
...["https://a.example/", "https://ab.example/"].map(contact),
notes("Notes"),
// Notes of no text leave the making the notes before them made: both are written.
notes(" "),
];
for (const change of typed)
act(() => {
editor.edit([change], { debounce: true });
});
await act(async () => {
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
});
expect(useCases.editReleaseDraft).toHaveBeenCalledTimes(1);
expect(useCases.editReleaseDraft).toHaveBeenCalledWith(DRAFT_URL, [
notes("Note"),
contact("https://ab.example/"),
notes("Notes"),
notes(" "),
]);
});

it("writes what waits before a change that is not typing, and when the screen goes", async () => {
const { useCases, view } = await renderEditor({
editReleaseDraft: vi.fn(async (_url, changes) => made(courseDraft(), changes)),
Expand Down
26 changes: 23 additions & 3 deletions apps/studio/src/ui/draftEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@ export interface DraftEditor {
* Make changes: at once on the screen, then in the pod. With
* `debounce` (typing), the write waits until the document has had no
* other for DEBOUNCE_MS; the changes of that time are written
* together. A change refused at once (an id taken, say) is not made,
* and its refusal returned; else null.
* together, a field's change that only replaces the one typed just
* before it (lastWins) in its place. A change refused at once (an id
* taken, say) is not made, and its refusal returned; else null.
*/
edit: (changes: readonly DraftChange[], options?: { debounce?: boolean }) => DraftRefusal | null;
/** Changes are waiting to be written, or being written. */
Expand All @@ -102,6 +103,22 @@ export interface DraftEditor {
failure: DraftFailure | null;
}

/**
* Whether `change` makes `before`, the change just before it, as if it
* had never been made, so that only `change` need be written: the
* contact set again, or the notes of the release's making set again to
* some text. Notes with none are not: notes typed before them may have
* made the release's own making, which they leave.
*/
function lastWins(before: DraftChange | undefined, change: DraftChange): boolean {
if (before?.kind !== change.kind) return false;
if (change.kind === "setContact") return true;
return (
change.kind === "setMakingNotes" &&
Object.values(change.notes).some((text) => text.trim() !== "")
);
}

/** Changes waiting for their document to be still for a moment. */
interface Waiting {
changes: DraftChange[];
Expand Down Expand Up @@ -255,8 +272,11 @@ export function useDraftEditor(useCases: UseCases, draftUrl: string): DraftEdito
const documentUrl = documents[0]!;
const held = waiting.documents.get(documentUrl);
if (held !== undefined) clearTimeout(held.timer);
const waited = held?.changes ?? [];
// A keystroke's change that only replaces the one before it takes its place: a write makes each change it is given.
const replaced = changes.length === 1 && lastWins(waited.at(-1), changes[0]!);
waiting.documents.set(documentUrl, {
changes: [...(held?.changes ?? []), ...changes],
changes: [...(replaced ? waited.slice(0, -1) : waited), ...changes],
timer: setTimeout(() => flush(documentUrl), DEBOUNCE_MS),
});
told();
Expand Down
17 changes: 16 additions & 1 deletion docs/studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -1781,6 +1781,16 @@ A draft changes by `DraftChange`s
card deleted takes along only the wrong options no other card names;
- its sources, and the checks it had.

A blank node only the statements a change took out named goes with
them. A change keeps each statement it leaves alone as it was, so what
it took out is found by identity, without comparing every statement by
its value, and only the blank nodes those name are looked at. Whether
it rewrites an activity carried from an earlier release is asked only
of the activities among the subjects of what it took out or put in.
What a change still does over the whole draft is a few cheap passes
over its statements, so a keystroke in the release's notes or contact
grows with the draft only by those.

A change is refused, writing nothing, for:

- deleting what an earlier release published (it's retired instead);
Expand Down Expand Up @@ -1822,7 +1832,12 @@ Every edit is made at once on the screen, then written in the pod
(`changedDocuments` in
[draftLayout.ts](../packages/domain/src/release/draftLayout.ts)) has
been still for 0.8 seconds (`DEBOUNCE_MS`). Changes typed in that
time are written together. The documents a change writes are found
time are written together. A keystroke in the contact replaces the
change before it when that is of the same field: the last one is what
stays, so the write makes it once, not once per keystroke. So does one
in the notes of the release's making that leaves some text. Notes left
empty don't, as notes typed before may have given the release a making
of its own, which they keep. The documents a change writes are found
from the records it replaced, not from the whole draft. Where the
draft's records go (its layout) is worked out again only when a
change moves one, and once for each draft read from the pod, the one
Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -1389,7 +1389,7 @@ Dependency inversion gives every layer a seam for its fakes:

| Layer | Seam | Technique |
|---|---|---|
| Domain | none needed | Pure functions; inputs (including `now: Date`) passed as parameters. Plain assertions. What runs over a whole deck or log is timed at two sizes in its own process's CPU time: eight times the input must take under four times as long as an eighth of it done eight times (a linear pass gives about one, work growing with the input about eight). The growth tests (`growth` in `src/testing/growth.ts`, `growthAsync` for work that awaits, `growthAfterSetup` for work that changes what it works on, set up afresh before each run and not timed) time `queryCards` over 5,000 cards, `lapseIndex`, `chainIndexOf` and `statisticsOf` over years of answers, `instanceGraphOf` over an instance's cards and answers and over a document thousands of folders deep, a course's outline (`courseOutlineFromRecords`) over its chapters and steps, the learner's progress in it (`courseProgress`) over a chapter's steps, the statistics of many courses (`getCourseStatistics`) over as many of their answers, the release check's course rules (`courseProblems`) and its rules on text written in Markdown (`markdownProblems`) over many cards, steps and chapters and their text formats and over one distractor that many cards name in many languages, a draft's outline (`draftOutline`) over the chapters and steps, a draft's cards with their wrong options, as the Studio's outline and its step, chapter and question pages show them (`draftCardOf`), over many cards, many wrong options of one card and one wrong option that many cards name in many languages, and the changes to a card's many wrong options (`distractorChanges`), the deck health's cards that say the same (`duplicateCardsOf`) over many cards alike and over cards in Markdown whose plain text is longer than the bound on a term, which of many decks with long release links are copies of a library of as many decks (`libraryCopyTest`) and which of the library's decks they copy (`copiedTest`), a JSON-LD copy (`jsonLdOf` in `solid`) over many values of one property, a document the guest's pod on the device stores and changes (`createLocalPod` in `solid`) over many lines past the bound on a term, alike but for their end, the search of a Turtle text for its base and prefix declarations, its long IRIs and its nesting (`writeTurtle` in `solid`, and the refusal of a long IRI with dot segments in the client modules' contract tests), the shape check's report on a pod document and its check of a write (`createShaclShapeValidator` in `solid`, over an engine that answers at once) over a catalog's many links that aren't members and many dates it can't read, the SHACL engine's check of a graph and of each of its subjects (`createEngine` in `shacl`) over many subjects linking to a node with many types, the notes on a release's making (`makingNotesOf`) over many in one language, a draft's listing (`draftLibraryDeck`) and its release diff (`releaseDiff` and `simulateLearnerUpgrade`) over many sources, the draft as a release (`draftLibraryContent`) and a learner's upgrade to it over one wrong option that many cards name in many languages, a merge of drafts (`mergedDraft`) over many statements of one subject, the blank nodes a removal takes along (`withOnlyTheirBlankNodes` in `solid`) over many of one subject and many subjects naming one, many decks taken out of a catalog whose deck group lists many links, one of them no URL the app can write (`withoutDecks` in `solid`), the agent nodes of many authors that share a slug and a hash, alone and beside as many nodes other authors hold (`agentUrlsOf`), authors whose agent URLs are longer than any term, from a long subject or long names, at each place that keeps them (`TextMap`, `agentUrlsOf`, `deckAgents`, `withProvenance`, `deckReport`, `otherAuthorsOf`, a draft's listing, and `toDecks`, `deckSubjects` and `toLibraryDeckContent` in `solid`) and refused before anything is keyed by them (`deckToDraft`, and `withDeck`, a draft's entries, moved past the bound too, and a release assembled at a longer address, in `solid`), a catalog's decks (`toDecks` in `solid`) and a deck file's refusal (`readDeckFile`) over many decks and agents, a document's cards (`toCards` in `solid`) and a release's, read and refused (`toLibraryDeckContent` in `solid`), over many cards that name one distractor in many languages, a catalog written over many licenses it no longer states (`withCatalog` in `solid`), a palette's color sets placed past many that another palette names (`withPalette` in `solid`), a deck's cards added from a release, or refused for a distractor many of them name, removed and brought to this app's format, one card's many distractors added and changed, a deck removed from a cards document another app wrote in, many decks of a catalog changed and removed at once, catalog entries and review states brought to this app's format, review states removed, a day's answers removed, many month summaries dropped from the digest and as many receipts noted in it at once as it holds, and many review states' snapshots dropped, decks removed and one deck's unreadable dates dropped by the instance check's repair, many registrations of an instance renamed and removed in a type index, and the storages found in a profile that names a large extended profile document, against a pod in memory (`importDeck`, `applyCardChanges`, `removeDeck`, `saveDecks`, `removeDecks`, `upgradeCards`, `upgradeDecks`, `upgradeReviewStates`, `applyReviewChanges`, `removeDay`, `updateDigest`, `applyRepairs`, `renameInstanceRegistrations`, `removeInstanceRegistrations` and `discoverStorages` in `solid`), the receipts a visit notes in the digest it keeps (`createDigestStore`) over a digest of as many, the repairs planned from a check of many subjects (`planRepair`), a subject's many values set or removed in one call (`setIriAll`, `setStringAll` and `removeLiteralAll`, in the client modules' contract tests), the credential gate over as many reads as instances registered, each on a server of its own (`createOwnPodGate` in `solid`), the Studio's graph layout and routes over a deck's cards, its card History tab and the learner's card page's history over many answers that name one of a card's many wrong options, and the Studio's cut of a label (`drawnLabel`), which must not grow with the label at all, over one millions of characters long. |
| Domain | none needed | Pure functions; inputs (including `now: Date`) passed as parameters. Plain assertions. What runs over a whole deck or log is timed at two sizes in its own process's CPU time: eight times the input must take under four times as long as an eighth of it done eight times (a linear pass gives about one, work growing with the input about eight). The growth tests (`growth` in `src/testing/growth.ts`, `growthAsync` for work that awaits, `growthAfterSetup` for work that changes what it works on, set up afresh before each run and not timed) time `queryCards` over 5,000 cards, `lapseIndex`, `chainIndexOf` and `statisticsOf` over years of answers, `instanceGraphOf` over an instance's cards and answers and over a document thousands of folders deep, a course's outline (`courseOutlineFromRecords`) over its chapters and steps, the learner's progress in it (`courseProgress`) over a chapter's steps, the statistics of many courses (`getCourseStatistics`) over as many of their answers, the release check's course rules (`courseProblems`) and its rules on text written in Markdown (`markdownProblems`) over many cards, steps and chapters and their text formats and over one distractor that many cards name in many languages, a draft's outline (`draftOutline`) over the chapters and steps, a draft's cards with their wrong options, as the Studio's outline and its step, chapter and question pages show them (`draftCardOf`), over many cards, many wrong options of one card and one wrong option that many cards name in many languages, and the changes to a card's many wrong options (`distractorChanges`), the deck health's cards that say the same (`duplicateCardsOf`) over many cards alike and over cards in Markdown whose plain text is longer than the bound on a term, which of many decks with long release links are copies of a library of as many decks (`libraryCopyTest`) and which of the library's decks they copy (`copiedTest`), a JSON-LD copy (`jsonLdOf` in `solid`) over many values of one property, a document the guest's pod on the device stores and changes (`createLocalPod` in `solid`) over many lines past the bound on a term, alike but for their end, the search of a Turtle text for its base and prefix declarations, its long IRIs and its nesting (`writeTurtle` in `solid`, and the refusal of a long IRI with dot segments in the client modules' contract tests), the shape check's report on a pod document and its check of a write (`createShaclShapeValidator` in `solid`, over an engine that answers at once) over a catalog's many links that aren't members and many dates it can't read, the SHACL engine's check of a graph and of each of its subjects (`createEngine` in `shacl`) over many subjects linking to a node with many types, the notes on a release's making (`makingNotesOf`) over many in one language, a draft's listing (`draftLibraryDeck`) and its release diff (`releaseDiff` and `simulateLearnerUpgrade`) over many sources, the draft as a release (`draftLibraryContent`) and a learner's upgrade to it over one wrong option that many cards name in many languages, a merge of drafts (`mergedDraft`) over many statements of one subject, the blank nodes a removal takes along (`withOnlyTheirBlankNodes` in `solid`) over many of one subject and many subjects naming one, many decks taken out of a catalog whose deck group lists many links, one of them no URL the app can write (`withoutDecks` in `solid`), the agent nodes of many authors that share a slug and a hash, alone and beside as many nodes other authors hold (`agentUrlsOf`), authors whose agent URLs are longer than any term, from a long subject or long names, at each place that keeps them (`TextMap`, `agentUrlsOf`, `deckAgents`, `withProvenance`, `deckReport`, `otherAuthorsOf`, a draft's listing, and `toDecks`, `deckSubjects` and `toLibraryDeckContent` in `solid`) and refused before anything is keyed by them (`deckToDraft`, and `withDeck`, a draft's entries, moved past the bound too, and a release assembled at a longer address, in `solid`), a catalog's decks (`toDecks` in `solid`) and a deck file's refusal (`readDeckFile`) over many decks and agents, a document's cards (`toCards` in `solid`) and a release's, read and refused (`toLibraryDeckContent` in `solid`), over many cards that name one distractor in many languages, a catalog written over many licenses it no longer states (`withCatalog` in `solid`), a palette's color sets placed past many that another palette names (`withPalette` in `solid`), a deck's cards added from a release, or refused for a distractor many of them name, removed and brought to this app's format, one card's many distractors added and changed, a deck removed from a cards document another app wrote in, many decks of a catalog changed and removed at once, catalog entries and review states brought to this app's format, review states removed, a day's answers removed, many month summaries dropped from the digest and as many receipts noted in it at once as it holds, and many review states' snapshots dropped, decks removed and one deck's unreadable dates dropped by the instance check's repair, many registrations of an instance renamed and removed in a type index, and the storages found in a profile that names a large extended profile document, against a pod in memory (`importDeck`, `applyCardChanges`, `removeDeck`, `saveDecks`, `removeDecks`, `upgradeCards`, `upgradeDecks`, `upgradeReviewStates`, `applyReviewChanges`, `removeDay`, `updateDigest`, `applyRepairs`, `renameInstanceRegistrations`, `removeInstanceRegistrations` and `discoverStorages` in `solid`), the receipts a visit notes in the digest it keeps (`createDigestStore`) over a digest of as many, the repairs planned from a check of many subjects (`planRepair`), a subject's many values set or removed in one call (`setIriAll`, `setStringAll` and `removeLiteralAll`, in the client modules' contract tests), the credential gate over as many reads as instances registered, each on a server of its own (`createOwnPodGate` in `solid`), the Studio's graph layout and routes over a deck's cards, its card History tab and the learner's card page's history over many answers that name one of a card's many wrong options, and the Studio's cut of a label (`drawnLabel`), which must not grow with the label at all, over one millions of characters long. A draft's change of the notes of its making or its contact, beside many other statements, is timed against one pass over them (`idsInUse`): it must take under six times as long, where comparing every statement by its value took many times that. |
| Application | ports | Fake ports are injected (`vi.fn` per method): the fixtures and fake ports in [fakePorts.ts](../packages/application/src/testing/fakePorts.ts), and `NO_PORTS` ([noPorts.ts](../packages/application/src/testing/noPorts.ts)), which other packages' tests spread too, for the ports a test doesn't use. Each slice is tested in its own `<feature>.test.ts`, the stores the slices share in theirs. No module mocking. |
| UI | `UseCases` prop | Screens render with a fake `UseCases`: `makeUseCasesFake` ([useCasesFake.ts](../packages/ui/src/test/useCasesFake.ts)) makes every slice's fake ([test/fakes/](../packages/ui/src/test/fakes/)) in one object, each calling the others through it. A screen that takes a slice (the trial's) can be given that slice's fake alone. Assertions use testing-library queries. Query-dependent components get a fresh `QueryClient` (retries off). |
| Infrastructure mappers | none needed | Pure `Dataset`/`Subject` → domain functions, fed in-memory datasets built with `datasetFrom`/`buildThing` ([datasets.ts](../packages/solid/src/testing/datasets.ts)). |
Expand Down
Loading
Loading