quizwitz-validator 0.0.0-stage → 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/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 +284 -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 +56 -0
- package/dist/rules.js +270 -0
- package/dist/text.d.ts +9 -0
- package/dist/text.js +29 -0
- package/dist/types.d.ts +230 -0
- package/dist/types.js +6 -0
- package/docs/design.md +160 -0
- package/fixtures/findings-fixtures.json +7040 -0
- package/package.json +56 -4
package/README.md
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
-
#
|
|
1
|
+
# quizwitz-validator
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Evaluates the **findings** of a QuizWitz quiz or round: validation issues
|
|
4
|
+
(errors) and surprising-settings warnings, as the game client's type
|
|
5
|
+
manifests declare them. One evaluator for the quiz editor
|
|
6
|
+
(`quizwitz-social`) and the MCP server (`quizwitz-mcp`), so both agree and
|
|
7
|
+
every rule is data in `client.quizwitz.com` (`types/manifests.json`), where
|
|
8
|
+
mods declare their own.
|
|
9
|
+
|
|
10
|
+
Design, schema and rule vocabulary: [docs/design.md](docs/design.md).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
npm install quizwitz-validator
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`dist/` is committed: no build step on install. ESM, TypeScript types
|
|
19
|
+
included, no runtime dependencies, no DOM, Node 20+.
|
|
20
|
+
|
|
21
|
+
## Use
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { evaluateFindings, describeRule, guidanceFor } from 'quizwitz-validator';
|
|
25
|
+
|
|
26
|
+
const manifests = await (await fetch(gameClient + '/types/manifests.json')).json();
|
|
27
|
+
const { errors, warnings, ignored } = evaluateFindings({
|
|
28
|
+
manifests,
|
|
29
|
+
quiz, // or round: the REST aggregate, as plain JSON
|
|
30
|
+
ignoredWarnings: [...], // optional: extra finding ids to treat as ignored
|
|
31
|
+
ignoredRules: [...], // optional: extra rule ids muted for the whole subject
|
|
32
|
+
language: 'nl' // optional: for manifest messages per language
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describeRule(manifests, 'odd:hideQuestion'); // the rule in one line, for an "ignored checks" list
|
|
36
|
+
guidanceFor(manifests, 'roundTypes', 'mod-betting'); // prose on what makes a good round of this type
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- `errors`: `ValidationIssue[]` (`type`, `label`, `detail`, `roundId`, `questionId`, `canUseDefaults`, `ruleId`, `messageKey`, `messageParams`).
|
|
40
|
+
- `warnings`: `Finding[]` in effect (`id`, `ruleId`, `roundId`, `questionId`, `message`, `messageKey`, `messageParams`).
|
|
41
|
+
- `ignored`: the findings that fired but are ignored, with `ignoredBy` (`question`, `round`, `quiz`, `caller`).
|
|
42
|
+
|
|
43
|
+
Ids and ignore keys are the editor's: `${questionUuid}:${ruleId}` and
|
|
44
|
+
`${roundUuid}:group:${groupId}:group-size` (`questionWarningId`,
|
|
45
|
+
`groupSizeWarningId`, `findingIgnoreTarget`).
|
|
46
|
+
|
|
47
|
+
## Develop
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
npm install
|
|
51
|
+
npm test # unit tests + every fixture (committed copy, and the game client checkout when it is next to this repo)
|
|
52
|
+
npm run verify # typecheck, build dist/, test
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The fixtures (`fixtures/findings-fixtures.json`) are a copy of the game
|
|
56
|
+
client's served `types/findings-fixtures.json`; refresh the copy when the
|
|
57
|
+
game client's rules change. Build `dist/` (`npm run build`) and commit it
|
|
58
|
+
before tagging a release.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readers for the REST aggregates (quiz, round, question) as plain JSON:
|
|
3
|
+
* per-language content in `resources[]`, flat play settings on the question,
|
|
4
|
+
* options under the resource, attachments as `{timing: {items}}`, the image
|
|
5
|
+
* map as an image attachment in option 1's `during` slot. Defaults are the
|
|
6
|
+
* editor's (DEFAULT_QUESTION_SETTINGS), applied the way it applies them on
|
|
7
|
+
* load.
|
|
8
|
+
*/
|
|
9
|
+
import type { QuestionAggregate, QuizAggregate, RoundAggregate } from './types.js';
|
|
10
|
+
export declare const DEFAULT_QUESTION_SETTINGS: Record<string, unknown>;
|
|
11
|
+
export declare const DEFAULT_QUESTION_TYPE = "multiplechoice";
|
|
12
|
+
export declare const DEFAULT_ROUND_TYPE = "trivia";
|
|
13
|
+
/** The type settings the API also keeps in question columns. */
|
|
14
|
+
export declare const COLUMN_TYPE_SETTINGS: readonly string[];
|
|
15
|
+
export declare function roundsOf(quiz: QuizAggregate | null | undefined): RoundAggregate[];
|
|
16
|
+
export declare function questionsOf(round: RoundAggregate | null | undefined): QuestionAggregate[];
|
|
17
|
+
/** The original-language resource: the one whose language is `original_language`, else the first. */
|
|
18
|
+
export declare function primaryResource(entity: Record<string, any> | null | undefined): Record<string, any> | null;
|
|
19
|
+
/** Trimmed text of a resource field (`text` is the question text, stored as `name`). */
|
|
20
|
+
export declare function resourceText(entity: Record<string, any>, field: string): string;
|
|
21
|
+
export declare function roundName(round: RoundAggregate): string;
|
|
22
|
+
export declare function questionTypeToken(question: QuestionAggregate): string;
|
|
23
|
+
export declare function roundTypeToken(round: RoundAggregate): string;
|
|
24
|
+
/** A play setting as the editor reads it: the flat field, else `settings.{key}`, else the default. */
|
|
25
|
+
export declare function questionSetting(question: QuestionAggregate, key: string): unknown;
|
|
26
|
+
/** A type setting: `typeSettings`, else the question's own column for the ones the API keeps flat. */
|
|
27
|
+
export declare function typeSettingValue(question: QuestionAggregate, key: string): unknown;
|
|
28
|
+
export interface Option {
|
|
29
|
+
text: string;
|
|
30
|
+
correct: boolean;
|
|
31
|
+
raw: Record<string, any>;
|
|
32
|
+
}
|
|
33
|
+
export declare function optionsOf(question: QuestionAggregate): Option[];
|
|
34
|
+
export declare function filledOptionCount(question: QuestionAggregate): number;
|
|
35
|
+
/** The attachments map `{timing: {items}}`; attachments are language-agnostic, the primary resource holds them. */
|
|
36
|
+
export declare function attachmentsOf(entity: Record<string, any>): Record<string, {
|
|
37
|
+
items: any[];
|
|
38
|
+
}>;
|
|
39
|
+
export interface ImageMap {
|
|
40
|
+
points: {
|
|
41
|
+
kind?: string;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
}[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The image map: an image attachment in the `during` slot of the first
|
|
47
|
+
* option (the editor's storage), or a flat `imageMap` snapshot. Null when
|
|
48
|
+
* there is none.
|
|
49
|
+
*/
|
|
50
|
+
export declare function imageMapOf(question: QuestionAggregate): ImageMap | null;
|
|
51
|
+
/** Whether the question shows or plays anything in a slot; an image map counts as `during`. */
|
|
52
|
+
export declare function fillsSlot(question: QuestionAggregate, slot: string): boolean;
|
|
53
|
+
/** A question's `group` as a usable id: a positive integer, also as the numeric string the API returns. */
|
|
54
|
+
export declare function validGroupId(value: unknown): number | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* The group id of every question, normalised as the editor does on load
|
|
57
|
+
* (TranslatedRound.normalizeQuestionGroups): a run of consecutive ungrouped
|
|
58
|
+
* questions becomes one new group above the highest id.
|
|
59
|
+
*/
|
|
60
|
+
export declare function normalizedGroupIds(questions: QuestionAggregate[]): number[];
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
export const DEFAULT_QUESTION_SETTINGS = {
|
|
2
|
+
points: 1000,
|
|
3
|
+
duration: 30,
|
|
4
|
+
randomizeOptions: true,
|
|
5
|
+
multipleCorrect: false,
|
|
6
|
+
skipOnAllAnswered: true,
|
|
7
|
+
showResults: true,
|
|
8
|
+
timeBasedScoring: true,
|
|
9
|
+
forceImmediateResults: false,
|
|
10
|
+
hideQuestion: false,
|
|
11
|
+
playTimerMusic: null,
|
|
12
|
+
autoJudge: null
|
|
13
|
+
};
|
|
14
|
+
export const DEFAULT_QUESTION_TYPE = 'multiplechoice';
|
|
15
|
+
export const DEFAULT_ROUND_TYPE = 'trivia';
|
|
16
|
+
/** The type settings the API also keeps in question columns. */
|
|
17
|
+
export const COLUMN_TYPE_SETTINGS = ['minAnswers', 'maxAnswers', 'scoreStrategy'];
|
|
18
|
+
export function roundsOf(quiz) {
|
|
19
|
+
const items = quiz?.rounds?.items;
|
|
20
|
+
return Array.isArray(items) ? items : [];
|
|
21
|
+
}
|
|
22
|
+
export function questionsOf(round) {
|
|
23
|
+
const items = round?.questions?.items;
|
|
24
|
+
return Array.isArray(items) ? items : [];
|
|
25
|
+
}
|
|
26
|
+
/** The original-language resource: the one whose language is `original_language`, else the first. */
|
|
27
|
+
export function primaryResource(entity) {
|
|
28
|
+
const resources = entity?.resources;
|
|
29
|
+
if (!Array.isArray(resources) || resources.length === 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const original = entity?.original_language;
|
|
33
|
+
return resources.find(r => r && r.language === original) ?? resources[0] ?? null;
|
|
34
|
+
}
|
|
35
|
+
/** Trimmed text of a resource field (`text` is the question text, stored as `name`). */
|
|
36
|
+
export function resourceText(entity, field) {
|
|
37
|
+
const key = field === 'text' ? 'name' : field;
|
|
38
|
+
const value = primaryResource(entity)?.[key];
|
|
39
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
40
|
+
}
|
|
41
|
+
export function roundName(round) {
|
|
42
|
+
return resourceText(round, 'name');
|
|
43
|
+
}
|
|
44
|
+
export function questionTypeToken(question) {
|
|
45
|
+
const token = question?.mode?.token;
|
|
46
|
+
return typeof token === 'string' && token !== '' ? token : DEFAULT_QUESTION_TYPE;
|
|
47
|
+
}
|
|
48
|
+
export function roundTypeToken(round) {
|
|
49
|
+
const token = round?.mode?.token;
|
|
50
|
+
return typeof token === 'string' && token !== '' ? token : DEFAULT_ROUND_TYPE;
|
|
51
|
+
}
|
|
52
|
+
/** A play setting as the editor reads it: the flat field, else `settings.{key}`, else the default. */
|
|
53
|
+
export function questionSetting(question, key) {
|
|
54
|
+
const flat = question?.[key];
|
|
55
|
+
if (flat !== undefined && flat !== null) {
|
|
56
|
+
return flat;
|
|
57
|
+
}
|
|
58
|
+
const nested = question?.settings?.[key];
|
|
59
|
+
if (nested !== undefined && nested !== null) {
|
|
60
|
+
return nested;
|
|
61
|
+
}
|
|
62
|
+
return key in DEFAULT_QUESTION_SETTINGS ? DEFAULT_QUESTION_SETTINGS[key] : undefined;
|
|
63
|
+
}
|
|
64
|
+
/** A type setting: `typeSettings`, else the question's own column for the ones the API keeps flat. */
|
|
65
|
+
export function typeSettingValue(question, key) {
|
|
66
|
+
const stored = question?.typeSettings && !Array.isArray(question.typeSettings) ? question.typeSettings[key] : undefined;
|
|
67
|
+
if (stored !== undefined && stored !== null) {
|
|
68
|
+
return stored;
|
|
69
|
+
}
|
|
70
|
+
const flat = COLUMN_TYPE_SETTINGS.includes(key) ? question?.[key] : undefined;
|
|
71
|
+
return flat !== undefined && flat !== null ? flat : undefined;
|
|
72
|
+
}
|
|
73
|
+
export function optionsOf(question) {
|
|
74
|
+
const items = primaryResource(question)?.options?.items;
|
|
75
|
+
if (!Array.isArray(items)) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
return items.filter(item => item && typeof item === 'object').map(item => ({
|
|
79
|
+
text: typeof item.text === 'string' ? item.text : '',
|
|
80
|
+
correct: item.correct === true,
|
|
81
|
+
raw: item
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
export function filledOptionCount(question) {
|
|
85
|
+
return optionsOf(question).filter(o => o.text.trim().length > 0).length;
|
|
86
|
+
}
|
|
87
|
+
/** The attachments map `{timing: {items}}`; attachments are language-agnostic, the primary resource holds them. */
|
|
88
|
+
export function attachmentsOf(entity) {
|
|
89
|
+
const map = primaryResource(entity)?.attachments ?? entity?.attachments;
|
|
90
|
+
return map && typeof map === 'object' && !Array.isArray(map) ? map : {};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The image map: an image attachment in the `during` slot of the first
|
|
94
|
+
* option (the editor's storage), or a flat `imageMap` snapshot. Null when
|
|
95
|
+
* there is none.
|
|
96
|
+
*/
|
|
97
|
+
export function imageMapOf(question) {
|
|
98
|
+
const flat = question?.imageMap;
|
|
99
|
+
if (flat && typeof flat === 'object' && Array.isArray(flat.points)) {
|
|
100
|
+
return { points: flat.points };
|
|
101
|
+
}
|
|
102
|
+
const during = optionsOf(question)[0]?.raw?.attachments?.during?.items;
|
|
103
|
+
if (!Array.isArray(during)) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const image = during.find(a => a && a.type === 'image');
|
|
107
|
+
if (!image) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const points = image.attributes?.points;
|
|
111
|
+
return { points: Array.isArray(points) ? points : [] };
|
|
112
|
+
}
|
|
113
|
+
/** Whether the question shows or plays anything in a slot; an image map counts as `during`. */
|
|
114
|
+
export function fillsSlot(question, slot) {
|
|
115
|
+
const items = attachmentsOf(question)[slot]?.items;
|
|
116
|
+
return (Array.isArray(items) && items.length > 0) || (slot === 'during' && imageMapOf(question) !== null);
|
|
117
|
+
}
|
|
118
|
+
/** A question's `group` as a usable id: a positive integer, also as the numeric string the API returns. */
|
|
119
|
+
export function validGroupId(value) {
|
|
120
|
+
if (value === undefined || value === null || value === '') {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const parsed = typeof value === 'number' ? value : parseInt(String(value), 10);
|
|
124
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The group id of every question, normalised as the editor does on load
|
|
128
|
+
* (TranslatedRound.normalizeQuestionGroups): a run of consecutive ungrouped
|
|
129
|
+
* questions becomes one new group above the highest id.
|
|
130
|
+
*/
|
|
131
|
+
export function normalizedGroupIds(questions) {
|
|
132
|
+
const ids = questions.map(q => validGroupId(q?.group));
|
|
133
|
+
let max = ids.reduce((m, id) => (id !== undefined && id > m ? id : m), 0);
|
|
134
|
+
let runId = null;
|
|
135
|
+
let prevUngrouped = false;
|
|
136
|
+
return ids.map(id => {
|
|
137
|
+
if (id !== undefined) {
|
|
138
|
+
prevUngrouped = false;
|
|
139
|
+
return id;
|
|
140
|
+
}
|
|
141
|
+
if (!(prevUngrouped && runId !== null)) {
|
|
142
|
+
runId = ++max;
|
|
143
|
+
}
|
|
144
|
+
prevUngrouped = true;
|
|
145
|
+
return runId;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { QuestionTypeEntry, RoundTypeEntry, ScoringStrategyEntry, ServedManifests, TypeKind, TypeManifest } from './types.js';
|
|
2
|
+
export interface EntryByKind {
|
|
3
|
+
roundTypes: RoundTypeEntry;
|
|
4
|
+
questionTypes: QuestionTypeEntry;
|
|
5
|
+
scoringStrategies: ScoringStrategyEntry;
|
|
6
|
+
}
|
|
7
|
+
export interface CatalogEntry<K extends TypeKind = TypeKind> {
|
|
8
|
+
token: string;
|
|
9
|
+
type: EntryByKind[K];
|
|
10
|
+
/** The mod's manifest, or null for a core type. */
|
|
11
|
+
mod: TypeManifest | null;
|
|
12
|
+
}
|
|
13
|
+
export declare const TYPE_KINDS: readonly TypeKind[];
|
|
14
|
+
/**
|
|
15
|
+
* The types of one served manifests file, by kind: a port of the game's
|
|
16
|
+
* TypeCatalog lookups the rules need (tokens ignore case, aliases resolve,
|
|
17
|
+
* core first, a token already taken is not learned twice).
|
|
18
|
+
*/
|
|
19
|
+
export declare class TypeCatalog {
|
|
20
|
+
readonly manifests: ServedManifests;
|
|
21
|
+
private readonly names;
|
|
22
|
+
private readonly entries;
|
|
23
|
+
constructor(manifests: ServedManifests);
|
|
24
|
+
private learn;
|
|
25
|
+
canonical(kind: TypeKind, token: unknown): string | null;
|
|
26
|
+
get<K extends TypeKind>(kind: K, token: unknown): CatalogEntry<K> | null;
|
|
27
|
+
all<K extends TypeKind>(kind: K): CatalogEntry<K>[];
|
|
28
|
+
private listAllows;
|
|
29
|
+
/** D12: the round type allows the question type (absent: all) and the question type accepts the round type (absent: none). */
|
|
30
|
+
allowsQuestionType(roundToken: unknown, questionToken: unknown): boolean;
|
|
31
|
+
/** Allowed by the round type, supported by the strategy, not listed as unsupported. Hidden is not checked. */
|
|
32
|
+
allowsScoringStrategy(roundToken: unknown, strategyToken: unknown): boolean;
|
|
33
|
+
/** The strategies offered for a round type: allowed, supported and not hidden. */
|
|
34
|
+
offeredScoringStrategies(roundToken: unknown): string[];
|
|
35
|
+
/** What a new round of this type scores with: its default, else the first allowed, else `default`; a hidden default reads as `default`. */
|
|
36
|
+
pickedScoringStrategy(roundToken: unknown): string;
|
|
37
|
+
questionList(roundToken: unknown): 'list' | 'grouped' | 'none';
|
|
38
|
+
}
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export const TYPE_KINDS = ['roundTypes', 'questionTypes', 'scoringStrategies'];
|
|
2
|
+
/**
|
|
3
|
+
* The types of one served manifests file, by kind: a port of the game's
|
|
4
|
+
* TypeCatalog lookups the rules need (tokens ignore case, aliases resolve,
|
|
5
|
+
* core first, a token already taken is not learned twice).
|
|
6
|
+
*/
|
|
7
|
+
export class TypeCatalog {
|
|
8
|
+
manifests;
|
|
9
|
+
names = { roundTypes: new Map(), questionTypes: new Map(), scoringStrategies: new Map() };
|
|
10
|
+
entries = { roundTypes: new Map(), questionTypes: new Map(), scoringStrategies: new Map() };
|
|
11
|
+
constructor(manifests) {
|
|
12
|
+
this.manifests = manifests;
|
|
13
|
+
for (const kind of TYPE_KINDS) {
|
|
14
|
+
for (const type of manifests.core?.[kind] ?? []) {
|
|
15
|
+
this.learn(kind, type, null);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
for (const mod of manifests.mods ?? []) {
|
|
19
|
+
for (const kind of TYPE_KINDS) {
|
|
20
|
+
for (const type of mod?.[kind] ?? []) {
|
|
21
|
+
this.learn(kind, type, mod);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
learn(kind, type, mod) {
|
|
27
|
+
if (!type || typeof type.token !== 'string' || type.token === '') {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const token = type.token.toLowerCase();
|
|
31
|
+
if (this.names[kind].has(token)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
this.names[kind].set(token, token);
|
|
35
|
+
this.entries[kind].set(token, { token, type, mod });
|
|
36
|
+
for (const alias of type.aliases ?? []) {
|
|
37
|
+
const key = String(alias).toLowerCase();
|
|
38
|
+
if (!this.names[kind].has(key)) {
|
|
39
|
+
this.names[kind].set(key, token);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
canonical(kind, token) {
|
|
44
|
+
if (token === null || token === undefined) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const key = String(token).trim().toLowerCase();
|
|
48
|
+
return key === '' ? null : (this.names[kind].get(key) ?? key);
|
|
49
|
+
}
|
|
50
|
+
get(kind, token) {
|
|
51
|
+
const key = this.canonical(kind, token);
|
|
52
|
+
return key === null ? null : this.entries[kind].get(key) ?? null;
|
|
53
|
+
}
|
|
54
|
+
all(kind) {
|
|
55
|
+
return [...this.entries[kind].values()];
|
|
56
|
+
}
|
|
57
|
+
listAllows(list, kind, token) {
|
|
58
|
+
if (list === undefined || list === '*') {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return Array.isArray(list) && list.some(item => this.canonical(kind, item) === token);
|
|
62
|
+
}
|
|
63
|
+
/** D12: the round type allows the question type (absent: all) and the question type accepts the round type (absent: none). */
|
|
64
|
+
allowsQuestionType(roundToken, questionToken) {
|
|
65
|
+
const round = this.get('roundTypes', roundToken);
|
|
66
|
+
const question = this.get('questionTypes', questionToken);
|
|
67
|
+
if (!round || !question) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return this.listAllows(round.type.allowedQuestionTypes, 'questionTypes', question.token)
|
|
71
|
+
&& question.type.addToRounds !== undefined
|
|
72
|
+
&& this.listAllows(question.type.addToRounds, 'roundTypes', round.token);
|
|
73
|
+
}
|
|
74
|
+
/** Allowed by the round type, supported by the strategy, not listed as unsupported. Hidden is not checked. */
|
|
75
|
+
allowsScoringStrategy(roundToken, strategyToken) {
|
|
76
|
+
const round = this.get('roundTypes', roundToken);
|
|
77
|
+
const strategy = this.get('scoringStrategies', strategyToken);
|
|
78
|
+
if (!round || !strategy) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const unsupported = strategy.type.unsupportedRoundTypes;
|
|
82
|
+
return this.listAllows(round.type.allowedScoringStrategies, 'scoringStrategies', strategy.token)
|
|
83
|
+
&& this.listAllows(strategy.type.supportedRoundTypes, 'roundTypes', round.token)
|
|
84
|
+
&& !(Array.isArray(unsupported) && unsupported.some(t => this.canonical('roundTypes', t) === round.token));
|
|
85
|
+
}
|
|
86
|
+
/** The strategies offered for a round type: allowed, supported and not hidden. */
|
|
87
|
+
offeredScoringStrategies(roundToken) {
|
|
88
|
+
return this.all('scoringStrategies')
|
|
89
|
+
.filter(s => s.type.hidden !== true && this.allowsScoringStrategy(roundToken, s.token))
|
|
90
|
+
.map(s => s.token);
|
|
91
|
+
}
|
|
92
|
+
/** What a new round of this type scores with: its default, else the first allowed, else `default`; a hidden default reads as `default`. */
|
|
93
|
+
pickedScoringStrategy(roundToken) {
|
|
94
|
+
const round = this.get('roundTypes', roundToken);
|
|
95
|
+
let token = 'default';
|
|
96
|
+
if (round) {
|
|
97
|
+
if (typeof round.type.defaultScoringStrategy === 'string') {
|
|
98
|
+
token = this.canonical('scoringStrategies', round.type.defaultScoringStrategy);
|
|
99
|
+
}
|
|
100
|
+
else if (Array.isArray(round.type.allowedScoringStrategies) && round.type.allowedScoringStrategies.length > 0) {
|
|
101
|
+
token = this.canonical('scoringStrategies', round.type.allowedScoringStrategies[0]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return this.get('scoringStrategies', token)?.type.hidden === true ? 'default' : token;
|
|
105
|
+
}
|
|
106
|
+
questionList(roundToken) {
|
|
107
|
+
const list = this.get('roundTypes', roundToken)?.type.questionList;
|
|
108
|
+
return list === 'grouped' || list === 'none' ? list : 'list';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FindingRule, RuleDescription, ServedManifests, TypeKind } from './types.js';
|
|
2
|
+
export interface DeclaredRule {
|
|
3
|
+
rule: FindingRule;
|
|
4
|
+
/** Where it is declared: the core manifest's root, or a type. */
|
|
5
|
+
scope: {
|
|
6
|
+
kind: TypeKind;
|
|
7
|
+
token: string;
|
|
8
|
+
} | 'core';
|
|
9
|
+
}
|
|
10
|
+
/** Every rule the manifests declare, root first, then per type in manifest order. */
|
|
11
|
+
export declare function listRules(manifests: ServedManifests): DeclaredRule[];
|
|
12
|
+
/**
|
|
13
|
+
* A rule id in one line, for the "ignored checks" list: the rule's
|
|
14
|
+
* `description` (its message without values), with fallbacks for the ids
|
|
15
|
+
* the legacy fields produce.
|
|
16
|
+
*/
|
|
17
|
+
export declare function describeRule(manifests: ServedManifests, ruleId: string, language?: string): RuleDescription;
|
|
18
|
+
/** The guidance text of a type, in `language`; empty when it has none or is unknown. */
|
|
19
|
+
export declare function guidanceFor(manifests: ServedManifests, kind: TypeKind, token: string, language?: string): string;
|
package/dist/describe.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { TypeCatalog, TYPE_KINDS } from './catalog.js';
|
|
2
|
+
import { COMPLETENESS_MESSAGES, render } from './rules.js';
|
|
3
|
+
import { localizedText } from './text.js';
|
|
4
|
+
/** Every rule the manifests declare, root first, then per type in manifest order. */
|
|
5
|
+
export function listRules(manifests) {
|
|
6
|
+
const out = [];
|
|
7
|
+
for (const rule of manifests.core?.findings ?? []) {
|
|
8
|
+
out.push({ rule, scope: 'core' });
|
|
9
|
+
}
|
|
10
|
+
for (const manifest of [manifests.core, ...(manifests.mods ?? [])]) {
|
|
11
|
+
for (const kind of TYPE_KINDS) {
|
|
12
|
+
for (const type of manifest?.[kind] ?? []) {
|
|
13
|
+
for (const rule of type.findings ?? []) {
|
|
14
|
+
out.push({ rule, scope: { kind, token: type.token } });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
function paramsOf(rule) {
|
|
22
|
+
switch (rule.rule) {
|
|
23
|
+
case 'oddOneOut':
|
|
24
|
+
return { setting: rule.label ?? rule.setting };
|
|
25
|
+
case 'consistency':
|
|
26
|
+
return rule.field !== undefined ? { field: rule.label ?? rule.field } : { slot: rule.label ?? rule.slot };
|
|
27
|
+
case 'require':
|
|
28
|
+
return rule.field !== undefined ? { field: rule.field } : rule.setting !== undefined ? { setting: rule.setting } : { check: rule.check };
|
|
29
|
+
default:
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The label of a setting as an oddOneOut rule names it, else the key. */
|
|
34
|
+
function settingLabel(manifests, key) {
|
|
35
|
+
const rule = listRules(manifests).find(d => d.rule.rule === 'oddOneOut' && d.rule.setting === key)?.rule;
|
|
36
|
+
return rule && rule.rule === 'oddOneOut' ? (rule.label ?? key) : key;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A rule id in one line, for the "ignored checks" list: the rule's
|
|
40
|
+
* `description` (its message without values), with fallbacks for the ids
|
|
41
|
+
* the legacy fields produce.
|
|
42
|
+
*/
|
|
43
|
+
export function describeRule(manifests, ruleId, language) {
|
|
44
|
+
const declared = listRules(manifests).find(d => d.rule.id === ruleId)?.rule;
|
|
45
|
+
if (declared) {
|
|
46
|
+
return render(declared.description ?? declared.message, paramsOf(declared), language);
|
|
47
|
+
}
|
|
48
|
+
if (ruleId.startsWith('setting:')) {
|
|
49
|
+
const key = ruleId.substring(ruleId.indexOf(':', 'setting:'.length) + 1);
|
|
50
|
+
const params = { setting: settingLabel(manifests, key) };
|
|
51
|
+
return render(ruleId.startsWith('setting:round:')
|
|
52
|
+
? 'Question with {setting} in a round type that advises against it'
|
|
53
|
+
: 'Question type that advises against {setting}', params, language);
|
|
54
|
+
}
|
|
55
|
+
if (ruleId.startsWith('completeness:')) {
|
|
56
|
+
const check = ruleId.substring('completeness:'.length);
|
|
57
|
+
return render(COMPLETENESS_MESSAGES[check] ?? check, {}, language);
|
|
58
|
+
}
|
|
59
|
+
if (ruleId.startsWith('type-warning:')) {
|
|
60
|
+
return render('Warning the question type gives on its answer options', {}, language);
|
|
61
|
+
}
|
|
62
|
+
if (ruleId.startsWith('odd:')) {
|
|
63
|
+
return render('Only question with {setting} switched on or off', { setting: settingLabel(manifests, ruleId.substring('odd:'.length)) }, language);
|
|
64
|
+
}
|
|
65
|
+
return render(ruleId, {}, language);
|
|
66
|
+
}
|
|
67
|
+
/** The guidance text of a type, in `language`; empty when it has none or is unknown. */
|
|
68
|
+
export function guidanceFor(manifests, kind, token, language) {
|
|
69
|
+
const entry = new TypeCatalog(manifests).get(kind, token);
|
|
70
|
+
return localizedText(entry?.type.guidance, language);
|
|
71
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { EvaluateInput, EvaluateResult } from './types.js';
|
|
2
|
+
export declare const ROUND_INCOMPLETE_MESSAGE = "{round}: scoring settings incomplete ({fields})";
|
|
3
|
+
export declare const STRATEGY_NOT_OFFERED_MESSAGE = "{round}: this round type does not offer the {strategy} scoring";
|
|
4
|
+
export declare const QUESTION_TYPE_NOT_OFFERED_MESSAGE = "{location}: this round type does not offer the question type {questionType}";
|
|
5
|
+
export declare const SCORING_STRATEGY_LABEL = "Scoring strategy";
|
|
6
|
+
/**
|
|
7
|
+
* The findings of a quiz or a round: validation issues (errors), the
|
|
8
|
+
* surprising-settings warnings in effect, and the ones that fired but are
|
|
9
|
+
* ignored (docs/design.md, 4).
|
|
10
|
+
*/
|
|
11
|
+
export declare function evaluateFindings(input: EvaluateInput): EvaluateResult;
|