@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.
- package/README.md +40 -12
- package/dist/client.d.ts +40 -0
- package/dist/client.js +67 -0
- package/dist/define-entry-BTz2F3qv.js +22 -0
- package/dist/define-entry-lsuxzKdD.d.ts +77 -0
- package/dist/index-I_CXC6Lj.d.ts +1958 -0
- package/dist/index.d.ts +7 -103
- package/dist/index.js +5 -80
- package/dist/react.d.ts +51 -0
- package/dist/react.js +112 -0
- package/dist/rpc.d.ts +28 -0
- package/dist/rpc.js +37 -0
- package/dist/testing.d.ts +248 -121
- package/dist/testing.js +779 -241
- package/docs/debugging.md +228 -0
- package/docs/no-build.md +135 -0
- package/docs/quick-start.md +194 -0
- package/docs/recipe-command-panel.md +355 -0
- package/docs/recipe-event-storage.md +326 -0
- package/docs/recipe-exercise-type.md +467 -0
- package/docs/recipe-hooks.md +158 -0
- package/docs/recipe-import-export.md +236 -0
- package/docs/recipe-mountable.md +322 -0
- package/docs/recipe-react.md +352 -0
- package/docs/recipe-rpc-and-app.md +366 -0
- package/docs/recipe-settings.md +183 -0
- package/docs/recipe-theme.md +148 -0
- package/docs/recipe-when-dependencies.md +264 -0
- package/package.json +31 -8
- package/dist/answer-element-BOQcuxYh.js +0 -114
- package/dist/answer-view-D6wnyThb.d.ts +0 -28
- package/dist/runtime.d.ts +0 -14
- package/dist/runtime.js +0 -18
|
@@ -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
|
+
```
|