@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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/dist/{assessment-By2GGTJC.mjs → assessment-Dy359O1O.mjs} +1 -1
  3. package/dist/{blocks-BmRrD7UY.mjs → blocks-CxtCPAa5.mjs} +2 -2
  4. package/dist/blocks.mjs +2 -2
  5. package/dist/{check-BkEl-m-F.mjs → check-CteIFN96.mjs} +1 -1
  6. package/dist/{content-cYBPYg_J.mjs → content-DO6oEJxj.mjs} +1 -1
  7. package/dist/{e2e-BiMI-pCL.mjs → e2e-CU0n_unV.mjs} +1 -1
  8. package/dist/{engine-BqF2Vffn.mjs → engine-CArxqizW.mjs} +1 -1
  9. package/dist/explainer-e2e.mjs +54 -19
  10. package/dist/explainer-plugin.mjs +6 -5
  11. package/dist/explainer.mjs +4 -4
  12. package/dist/interactive-e2e.mjs +1 -1
  13. package/dist/interactive-plugin.mjs +2 -2
  14. package/dist/{load-C-0hUaSC.mjs → load-RosvaPZf.mjs} +37 -8
  15. package/dist/questions-e2e.mjs +1 -1
  16. package/dist/questions-engine.mjs +2 -2
  17. package/dist/questions.mjs +2 -2
  18. package/dist/{quiz-BKc8JYtC.mjs → quiz-DnW-xtCh.mjs} +1 -1
  19. package/dist/quiz-e2e.mjs +52 -28
  20. package/dist/quiz-plugin.mjs +4 -4
  21. package/dist/quiz.mjs +3 -3
  22. package/dist/{render-BL6fV_NI.mjs → render-D5kwkNt1.mjs} +2 -2
  23. package/dist/{render-DpRO_RKm.mjs → render-DqHOJyHK.mjs} +3 -3
  24. package/dist/{render-BJey0bwl.mjs → render-GS1Htlu8.mjs} +9 -4
  25. package/dist/{result-_eQcamoz.mjs → result-D_mmCVwN.mjs} +2 -2
  26. package/dist/{validate-COaZso7u.mjs → validate-BDwL7Su3.mjs} +6 -2
  27. package/dist/{validate-CxtdeQ5S.mjs → validate-Bxy9NfB8.mjs} +1 -1
  28. package/docs/arc42/09-decisions.arc42.md +24 -0
  29. package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +7 -3
  30. package/explainer/e2e/runtime.e2e.ts +6 -0
  31. package/explainer/package.json +2 -1
  32. package/interactive/package.json +2 -1
  33. package/package.json +38 -2
  34. package/quiz/docs/arc42/05-quiz-building-blocks.arc42.md +7 -3
  35. package/quiz/e2e/runtime.e2e.ts +5 -0
  36. package/quiz/package.json +2 -1
  37. package/runtime/explainer/assessment-page.ts +2 -0
  38. package/runtime/explainer/e2e.ts +204 -149
  39. package/runtime/explainer/exercises.ts +2 -0
  40. package/runtime/explainer/outline.ts +8 -1
  41. package/runtime/quiz/e2e.ts +172 -135
  42. package/runtime/shared/questions/question.ts +3 -0
  43. package/runtime/shared/questions/question.v1.schema.json +4 -0
  44. package/runtime/shared/questions/render.ts +14 -6
  45. package/runtime/shared/text/markdown.ts +1 -1
  46. package/runtime/shared/text/speech.ts +39 -0
  47. package/scaffold/explainer/src/app.yaml +5 -0
  48. package/scaffold/explainer/src/thema/chapter.yaml +4 -0
  49. package/scaffold/explainer/src/thema/einstieg.yaml +28 -0
  50. package/scaffold/interactive/src/app.yaml +8 -0
  51. package/scaffold/interactive/src/core/index.ts +23 -0
  52. package/scaffold/interactive/src/messages/de.json +6 -0
  53. package/scaffold/interactive/src/playthroughs/zaehlen.yaml +12 -0
  54. package/scaffold/interactive/src/ui/App.vue +27 -0
  55. package/scaffold/quiz/src/quiz.json +31 -0
