@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,407 @@
|
|
|
1
|
+
// The expression language of generated exercises (`where`, `answer`, `mistakes`): a small
|
|
2
|
+
// language, parsed here into a tree and evaluated by walking it, with no eval and no access to anything but the
|
|
3
|
+
// values it is given. It has
|
|
4
|
+
// - arithmetic on exact fractions: + - * /, parentheses, unary minus; numbers as written (0.1 is 1/10);
|
|
5
|
+
// - comparisons == != < <= > >=, and, or, not; true, false; strings in quotes;
|
|
6
|
+
// - record fields (e.year), list index (xs[0]), list literals ([1, 2, 3]);
|
|
7
|
+
// - the functions round(x, n), decimals(x), map(xs, field), sortBy(xs, field), reverse(xs).
|
|
8
|
+
// Errors are ExpressionErrors whose message says what to change.
|
|
9
|
+
import { Fraction } from "./fraction.ts";
|
|
10
|
+
|
|
11
|
+
/** A text in several languages, from a data file or a list: compared and sorted by its German text. */
|
|
12
|
+
export class Localized {
|
|
13
|
+
readonly texts: Readonly<Record<string, string>>;
|
|
14
|
+
constructor(texts: Readonly<Record<string, string>>) {
|
|
15
|
+
this.texts = texts;
|
|
16
|
+
}
|
|
17
|
+
in(lang: string): string {
|
|
18
|
+
return this.texts[lang] ?? this.texts["de"] ?? "";
|
|
19
|
+
}
|
|
20
|
+
toString(): string {
|
|
21
|
+
return this.in("de");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A record of a data file: its fields by name. */
|
|
26
|
+
export type Rec = { readonly [field: string]: Value };
|
|
27
|
+
export type Value = Fraction | string | boolean | Localized | Rec | readonly Value[];
|
|
28
|
+
|
|
29
|
+
export class ExpressionError extends Error {}
|
|
30
|
+
|
|
31
|
+
type Node =
|
|
32
|
+
| { kind: "number"; value: Fraction }
|
|
33
|
+
| { kind: "string"; value: string }
|
|
34
|
+
| { kind: "boolean"; value: boolean }
|
|
35
|
+
| { kind: "name"; name: string }
|
|
36
|
+
| { kind: "list"; items: Node[] }
|
|
37
|
+
| { kind: "field"; of: Node; field: string }
|
|
38
|
+
| { kind: "index"; of: Node; index: Node }
|
|
39
|
+
| { kind: "call"; fn: string; args: Node[] }
|
|
40
|
+
| { kind: "unary"; op: "-" | "not"; arg: Node }
|
|
41
|
+
| { kind: "binary"; op: string; left: Node; right: Node };
|
|
42
|
+
|
|
43
|
+
export type Expression = Node;
|
|
44
|
+
|
|
45
|
+
/** The functions, with their number of arguments and which arguments are field names. */
|
|
46
|
+
const FUNCTIONS: Readonly<Record<string, { arity: number; field?: number }>> = {
|
|
47
|
+
round: { arity: 2 },
|
|
48
|
+
decimals: { arity: 1 },
|
|
49
|
+
map: { arity: 2, field: 1 },
|
|
50
|
+
sortBy: { arity: 2, field: 1 },
|
|
51
|
+
reverse: { arity: 1 },
|
|
52
|
+
};
|
|
53
|
+
const KEYWORDS = new Set(["and", "or", "not", "true", "false"]);
|
|
54
|
+
|
|
55
|
+
interface Token {
|
|
56
|
+
type: "number" | "string" | "name" | "op" | "end";
|
|
57
|
+
text: string;
|
|
58
|
+
at: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tokens(source: string): Token[] {
|
|
62
|
+
const list: Token[] = [];
|
|
63
|
+
const pattern =
|
|
64
|
+
/\s*(?:(\d+(?:\.\d+)?)|"([^"]*)"|'([^']*)'|([A-Za-z_][A-Za-z0-9_]*)|(==|!=|<=|>=|[-+*/()<>[\],.]|=)|(\S))/y;
|
|
65
|
+
let match: RegExpExecArray | null;
|
|
66
|
+
pattern.lastIndex = 0;
|
|
67
|
+
while (pattern.lastIndex < source.length && (match = pattern.exec(source)) !== null) {
|
|
68
|
+
const at = match.index + match[0].length - match[0].trimStart().length;
|
|
69
|
+
if (match[0].trim() === "") break;
|
|
70
|
+
if (match[1] !== undefined) list.push({ type: "number", text: match[1], at });
|
|
71
|
+
else if (match[2] !== undefined || match[3] !== undefined)
|
|
72
|
+
list.push({ type: "string", text: match[2] ?? match[3] ?? "", at });
|
|
73
|
+
else if (match[4] !== undefined) list.push({ type: "name", text: match[4], at });
|
|
74
|
+
else if (match[5] !== undefined) {
|
|
75
|
+
if (match[5] === "=") throw new ExpressionError(`= at ${at + 1}: use == to compare`);
|
|
76
|
+
list.push({ type: "op", text: match[5], at });
|
|
77
|
+
} else throw new ExpressionError(`unexpected ${match[6] ?? "character"} at ${at + 1}`);
|
|
78
|
+
}
|
|
79
|
+
list.push({ type: "end", text: "the end", at: source.length });
|
|
80
|
+
return list;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Parses `source` into an expression; throws an ExpressionError that says where it does not parse. */
|
|
84
|
+
export function parse(source: string): Expression {
|
|
85
|
+
const list = tokens(source);
|
|
86
|
+
let position = 0;
|
|
87
|
+
const peek = (): Token => list[position] ?? { type: "end", text: "the end", at: source.length };
|
|
88
|
+
const next = (): Token => {
|
|
89
|
+
const token = peek();
|
|
90
|
+
position++;
|
|
91
|
+
return token;
|
|
92
|
+
};
|
|
93
|
+
const isOp = (text: string) => peek().type === "op" && peek().text === text;
|
|
94
|
+
const isWord = (text: string) => peek().type === "name" && peek().text === text;
|
|
95
|
+
const expect = (text: string) => {
|
|
96
|
+
const token = next();
|
|
97
|
+
if (token.text !== text) throw new ExpressionError(`expected ${text} at ${token.at + 1}, found ${token.text}`);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const or = (): Node => {
|
|
101
|
+
let left = and();
|
|
102
|
+
while (isWord("or")) {
|
|
103
|
+
next();
|
|
104
|
+
left = { kind: "binary", op: "or", left, right: and() };
|
|
105
|
+
}
|
|
106
|
+
return left;
|
|
107
|
+
};
|
|
108
|
+
const and = (): Node => {
|
|
109
|
+
let left = not();
|
|
110
|
+
while (isWord("and")) {
|
|
111
|
+
next();
|
|
112
|
+
left = { kind: "binary", op: "and", left, right: not() };
|
|
113
|
+
}
|
|
114
|
+
return left;
|
|
115
|
+
};
|
|
116
|
+
const not = (): Node => {
|
|
117
|
+
if (isWord("not")) {
|
|
118
|
+
next();
|
|
119
|
+
return { kind: "unary", op: "not", arg: not() };
|
|
120
|
+
}
|
|
121
|
+
return comparison();
|
|
122
|
+
};
|
|
123
|
+
const comparison = (): Node => {
|
|
124
|
+
const left = sum();
|
|
125
|
+
const token = peek();
|
|
126
|
+
if (token.type === "op" && ["==", "!=", "<", "<=", ">", ">="].includes(token.text)) {
|
|
127
|
+
next();
|
|
128
|
+
return { kind: "binary", op: token.text, left, right: sum() };
|
|
129
|
+
}
|
|
130
|
+
return left;
|
|
131
|
+
};
|
|
132
|
+
const sum = (): Node => {
|
|
133
|
+
let left = product();
|
|
134
|
+
while (isOp("+") || isOp("-")) left = { kind: "binary", op: next().text, left, right: product() };
|
|
135
|
+
return left;
|
|
136
|
+
};
|
|
137
|
+
const product = (): Node => {
|
|
138
|
+
let left = unary();
|
|
139
|
+
while (isOp("*") || isOp("/")) left = { kind: "binary", op: next().text, left, right: unary() };
|
|
140
|
+
return left;
|
|
141
|
+
};
|
|
142
|
+
const unary = (): Node => {
|
|
143
|
+
if (isOp("-")) {
|
|
144
|
+
next();
|
|
145
|
+
return { kind: "unary", op: "-", arg: unary() };
|
|
146
|
+
}
|
|
147
|
+
return postfix();
|
|
148
|
+
};
|
|
149
|
+
const postfix = (): Node => {
|
|
150
|
+
let node = primary();
|
|
151
|
+
for (;;) {
|
|
152
|
+
if (isOp(".")) {
|
|
153
|
+
next();
|
|
154
|
+
const field = next();
|
|
155
|
+
if (field.type !== "name") throw new ExpressionError(`expected a field name at ${field.at + 1}`);
|
|
156
|
+
node = { kind: "field", of: node, field: field.text };
|
|
157
|
+
} else if (isOp("[")) {
|
|
158
|
+
next();
|
|
159
|
+
const index = or();
|
|
160
|
+
expect("]");
|
|
161
|
+
node = { kind: "index", of: node, index };
|
|
162
|
+
} else return node;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const primary = (): Node => {
|
|
166
|
+
const token = next();
|
|
167
|
+
if (token.type === "number") return { kind: "number", value: Fraction.from(Number(token.text)) };
|
|
168
|
+
if (token.type === "string") return { kind: "string", value: token.text };
|
|
169
|
+
if (token.type === "name") {
|
|
170
|
+
if (token.text === "true" || token.text === "false") return { kind: "boolean", value: token.text === "true" };
|
|
171
|
+
if (KEYWORDS.has(token.text)) throw new ExpressionError(`unexpected ${token.text} at ${token.at + 1}`);
|
|
172
|
+
if (!isOp("(")) return { kind: "name", name: token.text };
|
|
173
|
+
const fn = FUNCTIONS[token.text];
|
|
174
|
+
if (fn === undefined || !Object.hasOwn(FUNCTIONS, token.text)) {
|
|
175
|
+
throw new ExpressionError(
|
|
176
|
+
`${token.text} is not a function of the expression language; use one of ${Object.keys(FUNCTIONS).join(", ")}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
next();
|
|
180
|
+
const args: Node[] = [];
|
|
181
|
+
if (!isOp(")")) {
|
|
182
|
+
args.push(or());
|
|
183
|
+
while (isOp(",")) {
|
|
184
|
+
next();
|
|
185
|
+
args.push(or());
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
expect(")");
|
|
189
|
+
if (args.length !== fn.arity) {
|
|
190
|
+
throw new ExpressionError(`${token.text} takes ${fn.arity} argument${fn.arity === 1 ? "" : "s"}`);
|
|
191
|
+
}
|
|
192
|
+
if (fn.field !== undefined && args[fn.field]?.kind !== "name") {
|
|
193
|
+
throw new ExpressionError(`the argument ${fn.field + 1} of ${token.text} is a field name, e.g. year`);
|
|
194
|
+
}
|
|
195
|
+
return { kind: "call", fn: token.text, args };
|
|
196
|
+
}
|
|
197
|
+
if (token.type === "op" && token.text === "(") {
|
|
198
|
+
const inner = or();
|
|
199
|
+
expect(")");
|
|
200
|
+
return inner;
|
|
201
|
+
}
|
|
202
|
+
if (token.type === "op" && token.text === "[") {
|
|
203
|
+
const items: Node[] = [];
|
|
204
|
+
if (!isOp("]")) {
|
|
205
|
+
items.push(or());
|
|
206
|
+
while (isOp(",")) {
|
|
207
|
+
next();
|
|
208
|
+
items.push(or());
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
expect("]");
|
|
212
|
+
return { kind: "list", items };
|
|
213
|
+
}
|
|
214
|
+
throw new ExpressionError(`unexpected ${token.text} at ${token.at + 1}`);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const tree = or();
|
|
218
|
+
const rest = peek();
|
|
219
|
+
if (rest.type !== "end") throw new ExpressionError(`unexpected ${rest.text} at ${rest.at + 1}`);
|
|
220
|
+
return tree;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The variables an expression uses: its names, not its functions or the field names they take. */
|
|
224
|
+
export function variables(node: Expression, into = new Set<string>()): Set<string> {
|
|
225
|
+
switch (node.kind) {
|
|
226
|
+
case "name":
|
|
227
|
+
into.add(node.name);
|
|
228
|
+
break;
|
|
229
|
+
case "list":
|
|
230
|
+
for (const item of node.items) variables(item, into);
|
|
231
|
+
break;
|
|
232
|
+
case "field":
|
|
233
|
+
variables(node.of, into);
|
|
234
|
+
break;
|
|
235
|
+
case "index":
|
|
236
|
+
variables(node.of, into);
|
|
237
|
+
variables(node.index, into);
|
|
238
|
+
break;
|
|
239
|
+
case "call":
|
|
240
|
+
node.args.forEach((arg, index) => {
|
|
241
|
+
if (FUNCTIONS[node.fn]?.field !== index) variables(arg, into);
|
|
242
|
+
});
|
|
243
|
+
break;
|
|
244
|
+
case "unary":
|
|
245
|
+
variables(node.arg, into);
|
|
246
|
+
break;
|
|
247
|
+
case "binary":
|
|
248
|
+
variables(node.left, into);
|
|
249
|
+
variables(node.right, into);
|
|
250
|
+
break;
|
|
251
|
+
default:
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
return into;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const isList = (value: Value): value is readonly Value[] => Array.isArray(value);
|
|
258
|
+
const isRecord = (value: Value): value is Rec =>
|
|
259
|
+
typeof value === "object" && !Array.isArray(value) && !(value instanceof Fraction) && !(value instanceof Localized);
|
|
260
|
+
|
|
261
|
+
/** Whether two values are the same: numbers by value, texts by their German text, lists and records item by item. */
|
|
262
|
+
export function same(a: Value, b: Value): boolean {
|
|
263
|
+
if (a instanceof Fraction || b instanceof Fraction) {
|
|
264
|
+
return a instanceof Fraction && b instanceof Fraction && a.equals(b);
|
|
265
|
+
}
|
|
266
|
+
if (isList(a) || isList(b)) {
|
|
267
|
+
return isList(a) && isList(b) && a.length === b.length && a.every((item, index) => same(item, b[index] as Value));
|
|
268
|
+
}
|
|
269
|
+
if (typeof a === "boolean" || typeof b === "boolean") return a === b;
|
|
270
|
+
if (isRecord(a) || isRecord(b)) {
|
|
271
|
+
if (!isRecord(a) || !isRecord(b)) return false;
|
|
272
|
+
const keys = Object.keys(a);
|
|
273
|
+
return (
|
|
274
|
+
keys.length === Object.keys(b).length && keys.every((key) => key in b && same(a[key] as Value, b[key] as Value))
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return String(a) === String(b);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The order of two values: numbers by value, texts by their German text. */
|
|
281
|
+
function order(a: Value, b: Value, what: string): number {
|
|
282
|
+
if (a instanceof Fraction && b instanceof Fraction) return a.compare(b);
|
|
283
|
+
const text = (value: Value) => (typeof value === "string" || value instanceof Localized ? String(value) : undefined);
|
|
284
|
+
const left = text(a);
|
|
285
|
+
const right = text(b);
|
|
286
|
+
if (left === undefined || right === undefined) throw new ExpressionError(`${what} compares numbers or texts`);
|
|
287
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const describe = (value: Value): string =>
|
|
291
|
+
value instanceof Fraction
|
|
292
|
+
? "a number"
|
|
293
|
+
: typeof value === "string" || value instanceof Localized
|
|
294
|
+
? "a text"
|
|
295
|
+
: typeof value === "boolean"
|
|
296
|
+
? "true or false"
|
|
297
|
+
: isList(value)
|
|
298
|
+
? "a list"
|
|
299
|
+
: "a record";
|
|
300
|
+
|
|
301
|
+
function asNumber(value: Value, what: string): Fraction {
|
|
302
|
+
if (!(value instanceof Fraction)) throw new ExpressionError(`${what} needs numbers, not ${describe(value)}`);
|
|
303
|
+
return value;
|
|
304
|
+
}
|
|
305
|
+
function asList(value: Value, what: string): readonly Value[] {
|
|
306
|
+
if (!isList(value)) throw new ExpressionError(`${what} needs a list, not ${describe(value)}`);
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
function asBoolean(value: Value, what: string): boolean {
|
|
310
|
+
if (typeof value !== "boolean") throw new ExpressionError(`${what} needs true or false, not ${describe(value)}`);
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
function fieldOf(value: Value, field: string): Value {
|
|
314
|
+
if (!isRecord(value)) throw new ExpressionError(`.${field} needs a record, not ${describe(value)}`);
|
|
315
|
+
if (!Object.hasOwn(value, field)) {
|
|
316
|
+
throw new ExpressionError(`the record has no field ${field}; it has ${Object.keys(value).join(", ")}`);
|
|
317
|
+
}
|
|
318
|
+
return value[field] as Value;
|
|
319
|
+
}
|
|
320
|
+
function integer(value: Fraction, what: string): number {
|
|
321
|
+
if (!value.isInteger()) throw new ExpressionError(`${what} needs a whole number`);
|
|
322
|
+
return Number(value.n);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function call(fn: string, args: readonly Node[], env: Readonly<Record<string, Value>>): Value {
|
|
326
|
+
const value = (index: number) => evaluate(args[index] as Node, env);
|
|
327
|
+
const field = () => (args[1] as Extract<Node, { kind: "name" }>).name;
|
|
328
|
+
switch (fn) {
|
|
329
|
+
case "round":
|
|
330
|
+
return asNumber(value(0), "round").round(integer(asNumber(value(1), "round"), "round"));
|
|
331
|
+
case "decimals": {
|
|
332
|
+
const places = asNumber(value(0), "decimals").decimals();
|
|
333
|
+
// a decimal that never ends: more places than any limit
|
|
334
|
+
return Fraction.of(places === Infinity ? 1_000_000 : places);
|
|
335
|
+
}
|
|
336
|
+
case "map":
|
|
337
|
+
return asList(value(0), "map").map((item) => fieldOf(item, field()));
|
|
338
|
+
case "sortBy": {
|
|
339
|
+
const name = field();
|
|
340
|
+
return [...asList(value(0), "sortBy")].sort((a, b) => order(fieldOf(a, name), fieldOf(b, name), "sortBy"));
|
|
341
|
+
}
|
|
342
|
+
case "reverse":
|
|
343
|
+
return [...asList(value(0), "reverse")].reverse();
|
|
344
|
+
default:
|
|
345
|
+
throw new ExpressionError(`${fn} is not a function of the expression language`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The value of an expression with the variables of `env`; throws an ExpressionError when it has none. */
|
|
350
|
+
export function evaluate(node: Expression, env: Readonly<Record<string, Value>>): Value {
|
|
351
|
+
switch (node.kind) {
|
|
352
|
+
case "number":
|
|
353
|
+
case "string":
|
|
354
|
+
case "boolean":
|
|
355
|
+
return node.value;
|
|
356
|
+
case "name":
|
|
357
|
+
if (!Object.hasOwn(env, node.name)) throw new ExpressionError(`${node.name} is not declared`);
|
|
358
|
+
return env[node.name] as Value;
|
|
359
|
+
case "list":
|
|
360
|
+
return node.items.map((item) => evaluate(item, env));
|
|
361
|
+
case "field":
|
|
362
|
+
return fieldOf(evaluate(node.of, env), node.field);
|
|
363
|
+
case "index": {
|
|
364
|
+
const list = asList(evaluate(node.of, env), "[…]");
|
|
365
|
+
const index = integer(asNumber(evaluate(node.index, env), "[…]"), "[…]");
|
|
366
|
+
if (index < 0 || index >= list.length) {
|
|
367
|
+
throw new ExpressionError(`the list has no element ${index}; it has ${list.length}, from 0`);
|
|
368
|
+
}
|
|
369
|
+
return list[index] as Value;
|
|
370
|
+
}
|
|
371
|
+
case "call":
|
|
372
|
+
return call(node.fn, node.args, env);
|
|
373
|
+
case "unary": {
|
|
374
|
+
const arg = evaluate(node.arg, env);
|
|
375
|
+
return node.op === "-" ? asNumber(arg, "-").neg() : !asBoolean(arg, "not");
|
|
376
|
+
}
|
|
377
|
+
case "binary": {
|
|
378
|
+
if (node.op === "and")
|
|
379
|
+
return asBoolean(evaluate(node.left, env), "and") && asBoolean(evaluate(node.right, env), "and");
|
|
380
|
+
if (node.op === "or")
|
|
381
|
+
return asBoolean(evaluate(node.left, env), "or") || asBoolean(evaluate(node.right, env), "or");
|
|
382
|
+
const left = evaluate(node.left, env);
|
|
383
|
+
const right = evaluate(node.right, env);
|
|
384
|
+
switch (node.op) {
|
|
385
|
+
case "+":
|
|
386
|
+
return asNumber(left, "+").add(asNumber(right, "+"));
|
|
387
|
+
case "-":
|
|
388
|
+
return asNumber(left, "-").sub(asNumber(right, "-"));
|
|
389
|
+
case "*":
|
|
390
|
+
return asNumber(left, "*").mul(asNumber(right, "*"));
|
|
391
|
+
case "/": {
|
|
392
|
+
const divisor = asNumber(right, "/");
|
|
393
|
+
if (divisor.n === 0n) throw new ExpressionError("division by zero");
|
|
394
|
+
return asNumber(left, "/").div(divisor);
|
|
395
|
+
}
|
|
396
|
+
case "==":
|
|
397
|
+
return same(left, right);
|
|
398
|
+
case "!=":
|
|
399
|
+
return !same(left, right);
|
|
400
|
+
default: {
|
|
401
|
+
const sign = order(left, right, node.op);
|
|
402
|
+
return node.op === "<" ? sign < 0 : node.op === "<=" ? sign <= 0 : node.op === ">" ? sign > 0 : sign >= 0;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Exact fractions for the expression language of generated exercises: 0.1 + 0.2 is 0.3, and 1/3 stays 1/3 until it
|
|
2
|
+
// is rounded on purpose. Numerator and denominator are BigInts, always reduced, the denominator positive.
|
|
3
|
+
|
|
4
|
+
const abs = (n: bigint) => (n < 0n ? -n : n);
|
|
5
|
+
function gcd(a: bigint, b: bigint): bigint {
|
|
6
|
+
let x = abs(a);
|
|
7
|
+
let y = abs(b);
|
|
8
|
+
while (y !== 0n) [x, y] = [y, x % y];
|
|
9
|
+
return x;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class Fraction {
|
|
13
|
+
readonly n: bigint;
|
|
14
|
+
readonly d: bigint;
|
|
15
|
+
|
|
16
|
+
private constructor(n: bigint, d: bigint) {
|
|
17
|
+
if (d === 0n) throw new RangeError("division by zero");
|
|
18
|
+
const sign = d < 0n ? -1n : 1n;
|
|
19
|
+
const g = gcd(n, d) || 1n;
|
|
20
|
+
this.n = (sign * n) / g;
|
|
21
|
+
this.d = (sign * d) / g;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static of(n: number | bigint, d: number | bigint = 1): Fraction {
|
|
25
|
+
return new Fraction(BigInt(n), BigInt(d));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A number as written: 0.1 is exactly 1/10. */
|
|
29
|
+
static from(value: number): Fraction {
|
|
30
|
+
if (!Number.isFinite(value)) throw new RangeError(`${value} is not a number`);
|
|
31
|
+
const text = String(value);
|
|
32
|
+
const match = /^(-?)(\d+)(?:\.(\d+))?(?:e([+-]\d+))?$/.exec(text);
|
|
33
|
+
if (match === null) throw new RangeError(`${text} is not a number`);
|
|
34
|
+
const [, sign = "", whole = "0", part = "", exponent = "0"] = match;
|
|
35
|
+
let n = BigInt(`${sign}${whole}${part}`);
|
|
36
|
+
let d = 10n ** BigInt(part.length);
|
|
37
|
+
const e = Number(exponent);
|
|
38
|
+
if (e > 0) n *= 10n ** BigInt(e);
|
|
39
|
+
else if (e < 0) d *= 10n ** BigInt(-e);
|
|
40
|
+
return new Fraction(n, d);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
add(other: Fraction): Fraction {
|
|
44
|
+
return new Fraction(this.n * other.d + other.n * this.d, this.d * other.d);
|
|
45
|
+
}
|
|
46
|
+
sub(other: Fraction): Fraction {
|
|
47
|
+
return new Fraction(this.n * other.d - other.n * this.d, this.d * other.d);
|
|
48
|
+
}
|
|
49
|
+
mul(other: Fraction): Fraction {
|
|
50
|
+
return new Fraction(this.n * other.n, this.d * other.d);
|
|
51
|
+
}
|
|
52
|
+
div(other: Fraction): Fraction {
|
|
53
|
+
if (other.n === 0n) throw new RangeError("division by zero");
|
|
54
|
+
return new Fraction(this.n * other.d, this.d * other.n);
|
|
55
|
+
}
|
|
56
|
+
neg(): Fraction {
|
|
57
|
+
return new Fraction(-this.n, this.d);
|
|
58
|
+
}
|
|
59
|
+
abs(): Fraction {
|
|
60
|
+
return new Fraction(abs(this.n), this.d);
|
|
61
|
+
}
|
|
62
|
+
compare(other: Fraction): number {
|
|
63
|
+
const left = this.n * other.d;
|
|
64
|
+
const right = other.n * this.d;
|
|
65
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
66
|
+
}
|
|
67
|
+
equals(other: Fraction): boolean {
|
|
68
|
+
return this.n === other.n && this.d === other.d;
|
|
69
|
+
}
|
|
70
|
+
isInteger(): boolean {
|
|
71
|
+
return this.d === 1n;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Rounded to `places` decimals, half away from zero. */
|
|
75
|
+
round(places: number): Fraction {
|
|
76
|
+
const scale = 10n ** BigInt(Math.max(0, places));
|
|
77
|
+
const scaled = abs(this.n) * scale;
|
|
78
|
+
const q = scaled / this.d;
|
|
79
|
+
const r = scaled % this.d;
|
|
80
|
+
const rounded = 2n * r >= this.d ? q + 1n : q;
|
|
81
|
+
return new Fraction(this.n < 0n ? -rounded : rounded, scale);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The number of decimals when written out; Infinity when they never end (1/3). */
|
|
85
|
+
decimals(): number {
|
|
86
|
+
let d = this.d;
|
|
87
|
+
let twos = 0;
|
|
88
|
+
let fives = 0;
|
|
89
|
+
while (d % 2n === 0n) {
|
|
90
|
+
d /= 2n;
|
|
91
|
+
twos++;
|
|
92
|
+
}
|
|
93
|
+
while (d % 5n === 0n) {
|
|
94
|
+
d /= 5n;
|
|
95
|
+
fives++;
|
|
96
|
+
}
|
|
97
|
+
return d === 1n ? Math.max(twos, fives) : Infinity;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
toNumber(): number {
|
|
101
|
+
return Number(this.n) / Number(this.d);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
toString(): string {
|
|
105
|
+
return this.d === 1n ? String(this.n) : `${this.n}/${this.d}`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// How the comparator of a generated exercise maps the answer onto the answer kinds of a question.
|
|
2
|
+
import { Fraction } from "../fraction.ts";
|
|
3
|
+
import { type Text } from "../../shared/languages.ts";
|
|
4
|
+
import { type Compare, type Literal } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
type Kind =
|
|
7
|
+
| { kind: "number"; decimals: number; tolerance: Fraction; unit?: Text; money: boolean }
|
|
8
|
+
| { kind: "choice"; options: readonly Literal[] | string }
|
|
9
|
+
| { kind: "order" }
|
|
10
|
+
| { kind: "pairs"; left?: string; right?: string };
|
|
11
|
+
|
|
12
|
+
/** What the comparator of an exercise is. */
|
|
13
|
+
export function kindOf(compare: Compare): Kind {
|
|
14
|
+
if (compare === "number") return { kind: "number", decimals: 2, tolerance: Fraction.of(0), money: false };
|
|
15
|
+
if (compare === "order") return { kind: "order" };
|
|
16
|
+
if (compare === "pairs") return { kind: "pairs" };
|
|
17
|
+
if ("number" in compare) {
|
|
18
|
+
const { format = "decimal", decimals, tolerance = 0, unit } = compare.number;
|
|
19
|
+
const places = decimals ?? (format === "integer" ? 0 : 2);
|
|
20
|
+
const shown = unit ?? (format === "money" ? "€" : format === "percent" ? "%" : undefined);
|
|
21
|
+
return {
|
|
22
|
+
kind: "number",
|
|
23
|
+
decimals: places,
|
|
24
|
+
tolerance: Fraction.from(tolerance),
|
|
25
|
+
money: format === "money",
|
|
26
|
+
...(shown === undefined ? {} : { unit: shown }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if ("choice" in compare) return { kind: "choice", options: compare.choice };
|
|
30
|
+
return { kind: "pairs", left: compare.pairs.left, right: compare.pairs.right };
|
|
31
|
+
}
|