@dolphy-app/extension-sdk 0.4.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.
@@ -7,8 +7,8 @@ also the default). The files below are exactly what the generator writes for the
7
7
  id `acme.hello`. Read [quick-start.md](quick-start.md) first for the project
8
8
  layout and commands.
9
9
 
10
- The exercise type here compares the answer with an expected text. It has three
11
- parts: a declaration, the grading code (`host`) and the answer input (`views`).
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
12
 
13
13
  ## The manifest
14
14
 
@@ -23,86 +23,78 @@ File `extension.json` (exercise):
23
23
  "name": "Text match",
24
24
  "description": "Exercise type: the learner types a string that is compared with the expected text.",
25
25
  "author": "your-github-login",
26
- "tags": ["learning"],
27
- "contributes": {
28
- "exerciseTypes": [
29
- {
30
- "id": "acme.hello",
31
- "specSchema": {
32
- "type": "object",
33
- "required": ["expected"],
34
- "additionalProperties": false,
35
- "properties": {
36
- "expected": { "type": "string", "minLength": 1 },
37
- "ignoreCase": { "type": "boolean" }
38
- }
39
- },
40
- "answerSchema": { "type": "string" }
41
- }
42
- ],
43
- "settings": [
44
- {
45
- "id": "acme.hello.trim",
46
- "type": "boolean",
47
- "label": "Ignore spaces around the answer",
48
- "default": true
49
- }
50
- ],
51
- "commands": [
52
- { "id": "acme.hello.status", "title": "Show how answers are compared" }
53
- ]
54
- }
26
+ "tags": ["learning"]
55
27
  }
56
28
  ```
57
29
 
58
- - `exerciseTypes[].id` is the name a course uses in its exercises. `specSchema`
59
- and `answerSchema` are JSON Schemas: the app checks `spec` and the answer
60
- against them before your code runs, so `grade` can trust their shape.
61
- - `settings` declares a user setting; the code below reads it.
62
- - The command `acme.hello.status` shows in the palette how answers are compared.
63
- - No `element` key: the tag of the answer element defaults to one derived from
64
- the id (`acme.hello` → `acme-hello-answer`), and the build defines the custom
65
- element for you.
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.
66
32
 
67
- ## The code
33
+ ## The server part
68
34
 
69
35
  File `src/index.ts` (exercise):
70
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
+
71
48
  ```ts
72
49
  import {
73
- defineAnswerView,
74
50
  defineExerciseType,
75
- defineExtension,
76
- inActivate,
51
+ defineServer,
77
52
  notify,
78
53
  } from '@dolphy-app/extension-sdk';
79
- import type { ExtensionViews } from '@dolphy-app/extension-sdk';
80
54
 
81
55
  interface Spec {
82
56
  expected: string;
83
57
  ignoreCase?: boolean;
84
58
  }
85
59
 
