@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.
- package/README.md +28 -14
- 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 -111
- package/dist/index.js +5 -86
- 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 +184 -241
- package/dist/testing.js +574 -464
- package/docs/debugging.md +82 -64
- package/docs/no-build.md +84 -48
- package/docs/quick-start.md +78 -56
- package/docs/recipe-command-panel.md +262 -118
- package/docs/recipe-event-storage.md +188 -128
- package/docs/recipe-exercise-type.md +271 -183
- package/docs/recipe-hooks.md +158 -0
- package/docs/recipe-import-export.md +48 -53
- 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 +122 -100
- package/docs/recipe-theme.md +72 -40
- package/docs/recipe-when-dependencies.md +188 -82
- package/package.json +29 -7
- package/dist/answer-element-BOQcuxYh.js +0 -114
- package/dist/answer-view-D6wnyThb.d.ts +0 -28
- package/dist/runtime.d.ts +0 -17
- package/dist/runtime.js +0 -24
- package/docs/recipe-ui-kit.md +0 -172
|
@@ -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
|
|
11
|
-
parts:
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
- `
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
`
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
|
202
|
-
import {
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
disposables.push(
|
|
221
|
-
return
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
271
|
-
const type =
|
|
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
|
|
284
|
-
|
|
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 {
|
|
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(
|
|
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 {
|
|
420
|
+
const { input, submissions } = await mount();
|
|
339
421
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
340
|
-
expect(
|
|
422
|
+
expect(submissions()).toBe(1);
|
|
341
423
|
});
|
|
342
424
|
|
|
343
425
|
it('disabled blocks the input', async () => {
|
|
344
|
-
const {
|
|
345
|
-
await
|
|
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 {
|
|
351
|
-
await
|
|
432
|
+
const { changes, input, update } = await mount();
|
|
433
|
+
await update({ value: 'Hello' });
|
|
352
434
|
expect(input.value).toBe('Hello');
|
|
353
|
-
expect(
|
|
435
|
+
expect(changes).toEqual([]);
|
|
354
436
|
});
|
|
355
437
|
|
|
356
|
-
it('the
|
|
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
|
-
`
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|