@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/explainer/e2e.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
// The end-to-end tests of
|
|
2
|
-
//
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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)
|
|
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
|
|
499
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
825
|
-
test.
|
|
866
|
+
if (content)
|
|
867
|
+
test.describe("without JavaScript", () => {
|
|
868
|
+
test.use({ javaScriptEnabled: false });
|
|
826
869
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
}
|