@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,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"check": "Prüfen",
|
|
3
|
+
"next": "Nächste Frage",
|
|
4
|
+
"right": "Richtig!",
|
|
5
|
+
"wrong": "Leider nicht richtig.",
|
|
6
|
+
"incomplete": "Antworte zuerst: Wähle, tippe oder ordne alles zu.",
|
|
7
|
+
"tryAgain": "Versuche es noch einmal oder lass dir die Lösung zeigen.",
|
|
8
|
+
"lookAgain": "Lies dazu noch einmal den Text über der Aufgabe.",
|
|
9
|
+
"missingRight": "Es fehlt noch mindestens eine richtige Antwort.",
|
|
10
|
+
"showSolution": "Lösung",
|
|
11
|
+
"true": "Stimmt",
|
|
12
|
+
"false": "Stimmt nicht",
|
|
13
|
+
"yourAnswer": "Deine Antwort",
|
|
14
|
+
"choose": "bitte wählen",
|
|
15
|
+
"place": "Platz {place}",
|
|
16
|
+
"orderHint": "Gib jedem Eintrag seinen Platz: 1 für den ersten.",
|
|
17
|
+
"matchHint": "Wähle zu jedem Eintrag das Passende.",
|
|
18
|
+
"solution": "Lösung",
|
|
19
|
+
"rightAnswer": "Richtige Antwort:",
|
|
20
|
+
"statementTrue": "Die Aussage stimmt.",
|
|
21
|
+
"statementFalse": "Die Aussage stimmt nicht.",
|
|
22
|
+
"tolerance": "(erlaubte Abweichung: {tolerance})",
|
|
23
|
+
"rightOption": "richtige Antwort",
|
|
24
|
+
"yourChoice": "deine Wahl",
|
|
25
|
+
"moreReading": "Mehr dazu:"
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"check": "Check",
|
|
3
|
+
"next": "Next question",
|
|
4
|
+
"right": "Right!",
|
|
5
|
+
"wrong": "Sorry, that is not right.",
|
|
6
|
+
"incomplete": "Answer first: choose, type or match everything.",
|
|
7
|
+
"tryAgain": "Try again, or show the solution.",
|
|
8
|
+
"lookAgain": "Read the text above the exercise again.",
|
|
9
|
+
"missingRight": "At least one right answer is still missing.",
|
|
10
|
+
"showSolution": "Solution",
|
|
11
|
+
"true": "True",
|
|
12
|
+
"false": "False",
|
|
13
|
+
"yourAnswer": "Your answer",
|
|
14
|
+
"choose": "please choose",
|
|
15
|
+
"place": "Place {place}",
|
|
16
|
+
"orderHint": "Give each entry its place: 1 for the first.",
|
|
17
|
+
"matchHint": "Choose the match for each entry.",
|
|
18
|
+
"solution": "Solution",
|
|
19
|
+
"rightAnswer": "Right answer:",
|
|
20
|
+
"statementTrue": "The statement is true.",
|
|
21
|
+
"statementFalse": "The statement is false.",
|
|
22
|
+
"tolerance": "(allowed difference: {tolerance})",
|
|
23
|
+
"rightOption": "right answer",
|
|
24
|
+
"yourChoice": "your choice",
|
|
25
|
+
"moreReading": "More on it:"
|
|
26
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// The questions of a lernapps app and what counts as a right answer: the six types (single choice, multiple choice,
|
|
2
|
+
// true/false, number, ordering, matching), a learner's answer and its check, the right and a wrong answer, the order
|
|
3
|
+
// in which a page shows the mixed entries. Used at build time (validation, rendering), in the browser (the engine)
|
|
4
|
+
// and by the end-to-end tests, so all three agree, by every archetype that has questions (the quiz, the explainer).
|
|
5
|
+
// A learner text is a string; an app in several languages writes each as a map of texts by language,
|
|
6
|
+
// { de: …, en: … } (Question<Text>), and questionIn gives it in the page's language. The address of a link is one
|
|
7
|
+
// for every language, or a map of addresses by language where the page to read differs.
|
|
8
|
+
import { markerOf, materialIn, type LabelledFigure, type MaterialBlock } from "../blocks/block.ts";
|
|
9
|
+
import { inLanguage, type Text } from "../languages.ts";
|
|
10
|
+
export type { Text };
|
|
11
|
+
|
|
12
|
+
/** A link for further reading; in a quiz or page in several languages, its address may be a map by language too. */
|
|
13
|
+
export interface Link<T extends Text = string> {
|
|
14
|
+
title: T;
|
|
15
|
+
url: T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** An option of a choice: `info` is background on the option itself, true whether or not it is the right answer. */
|
|
19
|
+
export interface Option<T extends Text = string> {
|
|
20
|
+
text: T;
|
|
21
|
+
correct: boolean;
|
|
22
|
+
info: T;
|
|
23
|
+
/** The option spoken, as its radio or checkbox is named: for an option that is only a formula; else made from it. */
|
|
24
|
+
alt?: T;
|
|
25
|
+
link?: Link<T>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Base<T extends Text> {
|
|
29
|
+
id: string;
|
|
30
|
+
text: T;
|
|
31
|
+
/** Content the question shows between its text and its answers: figures, tables, quotations, code, ... */
|
|
32
|
+
material?: readonly MaterialBlock<T>[];
|
|
33
|
+
/** Why the right answer is right; for types without options also their background. */
|
|
34
|
+
explanation: T;
|
|
35
|
+
links: Link<T>[];
|
|
36
|
+
/** What to look at again after a wrong answer, without giving the answer away. */
|
|
37
|
+
hint?: T;
|
|
38
|
+
/** Test cases for the build, never shown to learners: answers the text allows, and typical wrong ones. */
|
|
39
|
+
examples?: Examples;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The examples of an exercise: answers that the build runs through the exercise's check (validate.ts). Each accepted
|
|
44
|
+
* one must pass, each rejected one must fail. Written as the learner would give them, in the shape of the exercise's
|
|
45
|
+
* type; never part of what a page renders or ships.
|
|
46
|
+
*/
|
|
47
|
+
export interface Examples<A = unknown> {
|
|
48
|
+
accepted?: readonly A[];
|
|
49
|
+
rejected?: readonly A[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SingleChoice<T extends Text = string> extends Base<T> {
|
|
53
|
+
type: "single-choice";
|
|
54
|
+
options: Option<T>[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MultipleChoice<T extends Text = string> extends Base<T> {
|
|
58
|
+
type: "multiple-choice";
|
|
59
|
+
options: Option<T>[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TrueFalse<T extends Text = string> extends Base<T> {
|
|
63
|
+
type: "true-false";
|
|
64
|
+
answer: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A typical wrong number, and what went wrong when the learner gives it. */
|
|
68
|
+
export interface NumberMistake<T extends Text = string> {
|
|
69
|
+
answer: number;
|
|
70
|
+
message: T;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface NumberQuestion<T extends Text = string> extends Base<T> {
|
|
74
|
+
type: "number";
|
|
75
|
+
answer: number;
|
|
76
|
+
tolerance: number;
|
|
77
|
+
unit?: T;
|
|
78
|
+
mistakes?: NumberMistake<T>[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `items` in the right order. */
|
|
82
|
+
export interface Ordering<T extends Text = string> extends Base<T> {
|
|
83
|
+
type: "ordering";
|
|
84
|
+
items: T[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `pairs` as they belong together. With `pairsFrom: "material"` the pairs are not written: they are the labels of the
|
|
89
|
+
* labelled figure in the material, each marker with its text (materialPairs); `pairs` is then only there after
|
|
90
|
+
* materialPairs has filled it in.
|
|
91
|
+
*/
|
|
92
|
+
export interface Matching<T extends Text = string> extends Base<T> {
|
|
93
|
+
type: "matching";
|
|
94
|
+
pairs: { left: T; right: T }[];
|
|
95
|
+
pairsFrom?: "material";
|
|
96
|
+
/** Right entries that belong to no left entry: wrong answers the learner can choose, shown mixed with the right ones. */
|
|
97
|
+
distractors?: T[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The right entries a learner can choose from: those of the pairs, then the distractors. A chosen index counts into this list. */
|
|
101
|
+
export const rightEntries = <T extends Text>(question: Pick<Matching<T>, "pairs" | "distractors">): T[] => [
|
|
102
|
+
...question.pairs.map((pair) => pair.right),
|
|
103
|
+
...(question.distractors ?? []),
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The pairs that `pairsFrom: "material"` takes from the first labelled figure of the material: left the marker of a
|
|
108
|
+
* label, right its text. A marker is the same in every language. Without `pairsFrom` there are none to take.
|
|
109
|
+
*/
|
|
110
|
+
export function materialPairs(question: Matching<Text>): Matching<Text>["pairs"] | undefined {
|
|
111
|
+
if (question.pairsFrom === undefined) return undefined;
|
|
112
|
+
const figure = question.material?.find((block): block is LabelledFigure<Text> => block.type === "labelled-figure");
|
|
113
|
+
return (figure?.labels ?? []).map((label, index) => {
|
|
114
|
+
const marker = markerOf(label, index);
|
|
115
|
+
const left: Text =
|
|
116
|
+
typeof label.text === "string"
|
|
117
|
+
? marker
|
|
118
|
+
: Object.fromEntries(Object.keys(label.text).map((lang) => [lang, marker]));
|
|
119
|
+
return { left, right: label.text };
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type Question<T extends Text = string> =
|
|
124
|
+
| SingleChoice<T>
|
|
125
|
+
| MultipleChoice<T>
|
|
126
|
+
| TrueFalse<T>
|
|
127
|
+
| NumberQuestion<T>
|
|
128
|
+
| Ordering<T>
|
|
129
|
+
| Matching<T>;
|
|
130
|
+
|
|
131
|
+
/** The question types. */
|
|
132
|
+
export const QUESTION_TYPES: readonly Question["type"][] = [
|
|
133
|
+
"single-choice",
|
|
134
|
+
"multiple-choice",
|
|
135
|
+
"true-false",
|
|
136
|
+
"number",
|
|
137
|
+
"ordering",
|
|
138
|
+
"matching",
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
/** Each learner text of a question with its place in it: ["options", 1, "info"]. */
|
|
142
|
+
export function questionTexts(question: Question<Text>): { path: (string | number)[]; text: Text }[] {
|
|
143
|
+
const texts: { path: (string | number)[]; text: Text }[] = [
|
|
144
|
+
{ path: ["text"], text: question.text },
|
|
145
|
+
{ path: ["explanation"], text: question.explanation },
|
|
146
|
+
...question.links.map((link, index) => ({ path: ["links", index, "title"], text: link.title })),
|
|
147
|
+
...(question.hint === undefined ? [] : [{ path: ["hint"], text: question.hint }]),
|
|
148
|
+
];
|
|
149
|
+
switch (question.type) {
|
|
150
|
+
case "single-choice":
|
|
151
|
+
case "multiple-choice":
|
|
152
|
+
question.options.forEach((option, index) => {
|
|
153
|
+
texts.push({ path: ["options", index, "text"], text: option.text });
|
|
154
|
+
texts.push({ path: ["options", index, "info"], text: option.info });
|
|
155
|
+
if (option.link) texts.push({ path: ["options", index, "link", "title"], text: option.link.title });
|
|
156
|
+
});
|
|
157
|
+
break;
|
|
158
|
+
case "number":
|
|
159
|
+
if (question.unit !== undefined) texts.push({ path: ["unit"], text: question.unit });
|
|
160
|
+
question.mistakes?.forEach((mistake, index) =>
|
|
161
|
+
texts.push({ path: ["mistakes", index, "message"], text: mistake.message }),
|
|
162
|
+
);
|
|
163
|
+
break;
|
|
164
|
+
case "ordering":
|
|
165
|
+
question.items.forEach((item, index) => texts.push({ path: ["items", index], text: item }));
|
|
166
|
+
break;
|
|
167
|
+
case "matching":
|
|
168
|
+
// the pairs taken from the material: their texts are the labels' texts, which the material's checks cover
|
|
169
|
+
(question.pairsFrom === undefined ? question.pairs : []).forEach((pair, index) => {
|
|
170
|
+
texts.push({ path: ["pairs", index, "left"], text: pair.left });
|
|
171
|
+
texts.push({ path: ["pairs", index, "right"], text: pair.right });
|
|
172
|
+
});
|
|
173
|
+
question.distractors?.forEach((text, index) => texts.push({ path: ["distractors", index], text }));
|
|
174
|
+
break;
|
|
175
|
+
case "true-false":
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
return texts;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Each link's address of a question with its place in it: ["links", 0, "url"]. */
|
|
182
|
+
export function questionAddresses(question: Question<Text>): { path: (string | number)[]; text: Text }[] {
|
|
183
|
+
const options = question.type === "single-choice" || question.type === "multiple-choice" ? question.options : [];
|
|
184
|
+
return [
|
|
185
|
+
...question.links.map((link, index) => ({ path: ["links", index, "url"], text: link.url })),
|
|
186
|
+
...options.flatMap((option, index) =>
|
|
187
|
+
option.link === undefined ? [] : [{ path: ["options", index, "link", "url"], text: option.link.url }],
|
|
188
|
+
),
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The question in `lang`: each text and address from its map, a plain string as it is; without its examples. */
|
|
193
|
+
export function questionIn(question: Question<Text>, lang: string): Question {
|
|
194
|
+
const t = (text: Text) => inLanguage(text, lang);
|
|
195
|
+
const link = (value: Link<Text>): Link => ({ title: t(value.title), url: t(value.url) });
|
|
196
|
+
const base = {
|
|
197
|
+
id: question.id,
|
|
198
|
+
text: t(question.text),
|
|
199
|
+
explanation: t(question.explanation),
|
|
200
|
+
links: question.links.map(link),
|
|
201
|
+
...(question.hint === undefined ? {} : { hint: t(question.hint) }),
|
|
202
|
+
...(question.material === undefined ? {} : { material: materialIn(question.material, lang) }),
|
|
203
|
+
};
|
|
204
|
+
switch (question.type) {
|
|
205
|
+
case "single-choice":
|
|
206
|
+
case "multiple-choice":
|
|
207
|
+
return {
|
|
208
|
+
...base,
|
|
209
|
+
type: question.type,
|
|
210
|
+
options: question.options.map((option) => ({
|
|
211
|
+
text: t(option.text),
|
|
212
|
+
correct: option.correct,
|
|
213
|
+
info: t(option.info),
|
|
214
|
+
...(option.alt === undefined ? {} : { alt: t(option.alt) }),
|
|
215
|
+
...(option.link === undefined ? {} : { link: link(option.link) }),
|
|
216
|
+
})),
|
|
217
|
+
};
|
|
218
|
+
case "true-false":
|
|
219
|
+
return { ...base, type: question.type, answer: question.answer };
|
|
220
|
+
case "number":
|
|
221
|
+
return {
|
|
222
|
+
...base,
|
|
223
|
+
type: question.type,
|
|
224
|
+
answer: question.answer,
|
|
225
|
+
tolerance: question.tolerance,
|
|
226
|
+
...(question.unit === undefined ? {} : { unit: t(question.unit) }),
|
|
227
|
+
...(question.mistakes === undefined
|
|
228
|
+
? {}
|
|
229
|
+
: {
|
|
230
|
+
mistakes: question.mistakes.map((mistake) => ({ answer: mistake.answer, message: t(mistake.message) })),
|
|
231
|
+
}),
|
|
232
|
+
};
|
|
233
|
+
case "ordering":
|
|
234
|
+
return { ...base, type: question.type, items: question.items.map(t) };
|
|
235
|
+
case "matching":
|
|
236
|
+
return {
|
|
237
|
+
...base,
|
|
238
|
+
type: question.type,
|
|
239
|
+
pairs: (materialPairs(question) ?? question.pairs).map((pair) => ({
|
|
240
|
+
left: t(pair.left),
|
|
241
|
+
right: t(pair.right),
|
|
242
|
+
})),
|
|
243
|
+
...(question.distractors === undefined ? {} : { distractors: question.distractors.map(t) }),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The id of a question's element on the page, and its address (#frage-<id>). */
|
|
249
|
+
export const questionId = (question: Pick<Question, "id">): string => `frage-${question.id}`;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A learner's answer, by the type of its question:
|
|
253
|
+
* - single choice: the index of the chosen option;
|
|
254
|
+
* - multiple choice: the indices of the chosen options;
|
|
255
|
+
* - true/false: the choice;
|
|
256
|
+
* - number: the text the learner typed;
|
|
257
|
+
* - ordering: for each item (index in the right order), the place the learner gave it, from 0;
|
|
258
|
+
* - matching: for each pair's left entry, the index of the right entry the learner chose (a distractor's index follows those of the pairs).
|
|
259
|
+
*/
|
|
260
|
+
export type Answer = number | readonly number[] | boolean | string;
|
|
261
|
+
|
|
262
|
+
/** A number as a learner types it: comma or point as decimal sign, spaces, the unit after it. */
|
|
263
|
+
export function parseNumber(input: string, unit?: string): number | undefined {
|
|
264
|
+
let text = input.trim();
|
|
265
|
+
if (unit !== undefined && text.endsWith(unit)) text = text.slice(0, -unit.length);
|
|
266
|
+
text = text.replace(/[\s ]/g, "").replace(",", ".");
|
|
267
|
+
if (!/^[+\-−]?(\d+\.?\d*|\.\d+)$/.test(text)) return undefined;
|
|
268
|
+
return Number(text.replace("−", "-"));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const isIndexList = (answer: Answer): answer is readonly number[] => Array.isArray(answer);
|
|
272
|
+
|
|
273
|
+
/** Whether `value` is within the tolerance of `target`, with a little slack for decimal rounding. */
|
|
274
|
+
const within = (value: number, target: number, tolerance: number) => Math.abs(value - target) <= tolerance + 1e-9;
|
|
275
|
+
|
|
276
|
+
/** Whether `answer` is the right answer to `question`; an answer of the wrong shape is not. */
|
|
277
|
+
export function isCorrect(question: Question, answer: Answer): boolean {
|
|
278
|
+
switch (question.type) {
|
|
279
|
+
case "single-choice":
|
|
280
|
+
return typeof answer === "number" && question.options[answer]?.correct === true;
|
|
281
|
+
case "multiple-choice": {
|
|
282
|
+
if (!isIndexList(answer)) return false;
|
|
283
|
+
const chosen = new Set(answer);
|
|
284
|
+
return question.options.every((option, index) => option.correct === chosen.has(index));
|
|
285
|
+
}
|
|
286
|
+
case "true-false":
|
|
287
|
+
return answer === question.answer;
|
|
288
|
+
case "number": {
|
|
289
|
+
if (typeof answer !== "string") return false;
|
|
290
|
+
const value = parseNumber(answer, question.unit);
|
|
291
|
+
// a little slack for decimal rounding: 0.1 + 0.2 is within 0.3 ± 0
|
|
292
|
+
return value !== undefined && within(value, question.answer, question.tolerance);
|
|
293
|
+
}
|
|
294
|
+
case "ordering":
|
|
295
|
+
return isIndexList(answer) && answer.length === question.items.length && answer.every((place, i) => place === i);
|
|
296
|
+
case "matching":
|
|
297
|
+
return isIndexList(answer) && answer.length === question.pairs.length && answer.every((pair, i) => pair === i);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Why `answer` to `question` is wrong, as specifically as the question says it: for a choice the background of each
|
|
303
|
+
* wrong option the learner chose (with the option), for a number the message of the typical mistake it matches; else
|
|
304
|
+
* the question's hint. None for a right answer, nor when the question says nothing more.
|
|
305
|
+
*/
|
|
306
|
+
export function wrongReasons(question: Question, answer: Answer): { option?: string; text: string }[] {
|
|
307
|
+
if (isCorrect(question, answer)) return [];
|
|
308
|
+
const specific: { option?: string; text: string }[] = [];
|
|
309
|
+
if (question.type === "single-choice" || question.type === "multiple-choice") {
|
|
310
|
+
const chosen = typeof answer === "number" ? [answer] : isIndexList(answer) ? answer : [];
|
|
311
|
+
for (const index of chosen) {
|
|
312
|
+
const option = question.options[index];
|
|
313
|
+
if (option !== undefined && !option.correct && option.info !== "") {
|
|
314
|
+
specific.push({ option: option.text, text: option.info });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (question.type === "number" && typeof answer === "string") {
|
|
319
|
+
const value = parseNumber(answer, question.unit);
|
|
320
|
+
const mistake =
|
|
321
|
+
value === undefined
|
|
322
|
+
? undefined
|
|
323
|
+
: question.mistakes?.find((candidate) => within(value, candidate.answer, question.tolerance));
|
|
324
|
+
if (mistake !== undefined) specific.push({ text: mistake.message });
|
|
325
|
+
}
|
|
326
|
+
if (specific.length > 0) return specific;
|
|
327
|
+
return question.hint === undefined ? [] : [{ text: question.hint }];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Whether a multiple-choice answer chose only right options, but not all of them. */
|
|
331
|
+
export const missesRight = (question: Question, answer: Answer): boolean =>
|
|
332
|
+
question.type === "multiple-choice" &&
|
|
333
|
+
isIndexList(answer) &&
|
|
334
|
+
answer.every((index) => question.options[index]?.correct === true) &&
|
|
335
|
+
!isCorrect(question, answer);
|
|
336
|
+
|
|
337
|
+
/** Whether the learner has answered at all: something chosen, typed or placed everywhere. */
|
|
338
|
+
export function isComplete(question: Question, answer: Answer | undefined): answer is Answer {
|
|
339
|
+
if (answer === undefined) return false;
|
|
340
|
+
switch (question.type) {
|
|
341
|
+
case "single-choice":
|
|
342
|
+
return typeof answer === "number";
|
|
343
|
+
case "multiple-choice":
|
|
344
|
+
return isIndexList(answer) && answer.length > 0;
|
|
345
|
+
case "true-false":
|
|
346
|
+
return typeof answer === "boolean";
|
|
347
|
+
case "number":
|
|
348
|
+
return typeof answer === "string" && parseNumber(answer, question.unit) !== undefined;
|
|
349
|
+
case "ordering":
|
|
350
|
+
case "matching":
|
|
351
|
+
return isIndexList(answer) && answer.every((value) => Number.isInteger(value) && value >= 0);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The right answer, as a learner would give it. */
|
|
356
|
+
export function rightAnswer(question: Question): Answer {
|
|
357
|
+
switch (question.type) {
|
|
358
|
+
case "single-choice":
|
|
359
|
+
return question.options.findIndex((option) => option.correct);
|
|
360
|
+
case "multiple-choice":
|
|
361
|
+
return question.options.flatMap((option, index) => (option.correct ? [index] : []));
|
|
362
|
+
case "true-false":
|
|
363
|
+
return question.answer;
|
|
364
|
+
case "number":
|
|
365
|
+
return String(question.answer).replace(".", ",");
|
|
366
|
+
case "ordering":
|
|
367
|
+
return question.items.map((_item, index) => index);
|
|
368
|
+
case "matching":
|
|
369
|
+
return question.pairs.map((_pair, index) => index);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** A complete answer that is wrong, as a learner might give it. */
|
|
374
|
+
export function wrongAnswer(question: Question): Answer {
|
|
375
|
+
switch (question.type) {
|
|
376
|
+
case "single-choice":
|
|
377
|
+
return question.options.findIndex((option) => !option.correct);
|
|
378
|
+
case "multiple-choice": {
|
|
379
|
+
const right = question.options.flatMap((option, index) => (option.correct ? [index] : []));
|
|
380
|
+
const wrong = question.options.findIndex((option) => !option.correct);
|
|
381
|
+
return wrong >= 0 ? [...right, wrong].sort((a, b) => a - b) : right.slice(1);
|
|
382
|
+
}
|
|
383
|
+
case "true-false":
|
|
384
|
+
return !question.answer;
|
|
385
|
+
case "number":
|
|
386
|
+
return String(question.answer + question.tolerance + Math.max(1, Math.abs(question.answer))).replace(".", ",");
|
|
387
|
+
case "ordering":
|
|
388
|
+
return question.items.map((_item, index) => question.items.length - 1 - index);
|
|
389
|
+
case "matching":
|
|
390
|
+
return question.pairs.map((_pair, index) => (index + 1) % question.pairs.length);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** A small seeded random generator (mulberry32): the same seed gives the same order everywhere. */
|
|
395
|
+
export function random(seed: number): () => number {
|
|
396
|
+
let state = seed >>> 0;
|
|
397
|
+
return () => {
|
|
398
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
399
|
+
let t = state;
|
|
400
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
401
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
402
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** A number from a text, to derive a seed per question. */
|
|
407
|
+
export function hash(text: string): number {
|
|
408
|
+
let value = 2166136261;
|
|
409
|
+
for (let i = 0; i < text.length; i++) value = Math.imul(value ^ text.charCodeAt(i), 16777619);
|
|
410
|
+
return value >>> 0;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The indices 0..length-1 in an order given by `seed`. */
|
|
414
|
+
export function shuffled(length: number, seed: number): number[] {
|
|
415
|
+
const next = random(seed);
|
|
416
|
+
const order = Array.from({ length }, (_value, index) => index);
|
|
417
|
+
for (let i = order.length - 1; i > 0; i--) {
|
|
418
|
+
const j = Math.floor(next() * (i + 1));
|
|
419
|
+
[order[i], order[j]] = [order[j] ?? j, order[i] ?? i];
|
|
420
|
+
}
|
|
421
|
+
return order;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The order in which the page shows the items of an ordering or the right entries of a matching question: mixed,
|
|
426
|
+
* the same on every build, and never the right order itself.
|
|
427
|
+
*/
|
|
428
|
+
export function mixed(length: number, id: string): number[] {
|
|
429
|
+
for (let attempt = 0; ; attempt++) {
|
|
430
|
+
const order = shuffled(length, hash(`${id}#${attempt}`));
|
|
431
|
+
if (length < 2 || order.some((value, index) => value !== index)) return order;
|
|
432
|
+
}
|
|
433
|
+
}
|