@@ -1,9 +1,15 @@
1
- // The end-to-end tests of every explainer (@lernapps/app-templates/explainer/e2e), run by `lernapps check --pre-push`
2
- // on the built app with the app's own content. An app's e2e/explainer.e2e.ts is
1
+ // The end-to-end tests of the explainer (@lernapps/app-templates/explainer/e2e), run by `lernapps check --pre-push` on
2
+ // the built app with its own content, in two parts. explainerTests() tests the app's content, and every app runs it:
3
+ // its e2e/explainer.e2e.ts, part of the scaffold, is
3
4
  //
4
5
  // import { explainerTests } from "@lernapps/app-templates/explainer/e2e";
5
6
  // explainerTests();
6
7
  //
8
+ // Without JavaScript every page as its content says, in every language; previous and next (at desktop width); the
9
+ // app's own components and the checks of its exercise components. explainerRuntimeTests() tests the runtime, the same
10
+ // in every app: the exercises of each type, the videos, the tutor ways, the start page, the self-assessments and what
11
+ // is stored. Only this repo runs it, on the template and its fixtures (explainer/e2e/runtime.e2e.ts, not in the
12
+ // scaffold). What follows is what the two parts check together.
7
13
  // What they expect comes from the content in src/ (outline.ts), not from the rendered pages. Without JavaScript, for
8
14
  // every page in every language: the heading levels follow the nesting (page h1, section h2, subsection h3, a box one
9
15
  // below its place), every section heading is visible, the number of <math> elements is the number of formulas in the
@@ -67,6 +73,7 @@ import { message as messageOf } from "./messages.ts";
67
73
  import { ASSESSMENT_TASK_EVENT, nextTaskNumber, taskNumber } from "./tasks.ts";
68
74
  import {
69
75
  outline,
76
+ shownText,
70
77
  type AssessmentOutline,
71
78
  type ComponentOutline,
72
79
  type ExerciseOutline,
@@ -157,6 +164,12 @@ const BAND_WORDS = {
157
164
  unasked: "assessmentUnasked",
158
165
  } as const;
159
166
 
167
+ /**
168
+ * `text` without space. A browser's text of a formula has space between its parts ("2 7" for 2⁷) where the content's
169
+ * has none, so texts with formulas are compared without it (in the order of their characters, which is what counts).
170
+ */
171
+ const squash = (text: string): string => text.replace(/\s+/g, "");
172
+
160
173
  /** The task number of the deep link the tests open a generated exercise with. */
161
174
  const SEED = 7;
162
175
 
@@ -169,7 +182,7 @@ async function expectExercises(page: Page, item: PageOutline): Promise<void> {
169
182
  const main = page.locator("main");
170
183
  const expected = item.exercises.map((exercise) =>
171
184
  exercise.kind === "question"
172
- ? { id: exercise.id, texts: [exercise.question.text], answers: [exercise.answers] }
185
+ ? { id: exercise.id, texts: [shownText(exercise.question.text)], answers: [exercise.answers] }
173
186
  : exercise.kind === "open"
174
187
  ? { id: exercise.id, texts: [exercise.task], answers: [exercise.solution] }
175
188
  : {
@@ -178,29 +191,31 @@ async function expectExercises(page: Page, item: PageOutline): Promise<void> {
178
191
  answers: exercise.worked.map((worked) => [worked.answer]),
179
192
  },
180
193
  );
181
- const read = () =>
182
- main.locator(".explainer-exercise").evaluateAll((elements) =>
183
- elements.map((element) => {
184
- const fold = (text: string | null) => (text ?? "").replace(/\s+/g, " ").trim();
185
- const own = element.classList.contains("explainer-question")
186
- ? [...element.querySelectorAll("legend")]
187
- : element.classList.contains("explainer-open-task")
188
- ? [...element.querySelectorAll(":scope > .explainer-open-task-text")]
189
- : [...element.querySelectorAll(".explainer-worked-task > .explainer-task")];
190
- const solutions = [...element.querySelectorAll<HTMLDetailsElement>("details.explainer-solution")];
191
- return {
192
- id: element.id,
193
- texts: own.map((text) => fold(text.textContent)),
194
- // what a learner sees of each "Lösung": nothing while it is closed
195
- shown: solutions.map((details) => (details.open ? fold((details as HTMLElement).innerText) : "")),
196
- };
197
- }),
198
- );
194
+ const read = async () =>
195
+ (
196
+ await main.locator(".explainer-exercise").evaluateAll((elements) =>
197
+ elements.map((element) => {
198
+ const fold = (text: string | null) => (text ?? "").replace(/\s+/g, " ").trim();
199
+ const own = element.classList.contains("explainer-question")
200
+ ? [...element.querySelectorAll("legend")]
201
+ : element.classList.contains("explainer-open-task")
202
+ ? [...element.querySelectorAll(":scope > .explainer-open-task-text")]
203
+ : [...element.querySelectorAll(".explainer-worked-task > .explainer-task")];
204
+ const solutions = [...element.querySelectorAll<HTMLDetailsElement>("details.explainer-solution")];
205
+ return {
206
+ id: element.id,
207
+ texts: own.map((text) => fold(text.textContent)),
208
+ // what a learner sees of each "Lösung": nothing while it is closed
209
+ shown: solutions.map((details) => (details.open ? fold((details as HTMLElement).innerText) : "")),
210
+ };
211
+ }),
212
+ )
213
+ ).map((item) => ({ ...item, texts: item.texts.map(squash), shown: item.shown.map(squash) }));
199
214
  const before = await read();
200
215
  expect(
201
216
  before.map(({ id, texts }) => ({ id, texts })),
202
217
  `${item.url}: each exercise with its text`,
203
- ).toEqual(expected.map(({ id, texts }) => ({ id, texts })));
218
+ ).toEqual(expected.map(({ id, texts }) => ({ id, texts: texts.map(squash) })));
204
219
  // a click on each summary, as a learner opens a <details> (its own behaviour, no script of the page)
205
220
  await main
206
221
  .locator(".explainer-exercise details.explainer-solution > summary")
@@ -212,7 +227,7 @@ async function expectExercises(page: Page, item: PageOutline): Promise<void> {
212
227
  expect(
213
228
  after[index]?.shown[at] ?? "",
214
229
  `${item.url}: ${exercise.id}, Lösung ${at + 1} shows ${answer}`,
215
- ).toContain(answer);
230
+ ).toContain(squash(answer));
216
231
  }
217
232
  });
