quizwitz-validator 0.0.0-stage → 0.2.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/README.md +57 -2
- package/dist/aggregate.d.ts +60 -0
- package/dist/aggregate.js +147 -0
- package/dist/catalog.d.ts +38 -0
- package/dist/catalog.js +110 -0
- package/dist/describe.d.ts +19 -0
- package/dist/describe.js +71 -0
- package/dist/evaluate.d.ts +11 -0
- package/dist/evaluate.js +287 -0
- package/dist/ignore.d.ts +25 -0
- package/dist/ignore.js +43 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/rules.d.ts +63 -0
- package/dist/rules.js +333 -0
- package/dist/text.d.ts +9 -0
- package/dist/text.js +29 -0
- package/dist/types.d.ts +237 -0
- package/dist/types.js +6 -0
- package/docs/design.md +161 -0
- package/fixtures/findings-fixtures.json +7847 -0
- package/package.json +56 -4
package/dist/rules.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule kinds (docs/design.md, 3.4): the comparative kinds look at a
|
|
3
|
+
* round, the per-question kinds at one question. Ported from the editor's
|
|
4
|
+
* question-anomaly.ts and question-type-descriptor.ts so behaviour and ids
|
|
5
|
+
* are identical.
|
|
6
|
+
*/
|
|
7
|
+
import { filledOptionCount, fillsSlot, imageMapOf, optionsOf, questionSetting, resourceText, typeSettingValue } from './aggregate.js';
|
|
8
|
+
import { groupSizeWarningId, questionWarningId } from './ignore.js';
|
|
9
|
+
import { interpolate, localizedText } from './text.js';
|
|
10
|
+
export function render(text, params, language) {
|
|
11
|
+
const messageKey = localizedText(text, language);
|
|
12
|
+
return { message: interpolate(messageKey, params), messageKey, messageParams: params };
|
|
13
|
+
}
|
|
14
|
+
function finding(ctx, q, rule, text, params, id) {
|
|
15
|
+
return {
|
|
16
|
+
id: id ?? questionWarningId(q.uuid, rule.id),
|
|
17
|
+
ruleId: rule.id,
|
|
18
|
+
roundId: ctx.roundId,
|
|
19
|
+
questionId: q.uuid,
|
|
20
|
+
...render(text, params, ctx.language)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function median(values) {
|
|
24
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
25
|
+
const mid = Math.floor(sorted.length / 2);
|
|
26
|
+
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
27
|
+
}
|
|
28
|
+
function byType(questions) {
|
|
29
|
+
const groups = new Map();
|
|
30
|
+
for (const q of questions) {
|
|
31
|
+
const list = groups.get(q.token) ?? [];
|
|
32
|
+
list.push(q);
|
|
33
|
+
groups.set(q.token, list);
|
|
34
|
+
}
|
|
35
|
+
return groups;
|
|
36
|
+
}
|
|
37
|
+
// --- comparative kinds ---
|
|
38
|
+
export function evaluateOutlier(rule, ctx) {
|
|
39
|
+
const carriers = ctx.questions.filter(q => {
|
|
40
|
+
const value = questionSetting(q.question, rule.field);
|
|
41
|
+
return typeof value === 'number' && value > 0;
|
|
42
|
+
});
|
|
43
|
+
if (carriers.length < (rule.minPopulation ?? 3)) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const m = median(carriers.map(q => questionSetting(q.question, rule.field)));
|
|
47
|
+
if (m <= 0) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const q of carriers) {
|
|
52
|
+
const value = questionSetting(q.question, rule.field);
|
|
53
|
+
if (value >= m * rule.factor || value <= m / rule.factor) {
|
|
54
|
+
out.push(finding(ctx, q, rule, rule.message, { value, median: m }));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
export function evaluateOddOneOut(rule, ctx) {
|
|
60
|
+
const on = rule.unsetIsOn ? (value) => value !== false : (value) => !!value;
|
|
61
|
+
const out = [];
|
|
62
|
+
byType(ctx.questions).forEach(siblings => {
|
|
63
|
+
if (siblings.length < (rule.minPopulation ?? 4)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const enabled = siblings.filter(q => on(questionSetting(q.question, rule.setting)));
|
|
67
|
+
const disabled = siblings.filter(q => !on(questionSetting(q.question, rule.setting)));
|
|
68
|
+
const deviant = enabled.length === 1 && disabled.length === siblings.length - 1 ? enabled[0] :
|
|
69
|
+
disabled.length === 1 && enabled.length === siblings.length - 1 ? disabled[0] :
|
|
70
|
+
null;
|
|
71
|
+
if (!deviant) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const deviantOn = on(questionSetting(deviant.question, rule.setting));
|
|
75
|
+
out.push(finding(ctx, deviant, rule, deviantOn ? rule.message : rule.messageDisabled, { setting: rule.label ?? rule.setting }));
|
|
76
|
+
});
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
export function evaluateOptionCountMajority(rule, ctx) {
|
|
80
|
+
const types = new Set(rule.types.map(t => t.toLowerCase()));
|
|
81
|
+
const out = [];
|
|
82
|
+
byType(ctx.questions).forEach((siblings, token) => {
|
|
83
|
+
if (!types.has(token)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const carriers = siblings.filter(q => filledOptionCount(q.question) > 0);
|
|
87
|
+
if (carriers.length < (rule.minPopulation ?? 4)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const tally = new Map();
|
|
91
|
+
for (const q of carriers) {
|
|
92
|
+
const count = filledOptionCount(q.question);
|
|
93
|
+
tally.set(count, (tally.get(count) ?? 0) + 1);
|
|
94
|
+
}
|
|
95
|
+
let expected = 0;
|
|
96
|
+
let agreeing = 0;
|
|
97
|
+
tally.forEach((questions, count) => {
|
|
98
|
+
if (questions > agreeing) {
|
|
99
|
+
expected = count;
|
|
100
|
+
agreeing = questions;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
if (agreeing === carriers.length || agreeing <= carriers.length * (rule.majority ?? 0.5)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (const q of carriers) {
|
|
107
|
+
const count = filledOptionCount(q.question);
|
|
108
|
+
if (count !== expected) {
|
|
109
|
+
out.push(finding(ctx, q, rule, rule.message, { count, expected }));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
export function evaluateGroupSize(rule, ctx) {
|
|
116
|
+
const order = [];
|
|
117
|
+
const groups = new Map();
|
|
118
|
+
ctx.questions.forEach((q, i) => {
|
|
119
|
+
const id = ctx.groupIds[i];
|
|
120
|
+
if (!groups.has(id)) {
|
|
121
|
+
groups.set(id, []);
|
|
122
|
+
order.push(id);
|
|
123
|
+
}
|
|
124
|
+
groups.get(id).push(q);
|
|
125
|
+
});
|
|
126
|
+
if (order.length < (rule.minPopulation ?? 3)) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
const bySize = new Map();
|
|
130
|
+
for (const id of order) {
|
|
131
|
+
const size = groups.get(id).length;
|
|
132
|
+
bySize.set(size, [...(bySize.get(size) ?? []), id]);
|
|
133
|
+
}
|
|
134
|
+
if (bySize.size !== 2) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
const [[sizeA, idsA], [sizeB, idsB]] = [...bySize.entries()];
|
|
138
|
+
const odd = idsA.length === 1 ? { id: idsA[0], count: sizeA, expected: sizeB }
|
|
139
|
+
: idsB.length === 1 ? { id: idsB[0], count: sizeB, expected: sizeA }
|
|
140
|
+
: null;
|
|
141
|
+
if (!odd) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
const first = groups.get(odd.id)[0];
|
|
145
|
+
return [finding(ctx, first, rule, odd.count === 1 ? rule.messageSingle : rule.message, { count: odd.count, expected: odd.expected }, groupSizeWarningId(ctx.roundId, odd.id))];
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The nth question of every group compared across the groups: where a strict
|
|
149
|
+
* majority (but not all) of the groups agree on the value, the nth questions
|
|
150
|
+
* that differ. Questions without a value (no positive setting, empty text)
|
|
151
|
+
* are left out of the count.
|
|
152
|
+
*/
|
|
153
|
+
export function evaluateGroupPosition(rule, ctx) {
|
|
154
|
+
const order = [];
|
|
155
|
+
const groups = new Map();
|
|
156
|
+
ctx.questions.forEach((q, i) => {
|
|
157
|
+
const id = ctx.groupIds[i];
|
|
158
|
+
if (!groups.has(id)) {
|
|
159
|
+
groups.set(id, []);
|
|
160
|
+
order.push(id);
|
|
161
|
+
}
|
|
162
|
+
groups.get(id).push(q);
|
|
163
|
+
});
|
|
164
|
+
const valueOf = (q) => {
|
|
165
|
+
if (rule.setting !== undefined) {
|
|
166
|
+
const value = questionSetting(q.question, rule.setting);
|
|
167
|
+
return typeof value === 'number' && value > 0 ? { key: String(value), value } : null;
|
|
168
|
+
}
|
|
169
|
+
const text = resourceText(q.question, rule.field);
|
|
170
|
+
return text !== '' ? { key: text.toLowerCase(), value: text } : null;
|
|
171
|
+
};
|
|
172
|
+
const longest = Math.max(0, ...order.map(id => groups.get(id).length));
|
|
173
|
+
const out = [];
|
|
174
|
+
for (let position = 0; position < longest; position++) {
|
|
175
|
+
const carriers = [];
|
|
176
|
+
for (const id of order) {
|
|
177
|
+
const q = groups.get(id)[position];
|
|
178
|
+
const value = q ? valueOf(q) : null;
|
|
179
|
+
if (q && value) {
|
|
180
|
+
carriers.push({ q, ...value });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (carriers.length < (rule.minPopulation ?? 3)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const tally = new Map();
|
|
187
|
+
for (const c of carriers) {
|
|
188
|
+
tally.set(c.key, (tally.get(c.key) ?? 0) + 1);
|
|
189
|
+
}
|
|
190
|
+
let expected = '';
|
|
191
|
+
let agreeing = 0;
|
|
192
|
+
tally.forEach((count, key) => {
|
|
193
|
+
if (count > agreeing) {
|
|
194
|
+
expected = key;
|
|
195
|
+
agreeing = count;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
if (agreeing === carriers.length || agreeing <= carriers.length * (rule.majority ?? 0.5)) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const shown = carriers.find(c => c.key === expected).value;
|
|
202
|
+
for (const c of carriers) {
|
|
203
|
+
if (c.key !== expected) {
|
|
204
|
+
out.push(finding(ctx, c.q, rule, rule.message, { value: c.value, expected: shown, position: position + 1 }));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
/** Explanation swaps; `quiet` names the `questionUuid:field` gaps the swap explains, for the consistency rules. */
|
|
211
|
+
export function evaluateExplanationPair(rule, ctx, quiet) {
|
|
212
|
+
const fields = rule.fields;
|
|
213
|
+
const has = (q, field) => resourceText(q.question, field) !== '';
|
|
214
|
+
const carriers = ctx.questions.filter(q => fields.some(f => has(q, f)));
|
|
215
|
+
if (carriers.length < (rule.minPopulation ?? 4)) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
const label = (field) => rule.labels?.[field] ?? field;
|
|
219
|
+
const out = [];
|
|
220
|
+
for (const expected of fields) {
|
|
221
|
+
const actual = fields.find(f => f !== expected);
|
|
222
|
+
const users = carriers.filter(q => has(q, expected));
|
|
223
|
+
if (users.length === carriers.length || users.length < carriers.length * (rule.majority ?? 0.75)) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
for (const q of carriers) {
|
|
227
|
+
if (has(q, expected) || !has(q, actual)) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
out.push(finding(ctx, q, rule, rule.message, { expected: label(expected), actual: label(actual) }));
|
|
231
|
+
quiet.add(q.uuid + ':' + expected);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
export function evaluateConsistency(rule, ctx, quiet) {
|
|
237
|
+
const questions = ctx.questions;
|
|
238
|
+
if (questions.length < (rule.minPopulation ?? 4)) {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
const field = rule.field;
|
|
242
|
+
const fills = field !== undefined
|
|
243
|
+
? (q) => resourceText(q.question, field) !== ''
|
|
244
|
+
: (q) => fillsSlot(q.question, rule.slot);
|
|
245
|
+
const carriers = questions.filter(fills);
|
|
246
|
+
if (carriers.length === questions.length || carriers.length < questions.length * (rule.majority ?? 0.75)) {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
const label = rule.label ?? (field ?? rule.slot);
|
|
250
|
+
const params = field !== undefined ? { field: label } : { slot: label };
|
|
251
|
+
const out = [];
|
|
252
|
+
for (const q of questions) {
|
|
253
|
+
if (fills(q) || (field !== undefined && quiet.has(q.uuid + ':' + field))) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
out.push(finding(ctx, q, rule, rule.message, params));
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
// --- per-question kinds ---
|
|
261
|
+
/** The completeness checks by name (the editor's QuestionTypeDescriptor.checkRule). */
|
|
262
|
+
export function passesCheck(question, check) {
|
|
263
|
+
const options = optionsOf(question);
|
|
264
|
+
const filled = (text) => text.trim().length > 0;
|
|
265
|
+
switch (check) {
|
|
266
|
+
case 'requireText':
|
|
267
|
+
return resourceText(question, 'text') !== '';
|
|
268
|
+
case 'correctOption':
|
|
269
|
+
case 'requireCorrectOption':
|
|
270
|
+
return options.some(o => o.correct && filled(o.text));
|
|
271
|
+
case 'allOptionsFilled':
|
|
272
|
+
case 'requireAllOptionsFilled':
|
|
273
|
+
return options.length > 0 && options.every(o => filled(o.text));
|
|
274
|
+
case 'answer':
|
|
275
|
+
case 'requireAnswer':
|
|
276
|
+
return options.length > 0 && filled(options[0].text);
|
|
277
|
+
case 'imageMap':
|
|
278
|
+
case 'requireImageMap': {
|
|
279
|
+
// A neutral zone scores nothing: a map with only neutral zones can never be answered correctly.
|
|
280
|
+
const map = imageMapOf(question);
|
|
281
|
+
return map !== null && map.points.some(p => p && p.kind !== 'neutral');
|
|
282
|
+
}
|
|
283
|
+
default:
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** What a failed `require` reports, or null when it passes. */
|
|
288
|
+
export function evaluateRequire(rule, q) {
|
|
289
|
+
if (rule.field !== undefined) {
|
|
290
|
+
return resourceText(q.question, rule.field) !== '' ? null : { field: rule.field };
|
|
291
|
+
}
|
|
292
|
+
if (rule.setting !== undefined) {
|
|
293
|
+
return questionSetting(q.question, rule.setting) === true ? null : { setting: rule.setting };
|
|
294
|
+
}
|
|
295
|
+
if (rule.check !== undefined) {
|
|
296
|
+
return passesCheck(q.question, rule.check) ? null : { check: rule.check };
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
export function matchesSettingEquals(rule, q) {
|
|
301
|
+
return questionSetting(q.question, rule.setting) === rule.equals;
|
|
302
|
+
}
|
|
303
|
+
/** A typeSettings value, or the field's default when unset (as the editor's form shows it). */
|
|
304
|
+
function typeSettingOrDefault(q, key) {
|
|
305
|
+
const value = typeSettingValue(q.question, key);
|
|
306
|
+
if (value !== undefined && value !== null) {
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
return q.settingsFields.find(f => f.key === key)?.default ?? null;
|
|
310
|
+
}
|
|
311
|
+
export function matchesOptionCountOver(rule, q) {
|
|
312
|
+
const settingOff = !questionSetting(q.question, rule.settingFalse);
|
|
313
|
+
return optionsOf(q.question).length > rule.optionsOver && settingOff;
|
|
314
|
+
}
|
|
315
|
+
export function matchesTypeSetting(rule, q) {
|
|
316
|
+
if (typeSettingOrDefault(q, rule.typeSetting.key) !== rule.typeSetting.equals) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
const value = Number(typeSettingOrDefault(q, rule.typeSettingBelow.key));
|
|
320
|
+
return !Number.isFinite(value) || value < rule.typeSettingBelow.min;
|
|
321
|
+
}
|
|
322
|
+
/** A legacy `warnings` entry of a question type (optionsOver / typeSetting shapes). */
|
|
323
|
+
export function matchesLegacyWarning(warning, q) {
|
|
324
|
+
return 'typeSetting' in warning ? matchesTypeSetting(warning, q) : matchesOptionCountOver(warning, q);
|
|
325
|
+
}
|
|
326
|
+
/** The English detail of a failed completeness rule. */
|
|
327
|
+
export const COMPLETENESS_MESSAGES = {
|
|
328
|
+
requireText: 'The question has no text',
|
|
329
|
+
requireCorrectOption: 'No answer option is marked correct',
|
|
330
|
+
requireAllOptionsFilled: 'Every answer option needs a text',
|
|
331
|
+
requireAnswer: 'The answer is missing',
|
|
332
|
+
requireImageMap: 'The image map needs a picture with at least one zone that scores'
|
|
333
|
+
};
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { LocalizedText } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The text of a manifest text in `language`: that language (a regional one
|
|
4
|
+
* matches its base: nl-BE -> nl), else English, else the first text there
|
|
5
|
+
* is. Empty texts are skipped (the editor's localizedMessage).
|
|
6
|
+
*/
|
|
7
|
+
export declare function localizedText(text: LocalizedText | null | undefined, language?: string): string;
|
|
8
|
+
/** Fills `{placeholder}`s; a placeholder without a value stays as it is. */
|
|
9
|
+
export declare function interpolate(template: string, params: Record<string, string | number>): string;
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text of a manifest text in `language`: that language (a regional one
|
|
3
|
+
* matches its base: nl-BE -> nl), else English, else the first text there
|
|
4
|
+
* is. Empty texts are skipped (the editor's localizedMessage).
|
|
5
|
+
*/
|
|
6
|
+
export function localizedText(text, language) {
|
|
7
|
+
if (typeof text === 'string') {
|
|
8
|
+
return text;
|
|
9
|
+
}
|
|
10
|
+
if (!text || typeof text !== 'object') {
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
const texts = new Map();
|
|
14
|
+
for (const key of Object.keys(text)) {
|
|
15
|
+
const value = text[key];
|
|
16
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
17
|
+
texts.set(key.toLowerCase().replace('_', '-'), value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const wanted = (language ?? '').toLowerCase().replace('_', '-');
|
|
21
|
+
return texts.get(wanted)
|
|
22
|
+
?? texts.get(wanted.split('-')[0])
|
|
23
|
+
?? texts.get('en')
|
|
24
|
+
?? (texts.size > 0 ? texts.values().next().value : '');
|
|
25
|
+
}
|
|
26
|
+
/** Fills `{placeholder}`s; a placeholder without a value stays as it is. */
|
|
27
|
+
export function interpolate(template, params) {
|
|
28
|
+
return template.replace(/\{(\w+)\}/g, (match, key) => (key in params ? String(params[key]) : match));
|
|
29
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The served manifests (the part this package reads), the rule vocabulary
|
|
3
|
+
* and the evaluator's input and output (docs/design.md, 3 and 4).
|
|
4
|
+
*/
|
|
5
|
+
/** A text, or a text per language (`{ "en": "…", "nl": "…" }`). */
|
|
6
|
+
export type LocalizedText = string | Record<string, string>;
|
|
7
|
+
/** `"*"` or a list of tokens. */
|
|
8
|
+
export type TokenList = '*' | string[];
|
|
9
|
+
export type QuestionList = 'list' | 'grouped' | 'none';
|
|
10
|
+
export interface SettingsField {
|
|
11
|
+
key: string;
|
|
12
|
+
type?: string;
|
|
13
|
+
target?: string;
|
|
14
|
+
label?: LocalizedText;
|
|
15
|
+
default?: unknown;
|
|
16
|
+
min?: number;
|
|
17
|
+
max?: number;
|
|
18
|
+
/** Shown (and so expected) only when another field of the same bag equals a value. */
|
|
19
|
+
showWhen?: {
|
|
20
|
+
key: string;
|
|
21
|
+
equals: unknown;
|
|
22
|
+
};
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
export type RuleKind = 'outlier' | 'oddOneOut' | 'consistency' | 'explanationPair' | 'optionCountMajority' | 'groupSize' | 'groupPosition' | 'require' | 'settingEquals' | 'optionCountOver' | 'typeSetting';
|
|
26
|
+
export declare const COMPARATIVE_KINDS: readonly RuleKind[];
|
|
27
|
+
export declare const PER_QUESTION_KINDS: readonly RuleKind[];
|
|
28
|
+
export type Severity = 'error' | 'warning';
|
|
29
|
+
export interface RuleBase {
|
|
30
|
+
/** The rule id: what ignoredWarnings stores. */
|
|
31
|
+
id: string;
|
|
32
|
+
rule: RuleKind;
|
|
33
|
+
severity?: Severity;
|
|
34
|
+
message: LocalizedText;
|
|
35
|
+
description?: LocalizedText;
|
|
36
|
+
/** Only in rounds whose type has this questionList (absent: every round with questions). */
|
|
37
|
+
questionList?: Exclude<QuestionList, 'none'> | Exclude<QuestionList, 'none'>[];
|
|
38
|
+
minPopulation?: number;
|
|
39
|
+
majority?: number;
|
|
40
|
+
}
|
|
41
|
+
export interface OutlierRule extends RuleBase {
|
|
42
|
+
rule: 'outlier';
|
|
43
|
+
field: string;
|
|
44
|
+
factor: number;
|
|
45
|
+
}
|
|
46
|
+
export interface OddOneOutRule extends RuleBase {
|
|
47
|
+
rule: 'oddOneOut';
|
|
48
|
+
setting: string;
|
|
49
|
+
label?: string;
|
|
50
|
+
/** null/undefined count as on (playTimerMusic). */
|
|
51
|
+
unsetIsOn?: boolean;
|
|
52
|
+
messageDisabled: LocalizedText;
|
|
53
|
+
}
|
|
54
|
+
export interface ConsistencyRule extends RuleBase {
|
|
55
|
+
rule: 'consistency';
|
|
56
|
+
field?: string;
|
|
57
|
+
slot?: string;
|
|
58
|
+
label?: string;
|
|
59
|
+
}
|
|
60
|
+
export interface ExplanationPairRule extends RuleBase {
|
|
61
|
+
rule: 'explanationPair';
|
|
62
|
+
fields: [string, string] | string[];
|
|
63
|
+
labels?: Record<string, string>;
|
|
64
|
+
}
|
|
65
|
+
export interface OptionCountMajorityRule extends RuleBase {
|
|
66
|
+
rule: 'optionCountMajority';
|
|
67
|
+
types: string[];
|
|
68
|
+
}
|
|
69
|
+
export interface GroupSizeRule extends RuleBase {
|
|
70
|
+
rule: 'groupSize';
|
|
71
|
+
messageSingle: LocalizedText;
|
|
72
|
+
}
|
|
73
|
+
export interface GroupPositionRule extends RuleBase {
|
|
74
|
+
rule: 'groupPosition';
|
|
75
|
+
/** A question setting (`points`), or else */
|
|
76
|
+
setting?: string;
|
|
77
|
+
/** a text on the primary resource (`label`), compared trimmed and ignoring case. */
|
|
78
|
+
field?: string;
|
|
79
|
+
}
|
|
80
|
+
export type RequireCheck = 'correctOption' | 'allOptionsFilled' | 'answer' | 'imageMap';
|
|
81
|
+
export interface RequireRule extends RuleBase {
|
|
82
|
+
rule: 'require';
|
|
83
|
+
field?: string;
|
|
84
|
+
setting?: string;
|
|
85
|
+
check?: RequireCheck;
|
|
86
|
+
}
|
|
87
|
+
export interface SettingEqualsRule extends RuleBase {
|
|
88
|
+
rule: 'settingEquals';
|
|
89
|
+
setting: string;
|
|
90
|
+
equals: unknown;
|
|
91
|
+
}
|
|
92
|
+
export interface OptionCountOverRule extends RuleBase {
|
|
93
|
+
rule: 'optionCountOver';
|
|
94
|
+
optionsOver: number;
|
|
95
|
+
settingFalse: string;
|
|
96
|
+
}
|
|
97
|
+
export interface TypeSettingRule extends RuleBase {
|
|
98
|
+
rule: 'typeSetting';
|
|
99
|
+
typeSetting: {
|
|
100
|
+
key: string;
|
|
101
|
+
equals: unknown;
|
|
102
|
+
};
|
|
103
|
+
typeSettingBelow: {
|
|
104
|
+
key: string;
|
|
105
|
+
min: number;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export type FindingRule = OutlierRule | OddOneOutRule | ConsistencyRule | ExplanationPairRule | OptionCountMajorityRule | GroupSizeRule | GroupPositionRule | RequireRule | SettingEqualsRule | OptionCountOverRule | TypeSettingRule;
|
|
109
|
+
/** The existing editor-facing fields the evaluator reads as well (docs/design.md, 3.2). */
|
|
110
|
+
export type CompletenessRule = 'requireText' | 'requireCorrectOption' | 'requireAllOptionsFilled' | 'requireAnswer' | 'requireImageMap';
|
|
111
|
+
export interface OptionCountWarning {
|
|
112
|
+
optionsOver: number;
|
|
113
|
+
settingFalse: string;
|
|
114
|
+
message: LocalizedText;
|
|
115
|
+
}
|
|
116
|
+
export interface TypeSettingWarning {
|
|
117
|
+
typeSetting: {
|
|
118
|
+
key: string;
|
|
119
|
+
equals: unknown;
|
|
120
|
+
};
|
|
121
|
+
typeSettingBelow: {
|
|
122
|
+
key: string;
|
|
123
|
+
min: number;
|
|
124
|
+
};
|
|
125
|
+
message: LocalizedText;
|
|
126
|
+
}
|
|
127
|
+
export type LegacyWarning = OptionCountWarning | TypeSettingWarning;
|
|
128
|
+
export interface SettingWarning {
|
|
129
|
+
setting: string;
|
|
130
|
+
equals: unknown;
|
|
131
|
+
message: LocalizedText;
|
|
132
|
+
}
|
|
133
|
+
export interface TypeEntryBase {
|
|
134
|
+
token: string;
|
|
135
|
+
name?: LocalizedText;
|
|
136
|
+
description?: LocalizedText;
|
|
137
|
+
aliases?: string[];
|
|
138
|
+
hidden?: boolean;
|
|
139
|
+
settingsFields?: SettingsField[];
|
|
140
|
+
findings?: FindingRule[];
|
|
141
|
+
guidance?: LocalizedText;
|
|
142
|
+
[key: string]: unknown;
|
|
143
|
+
}
|
|
144
|
+
export interface RoundTypeEntry extends TypeEntryBase {
|
|
145
|
+
questionList?: QuestionList;
|
|
146
|
+
allowedQuestionTypes?: TokenList;
|
|
147
|
+
allowedScoringStrategies?: TokenList;
|
|
148
|
+
defaultScoringStrategy?: string;
|
|
149
|
+
questionSettingWarnings?: SettingWarning[];
|
|
150
|
+
}
|
|
151
|
+
export interface QuestionTypeEntry extends TypeEntryBase {
|
|
152
|
+
addToRounds?: TokenList;
|
|
153
|
+
completeness?: CompletenessRule[];
|
|
154
|
+
warnings?: LegacyWarning[];
|
|
155
|
+
settingWarnings?: SettingWarning[];
|
|
156
|
+
}
|
|
157
|
+
export interface ScoringStrategyEntry extends TypeEntryBase {
|
|
158
|
+
supportedRoundTypes?: TokenList;
|
|
159
|
+
unsupportedRoundTypes?: string[];
|
|
160
|
+
questionSettingWarnings?: SettingWarning[];
|
|
161
|
+
}
|
|
162
|
+
export interface TypeManifest {
|
|
163
|
+
id: string;
|
|
164
|
+
name?: LocalizedText;
|
|
165
|
+
version?: string;
|
|
166
|
+
roundTypes?: RoundTypeEntry[];
|
|
167
|
+
questionTypes?: QuestionTypeEntry[];
|
|
168
|
+
scoringStrategies?: ScoringStrategyEntry[];
|
|
169
|
+
/** Core manifest only: the round-wide rules every round with questions gets. */
|
|
170
|
+
findings?: FindingRule[];
|
|
171
|
+
[key: string]: unknown;
|
|
172
|
+
}
|
|
173
|
+
export interface ServedManifests {
|
|
174
|
+
format: number;
|
|
175
|
+
core: TypeManifest;
|
|
176
|
+
mods: TypeManifest[];
|
|
177
|
+
}
|
|
178
|
+
export type TypeKind = 'roundTypes' | 'questionTypes' | 'scoringStrategies';
|
|
179
|
+
/** The REST aggregates, as plain JSON (docs/design.md, 4). */
|
|
180
|
+
export type QuizAggregate = Record<string, any>;
|
|
181
|
+
export type RoundAggregate = Record<string, any>;
|
|
182
|
+
export type QuestionAggregate = Record<string, any>;
|
|
183
|
+
export interface EvaluateInput {
|
|
184
|
+
manifests: ServedManifests;
|
|
185
|
+
quiz?: QuizAggregate;
|
|
186
|
+
round?: RoundAggregate;
|
|
187
|
+
/** Extra finding ids to treat as ignored (e.g. the editor's legacy per-browser list). */
|
|
188
|
+
ignoredWarnings?: string[];
|
|
189
|
+
/** Extra rule ids muted for the whole subject. */
|
|
190
|
+
ignoredRules?: string[];
|
|
191
|
+
/** For manifest messages that come per language. */
|
|
192
|
+
language?: string;
|
|
193
|
+
}
|
|
194
|
+
export interface ValidationIssue {
|
|
195
|
+
type: 'question' | 'language' | 'round';
|
|
196
|
+
/** The location (`R2.Q3`, `Q3`), or for round issues the whole message. */
|
|
197
|
+
label: string;
|
|
198
|
+
/** What exactly is wrong (question issues). */
|
|
199
|
+
detail?: string;
|
|
200
|
+
roundId?: string;
|
|
201
|
+
questionId?: string;
|
|
202
|
+
language?: string;
|
|
203
|
+
/** Round issues: the round's mode defaults can fill what is missing. */
|
|
204
|
+
canUseDefaults?: boolean;
|
|
205
|
+
ruleId?: string;
|
|
206
|
+
/** The message template (in the asked language) and its values, for consumers with their own translations. */
|
|
207
|
+
messageKey?: string;
|
|
208
|
+
messageParams?: Record<string, string | number>;
|
|
209
|
+
}
|
|
210
|
+
export interface Finding {
|
|
211
|
+
/** `${questionUuid}:${ruleId}`, or `${roundUuid}:group:${groupId}:group-size`. */
|
|
212
|
+
id: string;
|
|
213
|
+
ruleId: string;
|
|
214
|
+
roundId: string;
|
|
215
|
+
questionId: string;
|
|
216
|
+
/** Rendered in the asked language. */
|
|
217
|
+
message: string;
|
|
218
|
+
/** The template `message` was rendered from. */
|
|
219
|
+
messageKey: string;
|
|
220
|
+
messageParams: Record<string, string | number>;
|
|
221
|
+
}
|
|
222
|
+
export type IgnoredBy = 'question' | 'round' | 'quiz' | 'caller';
|
|
223
|
+
export interface IgnoredFinding extends Finding {
|
|
224
|
+
ignoredBy: IgnoredBy;
|
|
225
|
+
}
|
|
226
|
+
export interface EvaluateResult {
|
|
227
|
+
errors: ValidationIssue[];
|
|
228
|
+
/** Warnings in effect. */
|
|
229
|
+
warnings: Finding[];
|
|
230
|
+
/** Warnings that fired but are ignored, and by whom. */
|
|
231
|
+
ignored: IgnoredFinding[];
|
|
232
|
+
}
|
|
233
|
+
export interface RuleDescription {
|
|
234
|
+
message: string;
|
|
235
|
+
messageKey: string;
|
|
236
|
+
messageParams: Record<string, string | number>;
|
|
237
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The served manifests (the part this package reads), the rule vocabulary
|
|
3
|
+
* and the evaluator's input and output (docs/design.md, 3 and 4).
|
|
4
|
+
*/
|
|
5
|
+
export const COMPARATIVE_KINDS = ['outlier', 'oddOneOut', 'consistency', 'explanationPair', 'optionCountMajority', 'groupSize', 'groupPosition'];
|
|
6
|
+
export const PER_QUESTION_KINDS = ['require', 'settingEquals', 'optionCountOver', 'typeSetting'];
|