@lernapps/app-templates 0.1.0 → 0.2.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 +2 -2
- package/dist/{assessment-By2GGTJC.mjs → assessment-Dy359O1O.mjs} +1 -1
- package/dist/{blocks-BmRrD7UY.mjs → blocks-CxtCPAa5.mjs} +2 -2
- package/dist/blocks.mjs +2 -2
- package/dist/{check-BkEl-m-F.mjs → check-CteIFN96.mjs} +1 -1
- package/dist/{content-cYBPYg_J.mjs → content-DO6oEJxj.mjs} +1 -1
- package/dist/{e2e-BiMI-pCL.mjs → e2e-CU0n_unV.mjs} +1 -1
- package/dist/{engine-BqF2Vffn.mjs → engine-CArxqizW.mjs} +1 -1
- package/dist/explainer-e2e.mjs +54 -19
- package/dist/explainer-plugin.mjs +6 -5
- package/dist/explainer.mjs +4 -4
- package/dist/interactive-e2e.mjs +1 -1
- package/dist/interactive-plugin.mjs +2 -2
- package/dist/{load-C-0hUaSC.mjs → load-RosvaPZf.mjs} +37 -8
- package/dist/questions-e2e.mjs +1 -1
- package/dist/questions-engine.mjs +2 -2
- package/dist/questions.mjs +2 -2
- package/dist/{quiz-BKc8JYtC.mjs → quiz-DnW-xtCh.mjs} +1 -1
- package/dist/quiz-e2e.mjs +52 -28
- package/dist/quiz-plugin.mjs +4 -4
- package/dist/quiz.mjs +3 -3
- package/dist/{render-BL6fV_NI.mjs → render-D5kwkNt1.mjs} +2 -2
- package/dist/{render-DpRO_RKm.mjs → render-DqHOJyHK.mjs} +3 -3
- package/dist/{render-BJey0bwl.mjs → render-GS1Htlu8.mjs} +9 -4
- package/dist/{result-_eQcamoz.mjs → result-D_mmCVwN.mjs} +2 -2
- package/dist/{validate-COaZso7u.mjs → validate-BDwL7Su3.mjs} +6 -2
- package/dist/{validate-CxtdeQ5S.mjs → validate-Bxy9NfB8.mjs} +1 -1
- package/docs/arc42/09-decisions.arc42.md +24 -0
- package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +7 -3
- package/explainer/e2e/runtime.e2e.ts +6 -0
- package/explainer/package.json +2 -1
- package/interactive/package.json +2 -1
- package/package.json +38 -2
- package/quiz/docs/arc42/05-quiz-building-blocks.arc42.md +7 -3
- package/quiz/e2e/runtime.e2e.ts +5 -0
- package/quiz/package.json +2 -1
- package/runtime/explainer/assessment-page.ts +2 -0
- package/runtime/explainer/e2e.ts +204 -149
- package/runtime/explainer/exercises.ts +2 -0
- package/runtime/explainer/outline.ts +8 -1
- package/runtime/quiz/e2e.ts +172 -135
- package/runtime/shared/questions/question.ts +3 -0
- package/runtime/shared/questions/question.v1.schema.json +4 -0
- package/runtime/shared/questions/render.ts +14 -6
- package/runtime/shared/text/markdown.ts +1 -1
- package/runtime/shared/text/speech.ts +39 -0
- package/scaffold/explainer/src/app.yaml +5 -0
- package/scaffold/explainer/src/thema/chapter.yaml +4 -0
- package/scaffold/explainer/src/thema/einstieg.yaml +28 -0
- package/scaffold/interactive/src/app.yaml +8 -0
- package/scaffold/interactive/src/core/index.ts +23 -0
- package/scaffold/interactive/src/messages/de.json +6 -0
- package/scaffold/interactive/src/playthroughs/zaehlen.yaml +12 -0
- package/scaffold/interactive/src/ui/App.vue +27 -0
- package/scaffold/quiz/src/quiz.json +31 -0
package/runtime/quiz/e2e.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
// The end-to-end tests of
|
|
2
|
-
// app with
|
|
1
|
+
// The end-to-end tests of the quiz (@lernapps/app-templates/quiz/e2e), run by `lernapps check --pre-push` on the built
|
|
2
|
+
// app with its own question bank, in two parts. quizTests() tests the app's content, and every app runs it: its
|
|
3
|
+
// e2e/quiz.e2e.ts, part of the scaffold, is
|
|
3
4
|
//
|
|
4
5
|
// import { quizTests } from "@lernapps/app-templates/quiz/e2e";
|
|
5
6
|
// quizTests();
|
|
6
7
|
//
|
|
8
|
+
// Each question's link opens it with its text and what it shows, its images loaded; without JavaScript every question
|
|
9
|
+
// and its solution can be read; each other language has its page, its switch, and what a question shows there.
|
|
10
|
+
// quizRuntimeTests() tests the runtime, the same in every app; only this repo runs it, on the template and its
|
|
11
|
+
// fixtures (quiz/e2e/runtime.e2e.ts, not in the scaffold). What follows is what the two parts check together.
|
|
7
12
|
// For each question: its deep link opens it, the right answer counts and a wrong one does not; after either, the
|
|
8
13
|
// background of every option, the explanation and the links for further reading show, the learner's choice marked.
|
|
9
14
|
// The feedback then has the focus and its top is in view, and the button to the next question comes after it.
|
|
@@ -37,8 +42,29 @@ const de = QUIZ_MESSAGES.de;
|
|
|
37
42
|
/** The quiz's own texts in `lang`. */
|
|
38
43
|
const messagesOf = messagesIn;
|
|
39
44
|
|
|
40
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* The tests of a quiz's content, for the question bank in `bank` (relative to the app's root): what every app runs
|
|
47
|
+
* (e2e/quiz.e2e.ts). Each question's link opens it with what it shows; without JavaScript every question and its
|
|
48
|
+
* solution can be read; each other language has its page. What the runtime does the same in every app is
|
|
49
|
+
* quizRuntimeTests.
|
|
50
|
+
*/
|
|
41
51
|
export function quizTests(bank = "src/quiz.json"): void {
|
|
52
|
+
quizSuite(bank, "content");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The tests of the quiz's runtime on the question bank in `bank`: the right answer counts and a wrong one does not, with
|
|
57
|
+
* the feedback in view; the seed and the order; a run through every question to the full score; how many questions
|
|
58
|
+
* the learner chooses; what is stored. The template folder and the fixtures of lernapps/app-templates run them
|
|
59
|
+
* (e2e/runtime.e2e.ts); an app does not, its scaffold has no such file: they test the runtime, which is the same in
|
|
60
|
+
* every app, and their time grows with the bank (lernapps/app-templates#125).
|
|
61
|
+
*/
|
|
62
|
+
export function quizRuntimeTests(bank = "src/quiz.json"): void {
|
|
63
|
+
quizSuite(bank, "runtime");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function quizSuite(bank: string, part: "content" | "runtime"): void {
|
|
67
|
+
const content = part === "content";
|
|
42
68
|
const all = JSON.parse(readFileSync(bank, "utf8")) as Quiz<Text>;
|
|
43
69
|
const languages = quizLanguages(all);
|
|
44
70
|
const quiz = quizIn(all, "de");
|
|
@@ -48,94 +74,115 @@ export function quizTests(bank = "src/quiz.json"): void {
|
|
|
48
74
|
// answer: with motion, every click would wait for a scroll to settle, the run through a quiz grow with its size.
|
|
49
75
|
test.use({ reducedMotion: "reduce" });
|
|
50
76
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
77
|
+
if (content) {
|
|
78
|
+
for (const item of quiz.questions) {
|
|
79
|
+
test(`${item.id} (${item.type}): its link opens it, with what it shows`, async ({ page }) => {
|
|
80
|
+
await page.goto(`./#${questionId(item)}`);
|
|
81
|
+
await expect(question(page, item)).toBeVisible();
|
|
82
|
+
await expect(page.locator(".question:visible")).toHaveCount(1);
|
|
83
|
+
await expect(question(page, item).locator("legend")).toHaveText(item.text);
|
|
84
|
+
if (item.material !== undefined) await expectMaterial(page, item);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test.describe("without JavaScript", () => {
|
|
89
|
+
test.use({ javaScriptEnabled: false });
|
|
90
|
+
|
|
91
|
+
test("every question and its solution can be read", async ({ page }) => {
|
|
92
|
+
await page.goto("./");
|
|
93
|
+
for (const item of quiz.questions) {
|
|
94
|
+
await expect(question(page, item).locator("legend")).toHaveText(item.text);
|
|
95
|
+
await expectMaterial(page, item, { loaded: false });
|
|
96
|
+
await expectBackground(page.locator(`#loesung-${item.id}`), item);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
74
99
|
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!content)
|
|
103
|
+
for (const item of quiz.questions) {
|
|
104
|
+
test(`${item.id} (${item.type}): its link opens it and the right answer counts`, async ({ page }) => {
|
|
105
|
+
await page.goto(`./#${questionId(item)}`);
|
|
106
|
+
await expect(question(page, item)).toBeVisible();
|
|
107
|
+
await expect(page.locator(".question:visible")).toHaveCount(1);
|
|
108
|
+
await answerQuestion(page, item, rightAnswer(item));
|
|
109
|
+
await expect(question(page, item).locator(".question-feedback")).toContainText(de.right);
|
|
110
|
+
await expectFeedbackInView(page, item);
|
|
111
|
+
// from the feedback, the keyboard reaches the next question (past the links of the background)
|
|
112
|
+
const next = question(page, item).locator(".question-next");
|
|
113
|
+
for (let tab = 0; tab < 30 && !(await next.evaluate((button) => button === document.activeElement)); tab++) {
|
|
114
|
+
await page.keyboard.press("Tab");
|
|
115
|
+
}
|
|
116
|
+
await expect(next).toBeFocused();
|
|
117
|
+
const reveal = question(page, item).locator(".question-reveal");
|
|
118
|
+
await expectBackground(reveal, item);
|
|
119
|
+
if (item.type === "single-choice" || item.type === "multiple-choice") {
|
|
120
|
+
const right = item.options.filter((option) => option.correct).length;
|
|
121
|
+
await expect(reveal.locator(".question-badge-right")).toHaveCount(right);
|
|
122
|
+
await expect(reveal.locator(".question-badge-right").first()).toHaveText(de.rightOption);
|
|
123
|
+
} else {
|
|
124
|
+
await expect(reveal).toContainText(de.rightAnswer);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
75
127
|
|
|
76
|
-
|
|
77
|
-
test(`${item.id} (${item.type}): what it shows is there, and its images load`, async ({ page }) => {
|
|
128
|
+
test(`${item.id} (${item.type}): a wrong answer does not count`, async ({ page }) => {
|
|
78
129
|
await page.goto(`./#${questionId(item)}`);
|
|
79
|
-
await
|
|
130
|
+
await answerQuestion(page, item, wrongAnswer(item));
|
|
131
|
+
await expect(question(page, item).locator(".question-feedback")).toContainText(de.wrong);
|
|
132
|
+
await expectFeedbackInView(page, item);
|
|
133
|
+
await expectBackground(question(page, item).locator(".question-reveal"), item);
|
|
134
|
+
if (item.type === "single-choice" || item.type === "multiple-choice") {
|
|
135
|
+
await expect(question(page, item).locator(".question-badge-chosen").first()).toHaveText(de.yourChoice);
|
|
136
|
+
}
|
|
80
137
|
});
|
|
81
138
|
}
|
|
82
139
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
140
|
+
if (!content)
|
|
141
|
+
test("the seed in the address gives the order; without one, the address gets one", async ({ page }) => {
|
|
142
|
+
const everything = `&n=${quiz.questions.length}`;
|
|
143
|
+
const order = async (address: string) => {
|
|
144
|
+
await page.goto(address);
|
|
145
|
+
return page.locator(".question").evaluateAll((items) => items.map((item) => item.id).join());
|
|
146
|
+
};
|
|
147
|
+
const first = await order(`./?seed=11${everything}`);
|
|
148
|
+
expect(await order(`./?seed=11${everything}`)).toBe(first);
|
|
149
|
+
if (quiz.questions.length > 2) {
|
|
150
|
+
const others = [];
|
|
151
|
+
for (const seed of [12, 13, 14, 15, 16]) others.push(await order(`./?seed=${seed}${everything}`));
|
|
152
|
+
expect(others.some((other) => other !== first)).toBe(true);
|
|
91
153
|
}
|
|
154
|
+
await page.goto("./");
|
|
155
|
+
await expect.poll(() => new URL(page.url()).searchParams.get("seed")).toMatch(/^\d+$/);
|
|
92
156
|
});
|
|
93
|
-
}
|
|
94
157
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
await page.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
await question(page, item).locator(".question-next").click();
|
|
122
|
-
}
|
|
123
|
-
const total = String(quiz.questions.length);
|
|
124
|
-
await expect(page.locator(".quiz-score")).toHaveText(
|
|
125
|
-
de.score.replace("{score}", total).replace("{total}", total),
|
|
126
|
-
);
|
|
127
|
-
await expect(page.locator("#loesungen")).toBeVisible();
|
|
128
|
-
// only the last result, and only when the bank asks for it, under the app's own prefix
|
|
129
|
-
const stored = await page.evaluate(() => Object.keys(localStorage));
|
|
130
|
-
if (quiz.rememberLastResult === true) {
|
|
131
|
-
expect(stored).toHaveLength(1);
|
|
132
|
-
expect(stored[0]).toMatch(/^lernapps:.+:last-result$/);
|
|
133
|
-
} else {
|
|
134
|
-
expect(stored).toEqual([]);
|
|
135
|
-
}
|
|
136
|
-
});
|
|
158
|
+
if (!content)
|
|
159
|
+
test("all right answers give the full score, then the solutions show", async ({ page }) => {
|
|
160
|
+
await page.goto("./");
|
|
161
|
+
if (quiz.questions.length > 1) await page.locator(".quiz-start button").click();
|
|
162
|
+
expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
|
|
163
|
+
for (let step = 0; step < quiz.questions.length; step++) {
|
|
164
|
+
const id = await page.locator(".question:visible").getAttribute("data-id");
|
|
165
|
+
const item = quiz.questions.find((candidate) => candidate.id === id);
|
|
166
|
+
if (item === undefined) throw new Error(`no visible question at step ${step + 1}`);
|
|
167
|
+
await answerQuestion(page, item, rightAnswer(item));
|
|
168
|
+
await question(page, item).locator(".question-next").click();
|
|
169
|
+
}
|
|
170
|
+
const total = String(quiz.questions.length);
|
|
171
|
+
await expect(page.locator(".quiz-score")).toHaveText(
|
|
172
|
+
de.score.replace("{score}", total).replace("{total}", total),
|
|
173
|
+
);
|
|
174
|
+
await expect(page.locator("#loesungen")).toBeVisible();
|
|
175
|
+
// only the last result, and only when the bank asks for it, under the app's own prefix
|
|
176
|
+
const stored = await page.evaluate(() => Object.keys(localStorage));
|
|
177
|
+
if (quiz.rememberLastResult === true) {
|
|
178
|
+
expect(stored).toHaveLength(1);
|
|
179
|
+
expect(stored[0]).toMatch(/^lernapps:.+:last-result$/);
|
|
180
|
+
} else {
|
|
181
|
+
expect(stored).toEqual([]);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
137
184
|
|
|
138
|
-
if (quiz.questions.length > 1) {
|
|
185
|
+
if (!content && quiz.questions.length > 1) {
|
|
139
186
|
test("the learner chooses how many questions to answer; the address keeps the choice", async ({ page }) => {
|
|
140
187
|
await page.goto("./?seed=11");
|
|
141
188
|
await expect(page.locator(".quiz-start")).toBeVisible();
|
|
@@ -163,70 +210,60 @@ export function quizTests(bank = "src/quiz.json"): void {
|
|
|
163
210
|
});
|
|
164
211
|
}
|
|
165
212
|
|
|
166
|
-
test.describe("without JavaScript", () => {
|
|
167
|
-
test.use({ javaScriptEnabled: false });
|
|
168
|
-
|
|
169
|
-
test("every question and its solution can be read", async ({ page }) => {
|
|
170
|
-
await page.goto("./");
|
|
171
|
-
for (const item of quiz.questions) {
|
|
172
|
-
await expect(question(page, item).locator("legend")).toHaveText(item.text);
|
|
173
|
-
await expectMaterial(page, item, { loaded: false });
|
|
174
|
-
await expectBackground(page.locator(`#loesung-${item.id}`), item);
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
213
|
for (const lang of languages.slice(1)) {
|
|
180
214
|
const local = quizIn(all, lang);
|
|
181
215
|
const words = messagesOf(lang);
|
|
182
216
|
const address = `./${lang}/`;
|
|
183
217
|
|
|
184
218
|
test.describe(`in ${lang}`, () => {
|
|
185
|
-
|
|
186
|
-
test.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
page,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
await expect(page.locator("html")).toHaveAttribute("lang", lang);
|
|
193
|
-
await expect(page.locator("h1")).toHaveText(local.title);
|
|
194
|
-
await expect(page.locator("#loesungen h2")).toHaveText(words.solutions);
|
|
195
|
-
for (const item of local.questions) {
|
|
196
|
-
await expect(question(page, item).locator("legend")).toHaveText(item.text);
|
|
197
|
-
await expectMaterial(page, item, { loaded: false });
|
|
198
|
-
await expectBackground(page.locator(`#loesung-${item.id}`), item);
|
|
199
|
-
}
|
|
200
|
-
for (const other of languages) {
|
|
201
|
-
await expect(page.locator(`link[rel="alternate"][hreflang="${other}"]`)).toHaveCount(1);
|
|
202
|
-
}
|
|
203
|
-
for (const other of languages.filter((candidate) => candidate !== lang)) {
|
|
219
|
+
if (content)
|
|
220
|
+
test.describe("without JavaScript", () => {
|
|
221
|
+
test.use({ javaScriptEnabled: false });
|
|
222
|
+
|
|
223
|
+
test(`the page is in ${lang}, every question and its solution can be read, the switch leads to every language`, async ({
|
|
224
|
+
page,
|
|
225
|
+
}) => {
|
|
204
226
|
await page.goto(address);
|
|
205
|
-
await page.locator(
|
|
206
|
-
await expect(page.locator("
|
|
207
|
-
await expect(page.locator("
|
|
208
|
-
|
|
209
|
-
|
|
227
|
+
await expect(page.locator("html")).toHaveAttribute("lang", lang);
|
|
228
|
+
await expect(page.locator("h1")).toHaveText(local.title);
|
|
229
|
+
await expect(page.locator("#loesungen h2")).toHaveText(words.solutions);
|
|
230
|
+
for (const item of local.questions) {
|
|
231
|
+
await expect(question(page, item).locator("legend")).toHaveText(item.text);
|
|
232
|
+
await expectMaterial(page, item, { loaded: false });
|
|
233
|
+
await expectBackground(page.locator(`#loesung-${item.id}`), item);
|
|
234
|
+
}
|
|
235
|
+
for (const other of languages) {
|
|
236
|
+
await expect(page.locator(`link[rel="alternate"][hreflang="${other}"]`)).toHaveCount(1);
|
|
237
|
+
}
|
|
238
|
+
for (const other of languages.filter((candidate) => candidate !== lang)) {
|
|
239
|
+
await page.goto(address);
|
|
240
|
+
await page.locator(`.page-languages a[hreflang="${other}"]`).click();
|
|
241
|
+
await expect(page.locator("html")).toHaveAttribute("lang", other);
|
|
242
|
+
await expect(page.locator("h1")).toHaveText(quizIn(all, other).title);
|
|
243
|
+
await expect(page.locator('.page-languages a[aria-current="page"]')).toHaveAttribute("hreflang", other);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
210
246
|
});
|
|
211
|
-
});
|
|
212
247
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
248
|
+
if (content)
|
|
249
|
+
test(`what a question shows, in ${lang}`, async ({ page }) => {
|
|
250
|
+
const item = local.questions.find((candidate) => candidate.material !== undefined);
|
|
251
|
+
test.skip(item === undefined, "no question shows content");
|
|
252
|
+
if (item === undefined) return;
|
|
253
|
+
await page.goto(`${address}#${questionId(item)}`);
|
|
254
|
+
await expectMaterial(page, item);
|
|
255
|
+
});
|
|
220
256
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
257
|
+
if (!content)
|
|
258
|
+
test(`one question, answered in ${lang}`, async ({ page }) => {
|
|
259
|
+
const item = local.questions[0];
|
|
260
|
+
if (item === undefined) throw new Error("the quiz has no questions");
|
|
261
|
+
await page.goto(`${address}#${questionId(item)}`);
|
|
262
|
+
await expect(question(page, item)).toBeVisible();
|
|
263
|
+
await answerQuestion(page, item, rightAnswer(item), lang);
|
|
264
|
+
await expect(question(page, item).locator(".question-feedback")).toContainText(words.right);
|
|
265
|
+
await expectBackground(question(page, item).locator(".question-reveal"), item);
|
|
266
|
+
});
|
|
230
267
|
});
|
|
231
268
|
}
|
|
232
269
|
});
|
|
@@ -20,6 +20,8 @@ export interface Option<T extends Text = string> {
|
|
|
20
20
|
text: T;
|
|
21
21
|
correct: boolean;
|
|
22
22
|
info: T;
|
|
23
|
+
/** The option spoken, as its radio or checkbox is named: for an option that is only a formula; else made from it. */
|
|
24
|
+
alt?: T;
|
|
23
25
|
link?: Link<T>;
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -209,6 +211,7 @@ export function questionIn(question: Question<Text>, lang: string): Question {
|
|
|
209
211
|
text: t(option.text),
|
|
210
212
|
correct: option.correct,
|
|
211
213
|
info: t(option.info),
|
|
214
|
+
...(option.alt === undefined ? {} : { alt: t(option.alt) }),
|
|
212
215
|
...(option.link === undefined ? {} : { link: link(option.link) }),
|
|
213
216
|
})),
|
|
214
217
|
};
|
|
@@ -190,6 +190,10 @@
|
|
|
190
190
|
"description": "One to three sentences of background on this option itself, true whether or not it is the right answer, e.g. \"Der Uluru ist ein Inselberg in der Wüste Zentralaustraliens ...\". After an answer it shows for every option. For a wrong option it is also the reason a learner who chose it reads with the feedback, so say there why it does not fit.",
|
|
191
191
|
"$ref": "urn:lernapps:schema:text.v1#/$defs/text"
|
|
192
192
|
},
|
|
193
|
+
"alt": {
|
|
194
|
+
"description": "Optional: the option as it is spoken, for the name of its radio button or checkbox, when its text is only a formula, e.g. \"zwei hoch sieben\" for \"$2^7$\". Without it the name is made from the formula in the page's language (\"2 hoch 7\"); write it where that reads badly, e.g. for a chemical formula. Not needed when the text has words.",
|
|
195
|
+
"$ref": "urn:lernapps:schema:text.v1#/$defs/text"
|
|
196
|
+
},
|
|
193
197
|
"link": {
|
|
194
198
|
"description": "Further reading on this option.",
|
|
195
199
|
"$ref": "#/$defs/link"
|
|
@@ -44,17 +44,22 @@ export function questionWords(lang = "de"): Words {
|
|
|
44
44
|
/** How a learner text becomes HTML. */
|
|
45
45
|
export type Format = (text: string) => string;
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
/** A text as words for a name, where the text itself has none (a formula); undefined where it has. */
|
|
48
|
+
export type Describe = (text: string) => string | undefined;
|
|
49
|
+
|
|
50
|
+
function controls(question: Question, { t }: Words, format: Format, describe?: Describe): string {
|
|
48
51
|
const name = questionId(question);
|
|
49
52
|
switch (question.type) {
|
|
50
53
|
case "single-choice":
|
|
51
54
|
case "multiple-choice": {
|
|
52
55
|
const type = question.type === "single-choice" ? "radio" : "checkbox";
|
|
53
56
|
return question.options
|
|
54
|
-
.map(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
.map((option, index) => {
|
|
58
|
+
// an option that is only a formula has no text to name its control; the page says in words what it shows
|
|
59
|
+
const spoken = option.alt ?? describe?.(option.text);
|
|
60
|
+
const label = spoken === undefined ? "" : ` aria-label="${escape(spoken)}"`;
|
|
61
|
+
return `<label class="question-option"><input type="${type}" name="${name}" value="${index}"${label}> <span>${format(option.text)}</span></label>`;
|
|
62
|
+
})
|
|
58
63
|
.join("\n");
|
|
59
64
|
}
|
|
60
65
|
case "true-false":
|
|
@@ -111,6 +116,8 @@ function controls(question: Question, { t }: Words, format: Format): string {
|
|
|
111
116
|
export interface RenderQuestionOptions {
|
|
112
117
|
/** How a learner text becomes HTML, e.g. with its formulas; escaped as plain text by default. */
|
|
113
118
|
format?: (text: string) => string;
|
|
119
|
+
/** The words a screen reader names the control of an option by, when its text is only a formula. */
|
|
120
|
+
describe?: Describe;
|
|
114
121
|
element?: "div" | "li";
|
|
115
122
|
next?: boolean;
|
|
116
123
|
lang?: string;
|
|
@@ -142,6 +149,7 @@ export function renderQuestion(
|
|
|
142
149
|
materialHtml,
|
|
143
150
|
explanationHtml,
|
|
144
151
|
format = escape,
|
|
152
|
+
describe,
|
|
145
153
|
}: RenderQuestionOptions = {},
|
|
146
154
|
): string {
|
|
147
155
|
const words = questionWords(lang);
|
|
@@ -156,7 +164,7 @@ export function renderQuestion(
|
|
|
156
164
|
...(materialHtml === undefined || materialHtml === ""
|
|
157
165
|
? []
|
|
158
166
|
: [`<div class="question-material">${materialHtml}</div>`]),
|
|
159
|
-
controls(question, words, format),
|
|
167
|
+
controls(question, words, format, describe),
|
|
160
168
|
`</fieldset>`,
|
|
161
169
|
`<div class="question-feedback" role="status"></div>`,
|
|
162
170
|
`<div class="question-reveal" hidden>`,
|
|
@@ -158,7 +158,7 @@ export function renderInline(source: string, resolve: (href: string) => string):
|
|
|
158
158
|
/** A plain text as HTML. */
|
|
159
159
|
export const plain = escape;
|
|
160
160
|
|
|
161
|
-
const FORMULA = /\$((?:\\.|[^\\$\n])+?)\$/g;
|
|
161
|
+
export const FORMULA = /\$((?:\\.|[^\\$\n])+?)\$/g;
|
|
162
162
|
|
|
163
163
|
/** A plain text as HTML in which only $…$ is a formula (MathML); everything else stays text, as in a quiz. */
|
|
164
164
|
export function renderWithFormulas(text: string): string {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The text alternative of a formula, for a name a screen reader or the accessibility check can read where the page has
|
|
2
|
+
// only MathML (an option of a choice whose text is a formula, see the label of its radio): Speech Rule Engine speaks
|
|
3
|
+
// the MathML of the formula in the page's language. Build time only; the engine loads its rules for every language
|
|
4
|
+
// of the runtime once, here, so that speaking a formula in any of them is then synchronous.
|
|
5
|
+
// @ts-expect-error speech-rule-engine ships no types; the part this file uses is typed below (drop this line if it does)
|
|
6
|
+
import untyped from "speech-rule-engine";
|
|
7
|
+
import { isLanguage, LANGUAGES } from "../languages.ts";
|
|
8
|
+
import { renderFormula } from "./formula.ts";
|
|
9
|
+
import { FORMULA } from "./markdown.ts";
|
|
10
|
+
|
|
11
|
+
/** The part of Speech Rule Engine that this file uses. */
|
|
12
|
+
interface Engine {
|
|
13
|
+
/** Loads the rules of the locale; once loaded, switching to it takes effect at once. */
|
|
14
|
+
setupEngine(setup: { locale: string; domain: string; modality: string }): Promise<void>;
|
|
15
|
+
/** The words of a MathML string, in the locale set up last. */
|
|
16
|
+
toSpeech(mathml: string): string;
|
|
17
|
+
}
|
|
18
|
+
const SRE: Engine = untyped;
|
|
19
|
+
|
|
20
|
+
const SPEECH = { domain: "clearspeak", modality: "speech" } as const;
|
|
21
|
+
|
|
22
|
+
for (const locale of LANGUAGES) await SRE.setupEngine({ locale, ...SPEECH });
|
|
23
|
+
|
|
24
|
+
/** The formula `tex` as words in `lang` (German for a language the runtime has no texts for), e.g. "2 hoch 7". */
|
|
25
|
+
export function speakFormula(tex: string, lang: string): string {
|
|
26
|
+
// the rules of every language are loaded: switching to one takes effect at once
|
|
27
|
+
void SRE.setupEngine({ locale: isLanguage(lang) ? lang : "de", ...SPEECH });
|
|
28
|
+
return SRE.toSpeech(renderFormula(tex, false)).replace(/\s+/g, " ").trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The words of a text that is only formulas, $…$ (and space); undefined for any other text, which has words of its own
|
|
33
|
+
* for a name to be made of.
|
|
34
|
+
*/
|
|
35
|
+
export function formulaAlternative(text: string, lang: string): string | undefined {
|
|
36
|
+
const formulas = [...text.matchAll(FORMULA)];
|
|
37
|
+
if (formulas.length === 0 || text.replace(FORMULA, "").trim() !== "") return undefined;
|
|
38
|
+
return formulas.map((formula) => speakFormula(formula[1] ?? "", lang)).join(" ");
|
|
39
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# yaml-language-server: $schema=../../node_modules/@lernapps/app-templates/runtime/explainer/explainer.v1.schema.json
|
|
2
|
+
title: Einstieg
|
|
3
|
+
description: Eine Seite, die den Platz frei hält.
|
|
4
|
+
blocks:
|
|
5
|
+
- type: section
|
|
6
|
+
id: worum-es-geht
|
|
7
|
+
title: Worum es geht
|
|
8
|
+
blocks:
|
|
9
|
+
- type: text
|
|
10
|
+
text: Ersetze diese Seite durch die erste geplante.
|
|
11
|
+
- type: section
|
|
12
|
+
id: uebung
|
|
13
|
+
title: Übung
|
|
14
|
+
blocks:
|
|
15
|
+
- type: single-choice
|
|
16
|
+
id: platzhalter
|
|
17
|
+
text: Welche Antwort ist richtig?
|
|
18
|
+
explanation: Diese Übung ist ein Platzhalter. Ersetze sie durch die erste geplante.
|
|
19
|
+
links:
|
|
20
|
+
- title: lernapps.net
|
|
21
|
+
url: https://lernapps.net/
|
|
22
|
+
options:
|
|
23
|
+
- text: Diese
|
|
24
|
+
correct: true
|
|
25
|
+
info: "Richtig: Diese Antwort ist als richtig markiert."
|
|
26
|
+
- text: Diese nicht
|
|
27
|
+
correct: false
|
|
28
|
+
info: "Falsch: Diese Antwort ist als falsch markiert."
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# yaml-language-server: $schema=../node_modules/@lernapps/app-templates/runtime/interactive/interactive.v1.schema.json
|
|
2
|
+
title: Platzhalter
|
|
3
|
+
description: Ein Knopf, der bis drei zählt. Er hält den Platz frei, bis die geplante App da ist.
|
|
4
|
+
howto: |
|
|
5
|
+
- Drücke „Weiter“, bis du bei drei bist.
|
|
6
|
+
competencies:
|
|
7
|
+
- Ich kann bis drei zählen.
|
|
8
|
+
languages: [de]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The app's logic, a placeholder: a count from 0 to 3, one step for each "weiter" (the button of the component).
|
|
2
|
+
// Pure TypeScript without DOM; the runtime keeps the state and calls start and step
|
|
3
|
+
// (@lernapps/app-templates/interactive). Replace it with the planned core.
|
|
4
|
+
import type { Input } from "@lernapps/app-templates/interactive";
|
|
5
|
+
|
|
6
|
+
export interface State {
|
|
7
|
+
seed: number;
|
|
8
|
+
count: number;
|
|
9
|
+
done: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** How far the count goes. */
|
|
13
|
+
export const GOAL = 3;
|
|
14
|
+
|
|
15
|
+
export function start(seed: number): State {
|
|
16
|
+
return { seed, count: 0, done: false };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function step(state: State, input: Input): State {
|
|
20
|
+
if (state.done || input.kind !== "action" || input.name !== "weiter") return state;
|
|
21
|
+
const count = state.count + 1;
|
|
22
|
+
return { ...state, count, done: count >= GOAL };
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# yaml-language-server: $schema=../../node_modules/@lernapps/app-templates/runtime/interactive/playthrough.v1.schema.json
|
|
2
|
+
# Three times "weiter": the count reaches three and the app is done; a fourth press changes nothing.
|
|
3
|
+
seed: 1
|
|
4
|
+
steps:
|
|
5
|
+
- inputs: [{ kind: action, name: weiter }]
|
|
6
|
+
state: { count: 1, done: false }
|
|
7
|
+
announce: 1 von 3
|
|
8
|
+
- inputs: [{ kind: action, name: weiter }, { kind: action, name: weiter }]
|
|
9
|
+
state: { count: 3, done: true }
|
|
10
|
+
announce: "Fertig: bis drei gezählt."
|
|
11
|
+
- inputs: [{ kind: action, name: weiter }]
|
|
12
|
+
state: { count: 3, done: true }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A placeholder: the count and a button that emits the action "weiter". Replace it with the planned component.
|
|
3
|
+
import { watch } from "vue";
|
|
4
|
+
import { announce } from "@lernapps/tooling/a11y";
|
|
5
|
+
import { translator } from "@lernapps/tooling/i18n";
|
|
6
|
+
import type { Input } from "@lernapps/app-templates/interactive";
|
|
7
|
+
import { GOAL, type State } from "../core/index.ts";
|
|
8
|
+
import de from "../messages/de.json";
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{ state: State; lang: string }>();
|
|
11
|
+
const emit = defineEmits<{ input: [input: Input] }>();
|
|
12
|
+
const t = translator(de);
|
|
13
|
+
|
|
14
|
+
watch(
|
|
15
|
+
() => props.state.count,
|
|
16
|
+
(count) => announce(props.state.done ? t("done") : t("count", { count, goal: GOAL })),
|
|
17
|
+
);
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<section :aria-label="t('app')">
|
|
22
|
+
<p>{{ state.done ? t("done") : t("count", { count: state.count, goal: GOAL }) }}</p>
|
|
23
|
+
<button v-if="!state.done" type="button" @click="emit('input', { kind: 'action', name: 'weiter' })">
|
|
24
|
+
{{ t("next") }}
|
|
25
|
+
</button>
|
|
26
|
+
</section>
|
|
27
|
+
</template>
|