@lernapps/app-templates 0.0.1 → 0.1.1
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/LICENSE +22 -0
- package/README.md +195 -2
- package/dist/appname-Bh23cUuz.mjs +12 -0
- package/dist/assessment-Dy359O1O.mjs +1696 -0
- package/dist/block-BK_ohsH1.mjs +82 -0
- package/dist/blocks-CxtCPAa5.mjs +149 -0
- package/dist/blocks.mjs +4 -0
- package/dist/check-CteIFN96.mjs +254 -0
- package/dist/content-DO6oEJxj.mjs +140 -0
- package/dist/contract-PONpxJtd.mjs +52 -0
- package/dist/e2e-CU0n_unV.mjs +89 -0
- package/dist/engine-CArxqizW.mjs +136 -0
- package/dist/explainer-e2e.mjs +1311 -0
- package/dist/explainer-islands.mjs +197 -0
- package/dist/explainer-plugin.mjs +2132 -0
- package/dist/explainer.mjs +1246 -0
- package/dist/html-mffkHGHh.mjs +89 -0
- package/dist/image-CspJVfyQ.mjs +21 -0
- package/dist/interactive-e2e.mjs +140 -0
- package/dist/interactive-plugin.mjs +242 -0
- package/dist/interactive.mjs +75 -0
- package/dist/load-RosvaPZf.mjs +16976 -0
- package/dist/needs-BzEahJ6X.mjs +97 -0
- package/dist/pages-9RaaomoO.mjs +81 -0
- package/dist/questions-e2e.mjs +1 -0
- package/dist/questions-engine.mjs +3 -0
- package/dist/questions.mjs +6 -0
- package/dist/quiz-DnW-xtCh.mjs +70 -0
- package/dist/quiz-e2e.mjs +174 -0
- package/dist/quiz-plugin.mjs +266 -0
- package/dist/quiz.mjs +246 -0
- package/dist/render-D5kwkNt1.mjs +507 -0
- package/dist/render-DqHOJyHK.mjs +146 -0
- package/dist/render-GS1Htlu8.mjs +545 -0
- package/dist/result-D_mmCVwN.mjs +180 -0
- package/dist/tasks-DADUYy52.mjs +74 -0
- package/dist/validate-BDwL7Su3.mjs +1076 -0
- package/dist/validate-Bxy9NfB8.mjs +146 -0
- package/dist/vue-DO1m6q1R.mjs +40 -0
- package/dist/vue-code-BkJjW_I_.mjs +320 -0
- package/docs/arc42/01-introduction-and-goals.arc42.md +82 -0
- package/docs/arc42/02-constraints.arc42.md +228 -0
- package/docs/arc42/03-system-context.arc42.md +118 -0
- package/docs/arc42/04-solution-strategy.arc42.md +47 -0
- package/docs/arc42/05-building-blocks.arc42.md +307 -0
- package/docs/arc42/06-runtime-view.arc42.md +98 -0
- package/docs/arc42/07-deployment-view.arc42.md +160 -0
- package/docs/arc42/08-concepts.arc42.md +123 -0
- package/docs/arc42/09-decisions.arc42.md +246 -0
- package/docs/arc42/10-quality-requirements.arc42.md +155 -0
- package/docs/arc42/11-risks.arc42.md +113 -0
- package/docs/arc42/12-glossary.arc42.md +162 -0
- package/docs/arc42/architecture-evidence.md +395 -0
- package/explainer/.arc42ignore +3 -0
- package/explainer/README.md +54 -0
- package/explainer/_gitignore +5 -0
- package/explainer/docs/arc42/03-explainer-system-context.arc42.md +35 -0
- package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +659 -0
- package/explainer/docs/arc42/06-explainer-runtime-view.arc42.md +105 -0
- package/explainer/docs/arc42/07-explainer-deployment-view.arc42.md +34 -0
- package/explainer/docs/arc42/08-explainer-concepts.arc42.md +17 -0
- package/explainer/docs/arc42/09-explainer-decisions.arc42.md +443 -0
- package/explainer/docs/arc42/10-explainer-quality-requirements.arc42.md +54 -0
- package/explainer/docs/arc42/11-explainer-risks.arc42.md +17 -0
- package/explainer/docs/arc42/12-explainer-glossary.arc42.md +185 -0
- package/explainer/e2e/explainer.e2e.ts +3 -0
- package/explainer/index.html +11 -0
- package/explainer/package.json +18 -0
- package/explainer/playwright.config.ts +1 -0
- package/explainer/src/app.yaml +20 -0
- package/explainer/src/components/ProzentFeld.test.ts +30 -0
- package/explainer/src/components/ProzentFeld.ts +121 -0
- package/explainer/src/components/ProzentFeld.vue +84 -0
- package/explainer/src/images/hunderterfeld-klammern.svg +105 -0
- package/explainer/src/main.ts +4 -0
- package/explainer/src/prozent/chapter.yaml +18 -0
- package/explainer/src/prozent/grundwert.yaml +115 -0
- package/explainer/src/prozent/prozentsatz.yaml +99 -0
- package/explainer/src/prozent/prozentwert.yaml +119 -0
- package/explainer/tsconfig.json +1 -0
- package/explainer/vite.config.ts +4 -0
- package/interactive/.arc42ignore +5 -0
- package/interactive/README.md +28 -0
- package/interactive/_gitignore +4 -0
- package/interactive/docs/arc42/05-interactive-building-blocks.arc42.md +518 -0
- package/interactive/docs/arc42/06-interactive-runtime-view.arc42.md +58 -0
- package/interactive/docs/arc42/07-interactive-deployment-view.arc42.md +35 -0
- package/interactive/docs/arc42/08-interactive-concepts.arc42.md +52 -0
- package/interactive/docs/arc42/09-interactive-decisions.arc42.md +130 -0
- package/interactive/docs/arc42/10-interactive-quality-requirements.arc42.md +45 -0
- package/interactive/docs/arc42/11-interactive-risks.arc42.md +20 -0
- package/interactive/docs/arc42/12-interactive-glossary.arc42.md +80 -0
- package/interactive/e2e/interactive.e2e.ts +3 -0
- package/interactive/index.html +11 -0
- package/interactive/package.json +18 -0
- package/interactive/playwright.config.ts +1 -0
- package/interactive/src/app.yaml +28 -0
- package/interactive/src/core/index.ts +101 -0
- package/interactive/src/data/lessons.json +4 -0
- package/interactive/src/env.d.ts +6 -0
- package/interactive/src/main.ts +6 -0
- package/interactive/src/messages/de.json +12 -0
- package/interactive/src/playthroughs/grundreihe.yaml +33 -0
- package/interactive/src/playthroughs/obere-reihe.yaml +14 -0
- package/interactive/src/ui/App.vue +85 -0
- package/interactive/tsconfig.json +1 -0
- package/interactive/vite.config.ts +4 -0
- package/package.json +143 -4
- package/quiz/README.md +31 -0
- package/quiz/_gitignore +5 -0
- package/quiz/docs/arc42/05-quiz-building-blocks.arc42.md +283 -0
- package/quiz/docs/arc42/06-quiz-runtime-view.arc42.md +21 -0
- package/quiz/docs/arc42/07-quiz-deployment-view.arc42.md +33 -0
- package/quiz/docs/arc42/09-quiz-decisions.arc42.md +36 -0
- package/quiz/docs/arc42/10-quiz-quality-requirements.arc42.md +53 -0
- package/quiz/docs/arc42/11-quiz-risks.arc42.md +19 -0
- package/quiz/docs/arc42/12-quiz-glossary.arc42.md +27 -0
- package/quiz/e2e/quiz.e2e.ts +3 -0
- package/quiz/index.html +11 -0
- package/quiz/package.json +18 -0
- package/quiz/playwright.config.ts +1 -0
- package/quiz/src/images/berge-ordnen.jpg +0 -0
- package/quiz/src/images/everest-hoehe.jpg +0 -0
- package/quiz/src/images/galapagos.jpg +0 -0
- package/quiz/src/images/grand-canyon.jpg +0 -0
- package/quiz/src/images/totes-meer.jpg +0 -0
- package/quiz/src/images/uluru.jpg +0 -0
- package/quiz/src/images/wasserfaelle-laender.jpg +0 -0
- package/quiz/src/main.ts +4 -0
- package/quiz/src/quiz.json +312 -0
- package/quiz/tsconfig.json +1 -0
- package/quiz/vite.config.ts +4 -0
- package/runtime/explainer/assessment-client.ts +478 -0
- package/runtime/explainer/assessment-page.ts +448 -0
- package/runtime/explainer/assessment.ts +393 -0
- package/runtime/explainer/check-page.ts +520 -0
- package/runtime/explainer/components/exercise.ts +409 -0
- package/runtime/explainer/components/props.ts +230 -0
- package/runtime/explainer/components/read.ts +428 -0
- package/runtime/explainer/content.ts +277 -0
- package/runtime/explainer/e2e.ts +1488 -0
- package/runtime/explainer/exercises.ts +326 -0
- package/runtime/explainer/explainer.css +1283 -0
- package/runtime/explainer/explainer.ts +838 -0
- package/runtime/explainer/explainer.v1.schema.json +1165 -0
- package/runtime/explainer/expression.ts +407 -0
- package/runtime/explainer/fraction.ts +107 -0
- package/runtime/explainer/generated/comparator.ts +31 -0
- package/runtime/explainer/generated/question.ts +165 -0
- package/runtime/explainer/generated/soundness.ts +280 -0
- package/runtime/explainer/generated/texts.ts +257 -0
- package/runtime/explainer/generated/types.ts +103 -0
- package/runtime/explainer/generated/values.ts +136 -0
- package/runtime/explainer/islands.ts +281 -0
- package/runtime/explainer/load.ts +457 -0
- package/runtime/explainer/messages/de.json +200 -0
- package/runtime/explainer/messages/en.json +200 -0
- package/runtime/explainer/messages.ts +13 -0
- package/runtime/explainer/needs.ts +122 -0
- package/runtime/explainer/open-task.ts +235 -0
- package/runtime/explainer/outline.ts +306 -0
- package/runtime/explainer/plugin.ts +488 -0
- package/runtime/explainer/position.ts +62 -0
- package/runtime/explainer/prompt.ts +75 -0
- package/runtime/explainer/render.ts +686 -0
- package/runtime/explainer/result.ts +238 -0
- package/runtime/explainer/routes.ts +119 -0
- package/runtime/explainer/schema.ts +140 -0
- package/runtime/explainer/scope.ts +87 -0
- package/runtime/explainer/stuck.ts +57 -0
- package/runtime/explainer/tasks.ts +109 -0
- package/runtime/explainer/tutor.ts +469 -0
- package/runtime/explainer/tutorpage.ts +669 -0
- package/runtime/explainer/video.ts +123 -0
- package/runtime/interactive/contract.ts +121 -0
- package/runtime/interactive/e2e.ts +190 -0
- package/runtime/interactive/interactive.css +82 -0
- package/runtime/interactive/interactive.ts +115 -0
- package/runtime/interactive/interactive.v1.schema.json +146 -0
- package/runtime/interactive/load.ts +188 -0
- package/runtime/interactive/messages/de.json +14 -0
- package/runtime/interactive/messages/en.json +14 -0
- package/runtime/interactive/messages.ts +11 -0
- package/runtime/interactive/playthrough.v1.schema.json +143 -0
- package/runtime/interactive/plugin.ts +266 -0
- package/runtime/interactive/render.ts +126 -0
- package/runtime/quiz/e2e.ts +233 -0
- package/runtime/quiz/engine.ts +242 -0
- package/runtime/quiz/messages/de.json +23 -0
- package/runtime/quiz/messages/en.json +23 -0
- package/runtime/quiz/plugin.ts +150 -0
- package/runtime/quiz/quiz.css +154 -0
- package/runtime/quiz/quiz.ts +56 -0
- package/runtime/quiz/quiz.v1.schema.json +72 -0
- package/runtime/quiz/render.ts +173 -0
- package/runtime/quiz/validate.ts +108 -0
- package/runtime/shared/appname.ts +16 -0
- package/runtime/shared/blocks/block.ts +162 -0
- package/runtime/shared/blocks/blocks.css +190 -0
- package/runtime/shared/blocks/blocks.v1.schema.json +432 -0
- package/runtime/shared/blocks/code.ts +26 -0
- package/runtime/shared/blocks/figures.ts +49 -0
- package/runtime/shared/blocks/image.ts +25 -0
- package/runtime/shared/blocks/index.ts +13 -0
- package/runtime/shared/blocks/messages/de.json +4 -0
- package/runtime/shared/blocks/messages/en.json +4 -0
- package/runtime/shared/blocks/render.ts +134 -0
- package/runtime/shared/blocks/validate.ts +165 -0
- package/runtime/shared/content.ts +197 -0
- package/runtime/shared/examples.ts +20 -0
- package/runtime/shared/html.ts +10 -0
- package/runtime/shared/languages.ts +94 -0
- package/runtime/shared/page.css +101 -0
- package/runtime/shared/pages.ts +114 -0
- package/runtime/shared/questions/e2e.ts +115 -0
- package/runtime/shared/questions/engine.ts +209 -0
- package/runtime/shared/questions/index.ts +39 -0
- package/runtime/shared/questions/messages/de.json +26 -0
- package/runtime/shared/questions/messages/en.json +26 -0
- package/runtime/shared/questions/question.ts +433 -0
- package/runtime/shared/questions/question.v1.schema.json +346 -0
- package/runtime/shared/questions/questions.css +192 -0
- package/runtime/shared/questions/render.ts +255 -0
- package/runtime/shared/questions/validate.ts +518 -0
- package/runtime/shared/schema.ts +44 -0
- package/runtime/shared/text/check.ts +68 -0
- package/runtime/shared/text/formula.ts +69 -0
- package/runtime/shared/text/markdown.ts +185 -0
- package/runtime/shared/text/speech.ts +39 -0
- package/runtime/shared/text/text.v1.schema.json +34 -0
- package/runtime/shared/vue-code.ts +395 -0
- package/runtime/shared/vue.ts +38 -0
|
@@ -0,0 +1,1311 @@
|
|
|
1
|
+
import { a as routes, d as templatesOf, f as workedTasks, l as renderExercise, n as loadSite, s as dataOf, u as taskSeeds } from "./load-RosvaPZf.mjs";
|
|
2
|
+
import { i as inLanguage } from "./html-mffkHGHh.mjs";
|
|
3
|
+
import { a as inspect, c as renderMarkdown, l as renderWithFormulas } from "./check-CteIFN96.mjs";
|
|
4
|
+
import { C as wrongAnswer, _ as questionIn, b as rightAnswer, f as missesRight, g as questionId, i as questionMessagesIn, n as answerText, w as wrongReasons } from "./render-GS1Htlu8.mjs";
|
|
5
|
+
import { a as giveAnswer, i as expectMaterial, r as expectFeedbackInView, t as answerQuestion } from "./e2e-CU0n_unV.mjs";
|
|
6
|
+
import { A as answerFor, C as watchAddress, D as generate, F as questionFor, G as taskText, I as taskKey, R as fill, U as plainText, X as kindOf, a as assessments, j as diagnose, m as titleOf, v as timelineEvents, w as message, y as embedAddress } from "./assessment-Dy359O1O.mjs";
|
|
7
|
+
import { n as answerShown } from "./result-D_mmCVwN.mjs";
|
|
8
|
+
import { n as ASSESSMENT_TASK_EVENT, p as taskNumber, u as nextTaskNumber } from "./tasks-DADUYy52.mjs";
|
|
9
|
+
import { expect, test } from "playwright/test";
|
|
10
|
+
// runtime/explainer/outline.ts
|
|
11
|
+
/**
|
|
12
|
+
* A learner text of a question as the page shows it, as text: its formulas $…$ rendered (a formula has no space the
|
|
13
|
+
* tests could rely on in a browser's text, see squash in e2e.ts), the rest as written.
|
|
14
|
+
*/
|
|
15
|
+
const shownText = (text) => plainText(renderWithFormulas(text));
|
|
16
|
+
/** The headings, sections, formulas and figures of `blocks` in `lang`, under a heading of level `level`. */
|
|
17
|
+
function walk(site, blocks, lang, level, anchor, into) {
|
|
18
|
+
const text = (value) => inLanguage(value, lang);
|
|
19
|
+
const formulas = (value) => {
|
|
20
|
+
if (value !== void 0) into.formulas += inspect(text(value)).formulas.length;
|
|
21
|
+
};
|
|
22
|
+
for (const block of blocks) switch (block.type) {
|
|
23
|
+
case "section": {
|
|
24
|
+
const id = [...anchor, block.id].join("/");
|
|
25
|
+
into.headings.push({
|
|
26
|
+
level: level + 1,
|
|
27
|
+
text: text(block.title)
|
|
28
|
+
});
|
|
29
|
+
into.sections.push({
|
|
30
|
+
id,
|
|
31
|
+
title: text(block.title)
|
|
32
|
+
});
|
|
33
|
+
walk(site, block.blocks, lang, level + 1, [...anchor, block.id], into);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
case "example":
|
|
37
|
+
case "note":
|
|
38
|
+
into.headings.push({
|
|
39
|
+
level: level + 1,
|
|
40
|
+
text: text(block.title)
|
|
41
|
+
});
|
|
42
|
+
walk(site, block.blocks, lang, level + 1, anchor, into);
|
|
43
|
+
break;
|
|
44
|
+
case "text":
|
|
45
|
+
formulas(block.text);
|
|
46
|
+
break;
|
|
47
|
+
case "formula":
|
|
48
|
+
into.formulas += 1;
|
|
49
|
+
break;
|
|
50
|
+
case "figure":
|
|
51
|
+
into.figures.push(text(block.alt));
|
|
52
|
+
formulas(block.caption);
|
|
53
|
+
break;
|
|
54
|
+
case "labelled-figure":
|
|
55
|
+
into.figures.push(text(block.alt));
|
|
56
|
+
formulas(block.caption);
|
|
57
|
+
break;
|
|
58
|
+
case "table":
|
|
59
|
+
for (const cell of [...block.header ?? [], ...block.rows.flat()]) formulas(cell);
|
|
60
|
+
break;
|
|
61
|
+
case "timeline":
|
|
62
|
+
for (const event of timelineEvents(site, block)) formulas(event.text);
|
|
63
|
+
break;
|
|
64
|
+
case "definition":
|
|
65
|
+
formulas(block.explanation);
|
|
66
|
+
break;
|
|
67
|
+
case "quote":
|
|
68
|
+
formulas(block.text);
|
|
69
|
+
break;
|
|
70
|
+
case "code": break;
|
|
71
|
+
case "video":
|
|
72
|
+
into.videos.push({
|
|
73
|
+
youtube: block.youtube,
|
|
74
|
+
title: block.title,
|
|
75
|
+
channel: block.channel
|
|
76
|
+
});
|
|
77
|
+
break;
|
|
78
|
+
case "vue-component": {
|
|
79
|
+
const use = site.components.find((candidate) => candidate.block === block);
|
|
80
|
+
const nojs = block.nojs === void 0 ? "" : text(block.nojs);
|
|
81
|
+
into.formulas += inspect(nojs).formulas.length;
|
|
82
|
+
into.components.push({
|
|
83
|
+
name: block.component,
|
|
84
|
+
...use?.id === void 0 ? {} : { id: use.id },
|
|
85
|
+
nojs: plainText(renderMarkdown(nojs, (href) => href)),
|
|
86
|
+
exercise: use?.component.exercise ?? false,
|
|
87
|
+
caption: use?.component.caption ?? false,
|
|
88
|
+
...use?.component.exercise && block.examples !== void 0 ? { examples: {
|
|
89
|
+
...block.examples.accepted?.[0] === void 0 ? {} : { accepted: block.examples.accepted[0] },
|
|
90
|
+
...block.examples.rejected?.[0] === void 0 ? {} : { rejected: block.examples.rejected[0] }
|
|
91
|
+
} } : {}
|
|
92
|
+
});
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case "generated": {
|
|
96
|
+
const data = dataOf(block, site.data);
|
|
97
|
+
const templates = templatesOf(block, [lang]);
|
|
98
|
+
const worked = workedTasks(block, data, templates, lang);
|
|
99
|
+
for (const item of worked) for (const html of [
|
|
100
|
+
item.task,
|
|
101
|
+
item.hint ?? "",
|
|
102
|
+
...item.steps
|
|
103
|
+
]) into.formulas += html.split("<math").length - 1;
|
|
104
|
+
into.exercises.push({
|
|
105
|
+
kind: "generated",
|
|
106
|
+
id: block.id,
|
|
107
|
+
type: `generated:${kindOf(block.answer.compare).kind}`,
|
|
108
|
+
spec: block,
|
|
109
|
+
data,
|
|
110
|
+
templates,
|
|
111
|
+
worked: worked.map((item) => ({
|
|
112
|
+
task: plainText(item.task),
|
|
113
|
+
answer: plainText(item.answer)
|
|
114
|
+
}))
|
|
115
|
+
});
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case "open-task": {
|
|
119
|
+
const html = renderExercise(block, lang, site.data, (href) => href, {
|
|
120
|
+
lang,
|
|
121
|
+
href: (image) => image
|
|
122
|
+
});
|
|
123
|
+
into.formulas += (html.split("<script type=\"application/json\"")[0] ?? "").split("<math").length - 1;
|
|
124
|
+
into.exercises.push({
|
|
125
|
+
kind: "open",
|
|
126
|
+
id: block.id,
|
|
127
|
+
type: "open-task",
|
|
128
|
+
task: plainText(renderMarkdown(inLanguage(block.task, lang), (href) => href)),
|
|
129
|
+
answer: block.answer ?? ["text", "photo"],
|
|
130
|
+
points: block.points.map((point) => ({
|
|
131
|
+
text: inLanguage(point.text, lang),
|
|
132
|
+
source: point.source !== void 0
|
|
133
|
+
})),
|
|
134
|
+
solution: inLanguage(block.solution, lang).split(/\$[^$]+\$/).map((piece) => plainText(renderMarkdown(piece, (href) => href)).replace(/\s+/g, " ").trim()).filter((piece) => piece !== "")
|
|
135
|
+
});
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
default: {
|
|
139
|
+
const html = renderExercise(block, lang, site.data, (href) => href, {
|
|
140
|
+
lang,
|
|
141
|
+
href: (image) => image
|
|
142
|
+
});
|
|
143
|
+
into.formulas += (html.split("<script type=\"application/json\"")[0] ?? "").split("<math").length - 1;
|
|
144
|
+
const question = questionIn(block, lang);
|
|
145
|
+
into.exercises.push({
|
|
146
|
+
kind: "question",
|
|
147
|
+
id: block.id,
|
|
148
|
+
type: block.type,
|
|
149
|
+
question,
|
|
150
|
+
answers: question.type === "single-choice" || question.type === "multiple-choice" ? question.options.filter((option) => option.correct).map((option) => shownText(option.text)) : [plainText(answerText(question, lang))]
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** The outline of the explainer whose content is in `root`/src. */
|
|
156
|
+
function outline(root = ".") {
|
|
157
|
+
const site = loadSite(root, 0);
|
|
158
|
+
const pages = {};
|
|
159
|
+
const others = {};
|
|
160
|
+
for (const route of routes(site)) {
|
|
161
|
+
const item = {
|
|
162
|
+
kind: route.kind,
|
|
163
|
+
url: route.path,
|
|
164
|
+
lang: route.lang,
|
|
165
|
+
title: route.title,
|
|
166
|
+
headings: [{
|
|
167
|
+
level: 1,
|
|
168
|
+
text: route.title
|
|
169
|
+
}],
|
|
170
|
+
sections: [],
|
|
171
|
+
formulas: 0,
|
|
172
|
+
figures: [],
|
|
173
|
+
videos: [],
|
|
174
|
+
exercises: [],
|
|
175
|
+
components: []
|
|
176
|
+
};
|
|
177
|
+
if (route.page === void 0) (others[route.lang] ??= []).push(item);
|
|
178
|
+
else {
|
|
179
|
+
walk(site, route.page.blocks, route.lang, 1, [], item);
|
|
180
|
+
(pages[route.lang] ??= []).push(item);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const asked = assessments(site).map((assessment) => ({
|
|
184
|
+
url: assessment.local,
|
|
185
|
+
offer: assessment.chapter === void 0 ? "" : `${assessment.chapter.id}/`,
|
|
186
|
+
key: assessment.key,
|
|
187
|
+
steps: assessment.steps,
|
|
188
|
+
items: assessment.items.map((item) => ({
|
|
189
|
+
key: item.key,
|
|
190
|
+
step: item.step,
|
|
191
|
+
page: `${item.page.chapter}/${item.page.id}/`,
|
|
192
|
+
id: item.exercise
|
|
193
|
+
})),
|
|
194
|
+
pages: assessment.pages.map((page) => ({
|
|
195
|
+
url: `${page.chapter}/${page.id}/`,
|
|
196
|
+
title: titleOf(page, "de"),
|
|
197
|
+
asked: assessment.items.filter((item) => item.page === page).length
|
|
198
|
+
}))
|
|
199
|
+
}));
|
|
200
|
+
return {
|
|
201
|
+
languages: site.app.languages,
|
|
202
|
+
pages,
|
|
203
|
+
others,
|
|
204
|
+
assessments: asked
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// runtime/explainer/e2e.ts
|
|
208
|
+
/** The key a self-assessment's last result is kept under in localStorage: its key with the app's prefix. */
|
|
209
|
+
const storedKeys = (page) => page.evaluate(() => Object.keys(localStorage).filter((key) => /:self-assessment(:|$)/.test(key)));
|
|
210
|
+
/**
|
|
211
|
+
* One run of the self-assessment `assessment`, from the page that offers it: each of its pages in turn, with the right
|
|
212
|
+
* answer to each exercise when `right`, else none, and its exercises without "Prüfen"; then "Auswerten".
|
|
213
|
+
*/
|
|
214
|
+
async function runAssessment(page, app, assessment, right) {
|
|
215
|
+
await page.goto(`./${assessment.offer}`);
|
|
216
|
+
const offer = page.locator(".explainer-assessment-offer");
|
|
217
|
+
await expect(offer, "offered with JavaScript").toBeVisible();
|
|
218
|
+
await offer.locator(".explainer-assessment-link").click();
|
|
219
|
+
await expect(page.locator(".explainer-assessment-intro")).toBeVisible();
|
|
220
|
+
expect(await storedKeys(page), "nothing kept before a run").toEqual([]);
|
|
221
|
+
await page.locator(".explainer-assessment-begin").click();
|
|
222
|
+
for (let step = 0; step < assessment.steps; step++) {
|
|
223
|
+
const view = page.locator(`.explainer-assessment-step[data-step="${step}"]`);
|
|
224
|
+
await expect(view).toBeVisible();
|
|
225
|
+
await expect(view.locator("h2")).toBeFocused();
|
|
226
|
+
await expect(view.locator(".question-check:visible"), "no exercise is checked alone").toHaveCount(0);
|
|
227
|
+
for (const item of assessment.items.filter((candidate) => candidate.step === step)) {
|
|
228
|
+
if (!right) continue;
|
|
229
|
+
const content = app.pages["de"]?.find((candidate) => candidate.url === item.page);
|
|
230
|
+
const component = content?.components.find((candidate) => candidate.exercise && candidate.id === item.id);
|
|
231
|
+
if (component !== void 0) {
|
|
232
|
+
const element = view.locator(`[data-key="${item.key}"] .explainer-component`);
|
|
233
|
+
await expect(element, `${item.key}: ${component.name} hydrates`).toHaveAttribute("data-hydrated", "");
|
|
234
|
+
await expect(element.locator(".question-check, .question-feedback, .explainer-task-controls")).toHaveCount(0);
|
|
235
|
+
const example = component.examples?.accepted;
|
|
236
|
+
if (example === void 0) throw new Error(`${component.name}: its block has no accepted example`);
|
|
237
|
+
await element.evaluate((node, [name, seed]) => node.dispatchEvent(new CustomEvent(String(name), { detail: { seed } })), [ASSESSMENT_TASK_EVENT, example.seed ?? 1]);
|
|
238
|
+
await element.evaluate(emitAnswer, example.answer);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const exercise = content?.exercises.find((candidate) => candidate.id === item.id);
|
|
242
|
+
if (exercise === void 0) throw new Error(`${item.page}#${item.id} is not in the outline`);
|
|
243
|
+
if (exercise.kind === "open") {
|
|
244
|
+
await answerOpenTask(view.locator(`[data-key="${item.key}"]`), exercise, exercise.points.length);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
let question;
|
|
248
|
+
if (exercise.kind === "question") question = {
|
|
249
|
+
...exercise.question,
|
|
250
|
+
id: item.key
|
|
251
|
+
};
|
|
252
|
+
else {
|
|
253
|
+
const { spec, data, templates } = exercise;
|
|
254
|
+
const n = Number(await view.locator(`[data-key="${item.key}"]`).getAttribute("data-number"));
|
|
255
|
+
const seeds = taskSeeds(spec, data);
|
|
256
|
+
const task = generate(spec, seeds[taskNumber(n, seeds.length) - 1] ?? n, data);
|
|
257
|
+
question = {
|
|
258
|
+
...questionFor(spec, task, "de", templates),
|
|
259
|
+
id: item.key
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
await giveAnswer(page, question, rightAnswer(question));
|
|
263
|
+
}
|
|
264
|
+
const last = step === assessment.steps - 1;
|
|
265
|
+
await view.getByRole("button", {
|
|
266
|
+
name: message("de", last ? "assessmentFinish" : "assessmentNext"),
|
|
267
|
+
exact: true
|
|
268
|
+
}).click();
|
|
269
|
+
}
|
|
270
|
+
await expect(page.locator(".explainer-assessment-result h2")).toBeFocused();
|
|
271
|
+
}
|
|
272
|
+
/** The result of a run: each content page with its band, the pages asked as `band`, the others not asked. */
|
|
273
|
+
async function expectResult(page, assessment, band) {
|
|
274
|
+
const lines = page.locator(".explainer-assessment-page");
|
|
275
|
+
await expect(lines).toHaveCount(assessment.pages.length);
|
|
276
|
+
for (const [index, item] of assessment.pages.entries()) {
|
|
277
|
+
const line = lines.nth(index);
|
|
278
|
+
await expect(line.locator("a")).toHaveText(item.title);
|
|
279
|
+
await expect(line.locator("strong")).toHaveText(message("de", BAND_WORDS[item.asked === 0 ? "unasked" : band]));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const BAND_WORDS = {
|
|
283
|
+
known: "assessmentKnown",
|
|
284
|
+
read: "assessmentRead",
|
|
285
|
+
unasked: "assessmentUnasked"
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* `text` without space. A browser's text of a formula has space between its parts ("2 7" for 2⁷) where the content's
|
|
289
|
+
* has none, so texts with formulas are compared without it (in the order of their characters, which is what counts).
|
|
290
|
+
*/
|
|
291
|
+
const squash = (text) => text.replace(/\s+/g, "");
|
|
292
|
+
/** The task number of the deep link the tests open a generated exercise with. */
|
|
293
|
+
const SEED = 7;
|
|
294
|
+
/**
|
|
295
|
+
* Every exercise of the page shows its text, and each "Lösung" opens and shows the accepted answer: read in one go,
|
|
296
|
+
* before and after a click on each "Lösung".
|
|
297
|
+
*/
|
|
298
|
+
async function expectExercises(page, item) {
|
|
299
|
+
if (item.exercises.length === 0) return;
|
|
300
|
+
const main = page.locator("main");
|
|
301
|
+
const expected = item.exercises.map((exercise) => exercise.kind === "question" ? {
|
|
302
|
+
id: exercise.id,
|
|
303
|
+
texts: [shownText(exercise.question.text)],
|
|
304
|
+
answers: [exercise.answers]
|
|
305
|
+
} : exercise.kind === "open" ? {
|
|
306
|
+
id: exercise.id,
|
|
307
|
+
texts: [exercise.task],
|
|
308
|
+
answers: [exercise.solution]
|
|
309
|
+
} : {
|
|
310
|
+
id: exercise.id,
|
|
311
|
+
texts: exercise.worked.map((worked) => worked.task),
|
|
312
|
+
answers: exercise.worked.map((worked) => [worked.answer])
|
|
313
|
+
});
|
|
314
|
+
const read = async () => (await main.locator(".explainer-exercise").evaluateAll((elements) => elements.map((element) => {
|
|
315
|
+
const fold = (text) => (text ?? "").replace(/\s+/g, " ").trim();
|
|
316
|
+
const own = element.classList.contains("explainer-question") ? [...element.querySelectorAll("legend")] : element.classList.contains("explainer-open-task") ? [...element.querySelectorAll(":scope > .explainer-open-task-text")] : [...element.querySelectorAll(".explainer-worked-task > .explainer-task")];
|
|
317
|
+
const solutions = [...element.querySelectorAll("details.explainer-solution")];
|
|
318
|
+
return {
|
|
319
|
+
id: element.id,
|
|
320
|
+
texts: own.map((text) => fold(text.textContent)),
|
|
321
|
+
shown: solutions.map((details) => details.open ? fold(details.innerText) : "")
|
|
322
|
+
};
|
|
323
|
+
}))).map((item) => ({
|
|
324
|
+
...item,
|
|
325
|
+
texts: item.texts.map(squash),
|
|
326
|
+
shown: item.shown.map(squash)
|
|
327
|
+
}));
|
|
328
|
+
const before = await read();
|
|
329
|
+
expect(before.map(({ id, texts }) => ({
|
|
330
|
+
id,
|
|
331
|
+
texts
|
|
332
|
+
})), `${item.url}: each exercise with its text`).toEqual(expected.map(({ id, texts }) => ({
|
|
333
|
+
id,
|
|
334
|
+
texts: texts.map(squash)
|
|
335
|
+
})));
|
|
336
|
+
await main.locator(".explainer-exercise details.explainer-solution > summary").evaluateAll((summaries) => summaries.forEach((summary) => summary.click()));
|
|
337
|
+
const after = await read();
|
|
338
|
+
for (const [index, exercise] of expected.entries()) exercise.answers.forEach((answers, at) => {
|
|
339
|
+
for (const answer of answers) expect(after[index]?.shown[at] ?? "", `${item.url}: ${exercise.id}, Lösung ${at + 1} shows ${answer}`).toContain(squash(answer));
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
/** Without JavaScript: each component's nojs text is visible, not empty and as in the content; its island is hidden. */
|
|
343
|
+
async function expectNojs(page, item) {
|
|
344
|
+
if (item.components.length === 0) return;
|
|
345
|
+
const components = page.locator("main .explainer-component");
|
|
346
|
+
await expect(components, `${item.url}: one island per component`).toHaveCount(item.components.length);
|
|
347
|
+
for (const [index, component] of item.components.entries()) {
|
|
348
|
+
const element = components.nth(index);
|
|
349
|
+
const nojs = element.locator(":scope > .explainer-nojs");
|
|
350
|
+
await expect(nojs, `${item.url}: the nojs text of ${component.name}`).toBeVisible();
|
|
351
|
+
expect((await nojs.innerText()).trim(), `${item.url}: the nojs text of ${component.name}`).not.toBe("");
|
|
352
|
+
if (component.nojs !== "") await expect(nojs).toContainText(component.nojs);
|
|
353
|
+
if (component.exercise) {
|
|
354
|
+
await expect(nojs.locator(".explainer-task-number"), `${item.url}: ${component.name}, task 1`).toHaveText(message(item.lang, "taskNumber", { n: "1" }));
|
|
355
|
+
await expect(nojs.locator("details.explainer-solution")).not.toHaveAttribute("open");
|
|
356
|
+
}
|
|
357
|
+
await expect(element.locator(".explainer-island")).toBeHidden();
|
|
358
|
+
await expect(element.locator(".question-actions"), "no Prüfen without JavaScript").toBeHidden();
|
|
359
|
+
await expect(element.locator(":scope > .explainer-task-controls")).toBeHidden();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/** Without JavaScript: each video shows its title as the link to YouTube, its channel and the note; no player. */
|
|
363
|
+
async function expectVideos(page, item) {
|
|
364
|
+
if (item.videos.length === 0) return;
|
|
365
|
+
const videos = page.locator("main figure.explainer-video");
|
|
366
|
+
await expect(videos, `${item.url}: one figure per video`).toHaveCount(item.videos.length);
|
|
367
|
+
for (const [index, video] of item.videos.entries()) {
|
|
368
|
+
const figure = videos.nth(index);
|
|
369
|
+
const link = figure.getByRole("link", {
|
|
370
|
+
name: video.title,
|
|
371
|
+
exact: true
|
|
372
|
+
});
|
|
373
|
+
await expect(link, `${item.url}: the video "${video.title}" as a link`).toBeVisible();
|
|
374
|
+
await expect(link).toHaveAttribute("href", watchAddress(video.youtube));
|
|
375
|
+
await expect(figure.locator(".explainer-video-channel")).toHaveText(message(item.lang, "videoChannel", { channel: video.channel }));
|
|
376
|
+
await expect(figure.locator(".explainer-video-note")).toHaveText(message(item.lang, "videoNote"));
|
|
377
|
+
await expect(figure.locator(".explainer-video-controls"), "no button without JavaScript").toBeHidden();
|
|
378
|
+
}
|
|
379
|
+
await expect(page.locator("iframe"), `${item.url}: no player without JavaScript`).toHaveCount(0);
|
|
380
|
+
}
|
|
381
|
+
/** Whether `url` is of YouTube or Google: a player, a thumbnail, a script or a beacon of theirs. */
|
|
382
|
+
const ofYoutubeOrGoogle = (url) => /(^|\.)(youtube[a-z-]*\.com|youtu\.be|ytimg\.com|google[a-z-]*\.com|gstatic\.com|ggpht\.com|doubleclick\.net)$/.test(new URL(url).hostname);
|
|
383
|
+
/**
|
|
384
|
+
* With JavaScript: the videos of a page load nothing of another host before a click (every such request is blocked and
|
|
385
|
+
* recorded); "Video abspielen" puts the player of youtube-nocookie.com, named by the video's title, into its figure
|
|
386
|
+
* and stores nothing; "Videos immer direkt laden" is kept on the device, loads every player without the click from
|
|
387
|
+
* then on, and goes when the learner unticks it.
|
|
388
|
+
*/
|
|
389
|
+
async function checkVideos(page, item, origin) {
|
|
390
|
+
const requested = [];
|
|
391
|
+
await page.route((url) => url.origin !== origin, async (route) => {
|
|
392
|
+
requested.push(route.request().url());
|
|
393
|
+
await route.abort("blockedbyclient");
|
|
394
|
+
});
|
|
395
|
+
const stored = () => page.evaluate(() => [...Object.keys(localStorage), ...Object.keys(sessionStorage)]);
|
|
396
|
+
await page.goto(`./${item.url}`);
|
|
397
|
+
const videos = page.locator("main figure.explainer-video");
|
|
398
|
+
const [first] = item.videos;
|
|
399
|
+
if (first === void 0) return;
|
|
400
|
+
await expect(videos.first().locator(".explainer-video-controls"), "the controls show").toBeVisible();
|
|
401
|
+
await page.waitForLoadState("networkidle");
|
|
402
|
+
expect(requested, `${item.url}: no request to another host before a click`).toEqual([]);
|
|
403
|
+
await expect(page.locator("iframe"), "no player before a click").toHaveCount(0);
|
|
404
|
+
await videos.first().getByRole("button", { name: message("de", "videoPlay") }).click();
|
|
405
|
+
const player = videos.first().locator("iframe");
|
|
406
|
+
await expect(player).toHaveAttribute("src", embedAddress(first.youtube, true));
|
|
407
|
+
await expect(player).toHaveAttribute("title", first.title);
|
|
408
|
+
await expect(player).toBeFocused();
|
|
409
|
+
await expect(videos.first().locator(".explainer-video-play"), "the button goes").toHaveCount(0);
|
|
410
|
+
await expect.poll(() => requested.filter(ofYoutubeOrGoogle), "the player loads after the click").toContain(embedAddress(first.youtube, true));
|
|
411
|
+
expect(new Set(requested.map((url) => new URL(url).hostname)), "only the player's host").toEqual(/* @__PURE__ */ new Set(["www.youtube-nocookie.com"]));
|
|
412
|
+
await expect(page.locator("iframe"), "only the clicked video").toHaveCount(1);
|
|
413
|
+
expect(await stored(), "a click on the button stores nothing").toEqual([]);
|
|
414
|
+
const always = page.getByRole("checkbox", { name: message("de", "videoAlways") });
|
|
415
|
+
await always.first().check();
|
|
416
|
+
expect(await stored(), "the choice is kept").toHaveLength(1);
|
|
417
|
+
await page.reload();
|
|
418
|
+
await expect(page.locator("main figure.explainer-video iframe"), "every player, without a click").toHaveCount(item.videos.length);
|
|
419
|
+
for (const [index, video] of item.videos.entries()) await expect(videos.nth(index).locator("iframe")).toHaveAttribute("src", embedAddress(video.youtube, false));
|
|
420
|
+
for (const box of await always.all()) await expect(box).toBeChecked();
|
|
421
|
+
await always.first().uncheck();
|
|
422
|
+
for (const box of await always.all()) await expect(box).not.toBeChecked();
|
|
423
|
+
expect(await stored(), "the choice goes").toEqual([]);
|
|
424
|
+
requested.length = 0;
|
|
425
|
+
await page.reload();
|
|
426
|
+
await expect(videos.first().locator(".explainer-video-play")).toBeVisible();
|
|
427
|
+
await expect(page.locator("iframe")).toHaveCount(0);
|
|
428
|
+
expect(requested, `${item.url}: again no request before a click`).toEqual([]);
|
|
429
|
+
}
|
|
430
|
+
/** With JavaScript: each component hydrates without a console error; an exercise's "Prüfen" checks its answer. */
|
|
431
|
+
async function checkComponents(page, item) {
|
|
432
|
+
const errors = [];
|
|
433
|
+
page.on("console", (message) => {
|
|
434
|
+
if (message.type() === "error") errors.push(message.text());
|
|
435
|
+
});
|
|
436
|
+
page.on("pageerror", (error) => errors.push(error.message));
|
|
437
|
+
await page.goto(`./${item.url}`);
|
|
438
|
+
const components = page.locator("main .explainer-component");
|
|
439
|
+
for (const [index, component] of item.components.entries()) {
|
|
440
|
+
const element = components.nth(index);
|
|
441
|
+
await expect(element, `${item.url}: ${component.name} hydrates`).toHaveAttribute("data-hydrated", "");
|
|
442
|
+
await expect(element.locator(".explainer-island")).toBeVisible();
|
|
443
|
+
const nojs = element.locator(":scope > .explainer-nojs");
|
|
444
|
+
if (component.caption) {
|
|
445
|
+
const details = nojs.locator(":scope > details");
|
|
446
|
+
await expect(details.locator(":scope > summary"), "the nojs text folds").toHaveText(message("de", "textDescription"));
|
|
447
|
+
await expect(details).not.toHaveAttribute("open");
|
|
448
|
+
} else await expect(nojs, "the component replaces its nojs text").toBeHidden();
|
|
449
|
+
if (!component.exercise) continue;
|
|
450
|
+
const check = element.locator(":scope > form.explainer-component-form > .question-actions .question-check");
|
|
451
|
+
await expect(check, `${component.name}: "Prüfen" under it`).toHaveText(questionMessagesIn("de").check);
|
|
452
|
+
const look = await check.evaluate((node) => {
|
|
453
|
+
const style = getComputedStyle(node);
|
|
454
|
+
const accent = getComputedStyle(document.documentElement).getPropertyValue("--page-accent").trim();
|
|
455
|
+
const probe = document.createElement("span");
|
|
456
|
+
probe.style.color = accent;
|
|
457
|
+
document.body.append(probe);
|
|
458
|
+
const filled = getComputedStyle(probe).color;
|
|
459
|
+
probe.remove();
|
|
460
|
+
return {
|
|
461
|
+
height: node.getBoundingClientRect().height,
|
|
462
|
+
background: style.backgroundColor,
|
|
463
|
+
filled
|
|
464
|
+
};
|
|
465
|
+
});
|
|
466
|
+
expect(look.height, `${component.name}: "Prüfen" is at least 44 px high`).toBeGreaterThanOrEqual(44);
|
|
467
|
+
expect(look.background, `${component.name}: "Prüfen" is filled with the accent`).toBe(look.filled);
|
|
468
|
+
await expect(check, `${component.name}: "Prüfen" waits for an answer`).toBeDisabled();
|
|
469
|
+
const feedback = element.locator(":scope > .question-feedback");
|
|
470
|
+
for (const kind of ["accepted", "rejected"]) {
|
|
471
|
+
const example = component.examples?.[kind];
|
|
472
|
+
if (example === void 0) continue;
|
|
473
|
+
await showSeed(page, component, example.seed);
|
|
474
|
+
await element.evaluate(emitAnswer, example.answer);
|
|
475
|
+
await expect(check).toBeEnabled();
|
|
476
|
+
await check.click();
|
|
477
|
+
await expect(feedback, `${component.name}: the feedback on the ${kind} example`).toHaveText(new RegExp(`^${questionMessagesIn("de")[kind === "accepted" ? "right" : "wrong"].replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
478
|
+
await expect(feedback).toBeFocused();
|
|
479
|
+
await expect(feedback).toBeInViewport();
|
|
480
|
+
await expect(check, `${component.name}: "Prüfen" waits for another answer`).toBeDisabled();
|
|
481
|
+
await expect(element.locator(".question-feedback"), "one feedback").toHaveCount(1);
|
|
482
|
+
}
|
|
483
|
+
await checkTaskNumber(page, item, component);
|
|
484
|
+
}
|
|
485
|
+
expect(errors, `${item.url}: no console error`).toEqual([]);
|
|
486
|
+
}
|
|
487
|
+
/** Emits answer with `answer` from the component in the island of `node`, as the component itself does. */
|
|
488
|
+
function emitAnswer(node, answer) {
|
|
489
|
+
const wrapper = node.querySelector(".explainer-island")._vnode?.component;
|
|
490
|
+
(wrapper?.subTree?.component ?? wrapper)?.emit("answer", answer);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Opens the page at the task number of `seed` of the exercise component, keeping the rest of its address, or leaves
|
|
494
|
+
* it as it is without one.
|
|
495
|
+
*/
|
|
496
|
+
async function showSeed(page, component, seed) {
|
|
497
|
+
if (seed === void 0 || component.id === void 0) return;
|
|
498
|
+
const element = page.locator(`[id="${component.id}"]`);
|
|
499
|
+
const number = JSON.parse(await element.getAttribute("data-seeds") ?? "[1]").indexOf(seed) + 1;
|
|
500
|
+
if (number === 0) throw new Error(`${component.name}: no task number has the seed ${seed} of an example`);
|
|
501
|
+
const address = new URL(page.url());
|
|
502
|
+
address.searchParams.set(component.id, String(number));
|
|
503
|
+
address.hash = component.id;
|
|
504
|
+
await page.goto(address.href);
|
|
505
|
+
await expect(element).toHaveAttribute("data-hydrated", "");
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Answers the exercise component with its block's first rejected example, as the component emits it, and "Prüfen";
|
|
509
|
+
* the task number it is at.
|
|
510
|
+
*/
|
|
511
|
+
async function answerRejected(page, component) {
|
|
512
|
+
const example = component.examples?.rejected;
|
|
513
|
+
if (example === void 0 || component.id === void 0) throw new Error(`${component.name}: its block has no rejected example`);
|
|
514
|
+
await showSeed(page, component, example.seed);
|
|
515
|
+
const element = page.locator(`[id="${component.id}"]`);
|
|
516
|
+
await element.evaluate(emitAnswer, example.answer);
|
|
517
|
+
await element.locator(":scope > form.explainer-component-form .question-check").click();
|
|
518
|
+
return Number(new URL(page.url()).searchParams.get(component.id));
|
|
519
|
+
}
|
|
520
|
+
/** The task number the component in the island of `node` was given, as its prop seed. */
|
|
521
|
+
function seedOf(node) {
|
|
522
|
+
return node.querySelector(".explainer-island")._vnode?.component?.subTree?.component?.props["seed"];
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* An exercise component at its address ?<id>=<n>: it gets the task number n as its prop seed and shows "Aufgabe
|
|
526
|
+
* Nr. n"; "Neue Aufgabe" moves to the next number, in the address and on the page, with the focus on it.
|
|
527
|
+
*/
|
|
528
|
+
async function checkTaskNumber(page, item, component) {
|
|
529
|
+
if (component.id === void 0) return;
|
|
530
|
+
await page.goto(`./${item.url}?${component.id}=3#${component.id}`);
|
|
531
|
+
const element = page.locator(`[id="${component.id}"]`);
|
|
532
|
+
await expect(element, `${component.name}: hydrates at its address`).toHaveAttribute("data-hydrated", "");
|
|
533
|
+
const seeds = JSON.parse(await element.getAttribute("data-seeds") ?? "[1]");
|
|
534
|
+
const shown = taskNumber(3, seeds.length);
|
|
535
|
+
const number = element.locator(":scope > .explainer-task-controls .explainer-task-number");
|
|
536
|
+
await expect(number, `${component.name}: the task number of the address`).toHaveText(message("de", "taskNumber", { n: String(shown) }));
|
|
537
|
+
await expect.poll(() => element.evaluate(seedOf), `${component.name}: gets the seed of number ${shown}`).toBe(seeds[shown - 1]);
|
|
538
|
+
await element.locator(":scope > .explainer-task-controls .explainer-new-task").click();
|
|
539
|
+
const next = Number(new URL(page.url()).searchParams.get(component.id));
|
|
540
|
+
expect(next, `${component.name}: the next task number in the address`).toBe(nextTaskNumber(shown, seeds.length));
|
|
541
|
+
expect(new URL(page.url()).hash).toBe(`#${component.id}`);
|
|
542
|
+
await expect(number).toHaveText(message("de", "taskNumber", { n: String(next) }));
|
|
543
|
+
await expect(number).toBeFocused();
|
|
544
|
+
await expect.poll(() => element.evaluate(seedOf), `${component.name}: gets the seed of number ${next}`).toBe(seeds[next - 1]);
|
|
545
|
+
}
|
|
546
|
+
/** The feedback of the question `id` says `text`. */
|
|
547
|
+
const feedback = (page, id) => page.locator(`[id="${id}"] .question-feedback`);
|
|
548
|
+
/** The first reason the feedback gives for the wrong `answer`: the most specific the question has. */
|
|
549
|
+
function reasonFor(question, answer) {
|
|
550
|
+
const quiz = questionMessagesIn("de");
|
|
551
|
+
const [first] = wrongReasons(question, answer);
|
|
552
|
+
if (first !== void 0) return first.option === void 0 ? shownText(first.text) : `${shownText(first.option)}: ${shownText(first.text)}`;
|
|
553
|
+
return missesRight(question, answer) ? quiz.missingRight : quiz.lookAgain;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* A wrong answer to the question in `exercise` (an element's id): the feedback with the reason `reason`, in view
|
|
557
|
+
* with the focus; the solution still hidden and the controls open; then "Lösung zeigen" shows the solution.
|
|
558
|
+
*/
|
|
559
|
+
async function wrongThenSolution(page, exercise, question, reason) {
|
|
560
|
+
const item = page.locator(`[id="${questionId(question)}"]`);
|
|
561
|
+
await expect(feedback(page, questionId(question))).toContainText(questionMessagesIn("de").wrong);
|
|
562
|
+
await expect.poll(async () => squash(await item.locator(".question-diagnosis").first().textContent() ?? ""), `${exercise}: the reason of the wrong answer`).toBe(squash(reason));
|
|
563
|
+
await expectFeedbackInView(page, question);
|
|
564
|
+
await expect(item.locator(".question-reveal"), `${exercise}: no solution after a wrong answer`).toBeHidden();
|
|
565
|
+
await expect(item.locator("input, select").first(), `${exercise}: another try`).toBeEnabled();
|
|
566
|
+
await item.getByRole("button", { name: questionMessagesIn("de").showSolution }).click();
|
|
567
|
+
await expect(item.locator(".question-reveal"), `${exercise}: the solution on Lösung zeigen`).toBeVisible();
|
|
568
|
+
await expect(item.locator(".question-reveal")).toBeFocused();
|
|
569
|
+
}
|
|
570
|
+
/** With JavaScript: a question of the question types, answered before its solution shows, right and wrong. */
|
|
571
|
+
async function checkQuestion(page, url, exercise) {
|
|
572
|
+
const { question } = exercise;
|
|
573
|
+
const item = page.locator(`[id="${exercise.id}"]`);
|
|
574
|
+
await page.goto(`./${url}#${exercise.id}`);
|
|
575
|
+
await expectMaterial(page, question);
|
|
576
|
+
await expect(item.locator(".question-reveal"), `${exercise.id}: no solution before the answer`).toBeHidden();
|
|
577
|
+
await expect(item.locator("details.explainer-solution"), `${exercise.id}: no Lösung before the answer`).toBeHidden();
|
|
578
|
+
await answerQuestion(page, question, rightAnswer(question));
|
|
579
|
+
await expect(feedback(page, questionId(question))).toContainText(questionMessagesIn("de").right);
|
|
580
|
+
await expectFeedbackInView(page, question);
|
|
581
|
+
await expect(item.locator(".question-reveal"), `${exercise.id}: the solution after the right answer`).toBeVisible();
|
|
582
|
+
await page.reload();
|
|
583
|
+
const wrong = wrongAnswer(question);
|
|
584
|
+
await answerQuestion(page, question, wrong);
|
|
585
|
+
await wrongThenSolution(page, exercise.id, question, reasonFor(question, wrong));
|
|
586
|
+
}
|
|
587
|
+
/** An example answer to an open task, which the tests type. */
|
|
588
|
+
const OPEN_ANSWER = "Meine Antwort in eigenen Worten.";
|
|
589
|
+
/**
|
|
590
|
+
* Answers the open task in `item` as text (or, if it takes only photos, with none) and ticks the first `ticks` of its
|
|
591
|
+
* points: in a self-assessment that is the answer.
|
|
592
|
+
*/
|
|
593
|
+
async function answerOpenTask(item, exercise, ticks) {
|
|
594
|
+
if (!exercise.answer.includes("text")) return;
|
|
595
|
+
const choice = item.getByRole("radio", { name: message("de", "openTaskType") });
|
|
596
|
+
if (await choice.count() > 0) await choice.check();
|
|
597
|
+
await item.getByRole("textbox", { name: message("de", "openTaskAnswer") }).fill(OPEN_ANSWER);
|
|
598
|
+
await item.getByRole("button", { name: message("de", "openTaskCompare") }).click();
|
|
599
|
+
const boxes = item.getByRole("checkbox");
|
|
600
|
+
await expect(boxes, `${exercise.id}: a tick box per point`).toHaveCount(exercise.points.length);
|
|
601
|
+
for (let index = 0; index < ticks; index += 1) await boxes.nth(index).check();
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* With JavaScript: an open task is answered before its points and model answer show; "Vergleichen" waits for an
|
|
605
|
+
* answer; the ticks give right or partly right with the missing points; the model answer opens after "Auswerten".
|
|
606
|
+
*/
|
|
607
|
+
async function checkOpenTask(page, url, exercise) {
|
|
608
|
+
const item = page.locator(`[id="${exercise.id}"]`);
|
|
609
|
+
await page.goto(`./${url}#${exercise.id}`);
|
|
610
|
+
await expect(item.locator("details.explainer-solution"), `${exercise.id}: no Lösung before the answer`).toBeHidden();
|
|
611
|
+
await expect(item.locator(".explainer-open-points"), `${exercise.id}: no points before the answer`).toBeHidden();
|
|
612
|
+
if (!exercise.answer.includes("text")) return;
|
|
613
|
+
await item.getByRole("button", { name: message("de", "openTaskCompare") }).click();
|
|
614
|
+
await expect(item.locator(".explainer-open-need"), `${exercise.id}: compare waits for an answer`).toHaveText(message("de", "openTaskWriteFirst"));
|
|
615
|
+
await item.getByRole("textbox", { name: message("de", "openTaskAnswer") }).fill(OPEN_ANSWER);
|
|
616
|
+
await page.reload();
|
|
617
|
+
await expect(item.getByRole("textbox", { name: message("de", "openTaskAnswer") }), `${exercise.id}: kept`).toHaveValue(OPEN_ANSWER);
|
|
618
|
+
await answerOpenTask(item, exercise, exercise.points.length);
|
|
619
|
+
await expect(item.locator("details.explainer-solution"), `${exercise.id}: the Lösung after the answer`).toBeVisible();
|
|
620
|
+
await item.getByRole("button", { name: message("de", "openTaskChange") }).click();
|
|
621
|
+
await expect(item.locator("details.explainer-solution"), `${exercise.id}: no Lösung while writing`).toBeHidden();
|
|
622
|
+
await item.getByRole("button", { name: message("de", "openTaskCompare") }).click();
|
|
623
|
+
await expect(item.locator(".explainer-open-check").getByRole("link", { name: message("de", "openTaskSource") }), `${exercise.id}: a link to read up per point of the content`).toHaveCount(exercise.points.filter((point) => point.source).length);
|
|
624
|
+
}
|
|
625
|
+
/** With JavaScript: a generated exercise at its deep link, its answers, a mistake, and "Neue Aufgabe". */
|
|
626
|
+
async function checkGenerated(page, url, exercise) {
|
|
627
|
+
const { spec, data, templates } = exercise;
|
|
628
|
+
const seeds = taskSeeds(spec, data);
|
|
629
|
+
const seedOfNumber = (n) => seeds[taskNumber(n, seeds.length) - 1] ?? n;
|
|
630
|
+
const task = generate(spec, seedOfNumber(SEED), data);
|
|
631
|
+
const question = questionFor(spec, task, "de", templates);
|
|
632
|
+
const address = `./${url}?seed=${SEED}#${exercise.id}`;
|
|
633
|
+
const legend = page.locator(`[id="${questionId(question)}"] legend`);
|
|
634
|
+
const worked = page.locator(`[id="${exercise.id}"] .explainer-worked`);
|
|
635
|
+
await page.goto(address);
|
|
636
|
+
await expect(legend, "the deep link shows the task of its seed").toHaveText(plainText(taskText(spec, task, "de", templates)));
|
|
637
|
+
await expect(worked, `${exercise.id}: no worked task before the answer`).toBeHidden();
|
|
638
|
+
if (task.mistakes.length > 0) {
|
|
639
|
+
await answerQuestion(page, question, answerFor(spec, task, question, 0));
|
|
640
|
+
const message = fill(templates["de"]?.messages[0] ?? "", spec, task, "de", "text");
|
|
641
|
+
await wrongThenSolution(page, exercise.id, question, message);
|
|
642
|
+
} else {
|
|
643
|
+
const wrong = wrongAnswer(question);
|
|
644
|
+
await answerQuestion(page, question, wrong);
|
|
645
|
+
await wrongThenSolution(page, exercise.id, question, reasonFor(question, wrong));
|
|
646
|
+
}
|
|
647
|
+
await expect(worked, `${exercise.id}: the worked tasks stay hidden with JavaScript`).toBeHidden();
|
|
648
|
+
await page.locator(`[id="${exercise.id}"] .explainer-new-task`).click();
|
|
649
|
+
const number = Number(new URL(page.url()).searchParams.get(exercise.id));
|
|
650
|
+
expect(number, "the next task number in the address").toBe(nextTaskNumber(taskNumber(SEED, seeds.length), seeds.length));
|
|
651
|
+
const next = generate(spec, seedOfNumber(number), data);
|
|
652
|
+
const nextQuestion = questionFor(spec, next, "de", templates);
|
|
653
|
+
const nextLegend = page.locator(`[id="${questionId(nextQuestion)}"] legend`);
|
|
654
|
+
await expect(nextLegend).toHaveText(plainText(taskText(spec, next, "de", templates)));
|
|
655
|
+
if (seeds.length > 1) expect(taskKey(spec, next, "de", templates), "a new task").not.toBe(taskKey(spec, task, "de", templates));
|
|
656
|
+
await expect(nextLegend).toBeFocused();
|
|
657
|
+
await answerQuestion(page, nextQuestion, rightAnswer(nextQuestion));
|
|
658
|
+
await expect(feedback(page, questionId(nextQuestion))).toContainText(questionMessagesIn("de").right);
|
|
659
|
+
await expectFeedbackInView(page, nextQuestion);
|
|
660
|
+
}
|
|
661
|
+
/** Whether the page at `url` (absolute) has an element with the id `id`, fetched once per page. */
|
|
662
|
+
const known = /* @__PURE__ */ new Map();
|
|
663
|
+
async function hasAnchor(page, url, id) {
|
|
664
|
+
let html = known.get(url);
|
|
665
|
+
if (html === void 0) {
|
|
666
|
+
html = page.request.get(url).then((response) => response.text());
|
|
667
|
+
known.set(url, html);
|
|
668
|
+
}
|
|
669
|
+
const escaped = id.replace(/&/g, "&").replace(/"/g, """);
|
|
670
|
+
return (await html).includes(` id="${escaped}"`);
|
|
671
|
+
}
|
|
672
|
+
/** Every link of the page to an anchor of this app finds an element with that id. */
|
|
673
|
+
async function expectAnchors(page) {
|
|
674
|
+
const here = new URL(page.url());
|
|
675
|
+
const links = await page.locator("a[href*='#']").evaluateAll((anchors) => anchors.map((a) => a.href));
|
|
676
|
+
for (const link of new Set(links)) {
|
|
677
|
+
const url = new URL(link);
|
|
678
|
+
if (url.origin !== here.origin || url.hash === "") continue;
|
|
679
|
+
const id = decodeURIComponent(url.hash.slice(1));
|
|
680
|
+
const found = url.pathname === here.pathname ? await page.locator(`[id="${id.replace(/"/g, "\\\"")}"]`).count() === 1 : await hasAnchor(page, url.href.replace(/#.*$/, ""), id);
|
|
681
|
+
expect(found, `${here.pathname}: the link to ${url.pathname}${url.hash} finds its anchor`).toBe(true);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/** The page's language, its alternates and a switch to each other language, the archetype marker. */
|
|
685
|
+
async function expectLanguages(page, item, languages) {
|
|
686
|
+
await expect(page.locator("html")).toHaveAttribute("lang", item.lang);
|
|
687
|
+
await expect(page.locator("meta[name=\"lernapps-archetype\"]")).toHaveAttribute("content", "explainer");
|
|
688
|
+
for (const lang of languages) {
|
|
689
|
+
await expect(page.locator(`link[rel="alternate"][hreflang="${lang}"]`)).toHaveCount(1);
|
|
690
|
+
if (lang === item.lang) continue;
|
|
691
|
+
const other = page.locator(`.page-languages a[hreflang="${lang}"]`);
|
|
692
|
+
await expect(other).toBeVisible();
|
|
693
|
+
const response = await page.request.get(await other.evaluate((a) => a.href));
|
|
694
|
+
expect(response.status(), `${item.url}: the switch to ${lang}`).toBe(200);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* The clipboard of the page, recorded in the test (nothing leaves the browser): each text the page writes, kept even
|
|
699
|
+
* when the page goes on right after.
|
|
700
|
+
*/
|
|
701
|
+
async function recordClipboard(page) {
|
|
702
|
+
const copied = [];
|
|
703
|
+
await page.exposeBinding("lernappsRecordCopy", (_source, text) => {
|
|
704
|
+
copied.push(text);
|
|
705
|
+
});
|
|
706
|
+
await page.addInitScript(() => {
|
|
707
|
+
const recorder = window;
|
|
708
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
709
|
+
configurable: true,
|
|
710
|
+
value: { writeText: (text) => recorder.lernappsRecordCopy(text) }
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
return copied;
|
|
714
|
+
}
|
|
715
|
+
/** Records the addresses the page opens with window.open, which opens nothing. */
|
|
716
|
+
async function recordOpen(page) {
|
|
717
|
+
await page.addInitScript(() => {
|
|
718
|
+
const opened = [];
|
|
719
|
+
Object.assign(window, {
|
|
720
|
+
opened,
|
|
721
|
+
open: (url) => (opened.push(String(url ?? "")), null)
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
return () => page.evaluate(() => window.opened);
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Switches the way `label` to the tutor on under the title of a content page (an assistant's name, or copying the
|
|
728
|
+
* text): "Mit einem KI-Tutor lernen" is folded while none is on; then the bar with its button shows.
|
|
729
|
+
*/
|
|
730
|
+
async function switchOn(page, label) {
|
|
731
|
+
const tutor = page.locator(".explainer-tutor");
|
|
732
|
+
const off = tutor.locator("details.explainer-tutor-off");
|
|
733
|
+
await expect(off, "folded at first").not.toHaveAttribute("open");
|
|
734
|
+
await expect(tutor.locator(".explainer-tutor-bar")).toBeHidden();
|
|
735
|
+
await off.locator(":scope > summary").click();
|
|
736
|
+
await tutor.locator(".explainer-tutor-select").selectOption({ label });
|
|
737
|
+
await tutor.locator(".explainer-tutor-on").click();
|
|
738
|
+
await expect(off).toBeHidden();
|
|
739
|
+
await expect(tutor.locator(".explainer-tutor-go")).toBeVisible();
|
|
740
|
+
return tutor;
|
|
741
|
+
}
|
|
742
|
+
/** The app's first assistant, from the page's tutor section: its name and its address with {prompt}. */
|
|
743
|
+
async function firstAssistant(page) {
|
|
744
|
+
return (await page.locator("script.explainer-tutor-prompt").evaluate((script) => JSON.parse(script.textContent ?? "{}").ways))[0] ?? {
|
|
745
|
+
name: "",
|
|
746
|
+
url: ""
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
/** The prompt in the address `opened` of the assistant `template`. */
|
|
750
|
+
function promptOf(template, opened) {
|
|
751
|
+
const [before = "", after = ""] = template.split("{prompt}");
|
|
752
|
+
return decodeURIComponent(opened.slice(before.length, opened.length - after.length));
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* The address of the page's tutor page with where the learner is, as the ways to the tutor name it from the page
|
|
756
|
+
* `here` (its address before the click): the tutor page beside the page, every task number of `here`, and the exercise
|
|
757
|
+
* `id` at task `n` (none for one without numbers); for way A, without `id`, no exercise and no section.
|
|
758
|
+
*/
|
|
759
|
+
function expectTutorAddress(address, here, id, n) {
|
|
760
|
+
const url = new URL(address);
|
|
761
|
+
expect(`${url.origin}${url.pathname}`, "the page's tutor page").toBe(new URL("tutor/", new URL(".", here)).href);
|
|
762
|
+
for (const [key, value] of new URL(here).searchParams) if (key !== "via") expect(url.searchParams.get(key), `the task number of ${key}`).toBe(value);
|
|
763
|
+
expect(url.searchParams.get("via"), "no via=tutor").toBeNull();
|
|
764
|
+
if (id === void 0) {
|
|
765
|
+
expect(url.searchParams.get("uebung"), "no exercise").toBeNull();
|
|
766
|
+
expect(url.searchParams.get("abschnitt"), "no section").toBeNull();
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
expect(url.searchParams.get("uebung"), "the exercise").toBe(id);
|
|
770
|
+
if (n !== void 0) expect(url.searchParams.get(id), "its task number").toBe(String(n));
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* What "Zurück zum Tutor" copies on the page `here` (its address before the click): the page's tutor page with the
|
|
774
|
+
* page's task numbers, which answers and has the exercise `id`; a line with `line` for the task the learner did; the
|
|
775
|
+
* count of the session last.
|
|
776
|
+
*/
|
|
777
|
+
async function expectSessionLines(request, copied, here, line, id) {
|
|
778
|
+
await expect.poll(() => copied.length, "the lines are copied").toBe(1);
|
|
779
|
+
const lines = (copied[0] ?? "").split("\n");
|
|
780
|
+
const [intro, before = "", after = ""] = [lines[0] ?? "", ...message("de", "sessionIntro").split("{url}")];
|
|
781
|
+
expect(intro.startsWith(before) && intro.endsWith(after), "the first line: the page's tutor page").toBe(true);
|
|
782
|
+
const address = intro.slice(before.length, intro.length - after.length);
|
|
783
|
+
expectTutorAddress(address, here, void 0, void 0);
|
|
784
|
+
expect(lines.slice(1, -1), "a line per task").toContainEqual(expect.stringContaining(line));
|
|
785
|
+
expect(lines.at(-1), "the count of the session").toMatch(/^Sitzung: \d+ von \d+ richtig\.$/);
|
|
786
|
+
const response = await request.get(address);
|
|
787
|
+
expect(response.status(), `${address} answers`).toBe(200);
|
|
788
|
+
expect(await response.text(), "the tutor page has the exercise").toContain(id);
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* The result line "Zurück zum Tutor" copies on the page `here` (its address before the click): its first line has
|
|
792
|
+
* `first`, then the page's tutor page with the task `n` of the exercise `id`, which answers and has the exercise.
|
|
793
|
+
*/
|
|
794
|
+
async function expectResultLine(request, copied, here, first, id, n) {
|
|
795
|
+
await expect.poll(() => copied.length, "the result line is copied").toBe(1);
|
|
796
|
+
const lines = (copied[0] ?? "").split("\n");
|
|
797
|
+
expect(lines, "two lines").toHaveLength(2);
|
|
798
|
+
const [line = "", tutor = ""] = lines;
|
|
799
|
+
expect(line, "the first line: exercise, number, how far, the input").toContain(first);
|
|
800
|
+
const prefix = `${message("de", "resultTutor")} `;
|
|
801
|
+
expect(tutor.startsWith(prefix), "the page's tutor page").toBe(true);
|
|
802
|
+
const address = tutor.slice(prefix.length);
|
|
803
|
+
expectTutorAddress(address, here, id, n);
|
|
804
|
+
const response = await request.get(address);
|
|
805
|
+
expect(response.status(), `${address} answers`).toBe(200);
|
|
806
|
+
expect(await response.text(), "the tutor page has the exercise").toContain(id);
|
|
807
|
+
}
|
|
808
|
+
/** The explainer's tests, for the content in `root`/src. */
|
|
809
|
+
function explainerTests(root = ".") {
|
|
810
|
+
const app = outline(root);
|
|
811
|
+
test.describe("explainer", () => {
|
|
812
|
+
test.describe.configure({ mode: "parallel" });
|
|
813
|
+
test.use({ reducedMotion: "reduce" });
|
|
814
|
+
test.describe("without JavaScript", () => {
|
|
815
|
+
test.use({ javaScriptEnabled: false });
|
|
816
|
+
for (const lang of app.languages) {
|
|
817
|
+
for (const item of app.pages[lang] ?? []) test(`${item.url || "./"}: headings, sections, formulas, figures and anchors as in the content`, async ({ page }) => {
|
|
818
|
+
await page.goto(`./${item.url}`);
|
|
819
|
+
const main = page.locator("main");
|
|
820
|
+
const headings = await main.locator("h1, h2, h3, h4, h5, h6").evaluateAll((elements) => elements.map((element) => ({
|
|
821
|
+
level: Number(element.tagName.slice(1)),
|
|
822
|
+
text: (element.textContent ?? "").replace(/\s+/g, " ").trim()
|
|
823
|
+
})));
|
|
824
|
+
expect(headings, "the heading levels follow the nesting").toEqual(item.headings);
|
|
825
|
+
for (const section of item.sections) {
|
|
826
|
+
const heading = page.locator(`[id="${section.id}"]`);
|
|
827
|
+
await expect(heading, `section ${section.id}`).toBeVisible();
|
|
828
|
+
await expect(heading).toHaveText(section.title);
|
|
829
|
+
}
|
|
830
|
+
const formulas = await main.locator("math").evaluateAll((elements) => elements.filter((math) => !math.closest(".explainer-described")).length);
|
|
831
|
+
expect(formulas, "one <math> per formula").toBe(item.formulas);
|
|
832
|
+
for (const name of item.figures) await expect(main.getByRole("img", {
|
|
833
|
+
name,
|
|
834
|
+
exact: true
|
|
835
|
+
}).first(), `the figure "${name}"`).toBeVisible();
|
|
836
|
+
await expectAnchors(page);
|
|
837
|
+
await expectLanguages(page, item, app.languages);
|
|
838
|
+
await expectExercises(page, item);
|
|
839
|
+
await expectNojs(page, item);
|
|
840
|
+
await expectVideos(page, item);
|
|
841
|
+
});
|
|
842
|
+
for (const item of app.others[lang] ?? []) test(`${item.url || "./"}: its heading, its links and anchors, its languages`, async ({ page }) => {
|
|
843
|
+
await page.goto(`./${item.url}`);
|
|
844
|
+
await expect(page.locator("main h1")).toHaveText(item.title);
|
|
845
|
+
await expectAnchors(page);
|
|
846
|
+
await expectLanguages(page, item, app.languages);
|
|
847
|
+
await expect(page.locator(".explainer-tutor"), "no way to the tutor here").toHaveCount(0);
|
|
848
|
+
if (item.kind === "start") await expect(page.locator(".explainer-tutor-about h2")).toHaveText(message(item.lang, "tutorTitle"));
|
|
849
|
+
});
|
|
850
|
+
test(`${lang}: previous and next form one chain over all pages`, async ({ page }) => {
|
|
851
|
+
const order = (app.pages[lang] ?? []).map((item) => item.url);
|
|
852
|
+
const first = order[0];
|
|
853
|
+
if (first === void 0) throw new Error(`no page in ${lang}`);
|
|
854
|
+
const relative = (url) => {
|
|
855
|
+
const path = new URL(url).pathname;
|
|
856
|
+
return order.find((candidate) => path.endsWith(`/${candidate}`)) ?? path;
|
|
857
|
+
};
|
|
858
|
+
const forward = [];
|
|
859
|
+
await page.goto(`./${first}`);
|
|
860
|
+
for (let step = 0; step <= order.length; step++) {
|
|
861
|
+
forward.push(relative(page.url()));
|
|
862
|
+
const next = page.locator("a[rel=\"next\"]");
|
|
863
|
+
if (await next.count() === 0) break;
|
|
864
|
+
const here = page.url();
|
|
865
|
+
await next.click();
|
|
866
|
+
await page.waitForURL((url) => url.href !== here);
|
|
867
|
+
}
|
|
868
|
+
expect(forward, "next leads through every page in order").toEqual(order);
|
|
869
|
+
const backward = [];
|
|
870
|
+
for (let step = 0; step <= order.length; step++) {
|
|
871
|
+
backward.push(relative(page.url()));
|
|
872
|
+
const previous = page.locator("a[rel=\"prev\"]");
|
|
873
|
+
if (await previous.count() === 0) break;
|
|
874
|
+
const here = page.url();
|
|
875
|
+
await previous.click();
|
|
876
|
+
await page.waitForURL((url) => url.href !== here);
|
|
877
|
+
}
|
|
878
|
+
expect(backward, "previous leads back through every page").toEqual([...order].reverse());
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
test.describe("with JavaScript", () => {
|
|
883
|
+
test.skip(({ viewport }) => (viewport?.width ?? 0) > 400, "the exercises run at 360 px");
|
|
884
|
+
for (const item of app.pages["de"] ?? []) {
|
|
885
|
+
const types = /* @__PURE__ */ new Map();
|
|
886
|
+
for (const exercise of item.exercises) if (!types.has(exercise.type)) types.set(exercise.type, exercise);
|
|
887
|
+
if (types.size === 0) continue;
|
|
888
|
+
test(`${item.url}: one exercise of each type checks its answers (${[...types.keys()].join(", ")})`, async ({ page }) => {
|
|
889
|
+
for (const exercise of types.values()) if (exercise.kind === "question") await checkQuestion(page, item.url, exercise);
|
|
890
|
+
else if (exercise.kind === "open") await checkOpenTask(page, item.url, exercise);
|
|
891
|
+
else await checkGenerated(page, item.url, exercise);
|
|
892
|
+
expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
|
|
893
|
+
const kept = await page.evaluate(() => Object.keys(sessionStorage));
|
|
894
|
+
expect(kept.filter((key) => !/^lernapps:[^:]+:antwort:/.test(key)), "nothing else in the tab").toEqual([]);
|
|
895
|
+
expect(kept.length, "an answer per open task").toBe(types.has("open-task") ? 1 : 0);
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
for (const item of app.pages["de"] ?? []) {
|
|
899
|
+
if (item.videos.length === 0) continue;
|
|
900
|
+
test(`${item.url}: the videos load nothing before a click, the player after it, directly once chosen`, async ({ page, baseURL }) => {
|
|
901
|
+
await checkVideos(page, item, new URL(baseURL ?? page.url()).origin);
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
for (const item of app.pages["de"] ?? []) {
|
|
905
|
+
if (item.components.length === 0) continue;
|
|
906
|
+
test(`${item.url}: each component hydrates without a console error (${item.components.map((component) => component.name).join(", ")})`, async ({ page }) => {
|
|
907
|
+
await checkComponents(page, item);
|
|
908
|
+
expect(await page.evaluate(() => [...Object.keys(localStorage), ...Object.keys(sessionStorage)])).toEqual([]);
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
for (const item of (app.pages["de"] ?? []).filter((candidate) => candidate.exercises.some((exercise) => exercise.kind === "generated"))) test(`${item.url}: the address holds the task of each exercise, and the tutor button gives them to the prompt`, async ({ page }) => {
|
|
913
|
+
await page.addInitScript(() => {
|
|
914
|
+
const copied = [];
|
|
915
|
+
Object.assign(window, { copied });
|
|
916
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
917
|
+
configurable: true,
|
|
918
|
+
value: { writeText: (text) => (copied.push(text), Promise.resolve()) }
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
await page.goto(`./${item.url}`);
|
|
922
|
+
const ids = item.exercises.flatMap((exercise) => exercise.kind === "generated" ? [exercise.id] : []);
|
|
923
|
+
for (const id of ids) await expect.poll(() => new URL(page.url()).searchParams.get(id), `${id} is in the address`).not.toBeNull();
|
|
924
|
+
const first = ids[0];
|
|
925
|
+
if (first === void 0) return;
|
|
926
|
+
const before = new URL(page.url()).searchParams;
|
|
927
|
+
await page.locator(`[id="${first}"] .explainer-new-task`).click();
|
|
928
|
+
const after = new URL(page.url()).searchParams;
|
|
929
|
+
expect(after.get(first), "the exercise's task changes").not.toBe(before.get(first));
|
|
930
|
+
for (const id of ids.slice(1)) expect(after.get(id), `${id} stays`).toBe(before.get(id));
|
|
931
|
+
const tutor = await switchOn(page, message("de", "tutorOther"));
|
|
932
|
+
await expect(tutor.locator(".explainer-tutor-go")).toHaveText(`?${message("de", "tutorCopy")}`);
|
|
933
|
+
await tutor.locator(".explainer-tutor-go").click();
|
|
934
|
+
await expect(tutor.locator(".explainer-tutor-status")).toHaveText(message("de", "tutorCopied"));
|
|
935
|
+
const [prompt = "", ...markdown] = ((await page.evaluate(() => window.copied))[0] ?? "").split("\n");
|
|
936
|
+
const address = /https?:\/\/\S+/.exec(prompt)?.[0] ?? "";
|
|
937
|
+
expect(new URL(address).searchParams.get(first), "the task number in the tutor page's address").toBe(after.get(first));
|
|
938
|
+
expect(`${new URL(address).origin}${new URL(address).pathname}`).toBe(new URL("tutor/", page.url()).href);
|
|
939
|
+
expect(markdown.join("\n"), "the tutor page's Markdown, with the guide").toContain("## So leitest du an");
|
|
940
|
+
expect(markdown.join("\n"), "and the exercise").toContain(`### Übung ${first}`);
|
|
941
|
+
await page.reload();
|
|
942
|
+
await expect(tutor.locator(".explainer-tutor-go")).toBeVisible();
|
|
943
|
+
});
|
|
944
|
+
const generatedPage = (app.pages["de"] ?? []).find((candidate) => candidate.exercises.some((exercise) => exercise.kind === "generated"));
|
|
945
|
+
if (generatedPage !== void 0) {
|
|
946
|
+
const exercise = generatedPage.exercises.find((candidate) => candidate.kind === "generated");
|
|
947
|
+
test(`${generatedPage.url}: way A, the provider link names the page's tutor page with the page's task numbers`, async ({ page, request }) => {
|
|
948
|
+
if (exercise?.kind !== "generated") return;
|
|
949
|
+
const opened = await recordOpen(page);
|
|
950
|
+
await page.goto(`./${generatedPage.url}?${exercise.id}=${SEED}#${exercise.id}`);
|
|
951
|
+
const n = taskNumber(SEED, taskSeeds(exercise.spec, exercise.data).length);
|
|
952
|
+
const assistant = await firstAssistant(page);
|
|
953
|
+
const template = assistant.url;
|
|
954
|
+
test.skip(!template.includes("{prompt}"), "the app's first assistant takes no prompt in its address");
|
|
955
|
+
await (await switchOn(page, assistant.name)).locator(".explainer-tutor-go").click();
|
|
956
|
+
const addresses = await opened();
|
|
957
|
+
expect(addresses, "one assistant opens").toHaveLength(1);
|
|
958
|
+
const prompt = promptOf(template, addresses[0] ?? "");
|
|
959
|
+
const found = prompt.match(/https?:\/\/\S+/g) ?? [];
|
|
960
|
+
expect(found, "one address: the tutor page").toHaveLength(1);
|
|
961
|
+
const [address = ""] = found;
|
|
962
|
+
expectTutorAddress(address, page.url(), void 0, void 0);
|
|
963
|
+
expect(new URL(address).searchParams.get(exercise.id), "the task number").toBe(String(n));
|
|
964
|
+
expect(prompt, "in the learner's voice: be my tutor").toContain("Tutor");
|
|
965
|
+
const response = await request.get(address);
|
|
966
|
+
expect(response.status(), "the tutor page answers").toBe(200);
|
|
967
|
+
expect(await response.text()).toContain("<main id=\"anleitung\">");
|
|
968
|
+
});
|
|
969
|
+
/** The first line of way B1: the tutor page and the task in one sentence. */
|
|
970
|
+
const stuckFirst = (line, here, n) => {
|
|
971
|
+
const address = /https?:\/\/\S+/.exec(line)?.[0] ?? "";
|
|
972
|
+
expectTutorAddress(address, here, exercise?.id ?? "", n);
|
|
973
|
+
expect(line).toBe(message("de", "stuckAsk", {
|
|
974
|
+
url: address,
|
|
975
|
+
exercise: exercise?.id ?? "",
|
|
976
|
+
number: message("de", "resultNumber", { n: String(n) })
|
|
977
|
+
}));
|
|
978
|
+
return address;
|
|
979
|
+
};
|
|
980
|
+
test(`${generatedPage.url}: way B1, "Warum?" after a wrong answer opens the assistant with the input and the app's message word for word`, async ({ page, request }) => {
|
|
981
|
+
if (exercise?.kind !== "generated") return;
|
|
982
|
+
const opened = await recordOpen(page);
|
|
983
|
+
const { spec, data, templates } = exercise;
|
|
984
|
+
const seeds = taskSeeds(spec, data);
|
|
985
|
+
const n = taskNumber(SEED, seeds.length);
|
|
986
|
+
const task = generate(spec, seeds[n - 1] ?? n, data);
|
|
987
|
+
const question = questionFor(spec, task, "de", templates);
|
|
988
|
+
await page.goto(`./${generatedPage.url}?${exercise.id}=${SEED}#${exercise.id}`);
|
|
989
|
+
const assistant = await firstAssistant(page);
|
|
990
|
+
const template = assistant.url;
|
|
991
|
+
test.skip(!template.includes("{prompt}"), "the app's first assistant takes no prompt in its address");
|
|
992
|
+
await expect(page.locator(`[id="${questionId(question)}"] legend`)).toBeVisible();
|
|
993
|
+
await switchOn(page, assistant.name);
|
|
994
|
+
const wrong = task.mistakes.length > 0 ? answerFor(spec, task, question, 0) : wrongAnswer(question);
|
|
995
|
+
await answerQuestion(page, question, wrong);
|
|
996
|
+
await expect(feedback(page, questionId(question))).toContainText(questionMessagesIn("de").wrong);
|
|
997
|
+
const shown = (await feedback(page, questionId(question)).locator(".question-diagnosis").allTextContents()).slice(0, -1).join(" ");
|
|
998
|
+
if (task.mistakes.length > 0) expect(shown, "the message of the mistake").toBe(diagnose(spec, task, question, wrong, "de", templates));
|
|
999
|
+
const help = page.locator(`[id="${exercise.id}"] .question-actions button.explainer-help`);
|
|
1000
|
+
await expect(help).toHaveText(`${assistant.name.charAt(0).toUpperCase()}${message("de", "helpWhy")}`);
|
|
1001
|
+
await expect(help).toHaveAttribute("aria-label", message("de", "helpWhyLabel", { name: assistant.name }));
|
|
1002
|
+
await help.click();
|
|
1003
|
+
const addresses = await opened();
|
|
1004
|
+
expect(addresses, "one assistant opens").toHaveLength(1);
|
|
1005
|
+
const [first = "", ...rest] = promptOf(template, addresses[0] ?? "").split("\n");
|
|
1006
|
+
const address = stuckFirst(first, page.url(), n);
|
|
1007
|
+
expect(rest).toEqual([message("de", "stuckInput", {
|
|
1008
|
+
input: answerShown(question, wrong, "de"),
|
|
1009
|
+
tries: message("de", "resultTry")
|
|
1010
|
+
}), message("de", "stuckMessage", { message: shown.replace(/\s+/g, " ").trim() })]);
|
|
1011
|
+
expect((await request.get(address)).status(), `${address} answers`).toBe(200);
|
|
1012
|
+
});
|
|
1013
|
+
test(`${generatedPage.url}: way B1 before the first input, the sentence with the tutor page and the task alone`, async ({ page }) => {
|
|
1014
|
+
if (exercise?.kind !== "generated") return;
|
|
1015
|
+
const opened = await recordOpen(page);
|
|
1016
|
+
const n = taskNumber(SEED, taskSeeds(exercise.spec, exercise.data).length);
|
|
1017
|
+
await page.goto(`./${generatedPage.url}?${exercise.id}=${SEED}#${exercise.id}`);
|
|
1018
|
+
const assistant = await firstAssistant(page);
|
|
1019
|
+
const template = assistant.url;
|
|
1020
|
+
test.skip(!template.includes("{prompt}"), "the app's first assistant takes no prompt in its address");
|
|
1021
|
+
const button = page.locator(`[id="${exercise.id}"] .question-actions button.explainer-help`);
|
|
1022
|
+
await expect(button, "no help before a tutor is on").toBeHidden();
|
|
1023
|
+
await switchOn(page, assistant.name);
|
|
1024
|
+
await expect(button, "there before the first input").toHaveText(`${assistant.name.charAt(0).toUpperCase()}${message("de", "help")}`);
|
|
1025
|
+
await button.click();
|
|
1026
|
+
const addresses = await opened();
|
|
1027
|
+
expect(addresses, "one assistant opens").toHaveLength(1);
|
|
1028
|
+
const lines = promptOf(template, addresses[0] ?? "").split("\n");
|
|
1029
|
+
expect(lines, "the sentence alone").toHaveLength(1);
|
|
1030
|
+
stuckFirst(lines[0] ?? "", page.url(), n);
|
|
1031
|
+
});
|
|
1032
|
+
test(`${generatedPage.url}: way B2, on a page a tutor opened "Warum?" copies the lines and opens nothing`, async ({ page }) => {
|
|
1033
|
+
if (exercise?.kind !== "generated") return;
|
|
1034
|
+
const copied = await recordClipboard(page);
|
|
1035
|
+
const opened = await recordOpen(page);
|
|
1036
|
+
const { spec, data, templates } = exercise;
|
|
1037
|
+
const seeds = taskSeeds(spec, data);
|
|
1038
|
+
const n = taskNumber(SEED, seeds.length);
|
|
1039
|
+
const task = generate(spec, seeds[n - 1] ?? n, data);
|
|
1040
|
+
const question = questionFor(spec, task, "de", templates);
|
|
1041
|
+
await page.goto(`./${generatedPage.url}?${exercise.id}=${SEED}&via=tutor#${exercise.id}`);
|
|
1042
|
+
await expect(page.locator(`[id="${questionId(question)}"] legend`)).toBeVisible();
|
|
1043
|
+
const wrong = task.mistakes.length > 0 ? answerFor(spec, task, question, 0) : wrongAnswer(question);
|
|
1044
|
+
await answerQuestion(page, question, wrong);
|
|
1045
|
+
await expect(feedback(page, questionId(question))).toContainText(questionMessagesIn("de").wrong);
|
|
1046
|
+
await page.locator(`[id="${exercise.id}"] .question-actions button.explainer-help`).click();
|
|
1047
|
+
await expect.poll(() => copied.length, "the lines are copied").toBe(1);
|
|
1048
|
+
expect(await opened(), "no new chat").toEqual([]);
|
|
1049
|
+
const lines = (copied[0] ?? "").split("\n");
|
|
1050
|
+
expect(lines[0], "the task first").toBe(message("de", "stuckTask", {
|
|
1051
|
+
exercise: exercise.id,
|
|
1052
|
+
number: message("de", "resultNumber", { n: String(n) })
|
|
1053
|
+
}));
|
|
1054
|
+
expect(lines[1]).toBe(message("de", "stuckInput", {
|
|
1055
|
+
input: answerShown(question, wrong, "de"),
|
|
1056
|
+
tries: message("de", "resultTry")
|
|
1057
|
+
}));
|
|
1058
|
+
expect(lines[2]).toMatch(/^Die App sagt: „.+“$/);
|
|
1059
|
+
const prefix = `${message("de", "resultTutor")} `;
|
|
1060
|
+
expect(lines.at(-1)?.startsWith(prefix), "the tutor page last").toBe(true);
|
|
1061
|
+
expectTutorAddress((lines.at(-1) ?? "").slice(prefix.length), page.url(), exercise.id, n);
|
|
1062
|
+
});
|
|
1063
|
+
test(`${generatedPage.url}: on a page a tutor opened, "Zurück" in the row copies the result line once the solution shows`, async ({ page, request }) => {
|
|
1064
|
+
if (exercise?.kind !== "generated") return;
|
|
1065
|
+
const copied = await recordClipboard(page);
|
|
1066
|
+
const { spec, data, templates } = exercise;
|
|
1067
|
+
const seeds = taskSeeds(spec, data);
|
|
1068
|
+
const n = taskNumber(SEED, seeds.length);
|
|
1069
|
+
const task = generate(spec, seeds[n - 1] ?? n, data);
|
|
1070
|
+
const question = questionFor(spec, task, "de", templates);
|
|
1071
|
+
await page.goto(`./${generatedPage.url}?${exercise.id}=${SEED}&via=tutor#${exercise.id}`);
|
|
1072
|
+
await expect(page.locator(`[id="${questionId(question)}"] legend`)).toBeVisible();
|
|
1073
|
+
const wrong = task.mistakes.length > 0 ? answerFor(spec, task, question, 0) : wrongAnswer(question);
|
|
1074
|
+
await answerQuestion(page, question, wrong);
|
|
1075
|
+
await page.getByRole("button", {
|
|
1076
|
+
name: questionMessagesIn("de").showSolution,
|
|
1077
|
+
exact: true
|
|
1078
|
+
}).click();
|
|
1079
|
+
const back = page.locator(`[id="${exercise.id}"] .question-actions button.explainer-help`);
|
|
1080
|
+
await expect(back).toHaveAttribute("aria-label", message("de", "helpBackLabel", { name: message("de", "tutorSomeone") }));
|
|
1081
|
+
const here = page.url();
|
|
1082
|
+
await back.click();
|
|
1083
|
+
await expectResultLine(request, copied, here, `, ${exercise.id}, Nr. ${n}: ${message("de", "resultSolution")}`, exercise.id, n);
|
|
1084
|
+
});
|
|
1085
|
+
test(`${generatedPage.url}: "Zurück zum Tutor" copies the tutor page and the tasks of a generated exercise`, async ({ page, request }) => {
|
|
1086
|
+
if (exercise?.kind !== "generated") return;
|
|
1087
|
+
const copied = await recordClipboard(page);
|
|
1088
|
+
const { spec, data, templates } = exercise;
|
|
1089
|
+
const seeds = taskSeeds(spec, data);
|
|
1090
|
+
const n = taskNumber(SEED, seeds.length);
|
|
1091
|
+
const task = generate(spec, seeds[n - 1] ?? n, data);
|
|
1092
|
+
const question = questionFor(spec, task, "de", templates);
|
|
1093
|
+
await page.goto(`./${generatedPage.url}?${exercise.id}=${SEED}&via=tutor#${exercise.id}`);
|
|
1094
|
+
await expect(page.locator(`[id="${questionId(question)}"] legend`)).toBeVisible();
|
|
1095
|
+
const wrong = task.mistakes.length > 0 ? answerFor(spec, task, question, 0) : wrongAnswer(question);
|
|
1096
|
+
await answerQuestion(page, question, wrong);
|
|
1097
|
+
await expect(feedback(page, questionId(question))).toContainText(questionMessagesIn("de").wrong);
|
|
1098
|
+
const here = page.url();
|
|
1099
|
+
await expect(page.getByRole("button", { name: message("de", "backToTutor") })).toHaveCount(2);
|
|
1100
|
+
const back = page.locator(".explainer-back-end button");
|
|
1101
|
+
await back.click();
|
|
1102
|
+
await expect(back).toHaveText(message("de", "backToTutorDone"));
|
|
1103
|
+
await expect(page.locator(".explainer-back-end [role=status]")).toHaveText(message("de", "backToTutorCopied", { name: message("de", "tutorSomeoneTo") }));
|
|
1104
|
+
const status = `${message("de", "resultOpen")} (${message("de", "resultTry")}, ${message("de", "resultInput", { input: answerShown(question, wrong, "de") })})`;
|
|
1105
|
+
await expectSessionLines(request, copied, here, `- ${exercise.id}, Nr. ${n}: ${status}.`, exercise.id);
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
const firstPage = (app.pages["de"] ?? [])[0];
|
|
1109
|
+
if (firstPage !== void 0) {
|
|
1110
|
+
test("the start page switches an assistant on for every page, and off again", async ({ page }) => {
|
|
1111
|
+
await page.goto("./");
|
|
1112
|
+
const choose = page.locator("select.explainer-tutor-choose");
|
|
1113
|
+
await expect(choose, "none at first").toHaveValue("");
|
|
1114
|
+
const name = (await choose.locator("option").evaluateAll((options) => options.map((option) => option.value)))[1] ?? "";
|
|
1115
|
+
await choose.selectOption(name);
|
|
1116
|
+
await page.goto(`./${firstPage.url}`);
|
|
1117
|
+
const tutor = page.locator(".explainer-tutor");
|
|
1118
|
+
await expect(tutor.locator(".explainer-tutor-bar")).toBeVisible();
|
|
1119
|
+
await expect(tutor.locator("details.explainer-tutor-off")).toBeHidden();
|
|
1120
|
+
if (name !== "copy") await expect(tutor.locator(".explainer-tutor-go")).toContainText(name);
|
|
1121
|
+
await page.goto("./");
|
|
1122
|
+
await expect(choose).toHaveValue(name);
|
|
1123
|
+
await choose.selectOption("");
|
|
1124
|
+
await page.goto(`./${firstPage.url}`);
|
|
1125
|
+
await expect(tutor.locator(".explainer-tutor-bar")).toBeHidden();
|
|
1126
|
+
await expect(page.locator("button.explainer-help").first(), "no help without a tutor").toBeHidden();
|
|
1127
|
+
});
|
|
1128
|
+
test("the start page keeps the learner's needs for every explainer or this app; way A names them", async ({ page }) => {
|
|
1129
|
+
await page.goto("./");
|
|
1130
|
+
const needs = page.locator("dialog.explainer-tutor-needs");
|
|
1131
|
+
await expect(needs, "closed at first").toBeHidden();
|
|
1132
|
+
await page.locator("button.explainer-tutor-needs-open").click();
|
|
1133
|
+
await expect(needs, "opened by Einstellungen").toBeVisible();
|
|
1134
|
+
const language = needs.locator("select.explainer-tutor-language");
|
|
1135
|
+
await expect(language, "the first language only with andere-muttersprache").toBeHidden();
|
|
1136
|
+
await needs.locator("input.explainer-tutor-need[value=\"kurze-schritte\"]").check();
|
|
1137
|
+
await needs.locator("input.explainer-tutor-need[value=\"andere-muttersprache\"]").check();
|
|
1138
|
+
await expect(language).toBeVisible();
|
|
1139
|
+
await language.selectOption("tr");
|
|
1140
|
+
const stored = (key) => page.evaluate((name) => localStorage.getItem(name), key);
|
|
1141
|
+
expect(JSON.parse(await stored("lernapps:settings:explainer:tutor-bedarf") ?? "null"), "for every explainer").toEqual({
|
|
1142
|
+
needs: ["kurze-schritte", "andere-muttersprache"],
|
|
1143
|
+
language: "tr"
|
|
1144
|
+
});
|
|
1145
|
+
await needs.locator("input.explainer-tutor-needs-scope[value=\"app\"]").check();
|
|
1146
|
+
const own = await page.evaluate(() => Object.keys(localStorage).find((key) => /^lernapps:[a-z0-9-]+:settings:tutor-bedarf$/.test(key) && !key.startsWith("lernapps:settings:")));
|
|
1147
|
+
expect(own, "for this app only, under its own prefix").toBeDefined();
|
|
1148
|
+
await needs.locator("button.explainer-tutor-needs-done").click();
|
|
1149
|
+
await expect(needs, "closed by Fertig").toBeHidden();
|
|
1150
|
+
await page.reload();
|
|
1151
|
+
await page.locator("button.explainer-tutor-needs-open").click();
|
|
1152
|
+
await expect(needs.locator("input.explainer-tutor-needs-scope[value=\"app\"]")).toBeChecked();
|
|
1153
|
+
await expect(needs.locator("input.explainer-tutor-need[value=\"kurze-schritte\"]")).toBeChecked();
|
|
1154
|
+
const opened = await recordOpen(page);
|
|
1155
|
+
await page.goto(`./${firstPage.url}`);
|
|
1156
|
+
const assistant = await firstAssistant(page);
|
|
1157
|
+
test.skip(!assistant.url.includes("{prompt}"), "the app's first assistant takes no prompt in its address");
|
|
1158
|
+
await (await switchOn(page, assistant.name)).locator(".explainer-tutor-go").click();
|
|
1159
|
+
const prompt = promptOf(assistant.url, (await opened())[0] ?? "");
|
|
1160
|
+
const address = /https?:\/\/\S+/.exec(prompt)?.[0] ?? "";
|
|
1161
|
+
expect(new URL(address).searchParams.get("bedarf"), "the needs in the tutor page's query").toBe("kurze-schritte,andere-muttersprache");
|
|
1162
|
+
expect(prompt, "the first language in the learner's voice").toContain("Meine Muttersprache ist Türkisch.");
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
for (const item of app.pages["de"] ?? []) {
|
|
1166
|
+
const component = item.components.find((candidate) => candidate.exercise && candidate.id !== void 0);
|
|
1167
|
+
if (component?.id === void 0) continue;
|
|
1168
|
+
const id = component.id;
|
|
1169
|
+
for (const [first, held] of [["the component", "**/assets/index-*.js"], ["the client module", "**/assets/islands-*.js"]]) test(`${item.url}: on a page a tutor opened, ${component.name} has its help button once, ${first} loaded first`, async ({ page }) => {
|
|
1170
|
+
const errors = [];
|
|
1171
|
+
page.on("console", (message) => {
|
|
1172
|
+
if (message.type() === "error") errors.push(message.text());
|
|
1173
|
+
});
|
|
1174
|
+
page.on("pageerror", (error) => errors.push(error.message));
|
|
1175
|
+
await page.route(held, async (route) => {
|
|
1176
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1177
|
+
await route.continue();
|
|
1178
|
+
});
|
|
1179
|
+
await page.goto(`./${item.url}?via=tutor#${id}`);
|
|
1180
|
+
const element = page.locator(`[id="${id}"]`);
|
|
1181
|
+
await expect(element).toHaveAttribute("data-hydrated", "");
|
|
1182
|
+
const help = element.locator("button.explainer-help");
|
|
1183
|
+
await expect(help, "once").toHaveCount(1);
|
|
1184
|
+
await expect(help).toBeVisible();
|
|
1185
|
+
await expect(element.locator(".explainer-island button.explainer-help"), "not in the island").toHaveCount(0);
|
|
1186
|
+
await element.locator(".explainer-new-task").click();
|
|
1187
|
+
await expect(help, "once after a new task").toHaveCount(1);
|
|
1188
|
+
await expect(help).toBeVisible();
|
|
1189
|
+
await answerRejected(page, component);
|
|
1190
|
+
await expect(help, "after a wrong answer").toContainText(message("de", "helpWhy"));
|
|
1191
|
+
expect(errors, "no console error").toEqual([]);
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
for (const item of app.pages["de"] ?? []) {
|
|
1195
|
+
const component = item.components.find((candidate) => candidate.exercise && candidate.id !== void 0);
|
|
1196
|
+
if (component?.id === void 0) continue;
|
|
1197
|
+
const id = component.id;
|
|
1198
|
+
test(`${item.url}: "Zurück zum Tutor" copies the tutor page and the tasks of the exercise component ${component.name}`, async ({ page, request }) => {
|
|
1199
|
+
const copied = await recordClipboard(page);
|
|
1200
|
+
await page.goto(`./${item.url}?${id}=3&via=tutor#${id}`);
|
|
1201
|
+
const element = page.locator(`[id="${id}"]`);
|
|
1202
|
+
await expect(element).toHaveAttribute("data-hydrated", "");
|
|
1203
|
+
const n = await answerRejected(page, component);
|
|
1204
|
+
const here = page.url();
|
|
1205
|
+
await page.getByRole("button", { name: message("de", "backToTutor") }).first().click();
|
|
1206
|
+
const status = `${message("de", "resultOpen")} (${message("de", "resultTry")}`;
|
|
1207
|
+
await expectSessionLines(request, copied, here, `- ${id}, Nr. ${n}: ${status}`, id);
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
test.describe("a self-assessment", () => {
|
|
1211
|
+
test.skip(({ viewport }) => (viewport?.width ?? 0) > 400, "the self-assessments run at 360 px");
|
|
1212
|
+
for (const assessment of app.assessments) {
|
|
1213
|
+
test(`${assessment.url}: a run with every answer right says each page asked is known`, async ({ page }) => {
|
|
1214
|
+
await runAssessment(page, app, assessment, true);
|
|
1215
|
+
await expectResult(page, assessment, "known");
|
|
1216
|
+
const total = String(assessment.items.length);
|
|
1217
|
+
await expect(page.locator(".explainer-assessment-score")).toHaveText(message("de", "assessmentScore", {
|
|
1218
|
+
right: total,
|
|
1219
|
+
total
|
|
1220
|
+
}));
|
|
1221
|
+
});
|
|
1222
|
+
test(`${assessment.url}: opened by a tutor, a run keeps nothing and copies its result for the chat`, async ({ page, request }) => {
|
|
1223
|
+
const copied = await recordClipboard(page);
|
|
1224
|
+
await page.goto(`./${assessment.url}?via=tutor`);
|
|
1225
|
+
await expect(page.locator(".explainer-back")).toHaveCount(0);
|
|
1226
|
+
const errors = [];
|
|
1227
|
+
page.on("pageerror", (error) => errors.push(error.message));
|
|
1228
|
+
await page.locator(".explainer-assessment-begin").click();
|
|
1229
|
+
for (let step = 0; step < assessment.steps; step++) {
|
|
1230
|
+
const last = step === assessment.steps - 1;
|
|
1231
|
+
const view = page.locator(`.explainer-assessment-step[data-step="${step}"]`);
|
|
1232
|
+
for (const item of assessment.items.filter((candidate) => candidate.step === step)) {
|
|
1233
|
+
const example = (app.pages["de"]?.find((candidate) => candidate.url === item.page)?.components.find((candidate) => candidate.exercise && candidate.id === item.id))?.examples?.accepted;
|
|
1234
|
+
if (example === void 0) continue;
|
|
1235
|
+
const element = view.locator(`[data-key="${item.key}"] .explainer-component`);
|
|
1236
|
+
await expect(element).toHaveAttribute("data-hydrated", "");
|
|
1237
|
+
await element.evaluate(emitAnswer, example.answer);
|
|
1238
|
+
await element.evaluate(emitAnswer, void 0);
|
|
1239
|
+
}
|
|
1240
|
+
const ids = await view.locator("[id]").evaluateAll((nodes) => nodes.map((node) => node.id));
|
|
1241
|
+
expect(ids.length, `step ${step}: ids once`).toBe(new Set(ids).size);
|
|
1242
|
+
await view.getByRole("button", {
|
|
1243
|
+
name: message("de", last ? "assessmentFinish" : "assessmentNext"),
|
|
1244
|
+
exact: true
|
|
1245
|
+
}).click();
|
|
1246
|
+
}
|
|
1247
|
+
expect(errors, "no error in the page").toEqual([]);
|
|
1248
|
+
await expectResult(page, assessment, "read");
|
|
1249
|
+
expect(await storedKeys(page), "a run for the tutor keeps nothing").toEqual([]);
|
|
1250
|
+
await expect(page.locator(".explainer-assessment-delete")).toHaveCount(0);
|
|
1251
|
+
await page.getByRole("button", {
|
|
1252
|
+
name: message("de", "backToTutor"),
|
|
1253
|
+
exact: true
|
|
1254
|
+
}).click();
|
|
1255
|
+
await expect.poll(() => copied.length).toBe(1);
|
|
1256
|
+
const lines = (copied[0] ?? "").split("\n");
|
|
1257
|
+
expect(lines[0]).toBe(message("de", "assessmentBackLine"));
|
|
1258
|
+
const prefix = `${message("de", "resultTutor")} `;
|
|
1259
|
+
const tutor = lines.find((line) => line.startsWith(prefix))?.slice(prefix.length) ?? "";
|
|
1260
|
+
const [nextBefore = ""] = message("de", "assessmentNextRound").split("{url}");
|
|
1261
|
+
const next = new URL(lines.at(-1)?.slice(nextBefore.length) ?? "");
|
|
1262
|
+
expect(next.pathname.endsWith(`/${assessment.url}`), "the next round's link").toBe(true);
|
|
1263
|
+
expect(next.searchParams.get("via")).toBe("tutor");
|
|
1264
|
+
const url = new URL(tutor);
|
|
1265
|
+
expect(url.pathname.endsWith(`/${assessment.url}tutor/`), "the self-assessment's tutor page").toBe(true);
|
|
1266
|
+
for (const item of assessment.items) expect(url.searchParams.get(item.key) ?? "").toMatch(/(^|-)offen$/);
|
|
1267
|
+
expect((await request.get(tutor)).status(), "the tutor page answers").toBe(200);
|
|
1268
|
+
});
|
|
1269
|
+
test(`${assessment.url}: a run without an answer stays on the device, goes to the tutor and is deleted`, async ({ page, request }) => {
|
|
1270
|
+
const opened = await recordOpen(page);
|
|
1271
|
+
await runAssessment(page, app, assessment, false);
|
|
1272
|
+
await expectResult(page, assessment, "read");
|
|
1273
|
+
expect(await storedKeys(page)).toHaveLength(1);
|
|
1274
|
+
await page.reload();
|
|
1275
|
+
await expect(page.locator(".explainer-assessment-result")).toBeVisible();
|
|
1276
|
+
await expectResult(page, assessment, "read");
|
|
1277
|
+
await page.goto(`./${assessment.offer}`);
|
|
1278
|
+
await expect(page.locator(".explainer-assessment-link")).toHaveText(message("de", "assessmentOfferLast"));
|
|
1279
|
+
await page.locator(".explainer-assessment-link").click();
|
|
1280
|
+
const select = page.locator(".explainer-assessment-way");
|
|
1281
|
+
const first = await select.locator("option").first().textContent() ?? "";
|
|
1282
|
+
await select.selectOption({ index: 0 });
|
|
1283
|
+
await page.locator(".explainer-assessment-go").click();
|
|
1284
|
+
const [address = ""] = await opened();
|
|
1285
|
+
expect(address, `opens ${first}`).not.toBe("");
|
|
1286
|
+
const tutor = /https?:\/\/\S+\/tutor\/\?\S+/.exec(decodeURIComponent(address))?.[0] ?? "";
|
|
1287
|
+
const url = new URL(tutor);
|
|
1288
|
+
expect(url.pathname.endsWith(`/${assessment.url}tutor/`), "the self-assessment's tutor page").toBe(true);
|
|
1289
|
+
for (const item of assessment.items) expect(url.searchParams.get(item.key) ?? "").toMatch(/(^|-)offen$/);
|
|
1290
|
+
expect((await request.get(tutor)).status(), "the tutor page answers").toBe(200);
|
|
1291
|
+
await page.getByRole("button", {
|
|
1292
|
+
name: message("de", "assessmentDelete"),
|
|
1293
|
+
exact: true
|
|
1294
|
+
}).click();
|
|
1295
|
+
await expect(page.locator(".explainer-assessment-intro")).toBeVisible();
|
|
1296
|
+
expect(await storedKeys(page)).toEqual([]);
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
test("nothing is stored on the device", async ({ page, context }) => {
|
|
1301
|
+
const urls = app.languages.flatMap((lang) => [...app.others[lang] ?? [], ...app.pages[lang] ?? []]);
|
|
1302
|
+
for (const item of urls) {
|
|
1303
|
+
await page.goto(`./${item.url}`);
|
|
1304
|
+
const stored = await page.evaluate(() => [...Object.keys(localStorage), ...Object.keys(sessionStorage)]);
|
|
1305
|
+
expect(stored, `${item.url} stores nothing`).toEqual([]);
|
|
1306
|
+
}
|
|
1307
|
+
expect(await context.cookies()).toEqual([]);
|
|
1308
|
+
});
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
export { explainerTests };
|