86
- // filled from the setting in `activate`, read by the handlers below
87
- const options = { trim: true };
88
-
89
- const matches = (answer: string, spec: Spec): boolean => {
90
- const given = options.trim ? answer.trim() : answer;
91
- if (spec.ignoreCase === true) {
92
- return given.toLowerCase() === spec.expected.toLowerCase();
93
- }
94
- return given === spec.expected;
95
- };
96
-
97
- // extension code: runs in the extension process of the app
98
- // the schemas from extension.json have already checked `spec` and the answer
99
- // before the handlers run
100
- // the ids come from extension.json: `dolphy-ext types` (and every build)
101
- // writes them to .dolphy/ids.d.ts, so a misspelt id, a declared id without a
102
- // handler or an undeclared setting fails `pnpm typecheck`
103
- export const host = defineExtension({
104
- exerciseTypes: {
105
- 'acme.hello': defineExerciseType<Spec, string, Record<string, never>>({
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' },
106
98
  project: () => ({}),
107
99
  grade: ({ spec, answer }) =>
108
100
  matches(answer, spec)
@@ -110,78 +102,119 @@ export const host = defineExtension({
110
102
  : { outcome: 'failed', reason: 'mismatch' },
111
103
  referenceAnswer: ({ spec }) => spec.expected,
112
104
  }),
113
- },
114
- // this command is registered in `activate`: the marker names the id there
115
- commands: { 'acme.hello.status': inActivate },
116
- activate(ctx) {
117
- options.trim = ctx.settings.get('acme.hello.trim');
118
- ctx.settings.onDidChange((change) => {
119
- if (change.id === 'acme.hello.trim') options.trim = change.value;
120
- });
121
- ctx.commands.register('acme.hello.status', () =>
105
+ );
106
+
107
+ s.registerCommand({
108
+ id: 'acme.hello.status',
109
+ title: { en: 'Show how answers are compared', ru: 'Показать способ сравнения' },
110
+ run: () =>
122
111
  notify(
123
- options.trim
112
+ trims()
124
113
  ? 'Answers are compared without the spaces around them.'
125
114
  : 'Answers are compared exactly as typed.',
126
115
  ),
127
- );
128
- },
116
+ });
129
117
  });
118
+ ```
130
119
 
131
- // the answer input: runs in the app window; the build defines the custom
132
- // element with the tag from extension.json
133
- export const views = {
134
- 'acme.hello': defineAnswerView((api, initial) => {
135
- const input = document.createElement('input');
136
- input.type = 'text';
137
- input.spellcheck = false;
138
- if (api.label !== null) input.setAttribute('aria-label', api.label);
139
-
140
- const applyValue = (value: unknown) => {
141
- input.value = typeof value === 'string' ? value : '';
142
- };
143
- let appliedValue = initial.value;
144
- applyValue(appliedValue);
145
- input.disabled = initial.disabled;
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):
146
144
 
147
- input.addEventListener('input', () => {
148
- api.setAnswer(input.value, input.value.trim().length > 0);
149
- });
150
- input.addEventListener('keydown', (event) => {
151
- if (event.key === 'Enter') api.submit();
152
- });
153
- api.root.append(input);
145
+ ```ts
146
+ import { defineClient } from '@dolphy-app/extension-sdk';
147
+ import { TextAnswer } from './text-answer.ts';
154
148
 
155
- return {
156
- update: (props) => {
157
- input.disabled = props.disabled;
158
- // apply the value only when the app really changed it
159
- if (props.value !== appliedValue) {
160
- appliedValue = props.value;
161
- applyValue(appliedValue);
162
- }
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);
163
184
  },
164
- };
165
- }),
166
- } satisfies ExtensionViews;
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
+ });
167
207
  ```
168
208
 
169
- - `host` runs in the extension process. `defineExerciseType<Spec, Answer, Public>`
170
- takes `project` (the part of `spec` the window may see; never put the expected
171
- answer there), `grade` (the verdict) and, optionally, `referenceAnswer` (what
172
- "show the answer" displays).
173
- - A verdict is `{ outcome: 'passed' }` or `{ outcome: 'failed', reason }`.
174
- - `inActivate` marks an id that is registered in `activate`, where `ctx` exists.
175
- `ctx.settings.get(id)` is synchronous and typed by the manifest (`boolean`
176
- here); `onDidChange` delivers a change from Settings → Extensions to the
177
- running extension without a restart.
178
- - `views` runs in the app window. `defineAnswerView(mount)` gets `api.root` (a
179
- shadow root), `api.setAnswer(value, complete)` and `api.submit()`, and returns
180
- `update(props)` for new `value` and `disabled`. The keys of `views` must be
181
- exactly the declared exercise types; `satisfies ExtensionViews` makes the
182
- compiler check it.
183
- - `src/index.ts` has no side effects on import, so tests import it in plain
184
- Node. The build splits it: `host` goes to `main.mjs`, `views` to `view.mjs`.
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.
185
218
 
186
219
  ## The tests
187
220
 
@@ -189,21 +222,15 @@ File `test/index.test.ts` (exercise):
189
222
 
190
223
  ```ts
191
224
  // @vitest-environment happy-dom
192
- import type { SettingContribution } from '@dolphy-app/extension-sdk';
193
225
  import {
194
- createMemorySettings,
195
226
  createSchemaValidator,
196
- loadCommands,
197
- loadExerciseType,
198
- loadView,
227
+ createTestClient,
228
+ createTestServer,
199
229
  } from '@dolphy-app/extension-sdk/testing';
200
230
  import { afterEach, describe, expect, it } from 'vitest';
