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