218
233
  }
@@ -484,7 +499,9 @@ const feedback = (page: Page, id: string) => page.locator(`[id="${id}"] .questio
484
499
  function reasonFor(question: Question, answer: Answer): string {
485
500
  const quiz = questionMessagesIn("de");
486
501
  const [first] = wrongReasons(question, answer);
487
- if (first !== undefined) return first.option === undefined ? first.text : `${first.option}: ${first.text}`;
502
+ if (first !== undefined) {
503
+ return first.option === undefined ? shownText(first.text) : `${shownText(first.option)}: ${shownText(first.text)}`;
504
+ }
488
505
  return missesRight(question, answer) ? quiz.missingRight : quiz.lookAgain;
489
506
  }
490
507
 
@@ -495,9 +512,12 @@ function reasonFor(question: Question, answer: Answer): string {
495
512
  async function wrongThenSolution(page: Page, exercise: string, question: Question, reason: string): Promise<void> {
496
513
  const item = page.locator(`[id="${questionId(question)}"]`);
497
514
  await expect(feedback(page, questionId(question))).toContainText(questionMessagesIn("de").wrong);
498
- await expect(item.locator(".question-diagnosis").first(), `${exercise}: the reason of the wrong answer`).toHaveText(
499
- reason,
500
- );
515
+ await expect
516
+ .poll(
517
+ async () => squash((await item.locator(".question-diagnosis").first().textContent()) ?? ""),
518
+ `${exercise}: the reason of the wrong answer`,
519
+ )
520
+ .toBe(squash(reason));
501
521
  await expectFeedbackInView(page, question);
502
522
  await expect(item.locator(".question-reveal"), `${exercise}: no solution after a wrong answer`).toBeHidden();
503
523
  await expect(item.locator("input, select").first(), `${exercise}: another try`).toBeEnabled();
@@ -813,148 +833,183 @@ async function expectResultLine(
813
833
  expect(await response.text(), "the tutor page has the exercise").toContain(id);
814
834
  }
815
835
 
816
- /** The explainer's tests, for the content in `root`/src. */
836
+ /**
837
+ * The tests of an explainer's content, for the content in `root`/src: what every app runs (e2e/explainer.e2e.ts).
838
+ * Without JavaScript every page as its content says, previous and next, the app's own components and the checks of
839
+ * its exercise components. What the runtime does the same in every app is explainerRuntimeTests.
840
+ */
817
841
  export function explainerTests(root = "."): void {
842
+ explainerSuite(root, "content");
843
+ }
844
+
845
+ /**
846
+ * The tests of the explainer's runtime on the content in `root`/src: the exercises of each type, the videos, the tutor
847
+ * ways, the start page, the self-assessments, what is stored. The template folder and the fixtures of
848
+ * lernapps/app-templates run them (e2e/runtime.e2e.ts); an app does not, its scaffold has no such file: they test the
849
+ * runtime, which is the same in every app, not the app's content.
850
+ */
851
+ export function explainerRuntimeTests(root = "."): void {
852
+ explainerSuite(root, "runtime");
853
+ }
854
+
855
+ /** Only at desktop width, in a test of what does not depend on the width; what a page shows is tested at 360 px too. */
856
+ const desktopOnly = (page: Page) => test.skip((page.viewportSize()?.width ?? 0) <= 400, "only at desktop width");
857
+
858
+ function explainerSuite(root: string, part: "content" | "runtime"): void {
818
859
  const app = outline(root);
860
+ const content = part === "content";
819
861
 
820
862
  test.describe("explainer", () => {
821
863
  test.describe.configure({ mode: "parallel" });
822
864
  test.use({ reducedMotion: "reduce" });
823
865
 
824
- test.describe("without JavaScript", () => {
825
- test.use({ javaScriptEnabled: false });
866
+ if (content)
867
+ test.describe("without JavaScript", () => {
868
+ test.use({ javaScriptEnabled: false });
826
869
 
827
- for (const lang of app.languages) {
828
- for (const item of app.pages[lang] ?? []) {
829
- test(`${item.url || "./"}: headings, sections, formulas, figures and anchors as in the content`, async ({
830
- page,
831
- }) => {
832
- await page.goto(`./${item.url}`);
833
- const main = page.locator("main");
834
- const headings = await main.locator("h1, h2, h3, h4, h5, h6").evaluateAll((elements) =>
835
- elements.map((element) => ({
836
- level: Number(element.tagName.slice(1)),
837
- text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
838
- })),
839
- );
840
- expect(headings, "the heading levels follow the nesting").toEqual(item.headings);
841
- for (const section of item.sections) {
842
- const heading = page.locator(`[id="${section.id}"]`);
843
- await expect(heading, `section ${section.id}`).toBeVisible();
844
- await expect(heading).toHaveText(section.title);
845
- }
846
- // the formulas of the content; an exercise component's no-JS task, from its describe, is not content
847
- const formulas = await main
848
- .locator("math")
849
- .evaluateAll((elements) => elements.filter((math) => !math.closest(".explainer-described")).length);
850
- expect(formulas, "one <math> per formula").toBe(item.formulas);
851
- for (const name of item.figures) {
852
- await expect(main.getByRole("img", { name, exact: true }).first(), `the figure "${name}"`).toBeVisible();
853
- }
854
- await expectAnchors(page);
855
- await expectLanguages(page, item, app.languages);
856
- await expectExercises(page, item);
857
- await expectNojs(page, item);
858
- await expectVideos(page, item);
859
- });
860
- }
870
+ for (const lang of app.languages) {
871
+ for (const item of app.pages[lang] ?? []) {
872
+ test(`${item.url || "./"}: headings, sections, formulas, figures and anchors as in the content`, async ({
873
+ page,
874
+ }) => {
875
+ await page.goto(`./${item.url}`);
876
+ const main = page.locator("main");
877
+ const headings = await main.locator("h1, h2, h3, h4, h5, h6").evaluateAll((elements) =>
878
+ elements.map((element) => ({
879
+ level: Number(element.tagName.slice(1)),
880
+ text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
881
+ })),
882
+ );
883
+ expect(headings, "the heading levels follow the nesting").toEqual(item.headings);
884
+ for (const section of item.sections) {
885
+ const heading = page.locator(`[id="${section.id}"]`);
886
+ await expect(heading, `section ${section.id}`).toBeVisible();
887
+ await expect(heading).toHaveText(section.title);
888
+ }
889
+ // the formulas of the content; an exercise component's no-JS task, from its describe, is not content
890
+ const formulas = await main
891
+ .locator("math")
892
+ .evaluateAll((elements) => elements.filter((math) => !math.closest(".explainer-described")).length);
893
+ expect(formulas, "one <math> per formula").toBe(item.formulas);
894
+ for (const name of item.figures) {
895
+ await expect(
896
+ main.getByRole("img", { name, exact: true }).first(),
897
+ `the figure "${name}"`,
898
+ ).toBeVisible();
899
+ }
900
+ await expectAnchors(page);
901
+ await expectLanguages(page, item, app.languages);
902
+ await expectExercises(page, item);
903
+ await expectNojs(page, item);
904
+ await expectVideos(page, item);
905
+ });
906
+ }
907
+
908
+ for (const item of app.others[lang] ?? []) {
909
+ test(`${item.url || "./"}: its heading, its links and anchors, its languages`, async ({ page }) => {
910
+ await page.goto(`./${item.url}`);
911
+ await expect(page.locator("main h1")).toHaveText(item.title);
912
+ await expectAnchors(page);
913
+ await expectLanguages(page, item, app.languages);
914
+ // only a content page starts a tutor; the start page says what it is
915
+ await expect(page.locator(".explainer-tutor"), "no way to the tutor here").toHaveCount(0);
916
+ if (item.kind === "start") {
917
+ await expect(page.locator(".explainer-tutor-about h2")).toHaveText(messageOf(item.lang, "tutorTitle"));
918
+ }
919
+ });
920
+ }
861
921
 
862
- for (const item of app.others[lang] ?? []) {
863
- test(`${item.url || "./"}: its heading, its links and anchors, its languages`, async ({ page }) => {
864
- await page.goto(`./${item.url}`);
865
- await expect(page.locator("main h1")).toHaveText(item.title);
866
- await expectAnchors(page);
867
- await expectLanguages(page, item, app.languages);
868
- // only a content page starts a tutor; the start page says what it is
869
- await expect(page.locator(".explainer-tutor"), "no way to the tutor here").toHaveCount(0);
870
- if (item.kind === "start") {
871
- await expect(page.locator(".explainer-tutor-about h2")).toHaveText(messageOf(item.lang, "tutorTitle"));
922
+ test(`${lang}: previous and next form one chain over all pages`, async ({ page }) => {
923
+ desktopOnly(page);
924
+ const order = (app.pages[lang] ?? []).map((item) => item.url);
925
+ const first = order[0];
926
+ if (first === undefined) throw new Error(`no page in ${lang}`);
927
+ const relative = (url: string) => {
928
+ const path = new URL(url).pathname;
929
+ const at = order.find((candidate) => path.endsWith(`/${candidate}`));
930
+ return at ?? path;
931
+ };
932
+ const forward: string[] = [];
933
+ await page.goto(`./${first}`);
934
+ for (let step = 0; step <= order.length; step++) {
935
+ forward.push(relative(page.url()));
936
+ const next = page.locator('a[rel="next"]');
937
+ if ((await next.count()) === 0) break;
938
+ // the next page before its links count: a large page may still load when the loop looks again
939
+ const here = page.url();
940
+ await next.click();
941
+ await page.waitForURL((url) => url.href !== here);
942
+ }
943
+ expect(forward, "next leads through every page in order").toEqual(order);
944
+ const backward: string[] = [];
945
+ for (let step = 0; step <= order.length; step++) {
946
+ backward.push(relative(page.url()));
947
+ const previous = page.locator('a[rel="prev"]');
948
+ if ((await previous.count()) === 0) break;
949
+ const here = page.url();
950
+ await previous.click();
951
+ await page.waitForURL((url) => url.href !== here);
872
952
  }
953
+ expect(backward, "previous leads back through every page").toEqual([...order].reverse());
873
954
  });
874
955
  }
875
-
876
- test(`${lang}: previous and next form one chain over all pages`, async ({ page }) => {
877
- const order = (app.pages[lang] ?? []).map((item) => item.url);
878
- const first = order[0];
879
- if (first === undefined) throw new Error(`no page in ${lang}`);
880
- const relative = (url: string) => {
881
- const path = new URL(url).pathname;
882
- const at = order.find((candidate) => path.endsWith(`/${candidate}`));
883
- return at ?? path;
884
- };
885
- const forward: string[] = [];
886
- await page.goto(`./${first}`);
887
- for (let step = 0; step <= order.length; step++) {
888
- forward.push(relative(page.url()));
889
- const next = page.locator('a[rel="next"]');
890
- if ((await next.count()) === 0) break;
891
- // the next page before its links count: a large page may still load when the loop looks again
892
- const here = page.url();
893
- await next.click();
894
- await page.waitForURL((url) => url.href !== here);
895
- }
896
- expect(forward, "next leads through every page in order").toEqual(order);
897
- const backward: string[] = [];
898
- for (let step = 0; step <= order.length; step++) {
899
- backward.push(relative(page.url()));
900
- const previous = page.locator('a[rel="prev"]');
901
- if ((await previous.count()) === 0) break;
902
- const here = page.url();
903
- await previous.click();
904
- await page.waitForURL((url) => url.href !== here);
905
- }
906
- expect(backward, "previous leads back through every page").toEqual([...order].reverse());
907
- });
908
- }
909
- });
956
+ });
910
957
 
911
958
  // with JavaScript in German, one exercise of each type per page; the other languages render the same code
912
959
  test.describe("with JavaScript", () => {
913
960
  // at the narrowest width only, where the feedback is hardest to keep in view: the exercises' logic is the same
914
961
  // at every width, and the hooks stay fast
915
962
  test.skip(({ viewport }) => (viewport?.width ?? 0) > 400, "the exercises run at 360 px");
916
- for (const item of app.pages["de"] ?? []) {
917
- const types = new Map<string, ExerciseOutline>();
918
- for (const exercise of item.exercises) if (!types.has(exercise.type)) types.set(exercise.type, exercise);
919
- if (types.size === 0) continue;
920
- test(`${item.url}: one exercise of each type checks its answers (${[...types.keys()].join(", ")})`, async ({
921
- page,
922
- }) => {
923
- for (const exercise of types.values()) {
924
- if (exercise.kind === "question") await checkQuestion(page, item.url, exercise);
925
- else if (exercise.kind === "open") await checkOpenTask(page, item.url, exercise);
926
- else await checkGenerated(page, item.url, exercise);
927
- }
928
- // only an open task keeps something, its answer, and only while the tab is open
929
- expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
930
- const kept = await page.evaluate(() => Object.keys(sessionStorage));
931
- expect(
932
- kept.filter((key) => !/^lernapps:[^:]+:antwort:/.test(key)),
933
- "nothing else in the tab",
934
- ).toEqual([]);
935
- expect(kept.length, "an answer per open task").toBe(types.has("open-task") ? 1 : 0);
936
- });
937
- }
938
- for (const item of app.pages["de"] ?? []) {
939
- if (item.videos.length === 0) continue;
940
- test(`${item.url}: the videos load nothing before a click, the player after it, directly once chosen`, async ({
941
- page,
942
- baseURL,
943
- }) => {
944
- await checkVideos(page, item, new URL(baseURL ?? page.url()).origin);
945
- });
946
- }
947
- for (const item of app.pages["de"] ?? []) {
948
- if (item.components.length === 0) continue;
949
- test(`${item.url}: each component hydrates without a console error (${item.components.map((component) => component.name).join(", ")})`, async ({
950
- page,
951
- }) => {
952
- await checkComponents(page, item);
953
- expect(await page.evaluate(() => [...Object.keys(localStorage), ...Object.keys(sessionStorage)])).toEqual([]);
954
- });
955
- }
963
+ if (!content)
964
+ for (const item of app.pages["de"] ?? []) {
965
+ const types = new Map<string, ExerciseOutline>();
966
+ for (const exercise of item.exercises) if (!types.has(exercise.type)) types.set(exercise.type, exercise);
967
+ if (types.size === 0) continue;
968
+ test(`${item.url}: one exercise of each type checks its answers (${[...types.keys()].join(", ")})`, async ({
969
+ page,
970
+ }) => {
971
+ for (const exercise of types.values()) {
972
+ if (exercise.kind === "question") await checkQuestion(page, item.url, exercise);
973
+ else if (exercise.kind === "open") await checkOpenTask(page, item.url, exercise);
974
+ else await checkGenerated(page, item.url, exercise);
975
+ }
976
+ // only an open task keeps something, its answer, and only while the tab is open
977
+ expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
978
+ const kept = await page.evaluate(() => Object.keys(sessionStorage));
979
+ expect(
980
+ kept.filter((key) => !/^lernapps:[^:]+:antwort:/.test(key)),
981
+ "nothing else in the tab",
982
+ ).toEqual([]);
983
+ expect(kept.length, "an answer per open task").toBe(types.has("open-task") ? 1 : 0);
984
+ });
985
+ }
986
+ if (!content)
987
+ for (const item of app.pages["de"] ?? []) {
988
+ if (item.videos.length === 0) continue;
989
+ test(`${item.url}: the videos load nothing before a click, the player after it, directly once chosen`, async ({
990
+ page,
991
+ baseURL,
992
+ }) => {
993
+ await checkVideos(page, item, new URL(baseURL ?? page.url()).origin);
994
+ });
995
+ }
996
+ if (content)
997
+ for (const item of app.pages["de"] ?? []) {
998
+ if (item.components.length === 0) continue;
999
+ test(`${item.url}: each component hydrates without a console error (${item.components.map((component) => component.name).join(", ")})`, async ({
1000
+ page,
1001
+ }) => {
1002
+ await checkComponents(page, item);
1003
+ expect(await page.evaluate(() => [...Object.keys(localStorage), ...Object.keys(sessionStorage)])).toEqual(
1004
+ [],
1005
+ );
1006
+ });
1007
+ }
956
1008
  });
957
1009
 
1010
+ // everything below tests the runtime
1011
+ if (content) return;
1012
+
958
1013
  for (const item of (app.pages["de"] ?? []).filter((candidate) =>
959
1014
  candidate.exercises.some((exercise) => exercise.kind === "generated"),
960
1015
  )) {
@@ -16,6 +16,7 @@ import { questionFor, taskKey } from "./generated/question.ts";
16
16
  import { WORKED_TASKS } from "./tasks.ts";
17
17
  import { type Data, type Generated, type LanguageTemplates, type Templates } from "./generated/types.ts";
18
18
  import { plain, renderInline, renderMarkdown, renderWithFormulas } from "../shared/text/markdown.ts";
19
+ import { formulaAlternative } from "../shared/text/speech.ts";
19
20
  import { distinctSeeds } from "./tasks.ts";
20
21
  import { message } from "./messages.ts";
21
22
 
@@ -166,6 +167,7 @@ function questionExercise(block: QuestionBlock, lang: string, material: Material
166
167
  renderQuestion(question, {
167
168
  lang,
168
169
  format: renderWithFormulas,
170
+ describe: (text) => formulaAlternative(text, lang),
169
171
  ...(question.material === undefined ? {} : { materialHtml: renderMaterial(question.material, material) }),
170
172
  }),
171
173
  `<details class="explainer-solution"><summary>${plain(questionMessagesIn(lang).solution)}</summary>`,
@@ -8,6 +8,7 @@ import { answerText } from "../shared/questions/render.ts";
8
8
  import { inLanguage, timelineEvents, type Block, type ComponentExample, type Site, type Text } from "./content.ts";
9
9
  import { dataOf, renderExercise, templatesOf, workedTasks } from "./exercises.ts";
10
10
  import { kindOf } from "./generated/comparator.ts";
11
+ import { renderWithFormulas } from "../shared/text/markdown.ts";
11
12
  import { plainText } from "./generated/texts.ts";
12
13
  import { type Data, type Generated, type Templates } from "./generated/types.ts";
13
14
  import { loadSite } from "./load.ts";
@@ -48,6 +49,12 @@ export interface ComponentOutline {
48
49
  examples?: { accepted?: ComponentExample; rejected?: ComponentExample };
49
50
  }
50
51
 
52
+ /**
53
+ * A learner text of a question as the page shows it, as text: its formulas $…$ rendered (a formula has no space the
54
+ * tests could rely on in a browser's text, see squash in e2e.ts), the rest as written.
55
+ */
56
+ export const shownText = (text: string): string => plainText(renderWithFormulas(text));
57
+
51
58
  /** An exercise of a page, in the page's language: what it shows and what it accepts. */
52
59
  export type ExerciseOutline =
53
60
  | {
@@ -244,7 +251,7 @@ function walk(
244
251
  question,
245
252
  answers:
246
253
  question.type === "single-choice" || question.type === "multiple-choice"
247
- ? question.options.filter((option) => option.correct).map((option) => option.text)
254
+ ? question.options.filter((option) => option.correct).map((option) => shownText(option.text))
248
255
  : [plainText(answerText(question, lang))],
249
256
  });
250
257
  }