201
- import manifest from '../extension.json';
202
- import { host, views } from '../src/index.ts';
203
-
204
- const [contribution] = manifest.contributes.exerciseTypes;
205
- const validateSpec = createSchemaValidator(contribution.specSchema);
206
- const validateAnswer = createSchemaValidator(contribution.answerSchema);
231
+ import { createApp, h, nextTick, reactive } from 'vue';
232
+ import { client, server } from '../src/index.ts';
233
+ import { TextAnswer } from '../src/text-answer.ts';
207
234
 
208
235
  const spec = { expected: 'Hello' };
209
236
 
@@ -212,31 +239,66 @@ afterEach(async () => {
212
239
  await Promise.all(disposables.splice(0).map((item) => item.dispose()));
213
240
  });
214
241
 
215
- const newSettings = () =>
216
- createMemorySettings(manifest.contributes.settings as SettingContribution[]);
217
-
218
- const load = async (settings = newSettings()) => {
219
- const type = await loadExerciseType(host, 'acme.hello', { settings });
220
- disposables.push(type);
221
- return type;
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;
222
249
  };
223
250
 
251
+ // mounts the answer view the way the app does: the props of `AnswerViewProps`
252
+ // in, the `change` and `submit` events out
224
253
  const mount = async (label?: string) => {
225
- const view = await loadView(views, 'acme.hello', label === undefined ? {} : { label });
226
- disposables.push(view);
227
- const input = view.query<HTMLInputElement>('input');
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');
228
282
  if (input === null) throw new Error('no input');
229
- return { view, 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
+ };
230
292
  };
231
293
 
