@lernapps/app-templates 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +195 -2
- package/dist/appname-Bh23cUuz.mjs +12 -0
- package/dist/assessment-Dy359O1O.mjs +1696 -0
- package/dist/block-BK_ohsH1.mjs +82 -0
- package/dist/blocks-CxtCPAa5.mjs +149 -0
- package/dist/blocks.mjs +4 -0
- package/dist/check-CteIFN96.mjs +254 -0
- package/dist/content-DO6oEJxj.mjs +140 -0
- package/dist/contract-PONpxJtd.mjs +52 -0
- package/dist/e2e-CU0n_unV.mjs +89 -0
- package/dist/engine-CArxqizW.mjs +136 -0
- package/dist/explainer-e2e.mjs +1311 -0
- package/dist/explainer-islands.mjs +197 -0
- package/dist/explainer-plugin.mjs +2132 -0
- package/dist/explainer.mjs +1246 -0
- package/dist/html-mffkHGHh.mjs +89 -0
- package/dist/image-CspJVfyQ.mjs +21 -0
- package/dist/interactive-e2e.mjs +140 -0
- package/dist/interactive-plugin.mjs +242 -0
- package/dist/interactive.mjs +75 -0
- package/dist/load-RosvaPZf.mjs +16976 -0
- package/dist/needs-BzEahJ6X.mjs +97 -0
- package/dist/pages-9RaaomoO.mjs +81 -0
- package/dist/questions-e2e.mjs +1 -0
- package/dist/questions-engine.mjs +3 -0
- package/dist/questions.mjs +6 -0
- package/dist/quiz-DnW-xtCh.mjs +70 -0
- package/dist/quiz-e2e.mjs +174 -0
- package/dist/quiz-plugin.mjs +266 -0
- package/dist/quiz.mjs +246 -0
- package/dist/render-D5kwkNt1.mjs +507 -0
- package/dist/render-DqHOJyHK.mjs +146 -0
- package/dist/render-GS1Htlu8.mjs +545 -0
- package/dist/result-D_mmCVwN.mjs +180 -0
- package/dist/tasks-DADUYy52.mjs +74 -0
- package/dist/validate-BDwL7Su3.mjs +1076 -0
- package/dist/validate-Bxy9NfB8.mjs +146 -0
- package/dist/vue-DO1m6q1R.mjs +40 -0
- package/dist/vue-code-BkJjW_I_.mjs +320 -0
- package/docs/arc42/01-introduction-and-goals.arc42.md +82 -0
- package/docs/arc42/02-constraints.arc42.md +228 -0
- package/docs/arc42/03-system-context.arc42.md +118 -0
- package/docs/arc42/04-solution-strategy.arc42.md +47 -0
- package/docs/arc42/05-building-blocks.arc42.md +307 -0
- package/docs/arc42/06-runtime-view.arc42.md +98 -0
- package/docs/arc42/07-deployment-view.arc42.md +160 -0
- package/docs/arc42/08-concepts.arc42.md +123 -0
- package/docs/arc42/09-decisions.arc42.md +246 -0
- package/docs/arc42/10-quality-requirements.arc42.md +155 -0
- package/docs/arc42/11-risks.arc42.md +113 -0
- package/docs/arc42/12-glossary.arc42.md +162 -0
- package/docs/arc42/architecture-evidence.md +395 -0
- package/explainer/.arc42ignore +3 -0
- package/explainer/README.md +54 -0
- package/explainer/_gitignore +5 -0
- package/explainer/docs/arc42/03-explainer-system-context.arc42.md +35 -0
- package/explainer/docs/arc42/05-explainer-building-blocks.arc42.md +659 -0
- package/explainer/docs/arc42/06-explainer-runtime-view.arc42.md +105 -0
- package/explainer/docs/arc42/07-explainer-deployment-view.arc42.md +34 -0
- package/explainer/docs/arc42/08-explainer-concepts.arc42.md +17 -0
- package/explainer/docs/arc42/09-explainer-decisions.arc42.md +443 -0
- package/explainer/docs/arc42/10-explainer-quality-requirements.arc42.md +54 -0
- package/explainer/docs/arc42/11-explainer-risks.arc42.md +17 -0
- package/explainer/docs/arc42/12-explainer-glossary.arc42.md +185 -0
- package/explainer/e2e/explainer.e2e.ts +3 -0
- package/explainer/index.html +11 -0
- package/explainer/package.json +18 -0
- package/explainer/playwright.config.ts +1 -0
- package/explainer/src/app.yaml +20 -0
- package/explainer/src/components/ProzentFeld.test.ts +30 -0
- package/explainer/src/components/ProzentFeld.ts +121 -0
- package/explainer/src/components/ProzentFeld.vue +84 -0
- package/explainer/src/images/hunderterfeld-klammern.svg +105 -0
- package/explainer/src/main.ts +4 -0
- package/explainer/src/prozent/chapter.yaml +18 -0
- package/explainer/src/prozent/grundwert.yaml +115 -0
- package/explainer/src/prozent/prozentsatz.yaml +99 -0
- package/explainer/src/prozent/prozentwert.yaml +119 -0
- package/explainer/tsconfig.json +1 -0
- package/explainer/vite.config.ts +4 -0
- package/interactive/.arc42ignore +5 -0
- package/interactive/README.md +28 -0
- package/interactive/_gitignore +4 -0
- package/interactive/docs/arc42/05-interactive-building-blocks.arc42.md +518 -0
- package/interactive/docs/arc42/06-interactive-runtime-view.arc42.md +58 -0
- package/interactive/docs/arc42/07-interactive-deployment-view.arc42.md +35 -0
- package/interactive/docs/arc42/08-interactive-concepts.arc42.md +52 -0
- package/interactive/docs/arc42/09-interactive-decisions.arc42.md +130 -0
- package/interactive/docs/arc42/10-interactive-quality-requirements.arc42.md +45 -0
- package/interactive/docs/arc42/11-interactive-risks.arc42.md +20 -0
- package/interactive/docs/arc42/12-interactive-glossary.arc42.md +80 -0
- package/interactive/e2e/interactive.e2e.ts +3 -0
- package/interactive/index.html +11 -0
- package/interactive/package.json +18 -0
- package/interactive/playwright.config.ts +1 -0
- package/interactive/src/app.yaml +28 -0
- package/interactive/src/core/index.ts +101 -0
- package/interactive/src/data/lessons.json +4 -0
- package/interactive/src/env.d.ts +6 -0
- package/interactive/src/main.ts +6 -0
- package/interactive/src/messages/de.json +12 -0
- package/interactive/src/playthroughs/grundreihe.yaml +33 -0
- package/interactive/src/playthroughs/obere-reihe.yaml +14 -0
- package/interactive/src/ui/App.vue +85 -0
- package/interactive/tsconfig.json +1 -0
- package/interactive/vite.config.ts +4 -0
- package/package.json +143 -4
- package/quiz/README.md +31 -0
- package/quiz/_gitignore +5 -0
- package/quiz/docs/arc42/05-quiz-building-blocks.arc42.md +283 -0
- package/quiz/docs/arc42/06-quiz-runtime-view.arc42.md +21 -0
- package/quiz/docs/arc42/07-quiz-deployment-view.arc42.md +33 -0
- package/quiz/docs/arc42/09-quiz-decisions.arc42.md +36 -0
- package/quiz/docs/arc42/10-quiz-quality-requirements.arc42.md +53 -0
- package/quiz/docs/arc42/11-quiz-risks.arc42.md +19 -0
- package/quiz/docs/arc42/12-quiz-glossary.arc42.md +27 -0
- package/quiz/e2e/quiz.e2e.ts +3 -0
- package/quiz/index.html +11 -0
- package/quiz/package.json +18 -0
- package/quiz/playwright.config.ts +1 -0
- package/quiz/src/images/berge-ordnen.jpg +0 -0
- package/quiz/src/images/everest-hoehe.jpg +0 -0
- package/quiz/src/images/galapagos.jpg +0 -0
- package/quiz/src/images/grand-canyon.jpg +0 -0
- package/quiz/src/images/totes-meer.jpg +0 -0
- package/quiz/src/images/uluru.jpg +0 -0
- package/quiz/src/images/wasserfaelle-laender.jpg +0 -0
- package/quiz/src/main.ts +4 -0
- package/quiz/src/quiz.json +312 -0
- package/quiz/tsconfig.json +1 -0
- package/quiz/vite.config.ts +4 -0
- package/runtime/explainer/assessment-client.ts +478 -0
- package/runtime/explainer/assessment-page.ts +448 -0
- package/runtime/explainer/assessment.ts +393 -0
- package/runtime/explainer/check-page.ts +520 -0
- package/runtime/explainer/components/exercise.ts +409 -0
- package/runtime/explainer/components/props.ts +230 -0
- package/runtime/explainer/components/read.ts +428 -0
- package/runtime/explainer/content.ts +277 -0
- package/runtime/explainer/e2e.ts +1488 -0
- package/runtime/explainer/exercises.ts +326 -0
- package/runtime/explainer/explainer.css +1283 -0
- package/runtime/explainer/explainer.ts +838 -0
- package/runtime/explainer/explainer.v1.schema.json +1165 -0
- package/runtime/explainer/expression.ts +407 -0
- package/runtime/explainer/fraction.ts +107 -0
- package/runtime/explainer/generated/comparator.ts +31 -0
- package/runtime/explainer/generated/question.ts +165 -0
- package/runtime/explainer/generated/soundness.ts +280 -0
- package/runtime/explainer/generated/texts.ts +257 -0
- package/runtime/explainer/generated/types.ts +103 -0
- package/runtime/explainer/generated/values.ts +136 -0
- package/runtime/explainer/islands.ts +281 -0
- package/runtime/explainer/load.ts +457 -0
- package/runtime/explainer/messages/de.json +200 -0
- package/runtime/explainer/messages/en.json +200 -0
- package/runtime/explainer/messages.ts +13 -0
- package/runtime/explainer/needs.ts +122 -0
- package/runtime/explainer/open-task.ts +235 -0
- package/runtime/explainer/outline.ts +306 -0
- package/runtime/explainer/plugin.ts +488 -0
- package/runtime/explainer/position.ts +62 -0
- package/runtime/explainer/prompt.ts +75 -0
- package/runtime/explainer/render.ts +686 -0
- package/runtime/explainer/result.ts +238 -0
- package/runtime/explainer/routes.ts +119 -0
- package/runtime/explainer/schema.ts +140 -0
- package/runtime/explainer/scope.ts +87 -0
- package/runtime/explainer/stuck.ts +57 -0
- package/runtime/explainer/tasks.ts +109 -0
- package/runtime/explainer/tutor.ts +469 -0
- package/runtime/explainer/tutorpage.ts +669 -0
- package/runtime/explainer/video.ts +123 -0
- package/runtime/interactive/contract.ts +121 -0
- package/runtime/interactive/e2e.ts +190 -0
- package/runtime/interactive/interactive.css +82 -0
- package/runtime/interactive/interactive.ts +115 -0
- package/runtime/interactive/interactive.v1.schema.json +146 -0
- package/runtime/interactive/load.ts +188 -0
- package/runtime/interactive/messages/de.json +14 -0
- package/runtime/interactive/messages/en.json +14 -0
- package/runtime/interactive/messages.ts +11 -0
- package/runtime/interactive/playthrough.v1.schema.json +143 -0
- package/runtime/interactive/plugin.ts +266 -0
- package/runtime/interactive/render.ts +126 -0
- package/runtime/quiz/e2e.ts +233 -0
- package/runtime/quiz/engine.ts +242 -0
- package/runtime/quiz/messages/de.json +23 -0
- package/runtime/quiz/messages/en.json +23 -0
- package/runtime/quiz/plugin.ts +150 -0
- package/runtime/quiz/quiz.css +154 -0
- package/runtime/quiz/quiz.ts +56 -0
- package/runtime/quiz/quiz.v1.schema.json +72 -0
- package/runtime/quiz/render.ts +173 -0
- package/runtime/quiz/validate.ts +108 -0
- package/runtime/shared/appname.ts +16 -0
- package/runtime/shared/blocks/block.ts +162 -0
- package/runtime/shared/blocks/blocks.css +190 -0
- package/runtime/shared/blocks/blocks.v1.schema.json +432 -0
- package/runtime/shared/blocks/code.ts +26 -0
- package/runtime/shared/blocks/figures.ts +49 -0
- package/runtime/shared/blocks/image.ts +25 -0
- package/runtime/shared/blocks/index.ts +13 -0
- package/runtime/shared/blocks/messages/de.json +4 -0
- package/runtime/shared/blocks/messages/en.json +4 -0
- package/runtime/shared/blocks/render.ts +134 -0
- package/runtime/shared/blocks/validate.ts +165 -0
- package/runtime/shared/content.ts +197 -0
- package/runtime/shared/examples.ts +20 -0
- package/runtime/shared/html.ts +10 -0
- package/runtime/shared/languages.ts +94 -0
- package/runtime/shared/page.css +101 -0
- package/runtime/shared/pages.ts +114 -0
- package/runtime/shared/questions/e2e.ts +115 -0
- package/runtime/shared/questions/engine.ts +209 -0
- package/runtime/shared/questions/index.ts +39 -0
- package/runtime/shared/questions/messages/de.json +26 -0
- package/runtime/shared/questions/messages/en.json +26 -0
- package/runtime/shared/questions/question.ts +433 -0
- package/runtime/shared/questions/question.v1.schema.json +346 -0
- package/runtime/shared/questions/questions.css +192 -0
- package/runtime/shared/questions/render.ts +255 -0
- package/runtime/shared/questions/validate.ts +518 -0
- package/runtime/shared/schema.ts +44 -0
- package/runtime/shared/text/check.ts +68 -0
- package/runtime/shared/text/formula.ts +69 -0
- package/runtime/shared/text/markdown.ts +185 -0
- package/runtime/shared/text/speech.ts +39 -0
- package/runtime/shared/text/text.v1.schema.json +34 -0
- package/runtime/shared/vue-code.ts +395 -0
- package/runtime/shared/vue.ts +38 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The video block of an explainer: a YouTube video behind two clicks. Nothing loads from YouTube or Google before the
|
|
2
|
+
// learner asks for it. The build writes a figure with the video's title as a plain link to its page on youtube.com,
|
|
3
|
+
// its channel and a note that playing it sends data to YouTube/Google, among them the learner's IP address: that is
|
|
4
|
+
// the whole video without JavaScript. No thumbnail, as YouTube's would load before a click. The client module then
|
|
5
|
+
// shows the button "Video abspielen", which puts the player of youtube-nocookie.com into the figure, and the choice
|
|
6
|
+
// "Videos immer direkt laden". That choice is kept on the device (@lernapps/tooling/storage, the app's own prefix),
|
|
7
|
+
// is the same for every video of the app, loads each player without the click from then on, and goes when the learner
|
|
8
|
+
// clears it. The tutor page names the video in its section as a link with title and channel (videoLine). Browser-safe:
|
|
9
|
+
// render.ts writes the figure at build, explainer.ts starts it in the browser.
|
|
10
|
+
import { createStorage } from "@lernapps/tooling/storage";
|
|
11
|
+
import type { BlockOf } from "./content.ts";
|
|
12
|
+
import { escape } from "../shared/html.ts";
|
|
13
|
+
import { message } from "./messages.ts";
|
|
14
|
+
|
|
15
|
+
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
|
|
16
|
+
|
|
17
|
+
/** Whether `id` is a YouTube id: 11 letters, digits, - or _. */
|
|
18
|
+
export const isYoutubeId = (id: unknown): id is string => typeof id === "string" && YOUTUBE_ID.test(id);
|
|
19
|
+
|
|
20
|
+
function checked(id: string): string {
|
|
21
|
+
if (!isYoutubeId(id)) throw new Error(`${JSON.stringify(id)} is no YouTube id`);
|
|
22
|
+
return id;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The video's page on YouTube, which the link opens: no request before the learner clicks it. */
|
|
26
|
+
export const watchAddress = (id: string): string => `https://www.youtube.com/watch?v=${checked(id)}`;
|
|
27
|
+
|
|
28
|
+
/** The player of the video on youtube-nocookie.com, which starts at once when the learner clicked for it. */
|
|
29
|
+
export const embedAddress = (id: string, autoplay: boolean): string =>
|
|
30
|
+
`https://www.youtube-nocookie.com/embed/${checked(id)}${autoplay ? "?autoplay=1" : ""}`;
|
|
31
|
+
|
|
32
|
+
/** A text as Markdown that shows it as written: its brackets, emphasis, code, formulas and HTML escaped. */
|
|
33
|
+
const literal = (text: string) =>
|
|
34
|
+
text
|
|
35
|
+
.replace(/\s+/g, " ")
|
|
36
|
+
.trim()
|
|
37
|
+
.replace(/[\\`*_[\]<&$]/g, "\\$&");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The video as a KI tutor reads it in the page's content (tutor.ts): one line, `word` (e.g. "Video"), the title as a
|
|
41
|
+
* link to its page on YouTube, and the channel. A source the tutor can name; it cannot watch the video.
|
|
42
|
+
*/
|
|
43
|
+
export const videoLine = (block: BlockOf<"video">, word: string): string =>
|
|
44
|
+
`${word}: [${literal(block.title)}](${watchAddress(block.youtube)}), ${literal(block.channel)} (YouTube)`;
|
|
45
|
+
|
|
46
|
+
/** The key "Videos immer direkt laden" is kept under on the device, for every video of the app. */
|
|
47
|
+
export const VIDEO_KEY = "videos-direct";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A video block as the build writes it: a figure with the title as a link to YouTube and the channel as its caption,
|
|
51
|
+
* the note on privacy, and the button and the choice, hidden until the client module runs. `storage` is the app's
|
|
52
|
+
* name for what it keeps on the device.
|
|
53
|
+
*/
|
|
54
|
+
export function renderVideo(block: BlockOf<"video">, lang: string, storage: string): string {
|
|
55
|
+
const t = (key: "videoNote" | "videoPlay" | "videoAlways") => escape(message(lang, key));
|
|
56
|
+
return [
|
|
57
|
+
`<figure class="explainer-video" data-youtube="${escape(block.youtube)}" data-storage="${escape(storage)}">`,
|
|
58
|
+
`<figcaption><a class="explainer-video-link" href="${escape(watchAddress(block.youtube))}" rel="noopener">${escape(block.title)}</a>`,
|
|
59
|
+
` <span class="explainer-video-channel">${escape(message(lang, "videoChannel", { channel: block.channel }))}</span></figcaption>`,
|
|
60
|
+
`<p class="explainer-video-note">${t("videoNote")}</p>`,
|
|
61
|
+
`<p class="explainer-video-controls" hidden><button type="button" class="explainer-video-play">${t("videoPlay")}</button>`,
|
|
62
|
+
` <label class="explainer-video-always"><input type="checkbox"> ${t("videoAlways")}</label></p>`,
|
|
63
|
+
`</figure>`,
|
|
64
|
+
].join("");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isTrue = (value: unknown): value is true => value === true;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Puts the player of the video into its figure, above the caption, in place of the button. After a click it starts
|
|
71
|
+
* at once and gets the focus; loaded directly, it waits to be scrolled near and to be started.
|
|
72
|
+
*/
|
|
73
|
+
function loadPlayer(figure: HTMLElement, id: string, clicked: boolean): void {
|
|
74
|
+
if (figure.querySelector("iframe")) return;
|
|
75
|
+
const document = figure.ownerDocument;
|
|
76
|
+
const frame = document.createElement("div");
|
|
77
|
+
frame.className = "explainer-video-frame";
|
|
78
|
+
const player = document.createElement("iframe");
|
|
79
|
+
player.src = embedAddress(id, clicked);
|
|
80
|
+
player.title = figure.querySelector(".explainer-video-link")?.textContent ?? "";
|
|
81
|
+
player.allow = "autoplay; encrypted-media; picture-in-picture; fullscreen";
|
|
82
|
+
player.allowFullscreen = true;
|
|
83
|
+
// YouTube learns the app's origin, never the page's address with its task numbers or via=tutor
|
|
84
|
+
player.referrerPolicy = "strict-origin-when-cross-origin";
|
|
85
|
+
if (!clicked) player.loading = "lazy";
|
|
86
|
+
frame.append(player);
|
|
87
|
+
figure.prepend(frame);
|
|
88
|
+
figure.querySelector(".explainer-video-play")?.remove();
|
|
89
|
+
if (clicked) player.focus();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Starts every video of the page: the button and the choice show; the button loads the player. The choice is kept
|
|
94
|
+
* on the device as soon as the learner ticks it, goes as soon as they untick it, and is shown alike on every video of
|
|
95
|
+
* the page; while it is kept, each player loads without the click.
|
|
96
|
+
*/
|
|
97
|
+
export function startVideos(root: Document): void {
|
|
98
|
+
const figures = [...root.querySelectorAll<HTMLElement>("figure.explainer-video")];
|
|
99
|
+
const boxes = figures.flatMap((figure) => [
|
|
100
|
+
...figure.querySelectorAll<HTMLInputElement>(".explainer-video-always input"),
|
|
101
|
+
]);
|
|
102
|
+
for (const figure of figures) {
|
|
103
|
+
const id = figure.dataset["youtube"];
|
|
104
|
+
const controls = figure.querySelector<HTMLElement>(".explainer-video-controls");
|
|
105
|
+
if (!isYoutubeId(id) || !controls) continue;
|
|
106
|
+
const storage = createStorage(figure.dataset["storage"] ?? "explainer");
|
|
107
|
+
const box = controls.querySelector<HTMLInputElement>("input");
|
|
108
|
+
const direct = storage.load(VIDEO_KEY, isTrue) === true;
|
|
109
|
+
if (box) {
|
|
110
|
+
box.checked = direct;
|
|
111
|
+
box.addEventListener("change", () => {
|
|
112
|
+
if (box.checked) storage.save(VIDEO_KEY, true);
|
|
113
|
+
else storage.remove(VIDEO_KEY);
|
|
114
|
+
for (const other of boxes) other.checked = box.checked;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
controls
|
|
118
|
+
.querySelector(".explainer-video-play")
|
|
119
|
+
?.addEventListener("click", () => loadPlayer(figure, id, true), { once: true });
|
|
120
|
+
controls.hidden = false;
|
|
121
|
+
if (direct) loadPlayer(figure, id, false);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The contract between the runtime of an interactive app and the app's core (src/core/index.ts), and its
|
|
2
|
+
// playthroughs (src/playthroughs/*.yaml). The core is the app's logic: pure TypeScript without DOM, deterministic by
|
|
3
|
+
// its seed, its state plain JSON data. The runtime keeps the state: it starts it with start(seed, route), gives every
|
|
4
|
+
// input to step(state, input), a key the learner types or an input the app's root component emits, and calls
|
|
5
|
+
// tick(state, TICK) every TICK milliseconds when the core has it; the app's components only show the state. A
|
|
6
|
+
// playthrough is a seed, a route and a list of steps: inputs, then ticks (time passing), and the state (a part of it)
|
|
7
|
+
// or the announcement expected after them. The build plays each against the core (plugin.ts); the end-to-end tests
|
|
8
|
+
// play the same inputs and ticks in the browser (e2e.ts). Used in the browser and in Node alike.
|
|
9
|
+
|
|
10
|
+
/** A JSON value: what an action input carries. */
|
|
11
|
+
export type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A key the learner typed, as the browser names it (KeyboardEvent.key: "a", "A", "Enter", " ", "ArrowLeft"); the
|
|
15
|
+
* root component may emit one too.
|
|
16
|
+
*/
|
|
17
|
+
export interface KeyInput {
|
|
18
|
+
kind: "key";
|
|
19
|
+
key: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* An action the app's root component emits as input, for what the learner does with the pointer or by touch: the
|
|
24
|
+
* name the app gives it (e.g. "again", "pick"), and a JSON value if it needs one (e.g. the cell picked).
|
|
25
|
+
*/
|
|
26
|
+
export interface ActionInput {
|
|
27
|
+
kind: "action";
|
|
28
|
+
name: string;
|
|
29
|
+
value?: Json;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* An input to the core. Told apart by its kind; later versions of the runtime may add kinds, so a core handles the
|
|
34
|
+
* kinds it knows and returns the state unchanged for any other.
|
|
35
|
+
*/
|
|
36
|
+
export type Input = KeyInput | ActionInput;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The core of an interactive app, the exports of src/core/index.ts. `route` is the id of the hash route the app was
|
|
40
|
+
* opened at (app/#<id>, a route of src/app.yaml), the first route when the address names none, "" when the app has
|
|
41
|
+
* no routes. step returns the same state object for an input it ignores, so that a key keeps its default (Tab moves
|
|
42
|
+
* the focus).
|
|
43
|
+
*/
|
|
44
|
+
export interface Core<S> {
|
|
45
|
+
start(seed: number, route: string): S;
|
|
46
|
+
step(state: S, input: Input): S;
|
|
47
|
+
tick?(state: S, dt: number): S;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The milliseconds from one tick to the next: the runtime calls tick(state, TICK) every TICK ms. */
|
|
51
|
+
export const TICK = 20;
|
|
52
|
+
|
|
53
|
+
/** The property of the app's element that holds the state, for the end-to-end tests. */
|
|
54
|
+
export const STATE_PROPERTY = "lernappsState";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The global of the end-to-end tests' clock: when the page has it (an object, set before the client module runs),
|
|
58
|
+
* the runtime starts no interval and puts on it tick(count), which calls tick(state, TICK) count times, the same as
|
|
59
|
+
* count intervals would. Only the tests set it; for a learner, time passes on its own.
|
|
60
|
+
*/
|
|
61
|
+
export const CLOCK_PROPERTY = "lernappsClock";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One step of a playthrough: keys (the short form of key inputs) or inputs, then ticks, the time that passes after
|
|
65
|
+
* them (ticks × TICK ms); then what is expected, the state (a part of it), the announcement, or both.
|
|
66
|
+
*/
|
|
67
|
+
export interface PlaythroughStep {
|
|
68
|
+
keys?: readonly string[];
|
|
69
|
+
inputs?: readonly Input[];
|
|
70
|
+
ticks?: number;
|
|
71
|
+
state?: unknown;
|
|
72
|
+
announce?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A playthrough: the route and seed it starts with, and its steps. */
|
|
76
|
+
export interface Playthrough {
|
|
77
|
+
route?: string;
|
|
78
|
+
seed: number;
|
|
79
|
+
steps: readonly PlaythroughStep[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The inputs of a step, its keys as key inputs. */
|
|
83
|
+
export function inputsOf(step: PlaythroughStep): Input[] {
|
|
84
|
+
return step.inputs !== undefined ? [...step.inputs] : (step.keys ?? []).map((key): Input => ({ kind: "key", key }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** An input as a playthrough's problem names it: a key as its JSON text, an action with its name and value. */
|
|
88
|
+
export function inputText(input: Input): string {
|
|
89
|
+
if (input.kind === "key") return JSON.stringify(input.key);
|
|
90
|
+
return `action ${JSON.stringify(input.name)}${input.value === undefined ? "" : ` ${JSON.stringify(input.value)}`}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What a step does, as a playthrough's problem names it: its inputs, then its ticks. */
|
|
94
|
+
export function doingText(step: PlaythroughStep): string[] {
|
|
95
|
+
const ticks = step.ticks === undefined ? [] : [`${step.ticks} tick${step.ticks === 1 ? "" : "s"}`];
|
|
96
|
+
return [...inputsOf(step).map(inputText), ...ticks];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
100
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Where `actual` differs from `expected`: the path of the first difference, with the value found there, or undefined
|
|
104
|
+
* when it matches. An object expects only the fields it names (each compared in the same way); anything else, a list
|
|
105
|
+
* too, is compared as a whole.
|
|
106
|
+
*/
|
|
107
|
+
export function mismatch(
|
|
108
|
+
actual: unknown,
|
|
109
|
+
expected: unknown,
|
|
110
|
+
path: readonly (string | number)[] = [],
|
|
111
|
+
): { path: (string | number)[]; actual: unknown } | undefined {
|
|
112
|
+
if (isRecord(expected)) {
|
|
113
|
+
if (!isRecord(actual)) return { path: [...path], actual };
|
|
114
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
115
|
+
const found = mismatch(actual[key], value, [...path, key]);
|
|
116
|
+
if (found !== undefined) return found;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
return JSON.stringify(actual) === JSON.stringify(expected) ? undefined : { path: [...path], actual };
|
|
121
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The end-to-end tests of every interactive app (@lernapps/app-templates/interactive/e2e), run by `lernapps check
|
|
2
|
+
// --pre-push` on the built app. An app's e2e/interactive.e2e.ts is
|
|
3
|
+
//
|
|
4
|
+
// import { interactiveTests } from "@lernapps/app-templates/interactive/e2e";
|
|
5
|
+
// interactiveTests();
|
|
6
|
+
//
|
|
7
|
+
// What they expect comes from src/app.yaml and src/playthroughs/ (load.ts), not from the rendered pages. Without
|
|
8
|
+
// JavaScript, in every language: the start page shows the title, what the app does, the note that the app needs
|
|
9
|
+
// JavaScript, the link into the app, a link to each route, how to play, each competency and each background section,
|
|
10
|
+
// and loads no script; the app page shows the title, the note with a link back to the start page, and an element
|
|
11
|
+
// with each route's id; each page has its language, its alternates, a language switch and the archetype marker. With
|
|
12
|
+
// JavaScript, in German: the app page mounts the app without a console error, and every playthrough plays at its
|
|
13
|
+
// address (app/?seed=<seed>#<route>) on the tests' clock (CLOCK_PROPERTY), on which no time passes but the ticks a
|
|
14
|
+
// step names, as in the build: each step's inputs one after the other, a key pressed on the keyboard with the focus
|
|
15
|
+
// on the page, an action emitted as input by the app's root component, as the component itself does; then its ticks,
|
|
16
|
+
// stepped through the runtime's own tick; after each step the state it expects (the fields it names, read from the
|
|
17
|
+
// runtime) and the announcement it expects (the polite live region of @lernapps/tooling/a11y). The tests run in
|
|
18
|
+
// parallel, with reduced motion; the preset runs them at desktop width and at 360 px; `lernapps check` adds axe and
|
|
19
|
+
// the width on the pages it visits.
|
|
20
|
+
import { expect, test, type Page } from "playwright/test";
|
|
21
|
+
import { inLanguage } from "../shared/languages.ts";
|
|
22
|
+
import { languageRoot } from "../shared/pages.ts";
|
|
23
|
+
import { CLOCK_PROPERTY, doingText, inputsOf, mismatch, STATE_PROPERTY } from "./contract.ts";
|
|
24
|
+
import { loadInteractive } from "./load.ts";
|
|
25
|
+
import { message } from "./messages.ts";
|
|
26
|
+
import { APP_PAGE } from "./render.ts";
|
|
27
|
+
|
|
28
|
+
/** The page's language, its alternates and a switch to each other language, the archetype marker. */
|
|
29
|
+
async function expectFrame(page: Page, lang: string, languages: readonly string[]): Promise<void> {
|
|
30
|
+
await expect(page.locator("html")).toHaveAttribute("lang", lang);
|
|
31
|
+
await expect(page.locator('meta[name="lernapps-archetype"]')).toHaveAttribute("content", "interactive");
|
|
32
|
+
for (const other of languages) {
|
|
33
|
+
await expect(page.locator(`link[rel="alternate"][hreflang="${other}"]`)).toHaveCount(1);
|
|
34
|
+
if (other === lang) continue;
|
|
35
|
+
const link = page.locator(`.page-languages a[hreflang="${other}"]`);
|
|
36
|
+
await expect(link).toBeVisible();
|
|
37
|
+
const response = await page.request.get(await link.evaluate((a) => (a as HTMLAnchorElement).href));
|
|
38
|
+
expect(response.status(), `the switch to ${other}`).toBe(200);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Collects the page's console errors and uncaught exceptions. */
|
|
43
|
+
function consoleErrors(page: Page): string[] {
|
|
44
|
+
const errors: string[] = [];
|
|
45
|
+
page.on("console", (entry) => {
|
|
46
|
+
if (entry.type() === "error") errors.push(entry.text());
|
|
47
|
+
});
|
|
48
|
+
page.on("pageerror", (error) => errors.push(error.message));
|
|
49
|
+
return errors;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The instance of a component, as Vue mounts it. */
|
|
53
|
+
type Instance = { emit(event: string, payload: unknown): void; subTree?: { component?: Instance } };
|
|
54
|
+
|
|
55
|
+
/** Emits `input` (an Input) as input from the app's root component in `host`, as the component itself does. */
|
|
56
|
+
function emitInput(host: Element, input: unknown): void {
|
|
57
|
+
const wrapper = (host as unknown as { _vnode?: { component?: Instance } })._vnode?.component;
|
|
58
|
+
const root = wrapper?.subTree?.component;
|
|
59
|
+
if (root === undefined) throw new Error("the app's root component is not mounted");
|
|
60
|
+
root.emit("input", input);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Steps `count` ticks on the tests' clock of the page, the global `property`. */
|
|
64
|
+
function stepTicks({ count, property }: { count: number; property: string }): void {
|
|
65
|
+
(Reflect.get(globalThis, property) as { tick(count: number): void }).tick(count);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Presses `key` as the learner does. Playwright knows only the keys of a US keyboard by name; any other character
|
|
70
|
+
* (ä, ö, ü, ß, é) goes as the keydown event the runtime listens to, with that key.
|
|
71
|
+
*/
|
|
72
|
+
async function pressKey(page: Page, key: string): Promise<void> {
|
|
73
|
+
if (key.length === 1 && key > "~")
|
|
74
|
+
await page.evaluate((pressed) => {
|
|
75
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: pressed, bubbles: true, cancelable: true }));
|
|
76
|
+
}, key);
|
|
77
|
+
else await page.keyboard.press(key);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The interactive app's tests, for the app in `root`. */
|
|
81
|
+
export function interactiveTests(root = "."): void {
|
|
82
|
+
const { app, playthroughs } = loadInteractive(root);
|
|
83
|
+
const routes = app.routes ?? [];
|
|
84
|
+
|
|
85
|
+
test.describe("interactive", () => {
|
|
86
|
+
test.describe.configure({ mode: "parallel" });
|
|
87
|
+
test.use({ reducedMotion: "reduce" });
|
|
88
|
+
|
|
89
|
+
test.describe("without JavaScript", () => {
|
|
90
|
+
test.use({ javaScriptEnabled: false });
|
|
91
|
+
|
|
92
|
+
for (const lang of app.languages) {
|
|
93
|
+
const start = languageRoot(lang);
|
|
94
|
+
const text = (value: Parameters<typeof inLanguage>[0]) => inLanguage(value, lang).replace(/\s+/g, " ").trim();
|
|
95
|
+
|
|
96
|
+
test(`${start || "./"}: the start page says what the app does, how to play and what one learns`, async ({
|
|
97
|
+
page,
|
|
98
|
+
}) => {
|
|
99
|
+
await page.goto(`./${start}`);
|
|
100
|
+
await expectFrame(page, lang, app.languages);
|
|
101
|
+
const main = page.locator("main");
|
|
102
|
+
await expect(main.locator("h1")).toHaveText(text(app.title));
|
|
103
|
+
await expect(main.locator(".interactive-intro")).toBeVisible();
|
|
104
|
+
await expect(main.locator(".interactive-noscript"), "the note that the app needs JavaScript").toBeVisible();
|
|
105
|
+
const play = main.locator("a.interactive-start");
|
|
106
|
+
await expect(play).toBeVisible();
|
|
107
|
+
const response = await page.request.get(await play.evaluate((a) => (a as HTMLAnchorElement).href));
|
|
108
|
+
expect(response.status(), "the link into the app").toBe(200);
|
|
109
|
+
for (const route of routes) {
|
|
110
|
+
const link = main.locator(`a[href$="${APP_PAGE}#${route.id}"]`);
|
|
111
|
+
await expect(link, `the link to the route ${route.id}`).toHaveText(text(route.title));
|
|
112
|
+
}
|
|
113
|
+
await expect(
|
|
114
|
+
main.getByRole("heading", { level: 2, name: message(lang, "howto"), exact: true }),
|
|
115
|
+
).toBeVisible();
|
|
116
|
+
for (const competency of app.competencies) {
|
|
117
|
+
await expect(main.locator(".interactive-competencies li", { hasText: text(competency) })).toBeVisible();
|
|
118
|
+
}
|
|
119
|
+
for (const section of app.sections ?? []) {
|
|
120
|
+
await expect(main.locator(`h2[id="${section.id}"]`), `the section ${section.id}`).toHaveText(
|
|
121
|
+
text(section.title),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
expect(await page.locator("script").count(), "the start page loads no script").toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test(`${start}${APP_PAGE}: the app page says that the app needs JavaScript and has each route's anchor`, async ({
|
|
128
|
+
page,
|
|
129
|
+
}) => {
|
|
130
|
+
await page.goto(`./${start}${APP_PAGE}`);
|
|
131
|
+
await expectFrame(page, lang, app.languages);
|
|
132
|
+
const main = page.locator("main");
|
|
133
|
+
await expect(main.locator("h1"), "one h1: the title").toHaveText(text(app.title));
|
|
134
|
+
await expect(page.locator(".interactive-title-slot"), "the slot of the heading line").toBeHidden();
|
|
135
|
+
const note = main.locator(".interactive-noscript");
|
|
136
|
+
await expect(note, "the note that the app needs JavaScript").toBeVisible();
|
|
137
|
+
const back = await note.locator("a").evaluate((a) => (a as HTMLAnchorElement).href);
|
|
138
|
+
expect(back, "the note links the start page").toBe(new URL("../", page.url()).href);
|
|
139
|
+
for (const route of routes) {
|
|
140
|
+
await expect(main.locator(`[id="${route.id}"]`), `the anchor of the route ${route.id}`).toHaveText(
|
|
141
|
+
text(route.title),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test.describe("with JavaScript", () => {
|
|
149
|
+
test(`${APP_PAGE}: the app mounts without a console error`, async ({ page }) => {
|
|
150
|
+
const errors = consoleErrors(page);
|
|
151
|
+
await page.goto(`./${APP_PAGE}`);
|
|
152
|
+
await expect(page.locator(".interactive-app")).toHaveAttribute("data-mounted", "");
|
|
153
|
+
await expect(page.locator(".interactive-app")).not.toBeEmpty();
|
|
154
|
+
expect(errors, "no console error").toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
for (const { yaml, playthrough } of playthroughs) {
|
|
158
|
+
const route = playthrough.route ?? routes[0]?.id;
|
|
159
|
+
test(`${yaml.file}: played with its inputs and ticks`, async ({ page }) => {
|
|
160
|
+
const errors = consoleErrors(page);
|
|
161
|
+
// before the client module runs: the runtime then starts no interval, and time passes only by stepTicks
|
|
162
|
+
await page.addInitScript((property) => Reflect.set(globalThis, property, {}), CLOCK_PROPERTY);
|
|
163
|
+
await page.goto(`./${APP_PAGE}?seed=${playthrough.seed}${route === undefined ? "" : `#${route}`}`);
|
|
164
|
+
const host = page.locator(".interactive-app");
|
|
165
|
+
await expect(host).toHaveAttribute("data-mounted", "");
|
|
166
|
+
// the polite live region of @lernapps/tooling/a11y, which announce() fills
|
|
167
|
+
const live = page.locator('body > [aria-live="polite"]');
|
|
168
|
+
for (const [index, step] of playthrough.steps.entries()) {
|
|
169
|
+
for (const input of inputsOf(step)) {
|
|
170
|
+
if (input.kind === "key") await pressKey(page, input.key);
|
|
171
|
+
else await host.evaluate(emitInput, input as unknown);
|
|
172
|
+
}
|
|
173
|
+
if (step.ticks !== undefined)
|
|
174
|
+
await page.evaluate(stepTicks, { count: step.ticks, property: CLOCK_PROPERTY });
|
|
175
|
+
const where = `${yaml.file}: steps[${index}] after ${doingText(step).join(", ")}`;
|
|
176
|
+
if (step.state !== undefined) {
|
|
177
|
+
const state = await host.evaluate(
|
|
178
|
+
(element, property) => (element as unknown as Record<string, unknown>)[property],
|
|
179
|
+
STATE_PROPERTY,
|
|
180
|
+
);
|
|
181
|
+
expect(mismatch(state, step.state), `${where}: the state`).toBeUndefined();
|
|
182
|
+
}
|
|
183
|
+
if (step.announce !== undefined) await expect(live, `${where}: the announcement`).toHaveText(step.announce);
|
|
184
|
+
}
|
|
185
|
+
expect(errors, "no console error").toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/* The look of an interactive app's shell (@lernapps/app-templates/interactive/style.css): the frame is shared
|
|
2
|
+
(page.css); this is the start page and the frame of the app page. The app styles its own components. */
|
|
3
|
+
@import "../shared/page.css";
|
|
4
|
+
|
|
5
|
+
.interactive {
|
|
6
|
+
/* an app that needs a wider frame sets --page-max-width in its own :root rule (here only the fallback, so
|
|
7
|
+
that the order of the stylesheets does not matter) */
|
|
8
|
+
max-width: var(--page-max-width, 44rem);
|
|
9
|
+
margin: 0 auto;
|
|
10
|
+
padding: 0 1rem;
|
|
11
|
+
overflow-wrap: break-word;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.interactive-routes ul {
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-wrap: wrap;
|
|
17
|
+
gap: 0.25rem 0.75rem;
|
|
18
|
+
margin: 0;
|
|
19
|
+
padding: 0;
|
|
20
|
+
list-style: none;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.interactive-routes a[aria-current="true"] {
|
|
24
|
+
color: var(--page-text);
|
|
25
|
+
font-weight: 600;
|
|
26
|
+
text-decoration: none;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.interactive h1 {
|
|
30
|
+
font-size: 1.875rem;
|
|
31
|
+
line-height: 1.2;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.interactive-title {
|
|
35
|
+
display: flex;
|
|
36
|
+
flex-wrap: wrap;
|
|
37
|
+
gap: 0 1rem;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: space-between;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.interactive-title h1 {
|
|
43
|
+
min-width: 0;
|
|
44
|
+
flex: 1 1 auto;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.interactive-title-slot {
|
|
48
|
+
flex: 0 0 auto;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.interactive h2 {
|
|
52
|
+
line-height: 1.25;
|
|
53
|
+
scroll-margin-top: 1rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.interactive-noscript {
|
|
57
|
+
padding: 0.5rem 1rem;
|
|
58
|
+
border-left: 4px solid var(--page-accent);
|
|
59
|
+
background: var(--page-surface);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.interactive-start {
|
|
63
|
+
display: inline-block;
|
|
64
|
+
padding: 0.5rem 1.25rem;
|
|
65
|
+
border-radius: 0.375rem;
|
|
66
|
+
background: var(--page-accent);
|
|
67
|
+
color: var(--page-on-accent);
|
|
68
|
+
font-weight: 600;
|
|
69
|
+
text-decoration: none;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.interactive-description {
|
|
73
|
+
color: var(--page-muted);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.interactive-routes {
|
|
77
|
+
margin: 1rem 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.interactive-app {
|
|
81
|
+
margin: 1rem 0 2rem;
|
|
82
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The client module of an interactive app (@lernapps/app-templates/interactive), started by the app's src/main.ts:
|
|
2
|
+
//
|
|
3
|
+
// import "@lernapps/app-templates/interactive/style.css";
|
|
4
|
+
// import { startInteractive } from "@lernapps/app-templates/interactive";
|
|
5
|
+
// import * as core from "./core/index.ts";
|
|
6
|
+
// import App from "./ui/App.vue";
|
|
7
|
+
// startInteractive(core, App);
|
|
8
|
+
//
|
|
9
|
+
// An app that wants a small control in the heading line (next to the h1: "Help", "Sound off") passes a component for
|
|
10
|
+
// it as the option `title`: startInteractive(core, App, { title: Controls }). It gets the same props and emits the
|
|
11
|
+
// same input as the root component, and is mounted into the slot of the heading line, which stays hidden (and empty)
|
|
12
|
+
// without it.
|
|
13
|
+
//
|
|
14
|
+
// On the app page it keeps the core's state (contract.ts): it starts it with the seed of the address (?seed=<n>, else
|
|
15
|
+
// a random one) and the route of the address (#<id>, else the first route), gives every key typed on the page to
|
|
16
|
+
// step as a key input (not a key typed on a link, button or form field, which is theirs, nor one with Ctrl, Alt or
|
|
17
|
+
// Meta), and calls tick every TICK ms when the core has it (the end-to-end tests step the ticks themselves:
|
|
18
|
+
// CLOCK_PROPERTY). A key that changes the state loses its default (the space bar does not scroll). A new route in the
|
|
19
|
+
// address starts the core again with it. It mounts the app's root component into the page with the props state (the
|
|
20
|
+
// core's state) and lang (the page's language), and steps every input the component emits as input (an action of a
|
|
21
|
+
// button or a tap, or a key it stands for). The start page loads no script; the build removes it there.
|
|
22
|
+
import { createApp, h, shallowRef, type Component } from "vue";
|
|
23
|
+
import { CLOCK_PROPERTY, STATE_PROPERTY, TICK, type Core, type Input } from "./contract.ts";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
TICK,
|
|
27
|
+
type ActionInput,
|
|
28
|
+
type Core,
|
|
29
|
+
type Input,
|
|
30
|
+
type Json,
|
|
31
|
+
type KeyInput,
|
|
32
|
+
type Playthrough,
|
|
33
|
+
type PlaythroughStep,
|
|
34
|
+
} from "./contract.ts";
|
|
35
|
+
|
|
36
|
+
/** Where a key belongs to the page's own controls, not to the app. */
|
|
37
|
+
const CONTROLS = "a[href], button, input, select, textarea, summary, [contenteditable]";
|
|
38
|
+
|
|
39
|
+
/** What an app may add to the shell. */
|
|
40
|
+
export interface InteractiveOptions {
|
|
41
|
+
/**
|
|
42
|
+
* A component for the slot in the heading line, beside the h1. It is given the props of the root component, `state`
|
|
43
|
+
* and `lang`, and emits `input` the same way.
|
|
44
|
+
*/
|
|
45
|
+
title?: Component;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Starts the app on the app page: its state from `core`, shown by the component `root`, and in the heading line the
|
|
50
|
+
* component `options.title` if there is one. Nothing on another page.
|
|
51
|
+
*/
|
|
52
|
+
export function startInteractive<S>(core: Core<S>, root: Component, options: InteractiveOptions = {}): void {
|
|
53
|
+
const host = document.querySelector<HTMLElement>(".interactive-app");
|
|
54
|
+
if (host === null) return;
|
|
55
|
+
const routes = JSON.parse(host.dataset["routes"] ?? "[]") as string[];
|
|
56
|
+
const seedParam = Number(new URL(location.href).searchParams.get("seed"));
|
|
57
|
+
const seed = Number.isInteger(seedParam) && seedParam > 0 ? seedParam : 1 + Math.floor(Math.random() * 2 ** 31 - 1);
|
|
58
|
+
const routeOf = (): string => {
|
|
59
|
+
const id = decodeURIComponent(location.hash.slice(1));
|
|
60
|
+
return routes.includes(id) ? id : (routes[0] ?? "");
|
|
61
|
+
};
|
|
62
|
+
let route = routeOf();
|
|
63
|
+
const state = shallowRef(core.start(seed, route));
|
|
64
|
+
const set = (next: S): void => {
|
|
65
|
+
state.value = next;
|
|
66
|
+
Object.assign(host, { [STATE_PROPERTY]: next });
|
|
67
|
+
};
|
|
68
|
+
set(state.value);
|
|
69
|
+
const markRoute = (): void => {
|
|
70
|
+
for (const link of document.querySelectorAll<HTMLAnchorElement>(".interactive-routes a")) {
|
|
71
|
+
if (link.hash === `#${route}`) link.setAttribute("aria-current", "true");
|
|
72
|
+
else link.removeAttribute("aria-current");
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
markRoute();
|
|
76
|
+
/** Gives `given` to the core; whether it changed the state. */
|
|
77
|
+
const input = (given: Input): boolean => {
|
|
78
|
+
const next = core.step(state.value, given);
|
|
79
|
+
if (next === state.value) return false;
|
|
80
|
+
set(next);
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
document.addEventListener("keydown", (event) => {
|
|
84
|
+
if (event.ctrlKey || event.altKey || event.metaKey || event.isComposing) return;
|
|
85
|
+
if (event.target instanceof Element && event.target.closest(CONTROLS) !== null) return;
|
|
86
|
+
if (input({ kind: "key", key: event.key })) event.preventDefault();
|
|
87
|
+
});
|
|
88
|
+
addEventListener("hashchange", () => {
|
|
89
|
+
if (routeOf() === route) return;
|
|
90
|
+
route = routeOf();
|
|
91
|
+
set(core.start(seed, route));
|
|
92
|
+
markRoute();
|
|
93
|
+
});
|
|
94
|
+
const tick = (): void => set(core.tick?.(state.value, TICK) ?? state.value);
|
|
95
|
+
const clock: unknown = Reflect.get(globalThis, CLOCK_PROPERTY);
|
|
96
|
+
if (typeof clock === "object" && clock !== null) {
|
|
97
|
+
// the end-to-end tests' clock: no time passes but the ticks they step
|
|
98
|
+
Object.assign(clock, {
|
|
99
|
+
tick: (count: number) => {
|
|
100
|
+
for (let at = 0; at < count; at++) tick();
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
} else if (core.tick !== undefined) setInterval(tick, TICK);
|
|
104
|
+
const lang = document.documentElement.lang || "de";
|
|
105
|
+
const mount = (component: Component, into: HTMLElement): void => {
|
|
106
|
+
createApp({ render: () => h(component, { state: state.value, lang, onInput: input }) }).mount(into);
|
|
107
|
+
};
|
|
108
|
+
const slot = document.querySelector<HTMLElement>(".interactive-title-slot");
|
|
109
|
+
if (options.title !== undefined && slot !== null) {
|
|
110
|
+
mount(options.title, slot);
|
|
111
|
+
slot.hidden = false;
|
|
112
|
+
}
|
|
113
|
+
mount(root, host);
|
|
114
|
+
host.dataset["mounted"] = "";
|
|
115
|
+
}
|