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/evaluate.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { TypeCatalog } from './catalog.js';
|
|
2
|
+
import { questionSetting, questionTypeToken, questionsOf, normalizedGroupIds, roundName, roundTypeToken, roundsOf } from './aggregate.js';
|
|
3
|
+
import { ignoredBy } from './ignore.js';
|
|
4
|
+
import { questionWarningId } from './ignore.js';
|
|
5
|
+
import { COMPLETENESS_MESSAGES, evaluateConsistency, evaluateExplanationPair, evaluateGroupPosition, evaluateGroupSize, evaluateOddOneOut, evaluateOptionCountMajority, evaluateOutlier, evaluateRequire, matchesLegacyWarning, matchesOptionCountOver, matchesSettingEquals, matchesTypeSetting, passesCheck, render } from './rules.js';
|
|
6
|
+
import { localizedText } from './text.js';
|
|
7
|
+
import { COMPARATIVE_KINDS } from './types.js';
|
|
8
|
+
export const ROUND_INCOMPLETE_MESSAGE = '{round}: scoring settings incomplete ({fields})';
|
|
9
|
+
export const STRATEGY_NOT_OFFERED_MESSAGE = '{round}: this round type does not offer the {strategy} scoring';
|
|
10
|
+
export const QUESTION_TYPE_NOT_OFFERED_MESSAGE = '{location}: this round type does not offer the question type {questionType}';
|
|
11
|
+
export const SCORING_STRATEGY_LABEL = 'Scoring strategy';
|
|
12
|
+
/**
|
|
13
|
+
* The findings of a quiz or a round: validation issues (errors), the
|
|
14
|
+
* surprising-settings warnings in effect, and the ones that fired but are
|
|
15
|
+
* ignored (docs/design.md, 4).
|
|
16
|
+
*/
|
|
17
|
+
export function evaluateFindings(input) {
|
|
18
|
+
const catalog = new TypeCatalog(input.manifests);
|
|
19
|
+
const scope = {
|
|
20
|
+
quiz: input.quiz ?? null,
|
|
21
|
+
rounds: input.quiz ? roundsOf(input.quiz) : (input.round ? [input.round] : []),
|
|
22
|
+
singleRound: !input.quiz,
|
|
23
|
+
language: input.language,
|
|
24
|
+
ignoredWarnings: input.ignoredWarnings ?? [],
|
|
25
|
+
ignoredRules: input.ignoredRules ?? []
|
|
26
|
+
};
|
|
27
|
+
const errors = [];
|
|
28
|
+
const warnings = [];
|
|
29
|
+
const ignored = [];
|
|
30
|
+
scope.rounds.forEach((round, roundIndex) => {
|
|
31
|
+
const result = evaluateRound(catalog, scope, round, roundIndex);
|
|
32
|
+
errors.push(...result.errors);
|
|
33
|
+
const questions = questionsOf(round);
|
|
34
|
+
for (const finding of result.warnings) {
|
|
35
|
+
const question = questions.find(q => q?.uuid === finding.questionId) ?? null;
|
|
36
|
+
const by = ignoredBy(finding, {
|
|
37
|
+
round, question, quiz: scope.quiz, ignoredWarnings: scope.ignoredWarnings, ignoredRules: scope.ignoredRules
|
|
38
|
+
});
|
|
39
|
+
if (by === null) {
|
|
40
|
+
warnings.push(finding);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
ignored.push({ ...finding, ignoredBy: by });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return { errors, warnings, ignored };
|
|
48
|
+
}
|
|
49
|
+
function locationLabel(scope, roundIndex, questionIndex) {
|
|
50
|
+
const question = 'Q' + (questionIndex + 1);
|
|
51
|
+
return scope.singleRound ? question : roundLabel(scope, roundIndex) + '.' + question;
|
|
52
|
+
}
|
|
53
|
+
function roundLabel(scope, roundIndex) {
|
|
54
|
+
return scope.singleRound ? 'Round' : 'R' + (roundIndex + 1);
|
|
55
|
+
}
|
|
56
|
+
function questionListsOf(rule) {
|
|
57
|
+
if (rule.questionList === undefined) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return Array.isArray(rule.questionList) ? rule.questionList : [rule.questionList];
|
|
61
|
+
}
|
|
62
|
+
function evaluateRound(catalog, scope, round, roundIndex) {
|
|
63
|
+
const errors = [];
|
|
64
|
+
const warnings = [];
|
|
65
|
+
const language = scope.language;
|
|
66
|
+
const roundToken = catalog.canonical('roundTypes', roundTypeToken(round));
|
|
67
|
+
const roundEntry = catalog.get('roundTypes', roundToken);
|
|
68
|
+
const modeIssue = roundModeIssue(catalog, scope, round, roundIndex);
|
|
69
|
+
if (modeIssue) {
|
|
70
|
+
errors.push(modeIssue);
|
|
71
|
+
}
|
|
72
|
+
const questionList = catalog.questionList(roundToken);
|
|
73
|
+
if (questionList === 'none') {
|
|
74
|
+
return { errors, warnings };
|
|
75
|
+
}
|
|
76
|
+
const strategyEntry = catalog.get('scoringStrategies', round?.scoringStrategy?.token);
|
|
77
|
+
const questions = questionsOf(round);
|
|
78
|
+
const contexts = questions.map((question, index) => {
|
|
79
|
+
const token = catalog.canonical('questionTypes', questionTypeToken(question));
|
|
80
|
+
return {
|
|
81
|
+
question, index, token,
|
|
82
|
+
uuid: typeof question?.uuid === 'string' ? question.uuid : '',
|
|
83
|
+
settingsFields: catalog.get('questionTypes', token)?.type.settingsFields ?? []
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
// --- errors ---
|
|
87
|
+
for (const q of contexts) {
|
|
88
|
+
const entry = catalog.get('questionTypes', q.token);
|
|
89
|
+
const label = locationLabel(scope, roundIndex, q.index);
|
|
90
|
+
for (const check of entry?.type.completeness ?? []) {
|
|
91
|
+
if (!passesCheck(q.question, check)) {
|
|
92
|
+
const detail = COMPLETENESS_MESSAGES[check] ?? check;
|
|
93
|
+
errors.push({ type: 'question', label, roundId: round.uuid, questionId: q.uuid, ruleId: 'completeness:' + check, detail, messageKey: detail, messageParams: {} });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (roundEntry && entry && !catalog.allowsQuestionType(roundToken, q.token)) {
|
|
97
|
+
const params = { location: label, questionType: localizedText(entry.type.name, language) || entry.token };
|
|
98
|
+
const text = render(QUESTION_TYPE_NOT_OFFERED_MESSAGE, params, language);
|
|
99
|
+
errors.push({ type: 'round', roundId: round.uuid, questionId: q.uuid, label: text.message, messageKey: text.messageKey, messageParams: params });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Per-question rules, in scope order: round type, scoring strategy, question type, core.
|
|
103
|
+
const perQuestionRules = (q) => {
|
|
104
|
+
const applies = (rule) => {
|
|
105
|
+
const lists = questionListsOf(rule);
|
|
106
|
+
return !COMPARATIVE_KINDS.includes(rule.rule) && (lists === null || lists.includes(questionList));
|
|
107
|
+
};
|
|
108
|
+
return [
|
|
109
|
+
...(roundEntry?.type.findings ?? []).filter(applies).map(rule => ({ rule, source: 'round' })),
|
|
110
|
+
...(strategyEntry?.type.findings ?? []).filter(applies).map(rule => ({ rule, source: 'round' })),
|
|
111
|
+
...(catalog.get('questionTypes', q.token)?.type.findings ?? []).filter(applies).map(rule => ({ rule, source: 'type' })),
|
|
112
|
+
...(catalog.manifests.core?.findings ?? []).filter(applies).map(rule => ({ rule, source: 'core' }))
|
|
113
|
+
];
|
|
114
|
+
};
|
|
115
|
+
for (const q of contexts) {
|
|
116
|
+
for (const { rule } of perQuestionRules(q)) {
|
|
117
|
+
if (rule.rule !== 'require' || (rule.severity ?? 'error') !== 'error') {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const params = evaluateRequire(rule, q);
|
|
121
|
+
if (params) {
|
|
122
|
+
const text = render(rule.message, params, language);
|
|
123
|
+
errors.push({ type: 'question', label: locationLabel(scope, roundIndex, q.index), roundId: round.uuid, questionId: q.uuid, ruleId: rule.id, detail: text.message, messageKey: text.messageKey, messageParams: params });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// --- warnings ---
|
|
128
|
+
// A uuid-less question means the editor refused the entity; its warnings could not be ignored one by one, so the round has none.
|
|
129
|
+
if (typeof round?.uuid !== 'string' || round.uuid === '' || contexts.some(q => q.uuid === '')) {
|
|
130
|
+
return { errors, warnings };
|
|
131
|
+
}
|
|
132
|
+
const ctx = { roundId: round.uuid, questions: contexts, groupIds: normalizedGroupIds(questions), language };
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
const add = (finding) => {
|
|
135
|
+
if (!seen.has(finding.id)) {
|
|
136
|
+
seen.add(finding.id);
|
|
137
|
+
warnings.push(finding);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const perQuestion = (q, ruleId, text, params) => {
|
|
141
|
+
add({ id: questionWarningId(q.uuid, ruleId), ruleId, roundId: round.uuid, questionId: q.uuid, ...render(text, params, language) });
|
|
142
|
+
};
|
|
143
|
+
for (const q of contexts) {
|
|
144
|
+
// Legacy setting warnings: the round type's, then the strategy's (a setting the round type already warns about is dropped), then the question type's.
|
|
145
|
+
const roundRules = roundEntry?.type.questionSettingWarnings ?? [];
|
|
146
|
+
const roundSettings = new Set(roundRules.map(r => r.setting));
|
|
147
|
+
const declared = [
|
|
148
|
+
...roundRules.map(rule => ({ source: 'round', rule })),
|
|
149
|
+
...(strategyEntry?.type.questionSettingWarnings ?? []).filter(r => !roundSettings.has(r.setting)).map(rule => ({ source: 'round', rule })),
|
|
150
|
+
...(catalog.get('questionTypes', q.token)?.type.settingWarnings ?? []).map(rule => ({ source: 'type', rule }))
|
|
151
|
+
];
|
|
152
|
+
for (const { source, rule } of declared) {
|
|
153
|
+
if (questionSetting(q.question, rule.setting) === rule.equals) {
|
|
154
|
+
perQuestion(q, 'setting:' + source + ':' + rule.setting, rule.message, {});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Legacy answer warnings of the question type.
|
|
158
|
+
(catalog.get('questionTypes', q.token)?.type.warnings ?? []).forEach((warning, i) => {
|
|
159
|
+
if (matchesLegacyWarning(warning, q)) {
|
|
160
|
+
perQuestion(q, 'type-warning:' + i, warning.message, {});
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
// New per-question kinds.
|
|
164
|
+
for (const { rule } of perQuestionRules(q)) {
|
|
165
|
+
switch (rule.rule) {
|
|
166
|
+
case 'require': {
|
|
167
|
+
if ((rule.severity ?? 'error') !== 'warning') {
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
const params = evaluateRequire(rule, q);
|
|
171
|
+
if (params) {
|
|
172
|
+
perQuestion(q, rule.id, rule.message, params);
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case 'settingEquals':
|
|
177
|
+
if (matchesSettingEquals(rule, q)) {
|
|
178
|
+
perQuestion(q, rule.id, rule.message, {});
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
case 'optionCountOver':
|
|
182
|
+
if (matchesOptionCountOver(rule, q)) {
|
|
183
|
+
perQuestion(q, rule.id, rule.message, {});
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
case 'typeSetting':
|
|
187
|
+
if (matchesTypeSetting(rule, q)) {
|
|
188
|
+
perQuestion(q, rule.id, rule.message, {});
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Comparative rules: round type, scoring strategy, core. The explanation pair goes first: its swaps quiet the consistency gaps they explain.
|
|
195
|
+
const comparative = [
|
|
196
|
+
...(roundEntry?.type.findings ?? []),
|
|
197
|
+
...(strategyEntry?.type.findings ?? []),
|
|
198
|
+
...(catalog.manifests.core?.findings ?? [])
|
|
199
|
+
].filter(rule => {
|
|
200
|
+
const lists = questionListsOf(rule);
|
|
201
|
+
return COMPARATIVE_KINDS.includes(rule.rule) && (lists === null || lists.includes(questionList));
|
|
202
|
+
});
|
|
203
|
+
const quiet = new Set();
|
|
204
|
+
for (const rule of comparative) {
|
|
205
|
+
if (rule.rule === 'explanationPair') {
|
|
206
|
+
evaluateExplanationPair(rule, ctx, quiet).forEach(add);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const rule of comparative) {
|
|
210
|
+
switch (rule.rule) {
|
|
211
|
+
case 'outlier':
|
|
212
|
+
evaluateOutlier(rule, ctx).forEach(add);
|
|
213
|
+
break;
|
|
214
|
+
case 'oddOneOut':
|
|
215
|
+
evaluateOddOneOut(rule, ctx).forEach(add);
|
|
216
|
+
break;
|
|
217
|
+
case 'optionCountMajority':
|
|
218
|
+
evaluateOptionCountMajority(rule, ctx).forEach(add);
|
|
219
|
+
break;
|
|
220
|
+
case 'groupSize':
|
|
221
|
+
evaluateGroupSize(rule, ctx).forEach(add);
|
|
222
|
+
break;
|
|
223
|
+
case 'groupPosition':
|
|
224
|
+
evaluateGroupPosition(rule, ctx).forEach(add);
|
|
225
|
+
break;
|
|
226
|
+
case 'consistency':
|
|
227
|
+
evaluateConsistency(rule, ctx, quiet).forEach(add);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { errors, warnings };
|
|
232
|
+
}
|
|
233
|
+
// --- round settings (the editor's round-mode-defaults.ts) ---
|
|
234
|
+
function isMeaningfulDefault(value) {
|
|
235
|
+
return value !== undefined && value !== null && value !== '';
|
|
236
|
+
}
|
|
237
|
+
function isMissing(value, defaultValue) {
|
|
238
|
+
if (value === undefined) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
return (value === null || value === '') && isMeaningfulDefault(defaultValue);
|
|
242
|
+
}
|
|
243
|
+
function isVisible(field, bag) {
|
|
244
|
+
return !field.showWhen || bag[field.showWhen.key] === field.showWhen.equals;
|
|
245
|
+
}
|
|
246
|
+
function missingFields(fields, bag) {
|
|
247
|
+
return fields.filter(field => isMeaningfulDefault(field.default) && isVisible(field, bag) && isMissing(bag[field.key], field.default));
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* A round short of its mode's scoring settings: no (allowed) scoring
|
|
251
|
+
* strategy, or a manifest field with a default left empty. A strategy the
|
|
252
|
+
* round type does not allow gets its own sentence. Known round types only.
|
|
253
|
+
*/
|
|
254
|
+
function roundModeIssue(catalog, scope, round, roundIndex) {
|
|
255
|
+
const roundToken = catalog.canonical('roundTypes', roundTypeToken(round));
|
|
256
|
+
const roundEntry = catalog.get('roundTypes', roundToken);
|
|
257
|
+
if (!roundEntry) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const language = scope.language;
|
|
261
|
+
const current = round?.scoringStrategy && typeof round.scoringStrategy === 'object' ? round.scoringStrategy : null;
|
|
262
|
+
const currentToken = typeof current?.token === 'string' && current.token !== '' ? current.token : null;
|
|
263
|
+
const allowed = catalog.offeredScoringStrategies(roundToken);
|
|
264
|
+
const invalidStrategy = allowed.length > 0 && (currentToken === null || !catalog.allowsScoringStrategy(roundToken, currentToken));
|
|
265
|
+
const strategyToken = invalidStrategy ? catalog.pickedScoringStrategy(roundToken) : currentToken;
|
|
266
|
+
const strategyBag = invalidStrategy ? {} : (current ?? {});
|
|
267
|
+
const missing = [
|
|
268
|
+
...missingFields(catalog.get('scoringStrategies', strategyToken)?.type.settingsFields ?? [], strategyBag),
|
|
269
|
+
...missingFields(roundEntry.type.settingsFields ?? [], round?.modeSettings && typeof round.modeSettings === 'object' ? round.modeSettings : {})
|
|
270
|
+
];
|
|
271
|
+
if (!invalidStrategy && missing.length === 0) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
const name = roundName(round) || roundLabel(scope, roundIndex);
|
|
275
|
+
if (invalidStrategy && currentToken !== null) {
|
|
276
|
+
const strategy = catalog.get('scoringStrategies', currentToken);
|
|
277
|
+
const params = { round: name, strategy: localizedText(strategy?.type.name, language) || currentToken };
|
|
278
|
+
const text = render(STRATEGY_NOT_OFFERED_MESSAGE, params, language);
|
|
279
|
+
return { type: 'round', roundId: round.uuid, label: text.message, canUseDefaults: true, messageKey: text.messageKey, messageParams: params };
|
|
280
|
+
}
|
|
281
|
+
const fields = missing.length > 0
|
|
282
|
+
? missing.map(field => localizedText(field.label, language) || field.key)
|
|
283
|
+
: [SCORING_STRATEGY_LABEL];
|
|
284
|
+
const params = { round: name, fields: fields.join(', ') };
|
|
285
|
+
const text = render(ROUND_INCOMPLETE_MESSAGE, params, language);
|
|
286
|
+
return { type: 'round', roundId: round.uuid, label: text.message, canUseDefaults: true, messageKey: text.messageKey, messageParams: params };
|
|
287
|
+
}
|
package/dist/ignore.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Finding, IgnoredBy, QuestionAggregate, RoundAggregate } from './types.js';
|
|
2
|
+
/** The ignore key of a per-question finding: `${questionUuid}:${ruleId}`. */
|
|
3
|
+
export declare function questionWarningId(questionUuid: string, ruleId: string): string;
|
|
4
|
+
/** The ignore key of the group-size finding: `${roundUuid}:group:${groupId}:group-size`. */
|
|
5
|
+
export declare function groupSizeWarningId(roundUuid: string, groupId: number | string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Where ignoring a finding is stored (the entity's `ignoredWarnings`): the
|
|
8
|
+
* group-size warning on its round, as `group:{groupId}:group-size`; every
|
|
9
|
+
* other warning on its question, as the rule id.
|
|
10
|
+
*/
|
|
11
|
+
export declare function findingIgnoreTarget(finding: Finding): {
|
|
12
|
+
entity: 'round' | 'question';
|
|
13
|
+
key: string;
|
|
14
|
+
};
|
|
15
|
+
/** Whether the finding is ignored on its own entity (the question, or the round for a group). */
|
|
16
|
+
export declare function isIgnoredOnEntity(finding: Finding, round: RoundAggregate | null | undefined, question: QuestionAggregate | null | undefined): boolean;
|
|
17
|
+
export interface IgnoreContext {
|
|
18
|
+
round?: RoundAggregate | null;
|
|
19
|
+
question?: QuestionAggregate | null;
|
|
20
|
+
quiz?: Record<string, any> | null;
|
|
21
|
+
ignoredWarnings?: string[];
|
|
22
|
+
ignoredRules?: string[];
|
|
23
|
+
}
|
|
24
|
+
/** Who ignores the finding, or null when it is in effect. */
|
|
25
|
+
export declare function ignoredBy(finding: Finding, context: IgnoreContext): IgnoredBy | null;
|
package/dist/ignore.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** The ignore key of a per-question finding: `${questionUuid}:${ruleId}`. */
|
|
2
|
+
export function questionWarningId(questionUuid, ruleId) {
|
|
3
|
+
return questionUuid + ':' + ruleId;
|
|
4
|
+
}
|
|
5
|
+
/** The ignore key of the group-size finding: `${roundUuid}:group:${groupId}:group-size`. */
|
|
6
|
+
export function groupSizeWarningId(roundUuid, groupId) {
|
|
7
|
+
return roundUuid + ':group:' + groupId + ':group-size';
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Where ignoring a finding is stored (the entity's `ignoredWarnings`): the
|
|
11
|
+
* group-size warning on its round, as `group:{groupId}:group-size`; every
|
|
12
|
+
* other warning on its question, as the rule id.
|
|
13
|
+
*/
|
|
14
|
+
export function findingIgnoreTarget(finding) {
|
|
15
|
+
if (finding.id.startsWith(finding.roundId + ':')) {
|
|
16
|
+
return { entity: 'round', key: finding.id.substring(finding.roundId.length + 1) };
|
|
17
|
+
}
|
|
18
|
+
return { entity: 'question', key: finding.id.substring(finding.questionId.length + 1) };
|
|
19
|
+
}
|
|
20
|
+
function ignoredWarningsOf(entity) {
|
|
21
|
+
const list = entity?.ignoredWarnings;
|
|
22
|
+
return Array.isArray(list) ? list.filter((key) => typeof key === 'string') : [];
|
|
23
|
+
}
|
|
24
|
+
/** Whether the finding is ignored on its own entity (the question, or the round for a group). */
|
|
25
|
+
export function isIgnoredOnEntity(finding, round, question) {
|
|
26
|
+
const target = findingIgnoreTarget(finding);
|
|
27
|
+
return target.entity === 'round'
|
|
28
|
+
? ignoredWarningsOf(round).includes(target.key)
|
|
29
|
+
: ignoredWarningsOf(question).includes(target.key);
|
|
30
|
+
}
|
|
31
|
+
/** Who ignores the finding, or null when it is in effect. */
|
|
32
|
+
export function ignoredBy(finding, context) {
|
|
33
|
+
if (isIgnoredOnEntity(finding, context.round, context.question)) {
|
|
34
|
+
return findingIgnoreTarget(finding).entity;
|
|
35
|
+
}
|
|
36
|
+
if (ignoredWarningsOf(context.quiz).includes(finding.ruleId)) {
|
|
37
|
+
return 'quiz';
|
|
38
|
+
}
|
|
39
|
+
if ((context.ignoredWarnings ?? []).includes(finding.id) || (context.ignoredRules ?? []).includes(finding.ruleId)) {
|
|
40
|
+
return 'caller';
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export { evaluateFindings, ROUND_INCOMPLETE_MESSAGE, STRATEGY_NOT_OFFERED_MESSAGE, QUESTION_TYPE_NOT_OFFERED_MESSAGE, SCORING_STRATEGY_LABEL } from './evaluate.js';
|
|
3
|
+
export { describeRule, guidanceFor, listRules, type DeclaredRule } from './describe.js';
|
|
4
|
+
export { localizedText, interpolate } from './text.js';
|
|
5
|
+
export { questionWarningId, groupSizeWarningId, findingIgnoreTarget, isIgnoredOnEntity, ignoredBy, type IgnoreContext } from './ignore.js';
|
|
6
|
+
export { TypeCatalog, TYPE_KINDS, type CatalogEntry } from './catalog.js';
|
|
7
|
+
export { COMPLETENESS_MESSAGES, passesCheck } from './rules.js';
|
|
8
|
+
export { DEFAULT_QUESTION_SETTINGS, DEFAULT_QUESTION_TYPE, DEFAULT_ROUND_TYPE, questionSetting, typeSettingValue, optionsOf, filledOptionCount, attachmentsOf, imageMapOf, fillsSlot, primaryResource, resourceText, validGroupId, normalizedGroupIds, questionsOf, roundsOf } from './aggregate.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export { evaluateFindings, ROUND_INCOMPLETE_MESSAGE, STRATEGY_NOT_OFFERED_MESSAGE, QUESTION_TYPE_NOT_OFFERED_MESSAGE, SCORING_STRATEGY_LABEL } from './evaluate.js';
|
|
3
|
+
export { describeRule, guidanceFor, listRules } from './describe.js';
|
|
4
|
+
export { localizedText, interpolate } from './text.js';
|
|
5
|
+
export { questionWarningId, groupSizeWarningId, findingIgnoreTarget, isIgnoredOnEntity, ignoredBy } from './ignore.js';
|
|
6
|
+
export { TypeCatalog, TYPE_KINDS } from './catalog.js';
|
|
7
|
+
export { COMPLETENESS_MESSAGES, passesCheck } from './rules.js';
|
|
8
|
+
export { DEFAULT_QUESTION_SETTINGS, DEFAULT_QUESTION_TYPE, DEFAULT_ROUND_TYPE, questionSetting, typeSettingValue, optionsOf, filledOptionCount, attachmentsOf, imageMapOf, fillsSlot, primaryResource, resourceText, validGroupId, normalizedGroupIds, questionsOf, roundsOf } from './aggregate.js';
|
package/dist/rules.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { CompletenessRule, ConsistencyRule, ExplanationPairRule, Finding, GroupPositionRule, GroupSizeRule, LegacyWarning, LocalizedText, OddOneOutRule, OptionCountMajorityRule, OptionCountOverRule, OutlierRule, QuestionAggregate, RequireCheck, RequireRule, SettingEqualsRule, SettingsField, TypeSettingRule } from './types.js';
|
|
2
|
+
export interface QuestionContext {
|
|
3
|
+
question: QuestionAggregate;
|
|
4
|
+
uuid: string;
|
|
5
|
+
/** Canonical question type token. */
|
|
6
|
+
token: string;
|
|
7
|
+
index: number;
|
|
8
|
+
/** The type's settings fields, for type-setting defaults. */
|
|
9
|
+
settingsFields: SettingsField[];
|
|
10
|
+
}
|
|
11
|
+
export interface RoundContext {
|
|
12
|
+
roundId: string;
|
|
13
|
+
questions: QuestionContext[];
|
|
14
|
+
/** Normalised group id per question (index-aligned). */
|
|
15
|
+
groupIds: number[];
|
|
16
|
+
language?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface RenderedMessage {
|
|
19
|
+
message: string;
|
|
20
|
+
messageKey: string;
|
|
21
|
+
messageParams: Record<string, string | number>;
|
|
22
|
+
}
|
|
23
|
+
export declare function render(text: LocalizedText, params: Record<string, string | number>, language?: string): RenderedMessage;
|
|
24
|
+
export declare function evaluateOutlier(rule: OutlierRule, ctx: RoundContext): Finding[];
|
|
25
|
+
export declare function evaluateOddOneOut(rule: OddOneOutRule, ctx: RoundContext): Finding[];
|
|
26
|
+
export declare function evaluateOptionCountMajority(rule: OptionCountMajorityRule, ctx: RoundContext): Finding[];
|
|
27
|
+
export declare function evaluateGroupSize(rule: GroupSizeRule, ctx: RoundContext): Finding[];
|
|
28
|
+
/**
|
|
29
|
+
* The nth question of every group compared across the groups: where a strict
|
|
30
|
+
* majority (but not all) of the groups agree on the value, the nth questions
|
|
31
|
+
* that differ. Questions without a value (no positive setting, empty text)
|
|
32
|
+
* are left out of the count.
|
|
33
|
+
*/
|
|
34
|
+
export declare function evaluateGroupPosition(rule: GroupPositionRule, ctx: RoundContext): Finding[];
|
|
35
|
+
/** Explanation swaps; `quiet` names the `questionUuid:field` gaps the swap explains, for the consistency rules. */
|
|
36
|
+
export declare function evaluateExplanationPair(rule: ExplanationPairRule, ctx: RoundContext, quiet: Set<string>): Finding[];
|
|
37
|
+
export declare function evaluateConsistency(rule: ConsistencyRule, ctx: RoundContext, quiet: Set<string>): Finding[];
|
|
38
|
+
/** The completeness checks by name (the editor's QuestionTypeDescriptor.checkRule). */
|
|
39
|
+
export declare function passesCheck(question: QuestionAggregate, check: RequireCheck | CompletenessRule): boolean;
|
|
40
|
+
/** What a failed `require` reports, or null when it passes. */
|
|
41
|
+
export declare function evaluateRequire(rule: RequireRule, q: QuestionContext): Record<string, string | number> | null;
|
|
42
|
+
export declare function matchesSettingEquals(rule: SettingEqualsRule | {
|
|
43
|
+
setting: string;
|
|
44
|
+
equals: unknown;
|
|
45
|
+
}, q: QuestionContext): boolean;
|
|
46
|
+
export declare function matchesOptionCountOver(rule: OptionCountOverRule | {
|
|
47
|
+
optionsOver: number;
|
|
48
|
+
settingFalse: string;
|
|
49
|
+
}, q: QuestionContext): boolean;
|
|
50
|
+
export declare function matchesTypeSetting(rule: TypeSettingRule | {
|
|
51
|
+
typeSetting: {
|
|
52
|
+
key: string;
|
|
53
|
+
equals: unknown;
|
|
54
|
+
};
|
|
55
|
+
typeSettingBelow: {
|
|
56
|
+
key: string;
|
|
57
|
+
min: number;
|
|
58
|
+
};
|
|
59
|
+
}, q: QuestionContext): boolean;
|
|
60
|
+
/** A legacy `warnings` entry of a question type (optionsOver / typeSetting shapes). */
|
|
61
|
+
export declare function matchesLegacyWarning(warning: LegacyWarning, q: QuestionContext): boolean;
|
|
62
|
+
/** The English detail of a failed completeness rule. */
|
|
63
|
+
export declare const COMPLETENESS_MESSAGES: Record<CompletenessRule, string>;
|