232
294
  describe('acme.hello: handler', () => {
233
295
  it('project does not reveal the reference', async () => {
234
- const type = await load();
296
+ const type = (await start()).exerciseType('acme.hello');
235
297
  expect(await type.project(spec)).toEqual({});
236
298
  });
237
299
 
238
300
  it('grade: a match passes, a mismatch does not', async () => {
239
- const type = await load();
301
+ const type = (await start()).exerciseType('acme.hello');
240
302
  expect(await type.grade({ spec, answer: 'Hello' })).toEqual({
241
303
  outcome: 'passed',
242
304
  });
@@ -247,7 +309,7 @@ describe('acme.hello: handler', () => {
247
309
  });
248
310
 
249
311
  it('grade: ignoreCase turns case sensitivity off', async () => {
250
- const type = await load();
312
+ const type = (await start()).exerciseType('acme.hello');
251
313
  const relaxed = { ...spec, ignoreCase: true };
252
314
  expect(await type.grade({ spec: relaxed, answer: 'hELLO' })).toEqual({
253
315
  outcome: 'passed',
@@ -255,7 +317,7 @@ describe('acme.hello: handler', () => {
255
317
  });
256
318
 
257
319
  it('referenceAnswer passes the check itself', async () => {
258
- const type = await load();
320
+ const type = (await start()).exerciseType('acme.hello');
259
321
  const reference = await type.referenceAnswer(spec);
260
322
  expect(reference).toEqual({ found: true, answer: 'Hello' });
261
323
  if (!reference.found) throw new Error('reference expected');
@@ -267,12 +329,12 @@ describe('acme.hello: handler', () => {
267
329
 
268
330
  describe('acme.hello: settings and commands', () => {
269
331
  it('the trim setting decides whether the spaces around an answer count', async () => {
270
- const settings = newSettings();
271
- const type = await load(settings);
332
+ const running = await start();
333
+ const type = running.exerciseType('acme.hello');
272
334
  expect(await type.grade({ spec, answer: ' Hello ' })).toEqual({
273
335
  outcome: 'passed',
274
336
  });
275
- await settings.set('acme.hello.trim', false);
337
+ await running.settings.set('acme.hello.trim', false);
276
338
  expect(await type.grade({ spec, answer: ' Hello ' })).toEqual({
277
339
  outcome: 'failed',
278
340
  reason: 'mismatch',
@@ -280,29 +342,40 @@ describe('acme.hello: settings and commands', () => {
280
342
  });
281
343
 
282
344
  it('the status command reports the current mode', async () => {
283
- const settings = newSettings();
284
- const commands = await loadCommands(host, {
285
- declaredCommands: ['acme.hello.status'],
286
- settings,
287
- });
288
- disposables.push(commands);
289
- expect(await commands.run('acme.hello.status')).toEqual({
345
+ const running = await start();
346
+ expect(await running.commands.run('acme.hello.status')).toEqual({
290
347
  kind: 'notify',
291
348
  text: 'Answers are compared without the spaces around them.',
292
349
  });
293
- await settings.set('acme.hello.trim', false);
294
- expect(await commands.run('acme.hello.status')).toEqual({
350
+ await running.settings.set('acme.hello.trim', false);
351
+ expect(await running.commands.run('acme.hello.status')).toEqual({
295
352
  kind: 'notify',
296
353
  text: 'Answers are compared exactly as typed.',
297
354
  });
298
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
+ });
299
363
  });
300
364
 
301
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
+
302
375
  it.each([[{ expected: 'a' }], [{ expected: 'a', ignoreCase: true }]])(
303
376
  'spec %j is valid',
304
- (value) => {
305
- expect(validateSpec(value)).toEqual([]);
377
+ async (value) => {
378
+ expect((await registered()).validateSpec(value)).toEqual([]);
306
379
  },
307
380
  );
308
381
 
@@ -311,60 +384,75 @@ describe('acme.hello: schemas', () => {
311
384
  ['empty expected', { expected: '' }],
312
385
  ['ignoreCase is not a boolean', { expected: 'a', ignoreCase: 'yes' }],
313
386
  ['an extra field', { expected: 'a', extra: 1 }],
314
- ])('spec: %s is rejected', (_name, value) => {
315
- expect(validateSpec(value)).not.toEqual([]);
387
+ ])('spec: %s is rejected', async (_name, value) => {
388
+ expect((await registered()).validateSpec(value)).not.toEqual([]);
316
389
  });
317
390
 
318
- it('answer: a string is valid, a number is not', () => {
391
+ it('answer: a string is valid, a number is not', async () => {
392
+ const { validateAnswer } = await registered();
319
393
  expect(validateAnswer('text')).toEqual([]);
320
394
  expect(validateAnswer(42)).not.toEqual([]);
321
395
  });
322
396
  });
323
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
+
324
406
  describe('acme.hello: view', () => {
325
407
  it('typing reports the answer; an empty input is incomplete', async () => {
326
- const { view, input } = await mount();
408
+ const { changes, input } = await mount();
327
409
  input.value = 'Hello';
328
410
  input.dispatchEvent(new Event('input'));
329
411
  input.value = ' ';
330
412
  input.dispatchEvent(new Event('input'));
331
- expect(view.changes).toEqual([
413
+ expect(changes).toEqual([
332
414
  { value: 'Hello', complete: true },
333
415
  { value: ' ', complete: false },
334
416
  ]);
335
417
  });
336
418
 
337
419
  it('Enter submits the answer', async () => {
338
- const { view, input } = await mount();
420
+ const { input, submissions } = await mount();
339
421
  input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
340
- expect(view.submissions).toBe(1);
422
+ expect(submissions()).toBe(1);
341
423
  });
342
424
 
343
425
  it('disabled blocks the input', async () => {
344
- const { view, input } = await mount();
345
- await view.update({ disabled: true });
426
+ const { input, update } = await mount();
427
+ await update({ disabled: true });
346
428
  expect(input.disabled).toBe(true);
347
429
  });
348
430
 
349
431
  it('value restores the answer without events', async () => {
350
- const { view, input } = await mount();
351
- await view.update({ value: 'Hello' });
432
+ const { changes, input, update } = await mount();
433
+ await update({ value: 'Hello' });
352
434
  expect(input.value).toBe('Hello');
353
- expect(view.changes).toEqual([]);
435
+ expect(changes).toEqual([]);
354
436
  });
355
437
 
356
- it('the aria-label of the host goes to the input', async () => {
438
+ it('the label of the app becomes the aria-label of the input', async () => {
357
439
  const { input } = await mount('Your answer');
358
440
  expect(input.getAttribute('aria-label')).toBe('Your answer');
359
441
  });
360
442
  });
361
443
  ```
362
444
 
363
- `loadExerciseType` activates `host` and gives `project` and `grade` the way the
364
- app calls them, and checks the shape of the results. `loadView` mounts the view
365
- in the test DOM (the `@vitest-environment happy-dom` comment) and reports
366
- `changes` and `submissions`. `createSchemaValidator` checks fixtures against the
367
- schemas of the manifest, so a change to a schema fails the test.
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.
368
456
 
369
457
  ## Try and ship
370
458