@dolphy-app/extension-sdk 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,467 @@
1
+ # Recipe: an exercise type
2
+
3
+ A new kind of task: the course author writes `spec` (what to ask), the learner
4
+ types an answer, your code grades it. This recipe is the `exercise` template
5
+ (`npx @dolphy-app/create-extension <dir> --id acme.hello --template exercise`,
6
+ also the default). The files below are exactly what the generator writes for the
7
+ id `acme.hello`. Read [quick-start.md](quick-start.md) first for the project
8
+ layout and commands.
9
+
10
+ The exercise type here compares the answer with an expected text. It has two
11
+ parts: the grading code in `server` and the answer input in `client`.
12
+
13
+ ## The manifest
14
+
15
+ File `extension.json` (exercise):
16
+
17
+ ```json
18
+ {
19
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
20
+ "id": "acme.hello",
21
+ "version": "0.1.0",
22
+ "apiVersion": 1,
23
+ "name": "Text match",
24
+ "description": "Exercise type: the learner types a string that is compared with the expected text.",
25
+ "author": "your-github-login",
26
+ "tags": ["learning"]
27
+ }
28
+ ```
29
+
30
+ The manifest says who the extension is and nothing more. The exercise type, the
31
+ setting and the command are registered by the code below.
32
+
33
+ ## The server part
34
+
35
+ File `src/index.ts` (exercise):
36
+
37
+ ```ts
38
+ export { client } from './client.ts';
39
+ export { server } from './server.ts';
40
+ ```
41
+
42
+ `src/index.ts` only re-exports the two entries, each from its own file: the
43
+ server part must not import `vue` or a component, the client part must not
44
+ import `node:*` modules.
45
+
46
+ File `src/server.ts` (exercise):
47
+
48
+ ```ts
49
+ import {
50
+ defineExerciseType,
51
+ defineServer,
52
+ notify,
53
+ } from '@dolphy-app/extension-sdk';
54
+
55
+ interface Spec {
56
+ expected: string;
57
+ ignoreCase?: boolean;
58
+ }
59
+
60
+ // runs in the extension host: every call registers a contribution
61
+ export const server = defineServer((s) => {
62
+ s.registerSettings([
63
+ {
64
+ id: 'acme.hello.trim',
65
+ type: 'boolean',
66
+ label: { en: 'Ignore spaces around the answer', ru: 'Игнорировать пробелы вокруг ответа' },
67
+ default: true,
68
+ },
69
+ ]);
70
+
71
+ // read when the handler runs, so a change in the settings applies at once
72
+ const trims = (): boolean => s.settings.get('acme.hello.trim') === true;
73
+
74
+ const matches = (answer: string, spec: Spec): boolean => {
75
+ const given = trims() ? answer.trim() : answer;
76
+ if (spec.ignoreCase === true) {
77
+ return given.toLowerCase() === spec.expected.toLowerCase();
78
+ }
79
+ return given === spec.expected;
80
+ };
81
+
82
+ // the app checks `spec` and the answer against the schemas before the
83
+ // handlers run
84
+ s.registerExerciseType(
85
+ defineExerciseType<Spec, string, Record<string, never>>({
86
+ id: 'acme.hello',
87
+ title: 'Text match',
88
+ specSchema: {
89
+ type: 'object',
90
+ required: ['expected'],
91
+ additionalProperties: false,
92
+ properties: {
93
+ expected: { type: 'string', minLength: 1 },
94
+ ignoreCase: { type: 'boolean' },
95
+ },
96
+ },
97
+ answerSchema: { type: 'string' },
98
+ project: () => ({}),
99
+ grade: ({ spec, answer }) =>
100
+ matches(answer, spec)
101
+ ? { outcome: 'passed' }
102
+ : { outcome: 'failed', reason: 'mismatch' },
103
+ referenceAnswer: ({ spec }) => spec.expected,
104
+ }),
105
+ );
106
+
107
+ s.registerCommand({
108
+ id: 'acme.hello.status',
109
+ title: { en: 'Show how answers are compared', ru: 'Показать способ сравнения' },
110
+ run: () =>
111
+ notify(
112
+ trims()
113
+ ? 'Answers are compared without the spaces around them.'
114
+ : 'Answers are compared exactly as typed.',
115
+ ),
116
+ });
117
+ });
118
+ ```
119
+
120
+ - `server.registerExerciseType(defineExerciseType<Spec, Answer, View>({ … }))`
121
+ registers the type. Its `id` is the name a course uses in its exercises, and
122
+ it is the extension id or starts with it and a dot. `defineExerciseType` only
123
+ infers `Spec`, `Answer` and `View` from the handlers.
124
+ - `specSchema` and `answerSchema` are JSON Schema (2020-12) objects. The app
125
+ checks the `spec` of an exercise and the learner's answer against them before
126
+ a handler runs, so `grade` never sees a malformed value.
127
+ - `project(spec)` returns what the answer input may see of the `spec` (`View`);
128
+ here nothing, so the expected text never reaches the window. `grade` returns
129
+ `{ outcome: 'passed' }` or `{ outcome: 'failed', reason }`. `referenceAnswer`
130
+ is optional: it gives the author a way to see the correct answer.
131
+ - `server.registerSettings([...])` adds a setting to Settings → Extensions;
132
+ `server.settings.get(id)` returns the user's value or the default. Read it
133
+ where you use it, as `trims()` does, and a change applies at once. The
134
+ [settings recipe](recipe-settings.md) shows the rest of the types.
135
+ - `server.registerCommand({ id, title, run })` adds a palette command; `notify`
136
+ shows a notification.
137
+ - Everything registered together is all or nothing: when `server` throws, or
138
+ takes more than 10 seconds, the extension contributes nothing and shows
139
+ `load-failed`.
140
+
141
+ ## The client part
142
+
143
+ File `src/client.ts` (exercise):
144
+
145
+ ```ts
146
+ import { defineClient } from '@dolphy-app/extension-sdk';
147
+ import { TextAnswer } from './text-answer.ts';
148
+
149
+ // runs in the app window: the answer view is a Vue component for the exercise
150
+ // type that `server` registers
151
+ export const client = defineClient((c) => {
152
+ c.addAnswerView('acme.hello', TextAnswer);
153
+ });
154
+ ```
155
+
156
+ File `src/text-answer.ts` (exercise):
157
+
158
+ ```ts
159
+ import type { AnswerChange } from '@dolphy-app/extension-sdk';
160
+ import { defineComponent, h, ref, watch } from 'vue';
161
+ import type { PropType } from 'vue';
162
+
163
+ // the answer input: a Vue component the app draws in its own window tree. It
164
+ // takes the props of `AnswerViewProps` and reports the answer with `change`;
165
+ // `submit` asks the app to check it
166
+ export const TextAnswer = defineComponent({
167
+ props: {
168
+ view: { type: null },
169
+ value: { type: null },
170
+ disabled: Boolean,
171
+ verdict: { type: null },
172
+ label: { type: String as PropType<string | null>, default: null },
173
+ },
174
+ emits: ['change', 'submit'],
175
+ setup(props, { emit }) {
176
+ const asText = (value: unknown): string =>
177
+ typeof value === 'string' ? value : '';
178
+ // what is typed stays on screen even if the app never returns `value`
179
+ const text = ref(asText(props.value));
180
+ watch(
181
+ () => props.value,
182
+ (value) => {
183
+ text.value = asText(value);
184
+ },
185
+ );
186
+ return () =>
187
+ h('input', {
188
+ type: 'text',
189
+ spellcheck: false,
190
+ value: text.value,
191
+ disabled: props.disabled,
192
+ 'aria-label': props.label ?? undefined,
193
+ onInput: (event: Event) => {
194
+ text.value = (event.target as HTMLInputElement).value;
195
+ const change: AnswerChange<string> = {
196
+ value: text.value,
197
+ complete: text.value.trim().length > 0,
198
+ };
199
+ emit('change', change);
200
+ },
201
+ onKeydown: (event: KeyboardEvent) => {
202
+ if (event.key === 'Enter') emit('submit');
203
+ },
204
+ });
205
+ },
206
+ });
207
+ ```
208
+
209
+ - `client.addAnswerView(exerciseTypeId, component)` tells the window to draw
210
+ `component` for the exercises of that type. The id is the one `server`
211
+ registers.
212
+ - The component is a Vue component the app draws in its own window tree, so the
213
+ theme and the language apply. It takes the props of `AnswerViewProps` (`view`,
214
+ `value`, `disabled`, `verdict`, `label`) and emits `change` with an
215
+ `AnswerChange` (`{ value, complete }`) and `submit` without data.
216
+ - `vue` (and `vuetify`) are imported as usual: the build leaves them out of the
217
+ bundle and the app gives the component its own instances.
218
+
219
+ ## The tests
220
+
221
+ File `test/index.test.ts` (exercise):
222
+
223
+ ```ts
224
+ // @vitest-environment happy-dom
225
+ import {
226
+ createSchemaValidator,
227
+ createTestClient,
228
+ createTestServer,
229
+ } from '@dolphy-app/extension-sdk/testing';
230
+ import { afterEach, describe, expect, it } from 'vitest';
231
+ import { createApp, h, nextTick, reactive } from 'vue';
232
+ import { client, server } from '../src/index.ts';
233
+ import { TextAnswer } from '../src/text-answer.ts';
234
+
235
+ const spec = { expected: 'Hello' };
236
+
237
+ const disposables: { dispose(): unknown }[] = [];
238
+ afterEach(async () => {
239
+ await Promise.all(disposables.splice(0).map((item) => item.dispose()));
240
+ });
241
+
242
+ const start = async (settingValues = {}) => {
243
+ const running = await createTestServer(server, {
244
+ extensionId: 'acme.hello',
245
+ settingValues,
246
+ });
247
+ disposables.push(running);
248
+ return running;
249
+ };
250
+
251
+ // mounts the answer view the way the app does: the props of `AnswerViewProps`
252
+ // in, the `change` and `submit` events out
253
+ const mount = async (label?: string) => {
254
+ const props = reactive<Record<string, unknown>>({
255
+ view: {},
256
+ value: undefined,
257
+ disabled: false,
258
+ verdict: null,
259
+ label: label ?? null,
260
+ });
261
+ const changes: unknown[] = [];
262
+ let submissions = 0;
263
+ const host = document.createElement('div');
264
+ document.body.append(host);
265
+ const app = createApp({
266
+ render: () =>
267
+ h(TextAnswer, {
268
+ ...props,
269
+ onChange: (change: unknown) => changes.push(change),
270
+ onSubmit: () => (submissions += 1),
271
+ }),
272
+ });
273
+ app.mount(host);
274
+ disposables.push({
275
+ dispose: () => {
276
+ app.unmount();
277
+ host.remove();
278
+ },
279
+ });
280
+ await nextTick();
281
+ const input = host.querySelector('input');
282
+ if (input === null) throw new Error('no input');
283
+ return {
284
+ input,
285
+ changes,
286
+ submissions: () => submissions,
287
+ update: async (next: Record<string, unknown>) => {
288
+ Object.assign(props, next);
289
+ await nextTick();
290
+ },
291
+ };
292
+ };
293
+
294
+ describe('acme.hello: handler', () => {
295
+ it('project does not reveal the reference', async () => {
296
+ const type = (await start()).exerciseType('acme.hello');
297
+ expect(await type.project(spec)).toEqual({});
298
+ });
299
+
300
+ it('grade: a match passes, a mismatch does not', async () => {
301
+ const type = (await start()).exerciseType('acme.hello');
302
+ expect(await type.grade({ spec, answer: 'Hello' })).toEqual({
303
+ outcome: 'passed',
304
+ });
305
+ expect(await type.grade({ spec, answer: 'hello' })).toEqual({
306
+ outcome: 'failed',
307
+ reason: 'mismatch',
308
+ });
309
+ });
310
+
311
+ it('grade: ignoreCase turns case sensitivity off', async () => {
312
+ const type = (await start()).exerciseType('acme.hello');
313
+ const relaxed = { ...spec, ignoreCase: true };
314
+ expect(await type.grade({ spec: relaxed, answer: 'hELLO' })).toEqual({
315
+ outcome: 'passed',
316
+ });
317
+ });
318
+
319
+ it('referenceAnswer passes the check itself', async () => {
320
+ const type = (await start()).exerciseType('acme.hello');
321
+ const reference = await type.referenceAnswer(spec);
322
+ expect(reference).toEqual({ found: true, answer: 'Hello' });
323
+ if (!reference.found) throw new Error('reference expected');
324
+ expect(await type.grade({ spec, answer: reference.answer })).toEqual({
325
+ outcome: 'passed',
326
+ });
327
+ });
328
+ });
329
+
330
+ describe('acme.hello: settings and commands', () => {
331
+ it('the trim setting decides whether the spaces around an answer count', async () => {
332
+ const running = await start();
333
+ const type = running.exerciseType('acme.hello');
334
+ expect(await type.grade({ spec, answer: ' Hello ' })).toEqual({
335
+ outcome: 'passed',
336
+ });
337
+ await running.settings.set('acme.hello.trim', false);
338
+ expect(await type.grade({ spec, answer: ' Hello ' })).toEqual({
339
+ outcome: 'failed',
340
+ reason: 'mismatch',
341
+ });
342
+ });
343
+
344
+ it('the status command reports the current mode', async () => {
345
+ const running = await start();
346
+ expect(await running.commands.run('acme.hello.status')).toEqual({
347
+ kind: 'notify',
348
+ text: 'Answers are compared without the spaces around them.',
349
+ });
350
+ await running.settings.set('acme.hello.trim', false);
351
+ expect(await running.commands.run('acme.hello.status')).toEqual({
352
+ kind: 'notify',
353
+ text: 'Answers are compared exactly as typed.',
354
+ });
355
+ });
356
+
357
+ it('a user value of the setting replaces the default', async () => {
358
+ const running = await start({ 'acme.hello.trim': false });
359
+ expect(await running.commands.run('acme.hello.status')).toMatchObject({
360
+ text: 'Answers are compared exactly as typed.',
361
+ });
362
+ });
363
+ });
364
+
365
+ describe('acme.hello: schemas', () => {
366
+ const registered = async () => {
367
+ const [type] = (await start()).registration.exerciseTypes;
368
+ if (type === undefined) throw new Error('exercise type expected');
369
+ return {
370
+ validateSpec: createSchemaValidator(type.specSchema),
371
+ validateAnswer: createSchemaValidator(type.answerSchema),
372
+ };
373
+ };
374
+
375
+ it.each([[{ expected: 'a' }], [{ expected: 'a', ignoreCase: true }]])(
376
+ 'spec %j is valid',
377
+ async (value) => {
378
+ expect((await registered()).validateSpec(value)).toEqual([]);
379
+ },
380
+ );
381
+
382
+ it.each([
383
+ ['no expected', {}],
384
+ ['empty expected', { expected: '' }],
385
+ ['ignoreCase is not a boolean', { expected: 'a', ignoreCase: 'yes' }],
386
+ ['an extra field', { expected: 'a', extra: 1 }],
387
+ ])('spec: %s is rejected', async (_name, value) => {
388
+ expect((await registered()).validateSpec(value)).not.toEqual([]);
389
+ });
390
+
391
+ it('answer: a string is valid, a number is not', async () => {
392
+ const { validateAnswer } = await registered();
393
+ expect(validateAnswer('text')).toEqual([]);
394
+ expect(validateAnswer(42)).not.toEqual([]);
395
+ });
396
+ });
397
+
398
+ describe('acme.hello: client', () => {
399
+ it('adds the answer view for the exercise type', async () => {
400
+ const running = await createTestClient(client, { extensionId: 'acme.hello' });
401
+ disposables.push(running);
402
+ expect(running.answerViews.get('acme.hello')).toBe(TextAnswer);
403
+ });
404
+ });
405
+
406
+ describe('acme.hello: view', () => {
407
+ it('typing reports the answer; an empty input is incomplete', async () => {
408
+ const { changes, input } = await mount();
409
+ input.value = 'Hello';
410
+ input.dispatchEvent(new Event('input'));
411
+ input.value = ' ';
412
+ input.dispatchEvent(new Event('input'));
413
+ expect(changes).toEqual([
414
+ { value: 'Hello', complete: true },
415
+ { value: ' ', complete: false },
416
+ ]);
417
+ });
418
+
419
+ it('Enter submits the answer', async () => {
420
+ const { input, submissions } = await mount();
421
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
422
+ expect(submissions()).toBe(1);
423
+ });
424
+
425
+ it('disabled blocks the input', async () => {
426
+ const { input, update } = await mount();
427
+ await update({ disabled: true });
428
+ expect(input.disabled).toBe(true);
429
+ });
430
+
431
+ it('value restores the answer without events', async () => {
432
+ const { changes, input, update } = await mount();
433
+ await update({ value: 'Hello' });
434
+ expect(input.value).toBe('Hello');
435
+ expect(changes).toEqual([]);
436
+ });
437
+
438
+ it('the label of the app becomes the aria-label of the input', async () => {
439
+ const { input } = await mount('Your answer');
440
+ expect(input.getAttribute('aria-label')).toBe('Your answer');
441
+ });
442
+ });
443
+ ```
444
+
445
+ `createTestServer(server, { extensionId })` starts `server` on in-memory
446
+ settings, storage and library and gives `exerciseType(id)` with `project`,
447
+ `grade` and `referenceAnswer` the way the app calls them, with the checks of the
448
+ host on the shape of the results. `settingValues` plays the user's choice, and
449
+ `running.settings.set` changes a value the way the settings dialog does.
450
+ `createSchemaValidator` checks fixtures against the schemas that were
451
+ registered, so a change to a schema fails the test. `createTestClient(client)`
452
+ records what `client` adds; the view itself is tested as a Vue component:
453
+ `mount` in the file draws it with `createApp` in the test DOM (the
454
+ `@vitest-environment happy-dom` comment), passes the props of `AnswerViewProps`
455
+ and collects the `change` and `submit` events.
456
+
457
+ ## Try and ship
458
+
459
+ ```sh
460
+ pnpm install
461
+ pnpm test
462
+ pnpm dev
463
+ ```
464
+
465
+ A course names the exercise type by its id (`acme.hello`) and supplies a `spec`
466
+ that matches `specSchema`. To make it yours, rename the id, change the schemas
467
+ and the grading, and keep the structure.
@@ -0,0 +1,158 @@
1
+ # Recipe: hooks before a session and a batch
2
+
3
+ Step into the learning loop. A `before` hook runs before the engine does
4
+ something and may change its input or cancel it. This recipe reorders the
5
+ exercises of a session so that reviews come before new material and the most
6
+ forgotten come first, and refuses to
7
+ start a session in the small hours. There is no template for it; start from
8
+ `blank` and replace the files below, which are checked as a whole project. See
9
+ [quick-start.md](quick-start.md) for the commands. Events, which only observe,
10
+ are in [recipe-event-storage.md](recipe-event-storage.md).
11
+
12
+ ## The manifest
13
+
14
+ File `extension.json` (hooks):
15
+
16
+ ```json
17
+ {
18
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
19
+ "id": "acme.hello",
20
+ "version": "0.1.0",
21
+ "apiVersion": 1,
22
+ "name": "Reviews first",
23
+ "description": "Puts reviews before new exercises and keeps sessions out of the small hours.",
24
+ "author": "your-github-login",
25
+ "tags": ["learning"]
26
+ }
27
+ ```
28
+
29
+ Nothing about hooks is in the manifest: `server.before` is the registration.
30
+
31
+ ## The code
32
+
33
+ File `src/index.ts` (hooks):
34
+
35
+ ```ts
36
+ import { defineServer } from '@dolphy-app/extension-sdk';
37
+
38
+ const REVIEW_FIRST = { review: 0, remediation: 1, new: 2 } as const;
39
+
40
+ export const server = defineServer((s) => {
41
+ // may only cancel: throw to stop the session from starting
42
+ s.before('session.start', ({ now }) => {
43
+ if (new Date(now).getHours() < 6) {
44
+ throw new Error('Sessions are closed until six in the morning');
45
+ }
46
+ });
47
+
48
+ // gets the batch the engine built and returns the one to use
49
+ // reviews before new material, the most forgotten first within a group
50
+ s.before('practice.batch', ({ exerciseIds, reasons, memory }) => {
51
+ const items = exerciseIds
52
+ .map((id, index) => ({
53
+ id,
54
+ reason: reasons[index] ?? 'new',
55
+ // no attempts yet: nothing to forget
56
+ retrievability: memory[index]?.retrievability ?? 1,
57
+ }))
58
+ .sort(
59
+ (a, b) =>
60
+ REVIEW_FIRST[a.reason] - REVIEW_FIRST[b.reason] ||
61
+ a.retrievability - b.retrievability,
62
+ );
63
+ return {
64
+ exerciseIds: items.map(({ id }) => id),
65
+ reasons: items.map(({ reason }) => reason),
66
+ };
67
+ });
68
+ });
69
+ ```
70
+
71
+ - `server.before(name, handler)` registers a hook. The names are a closed list:
72
+ `session.start` and `practice.batch`; another name fails the registration.
73
+ One handler per name; the call returns a `Disposable`.
74
+ - `session.start` gets `{ now }` (epoch milliseconds) and returns nothing. The
75
+ only way to act is to throw: the session is not created and the learner sees
76
+ the message of your error.
77
+ - `practice.batch` gets `{ sessionId, exerciseIds, reasons, memory, source }`
78
+ (`sessionId` is `null` while the session does not exist yet, as in the daily
79
+ plan before it starts).
80
+ `reasons[i]` (`review`, `new` or `remediation`) belongs to `exerciseIds[i]`;
81
+ `source` is `batch` for `practice.getBatch` and `plan` for the daily plan the
82
+ window builds a session from. `memory[i]` is what the engine remembers of
83
+ `exerciseIds[i]`, read-only: `retrievability` (0..1 chance to recall now),
84
+ `lastAttemptAt` (epoch milliseconds), `attempts`, and the FSRS `stability`
85
+ (days) and `difficulty`. An exercise with no attempts has `attempts: 0` and
86
+ `null` in the other fields. Return `{ exerciseIds, reasons }` of the same
87
+ length. You may reorder, drop and add exercises; every id must exist in the
88
+ library, at most 500 ids.
89
+ - Extensions with a hook run one after another in the order of their ids; each
90
+ gets the result of the previous one. A handler has 30 seconds.
91
+ - An error, a timeout or an answer the engine rejects (a missing exercise,
92
+ unequal lengths, too many ids) cancels the whole operation: no session, no
93
+ batch, nothing written to the journal. The caller gets the engine error
94
+ `EXTENSION_HOOK_FAILED` with the extension id and your message, and the window
95
+ shows it like any other failure to start a session.
96
+
97
+ ## The test
98
+
99
+ File `test/index.test.ts` (hooks):
100
+
101
+ ```ts
102
+ import { createTestServer } from '@dolphy-app/extension-sdk/testing';
103
+ import { expect, it } from 'vitest';
104
+ import { server } from '../src/index.ts';
105
+
106
+ const start = () => createTestServer(server, { extensionId: 'acme.hello' });
107
+
108
+ it('puts reviews before new exercises', async () => {
109
+ const running = await start();
110
+ const batch = await running.hook('practice.batch', {
111
+ sessionId: 's1',
112
+ exerciseIds: ['c::l::a', 'c::l::b', 'c::l::c'],
113
+ reasons: ['new', 'review', 'remediation'],
114
+ memory: [
115
+ { retrievability: null, lastAttemptAt: null, attempts: 0, stability: null, difficulty: null },
116
+ { retrievability: 0.8, lastAttemptAt: 1_000, attempts: 3, stability: 9, difficulty: 4 },
117
+ { retrievability: 0.3, lastAttemptAt: 2_000, attempts: 1, stability: 2, difficulty: 6 },
118
+ ],
119
+ source: 'batch',
120
+ });
121
+ expect(batch).toEqual({
122
+ exerciseIds: ['c::l::b', 'c::l::c', 'c::l::a'],
123
+ reasons: ['review', 'remediation', 'new'],
124
+ });
125
+ await running.dispose();
126
+ });
127
+
128
+ it('puts the most forgotten exercises first within a group', async () => {
129
+ const running = await start();
130
+ const batch = await running.hook('practice.batch', {
131
+ sessionId: 's1',
132
+ exerciseIds: ['c::l::a', 'c::l::b', 'c::l::c'],
133
+ reasons: ['new', 'review', 'review'],
134
+ memory: [
135
+ { retrievability: null, lastAttemptAt: null, attempts: 0, stability: null, difficulty: null },
136
+ { retrievability: 0.8, lastAttemptAt: 1_000, attempts: 3, stability: 9, difficulty: 4 },
137
+ { retrievability: 0.3, lastAttemptAt: 2_000, attempts: 1, stability: 2, difficulty: 6 },
138
+ ],
139
+ source: 'batch',
140
+ });
141
+ expect(batch.exerciseIds).toEqual(['c::l::c', 'c::l::b', 'c::l::a']);
142
+ expect(batch.reasons).toEqual(['review', 'review', 'new']);
143
+ await running.dispose();
144
+ });
145
+
146
+ it('refuses to start a session in the small hours', async () => {
147
+ const running = await start();
148
+ const night = new Date(2026, 9, 7, 3).getTime();
149
+ await expect(running.hook('session.start', { now: night })).rejects.toThrow(
150
+ 'Sessions are closed until six in the morning',
151
+ );
152
+ const morning = new Date(2026, 9, 7, 9).getTime();
153
+ await expect(
154
+ running.hook('session.start', { now: morning }),
155
+ ).resolves.toBeUndefined();
156
+ await running.dispose();
157
+ });
158
+ ```