quizwitz-validator 0.1.1 → 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/dist/evaluate.js CHANGED
@@ -2,7 +2,7 @@ import { TypeCatalog } from './catalog.js';
2
2
  import { questionSetting, questionTypeToken, questionsOf, normalizedGroupIds, roundName, roundTypeToken, roundsOf } from './aggregate.js';
3
3
  import { ignoredBy } from './ignore.js';
4
4
  import { questionWarningId } from './ignore.js';
5
- import { COMPLETENESS_MESSAGES, evaluateConsistency, evaluateExplanationPair, evaluateGroupSize, evaluateOddOneOut, evaluateOptionCountMajority, evaluateOutlier, evaluateRequire, matchesLegacyWarning, matchesOptionCountOver, matchesSettingEquals, matchesTypeSetting, passesCheck, render } from './rules.js';
5
+ import { COMPLETENESS_MESSAGES, evaluateConsistency, evaluateExplanationPair, evaluateGroupPosition, evaluateGroupSize, evaluateOddOneOut, evaluateOptionCountMajority, evaluateOutlier, evaluateRequire, matchesLegacyWarning, matchesOptionCountOver, matchesSettingEquals, matchesTypeSetting, passesCheck, render } from './rules.js';
6
6
  import { localizedText } from './text.js';
7
7
  import { COMPARATIVE_KINDS } from './types.js';
8
8
  export const ROUND_INCOMPLETE_MESSAGE = '{round}: scoring settings incomplete ({fields})';
@@ -220,6 +220,9 @@ function evaluateRound(catalog, scope, round, roundIndex) {
220
220
  case 'groupSize':
221
221
  evaluateGroupSize(rule, ctx).forEach(add);
222
222
  break;
223
+ case 'groupPosition':
224
+ evaluateGroupPosition(rule, ctx).forEach(add);
225
+ break;
223
226
  case 'consistency':
224
227
  evaluateConsistency(rule, ctx, quiet).forEach(add);
225
228
  break;
package/dist/rules.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CompletenessRule, ConsistencyRule, ExplanationPairRule, Finding, GroupSizeRule, LegacyWarning, LocalizedText, OddOneOutRule, OptionCountMajorityRule, OptionCountOverRule, OutlierRule, QuestionAggregate, RequireCheck, RequireRule, SettingEqualsRule, SettingsField, TypeSettingRule } from './types.js';
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
2
  export interface QuestionContext {
3
3
  question: QuestionAggregate;
4
4
  uuid: string;
@@ -25,6 +25,13 @@ export declare function evaluateOutlier(rule: OutlierRule, ctx: RoundContext): F
25
25
  export declare function evaluateOddOneOut(rule: OddOneOutRule, ctx: RoundContext): Finding[];
26
26
  export declare function evaluateOptionCountMajority(rule: OptionCountMajorityRule, ctx: RoundContext): Finding[];
27
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[];
28
35
  /** Explanation swaps; `quiet` names the `questionUuid:field` gaps the swap explains, for the consistency rules. */
29
36
  export declare function evaluateExplanationPair(rule: ExplanationPairRule, ctx: RoundContext, quiet: Set<string>): Finding[];
30
37
  export declare function evaluateConsistency(rule: ConsistencyRule, ctx: RoundContext, quiet: Set<string>): Finding[];
package/dist/rules.js CHANGED
@@ -144,6 +144,69 @@ export function evaluateGroupSize(rule, ctx) {
144
144
  const first = groups.get(odd.id)[0];
145
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
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
+ }
147
210
  /** Explanation swaps; `quiet` names the `questionUuid:field` gaps the swap explains, for the consistency rules. */
148
211
  export function evaluateExplanationPair(rule, ctx, quiet) {
149
212
  const fields = rule.fields;
package/dist/types.d.ts CHANGED
@@ -22,7 +22,7 @@ export interface SettingsField {
22
22
  };
23
23
  [key: string]: unknown;
24
24
  }
25
- export type RuleKind = 'outlier' | 'oddOneOut' | 'consistency' | 'explanationPair' | 'optionCountMajority' | 'groupSize' | 'require' | 'settingEquals' | 'optionCountOver' | 'typeSetting';
25
+ export type RuleKind = 'outlier' | 'oddOneOut' | 'consistency' | 'explanationPair' | 'optionCountMajority' | 'groupSize' | 'groupPosition' | 'require' | 'settingEquals' | 'optionCountOver' | 'typeSetting';
26
26
  export declare const COMPARATIVE_KINDS: readonly RuleKind[];
27
27
  export declare const PER_QUESTION_KINDS: readonly RuleKind[];
28
28
  export type Severity = 'error' | 'warning';
@@ -70,6 +70,13 @@ export interface GroupSizeRule extends RuleBase {
70
70
  rule: 'groupSize';
71
71
  messageSingle: LocalizedText;
72
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
+ }
73
80
  export type RequireCheck = 'correctOption' | 'allOptionsFilled' | 'answer' | 'imageMap';
74
81
  export interface RequireRule extends RuleBase {
75
82
  rule: 'require';
@@ -98,7 +105,7 @@ export interface TypeSettingRule extends RuleBase {
98
105
  min: number;
99
106
  };
100
107
  }
101
- export type FindingRule = OutlierRule | OddOneOutRule | ConsistencyRule | ExplanationPairRule | OptionCountMajorityRule | GroupSizeRule | RequireRule | SettingEqualsRule | OptionCountOverRule | TypeSettingRule;
108
+ export type FindingRule = OutlierRule | OddOneOutRule | ConsistencyRule | ExplanationPairRule | OptionCountMajorityRule | GroupSizeRule | GroupPositionRule | RequireRule | SettingEqualsRule | OptionCountOverRule | TypeSettingRule;
102
109
  /** The existing editor-facing fields the evaluator reads as well (docs/design.md, 3.2). */
103
110
  export type CompletenessRule = 'requireText' | 'requireCorrectOption' | 'requireAllOptionsFilled' | 'requireAnswer' | 'requireImageMap';
104
111
  export interface OptionCountWarning {
package/dist/types.js CHANGED
@@ -2,5 +2,5 @@
2
2
  * The served manifests (the part this package reads), the rule vocabulary
3
3
  * and the evaluator's input and output (docs/design.md, 3 and 4).
4
4
  */
5
- export const COMPARATIVE_KINDS = ['outlier', 'oddOneOut', 'consistency', 'explanationPair', 'optionCountMajority', 'groupSize'];
5
+ export const COMPARATIVE_KINDS = ['outlier', 'oddOneOut', 'consistency', 'explanationPair', 'optionCountMajority', 'groupSize', 'groupPosition'];
6
6
  export const PER_QUESTION_KINDS = ['require', 'settingEquals', 'optionCountOver', 'typeSetting'];
package/docs/design.md CHANGED
@@ -79,6 +79,7 @@ Messages are **English keys**: the editor translates them with its own catalogue
79
79
  | `consistency` | `field` (narration field on the primary resource: `long`, `tips`, `explanation`, `longExplanation`) **or** `slot` (question attachment slot: `before`, `during`, ...), `label`, `minPopulation` (4), `majority` (0.75) | Questions missing what at least `majority` of the round (but not all) has. Param `{field}` or `{slot}` = label. `during` counts as filled by an image map. A gap `explanationPair` already explains is skipped. |
80
80
  | `explanationPair` | `fields` (`["longExplanation", "explanation"]`), `labels` (`{field: label}`), `minPopulation` (4), `majority` (0.75) | Among questions that fill either field, the ones using the other half than the majority. Params `{expected, actual}` = labels. |
81
81
  | `optionCountMajority` | `types` (question type tokens), `minPopulation` (4), `majority` (0.5) | Same-type siblings (types listed) with a different number of filled options than the strict majority. Params `{count, expected}`. |
82
+ | `groupPosition` | exactly one of `setting` (a numeric question setting: `points`) or `field` (a text on the primary resource: `label`, compared trimmed and ignoring case), `minPopulation` (3, groups), `majority` (0.5) | In a grouped round, the nth question of every group is compared with the nth question of the other groups: where a strict majority of the groups (but not all) agree, the nth questions that differ. Questions without a value (setting not > 0, empty text) are left out. Params `{value, expected, position}` (position from 1). |
82
83
  | `groupSize` | `minPopulation` (3, groups), `message` (several), `messageSingle` (one question) | In a grouped round, the one group whose size differs when every other group agrees. Id `<roundUuid>:group:<groupId>:group-size`, anchored on the group's first question. Params `{count, expected}`. |
83
84
 
84
85
  **Per-question kinds** — checked on every question in scope:
@@ -44,11 +44,11 @@
44
44
  "settingsFields": [
45
45
  {
46
46
  "key": "instantFeedback",
47
- "label": "Show the answer right after each question",
47
+ "label": "Reveal answers instantly",
48
48
  "type": "boolean",
49
49
  "target": "modeSettings",
50
50
  "default": false,
51
- "hint": "The answer appears on the question itself and the round moves on. No result screens: explanations and feedback attachments are not shown."
51
+ "hint": "Instead of saving the results for the end of the round, the correct answer appears on the question as soon as its time is up, and the round moves on. No result screens: explanations and feedback attachments are not shown."
52
52
  }
53
53
  ]
54
54
  },
@@ -71,10 +71,11 @@
71
71
  {
72
72
  "token": "multiquestion",
73
73
  "name": "Multiquestion",
74
- "guidance": "Questions come in groups of at most 4. A group is shown at once; each player picks one question of the group by its label and answers only that one, within one shared timer (the longest duration in the group). Give every question a short, distinct label and every group the same number of questions.",
74
+ "guidance": "Questions come in groups of at most 4. A group is shown at once; each player picks one question of the group by its label and answers only that one, within one shared timer (the longest duration in the group). Give every question a short, distinct label and every group the same number of questions. The group shares one screen: the pictures and videos of all its questions (before and during) are shown side by side before it, and only sounds play while it runs.",
75
75
  "description": "Players choose from a selection of multiple questions, each worth different point values.",
76
76
  "category": "Interactive",
77
77
  "questionList": "grouped",
78
+ "sharedQuestionScreen": true,
78
79
  "questionLabel": {
79
80
  "label": "Label",
80
81
  "hint": "Players tap this label to pick this question"
@@ -84,6 +85,75 @@
84
85
  "defaultScoringStrategy": "default",
85
86
  "limiters": [
86
87
  "all"
88
+ ],
89
+ "findings": [
90
+ {
91
+ "id": "group-size",
92
+ "rule": "groupSize",
93
+ "minPopulation": 3,
94
+ "message": "This group has {count} questions while the other groups have {expected}",
95
+ "messageSingle": "This group has 1 question while the other groups have {expected}",
96
+ "description": "Question group with more or fewer questions than the other groups"
97
+ },
98
+ {
99
+ "id": "group-position:points",
100
+ "rule": "groupPosition",
101
+ "setting": "points",
102
+ "minPopulation": 3,
103
+ "majority": 0.5,
104
+ "message": "Worth {value} points while question {position} of most other groups is worth {expected}",
105
+ "description": "Question worth different points than the same question in the other groups"
106
+ },
107
+ {
108
+ "id": "group-position:label",
109
+ "rule": "groupPosition",
110
+ "field": "label",
111
+ "minPopulation": 3,
112
+ "majority": 0.5,
113
+ "message": "Labelled \"{value}\" while question {position} of most other groups is labelled \"{expected}\"",
114
+ "description": "Question with a different label than the same question in the other groups"
115
+ }
116
+ ]
117
+ },
118
+ {
119
+ "token": "questiongroups",
120
+ "name": "Question groups",
121
+ "guidance": "Questions come in groups, asked one after another: first the group's attachment (a video or a sound clip, in the 'Show before the question group' slot of the group's first question) plays once, then every question of the group is asked as a normal question, in order. Use it for several questions about one clip. A group is drawn whole when the round asks a random selection. The option 'Play as a lightning round' postpones every result to the end of the round and keeps the countdown music running within a group.",
122
+ "description": "Questions come in groups: a group's attachment plays once, then its questions are asked one after another.",
123
+ "category": "Regular",
124
+ "questionList": "grouped",
125
+ "groupAttachments": true,
126
+ "allowedQuestionTypes": "*",
127
+ "allowedScoringStrategies": "*",
128
+ "defaultScoringStrategy": "default",
129
+ "limiters": [
130
+ "all",
131
+ "no-private-screens",
132
+ "no-screens"
133
+ ],
134
+ "settingsFields": [
135
+ {
136
+ "key": "lightning",
137
+ "label": "Play as a lightning round",
138
+ "type": "boolean",
139
+ "target": "modeSettings",
140
+ "default": false,
141
+ "postponesFeedback": true,
142
+ "hint": "Every result is shown at the end of the round, and the countdown music keeps running from one question of a group to the next; it stops when the group is done."
143
+ },
144
+ {
145
+ "key": "instantFeedback",
146
+ "label": "Reveal answers instantly",
147
+ "type": "boolean",
148
+ "target": "modeSettings",
149
+ "default": false,
150
+ "postponesFeedback": true,
151
+ "showWhen": {
152
+ "key": "lightning",
153
+ "equals": true
154
+ },
155
+ "hint": "Instead of saving the results for the end of the round, the correct answer appears on the question as soon as its time is up, and the round moves on. No result screens: explanations and feedback attachments are not shown."
156
+ }
87
157
  ]
88
158
  },
89
159
  {
@@ -744,7 +814,7 @@
744
814
  {
745
815
  "token": "imagemapquestion",
746
816
  "name": "Image Map Question",
747
- "guidance": "Players tap spots on an image; taps inside a target zone score. The image and its zones are set in the image-map editor; a map with only neutral zones can never be answered correctly. Give the players a picture large enough to tell the zones apart.",
817
+ "guidance": "Players tap spots on an image; taps inside a target zone score. The image and its zones are set in the image-map editor; the option texts only name the zones there and players never see them, so the question needs no answering instructions or coordinates. A map with only neutral zones can never be answered correctly. Give the players a picture large enough to tell the zones apart.",
748
818
  "description": "Players are presented with an image and must click on the correct position.",
749
819
  "icon": "ads_click",
750
820
  "addToRounds": "*",
@@ -992,7 +1062,8 @@
992
1062
  {
993
1063
  "token": "strike",
994
1064
  "name": "Strike",
995
- "guidance": "Each correct answer in a row climbs a level and adds a bonus per level; a wrong answer drops levels (see the strike round type).",
1065
+ "guidance": "Each correct answer in a row climbs a level and adds a bonus per level; a wrong answer drops levels (see the strike round type). The level ladder moves with every result, so a round scored this way shows each result right after its question and cannot play as a lightning round. In a question groups round the levels can start over with every group.",
1066
+ "immediateFeedback": true,
996
1067
  "settingsFields": [
997
1068
  {
998
1069
  "key": "bonusPerLevel",
@@ -1017,6 +1088,16 @@
1017
1088
  "target": "scoringStrategy",
1018
1089
  "default": 5,
1019
1090
  "min": 0
1091
+ },
1092
+ {
1093
+ "key": "resetLevelsPerGroup",
1094
+ "label": "Start every question group at level 1",
1095
+ "type": "boolean",
1096
+ "target": "scoringStrategy",
1097
+ "roundTypes": [
1098
+ "questiongroups"
1099
+ ],
1100
+ "hint": "Every player is back on level 1 when a new group of questions starts, so each group is a ladder of its own."
1020
1101
  }
1021
1102
  ]
1022
1103
  },
@@ -1221,15 +1302,6 @@
1221
1302
  "message": "Has {count} answer options while most of this round has {expected}",
1222
1303
  "description": "Different number of answer options than the rest of the round"
1223
1304
  },
1224
- {
1225
- "id": "group-size",
1226
- "rule": "groupSize",
1227
- "minPopulation": 3,
1228
- "questionList": "grouped",
1229
- "message": "This group has {count} questions while the other groups have {expected}",
1230
- "messageSingle": "This group has 1 question while the other groups have {expected}",
1231
- "description": "Question group with more or fewer questions than the other groups"
1232
- },
1233
1305
  {
1234
1306
  "id": "explanation-pair",
1235
1307
  "rule": "explanationPair",
@@ -3080,6 +3152,741 @@
3080
3152
  ]
3081
3153
  }
3082
3154
  },
3155
+ {
3156
+ "name": "group-position-label",
3157
+ "input": {
3158
+ "round": {
3159
+ "uuid": "r1",
3160
+ "original_language": "en",
3161
+ "mode": {
3162
+ "token": "multiquestion"
3163
+ },
3164
+ "scoringStrategy": {
3165
+ "token": "default"
3166
+ },
3167
+ "resources": [
3168
+ {
3169
+ "language": "en",
3170
+ "name": "Round one"
3171
+ }
3172
+ ],
3173
+ "questions": {
3174
+ "items": [
3175
+ {
3176
+ "uuid": "q1a",
3177
+ "original_language": "en",
3178
+ "mode": {
3179
+ "token": "openquestion"
3180
+ },
3181
+ "points": 1000,
3182
+ "duration": 30,
3183
+ "group": "1",
3184
+ "resources": [
3185
+ {
3186
+ "language": "en",
3187
+ "name": "Question q1a",
3188
+ "label": "Multiple choice",
3189
+ "options": {
3190
+ "items": [
3191
+ {
3192
+ "option": 1,
3193
+ "text": "Answer",
3194
+ "correct": true
3195
+ }
3196
+ ]
3197
+ }
3198
+ }
3199
+ ]
3200
+ },
3201
+ {
3202
+ "uuid": "q1b",
3203
+ "original_language": "en",
3204
+ "mode": {
3205
+ "token": "openquestion"
3206
+ },
3207
+ "points": 1000,
3208
+ "duration": 30,
3209
+ "group": "1",
3210
+ "resources": [
3211
+ {
3212
+ "language": "en",
3213
+ "name": "Question q1b",
3214
+ "label": "Open question",
3215
+ "options": {
3216
+ "items": [
3217
+ {
3218
+ "option": 1,
3219
+ "text": "Answer",
3220
+ "correct": true
3221
+ }
3222
+ ]
3223
+ }
3224
+ }
3225
+ ]
3226
+ },
3227
+ {
3228
+ "uuid": "q2a",
3229
+ "original_language": "en",
3230
+ "mode": {
3231
+ "token": "openquestion"
3232
+ },
3233
+ "points": 1000,
3234
+ "duration": 30,
3235
+ "group": "2",
3236
+ "resources": [
3237
+ {
3238
+ "language": "en",
3239
+ "name": "Question q2a",
3240
+ "label": "Open question",
3241
+ "options": {
3242
+ "items": [
3243
+ {
3244
+ "option": 1,
3245
+ "text": "Answer",
3246
+ "correct": true
3247
+ }
3248
+ ]
3249
+ }
3250
+ }
3251
+ ]
3252
+ },
3253
+ {
3254
+ "uuid": "q2b",
3255
+ "original_language": "en",
3256
+ "mode": {
3257
+ "token": "openquestion"
3258
+ },
3259
+ "points": 1000,
3260
+ "duration": 30,
3261
+ "group": "2",
3262
+ "resources": [
3263
+ {
3264
+ "language": "en",
3265
+ "name": "Question q2b",
3266
+ "label": "Open question",
3267
+ "options": {
3268
+ "items": [
3269
+ {
3270
+ "option": 1,
3271
+ "text": "Answer",
3272
+ "correct": true
3273
+ }
3274
+ ]
3275
+ }
3276
+ }
3277
+ ]
3278
+ },
3279
+ {
3280
+ "uuid": "q3a",
3281
+ "original_language": "en",
3282
+ "mode": {
3283
+ "token": "openquestion"
3284
+ },
3285
+ "points": 1000,
3286
+ "duration": 30,
3287
+ "group": "3",
3288
+ "resources": [
3289
+ {
3290
+ "language": "en",
3291
+ "name": "Question q3a",
3292
+ "label": "multiple choice ",
3293
+ "options": {
3294
+ "items": [
3295
+ {
3296
+ "option": 1,
3297
+ "text": "Answer",
3298
+ "correct": true
3299
+ }
3300
+ ]
3301
+ }
3302
+ }
3303
+ ]
3304
+ },
3305
+ {
3306
+ "uuid": "q3b",
3307
+ "original_language": "en",
3308
+ "mode": {
3309
+ "token": "openquestion"
3310
+ },
3311
+ "points": 1000,
3312
+ "duration": 30,
3313
+ "group": "3",
3314
+ "resources": [
3315
+ {
3316
+ "language": "en",
3317
+ "name": "Question q3b",
3318
+ "label": "Open question",
3319
+ "options": {
3320
+ "items": [
3321
+ {
3322
+ "option": 1,
3323
+ "text": "Answer",
3324
+ "correct": true
3325
+ }
3326
+ ]
3327
+ }
3328
+ }
3329
+ ]
3330
+ },
3331
+ {
3332
+ "uuid": "q4a",
3333
+ "original_language": "en",
3334
+ "mode": {
3335
+ "token": "openquestion"
3336
+ },
3337
+ "points": 1000,
3338
+ "duration": 30,
3339
+ "group": "4",
3340
+ "resources": [
3341
+ {
3342
+ "language": "en",
3343
+ "name": "Question q4a",
3344
+ "label": "Multiple choice",
3345
+ "options": {
3346
+ "items": [
3347
+ {
3348
+ "option": 1,
3349
+ "text": "Answer",
3350
+ "correct": true
3351
+ }
3352
+ ]
3353
+ }
3354
+ }
3355
+ ]
3356
+ },
3357
+ {
3358
+ "uuid": "q4b",
3359
+ "original_language": "en",
3360
+ "mode": {
3361
+ "token": "openquestion"
3362
+ },
3363
+ "points": 1000,
3364
+ "duration": 30,
3365
+ "group": "4",
3366
+ "resources": [
3367
+ {
3368
+ "language": "en",
3369
+ "name": "Question q4b",
3370
+ "label": "Open question",
3371
+ "options": {
3372
+ "items": [
3373
+ {
3374
+ "option": 1,
3375
+ "text": "Answer",
3376
+ "correct": true
3377
+ }
3378
+ ]
3379
+ }
3380
+ }
3381
+ ]
3382
+ }
3383
+ ]
3384
+ }
3385
+ }
3386
+ },
3387
+ "expected": {
3388
+ "errors": [],
3389
+ "warnings": [
3390
+ {
3391
+ "id": "q2a:group-position:label",
3392
+ "ruleId": "group-position:label",
3393
+ "roundId": "r1",
3394
+ "questionId": "q2a",
3395
+ "message": "Labelled \"Open question\" while question 1 of most other groups is labelled \"Multiple choice\"",
3396
+ "messageKey": "Labelled \"{value}\" while question {position} of most other groups is labelled \"{expected}\"",
3397
+ "messageParams": {
3398
+ "value": "Open question",
3399
+ "expected": "Multiple choice",
3400
+ "position": 1
3401
+ }
3402
+ }
3403
+ ]
3404
+ }
3405
+ },
3406
+ {
3407
+ "name": "group-position-points",
3408
+ "input": {
3409
+ "round": {
3410
+ "uuid": "r1",
3411
+ "original_language": "en",
3412
+ "mode": {
3413
+ "token": "multiquestion"
3414
+ },
3415
+ "scoringStrategy": {
3416
+ "token": "default"
3417
+ },
3418
+ "resources": [
3419
+ {
3420
+ "language": "en",
3421
+ "name": "Round one"
3422
+ }
3423
+ ],
3424
+ "questions": {
3425
+ "items": [
3426
+ {
3427
+ "uuid": "q1a",
3428
+ "original_language": "en",
3429
+ "mode": {
3430
+ "token": "openquestion"
3431
+ },
3432
+ "points": 750,
3433
+ "duration": 30,
3434
+ "group": "1",
3435
+ "resources": [
3436
+ {
3437
+ "language": "en",
3438
+ "name": "Question q1a",
3439
+ "label": "Multiple choice",
3440
+ "options": {
3441
+ "items": [
3442
+ {
3443
+ "option": 1,
3444
+ "text": "Answer",
3445
+ "correct": true
3446
+ }
3447
+ ]
3448
+ }
3449
+ }
3450
+ ]
3451
+ },
3452
+ {
3453
+ "uuid": "q1b",
3454
+ "original_language": "en",
3455
+ "mode": {
3456
+ "token": "openquestion"
3457
+ },
3458
+ "points": 1500,
3459
+ "duration": 30,
3460
+ "group": "1",
3461
+ "resources": [
3462
+ {
3463
+ "language": "en",
3464
+ "name": "Question q1b",
3465
+ "label": "Open question",
3466
+ "options": {
3467
+ "items": [
3468
+ {
3469
+ "option": 1,
3470
+ "text": "Answer",
3471
+ "correct": true
3472
+ }
3473
+ ]
3474
+ }
3475
+ }
3476
+ ]
3477
+ },
3478
+ {
3479
+ "uuid": "q2a",
3480
+ "original_language": "en",
3481
+ "mode": {
3482
+ "token": "openquestion"
3483
+ },
3484
+ "points": 750,
3485
+ "duration": 30,
3486
+ "group": "2",
3487
+ "resources": [
3488
+ {
3489
+ "language": "en",
3490
+ "name": "Question q2a",
3491
+ "label": "Multiple choice",
3492
+ "options": {
3493
+ "items": [
3494
+ {
3495
+ "option": 1,
3496
+ "text": "Answer",
3497
+ "correct": true
3498
+ }
3499
+ ]
3500
+ }
3501
+ }
3502
+ ]
3503
+ },
3504
+ {
3505
+ "uuid": "q2b",
3506
+ "original_language": "en",
3507
+ "mode": {
3508
+ "token": "openquestion"
3509
+ },
3510
+ "points": 1500,
3511
+ "duration": 30,
3512
+ "group": "2",
3513
+ "resources": [
3514
+ {
3515
+ "language": "en",
3516
+ "name": "Question q2b",
3517
+ "label": "Open question",
3518
+ "options": {
3519
+ "items": [
3520
+ {
3521
+ "option": 1,
3522
+ "text": "Answer",
3523
+ "correct": true
3524
+ }
3525
+ ]
3526
+ }
3527
+ }
3528
+ ]
3529
+ },
3530
+ {
3531
+ "uuid": "q3a",
3532
+ "original_language": "en",
3533
+ "mode": {
3534
+ "token": "openquestion"
3535
+ },
3536
+ "points": 750,
3537
+ "duration": 30,
3538
+ "group": "3",
3539
+ "resources": [
3540
+ {
3541
+ "language": "en",
3542
+ "name": "Question q3a",
3543
+ "label": "Multiple choice",
3544
+ "options": {
3545
+ "items": [
3546
+ {
3547
+ "option": 1,
3548
+ "text": "Answer",
3549
+ "correct": true
3550
+ }
3551
+ ]
3552
+ }
3553
+ }
3554
+ ]
3555
+ },
3556
+ {
3557
+ "uuid": "q3b",
3558
+ "original_language": "en",
3559
+ "mode": {
3560
+ "token": "openquestion"
3561
+ },
3562
+ "points": 1000,
3563
+ "duration": 30,
3564
+ "group": "3",
3565
+ "resources": [
3566
+ {
3567
+ "language": "en",
3568
+ "name": "Question q3b",
3569
+ "label": "Open question",
3570
+ "options": {
3571
+ "items": [
3572
+ {
3573
+ "option": 1,
3574
+ "text": "Answer",
3575
+ "correct": true
3576
+ }
3577
+ ]
3578
+ }
3579
+ }
3580
+ ]
3581
+ },
3582
+ {
3583
+ "uuid": "q4a",
3584
+ "original_language": "en",
3585
+ "mode": {
3586
+ "token": "openquestion"
3587
+ },
3588
+ "points": 750,
3589
+ "duration": 30,
3590
+ "group": "4",
3591
+ "resources": [
3592
+ {
3593
+ "language": "en",
3594
+ "name": "Question q4a",
3595
+ "label": "Multiple choice",
3596
+ "options": {
3597
+ "items": [
3598
+ {
3599
+ "option": 1,
3600
+ "text": "Answer",
3601
+ "correct": true
3602
+ }
3603
+ ]
3604
+ }
3605
+ }
3606
+ ]
3607
+ },
3608
+ {
3609
+ "uuid": "q4b",
3610
+ "original_language": "en",
3611
+ "mode": {
3612
+ "token": "openquestion"
3613
+ },
3614
+ "points": 1500,
3615
+ "duration": 30,
3616
+ "group": "4",
3617
+ "resources": [
3618
+ {
3619
+ "language": "en",
3620
+ "name": "Question q4b",
3621
+ "label": "Open question",
3622
+ "options": {
3623
+ "items": [
3624
+ {
3625
+ "option": 1,
3626
+ "text": "Answer",
3627
+ "correct": true
3628
+ }
3629
+ ]
3630
+ }
3631
+ }
3632
+ ]
3633
+ }
3634
+ ]
3635
+ }
3636
+ }
3637
+ },
3638
+ "expected": {
3639
+ "errors": [],
3640
+ "warnings": [
3641
+ {
3642
+ "id": "q3b:group-position:points",
3643
+ "ruleId": "group-position:points",
3644
+ "roundId": "r1",
3645
+ "questionId": "q3b",
3646
+ "message": "Worth 1000 points while question 2 of most other groups is worth 1500",
3647
+ "messageKey": "Worth {value} points while question {position} of most other groups is worth {expected}",
3648
+ "messageParams": {
3649
+ "value": 1000,
3650
+ "expected": 1500,
3651
+ "position": 2
3652
+ }
3653
+ }
3654
+ ]
3655
+ }
3656
+ },
3657
+ {
3658
+ "name": "group-size-not-in-question-groups",
3659
+ "input": {
3660
+ "round": {
3661
+ "uuid": "r1",
3662
+ "original_language": "en",
3663
+ "mode": {
3664
+ "token": "questiongroups"
3665
+ },
3666
+ "modeSettings": {
3667
+ "lightning": false,
3668
+ "instantFeedback": false
3669
+ },
3670
+ "scoringStrategy": {
3671
+ "token": "default"
3672
+ },
3673
+ "resources": [
3674
+ {
3675
+ "language": "en",
3676
+ "name": "Round one"
3677
+ }
3678
+ ],
3679
+ "questions": {
3680
+ "items": [
3681
+ {
3682
+ "uuid": "q1",
3683
+ "original_language": "en",
3684
+ "mode": {
3685
+ "token": "multiplechoice"
3686
+ },
3687
+ "points": 1000,
3688
+ "duration": 30,
3689
+ "group": "1",
3690
+ "resources": [
3691
+ {
3692
+ "language": "en",
3693
+ "name": "What is the capital of France?",
3694
+ "options": {
3695
+ "items": [
3696
+ {
3697
+ "option": 1,
3698
+ "text": "Paris",
3699
+ "correct": true
3700
+ },
3701
+ {
3702
+ "option": 2,
3703
+ "text": "Rome",
3704
+ "correct": false
3705
+ },
3706
+ {
3707
+ "option": 3,
3708
+ "text": "Berlin",
3709
+ "correct": false
3710
+ },
3711
+ {
3712
+ "option": 4,
3713
+ "text": "Madrid",
3714
+ "correct": false
3715
+ }
3716
+ ]
3717
+ }
3718
+ }
3719
+ ]
3720
+ },
3721
+ {
3722
+ "uuid": "q2",
3723
+ "original_language": "en",
3724
+ "mode": {
3725
+ "token": "multiplechoice"
3726
+ },
3727
+ "points": 1000,
3728
+ "duration": 30,
3729
+ "group": "1",
3730
+ "resources": [
3731
+ {
3732
+ "language": "en",
3733
+ "name": "What is the capital of France?",
3734
+ "options": {
3735
+ "items": [
3736
+ {
3737
+ "option": 1,
3738
+ "text": "Paris",
3739
+ "correct": true
3740
+ },
3741
+ {
3742
+ "option": 2,
3743
+ "text": "Rome",
3744
+ "correct": false
3745
+ },
3746
+ {
3747
+ "option": 3,
3748
+ "text": "Berlin",
3749
+ "correct": false
3750
+ },
3751
+ {
3752
+ "option": 4,
3753
+ "text": "Madrid",
3754
+ "correct": false
3755
+ }
3756
+ ]
3757
+ }
3758
+ }
3759
+ ]
3760
+ },
3761
+ {
3762
+ "uuid": "q3",
3763
+ "original_language": "en",
3764
+ "mode": {
3765
+ "token": "multiplechoice"
3766
+ },
3767
+ "points": 1000,
3768
+ "duration": 30,
3769
+ "group": "2",
3770
+ "resources": [
3771
+ {
3772
+ "language": "en",
3773
+ "name": "What is the capital of France?",
3774
+ "options": {
3775
+ "items": [
3776
+ {
3777
+ "option": 1,
3778
+ "text": "Paris",
3779
+ "correct": true
3780
+ },
3781
+ {
3782
+ "option": 2,
3783
+ "text": "Rome",
3784
+ "correct": false
3785
+ },
3786
+ {
3787
+ "option": 3,
3788
+ "text": "Berlin",
3789
+ "correct": false
3790
+ },
3791
+ {
3792
+ "option": 4,
3793
+ "text": "Madrid",
3794
+ "correct": false
3795
+ }
3796
+ ]
3797
+ }
3798
+ }
3799
+ ]
3800
+ },
3801
+ {
3802
+ "uuid": "q4",
3803
+ "original_language": "en",
3804
+ "mode": {
3805
+ "token": "multiplechoice"
3806
+ },
3807
+ "points": 1000,
3808
+ "duration": 30,
3809
+ "group": "2",
3810
+ "resources": [
3811
+ {
3812
+ "language": "en",
3813
+ "name": "What is the capital of France?",
3814
+ "options": {
3815
+ "items": [
3816
+ {
3817
+ "option": 1,
3818
+ "text": "Paris",
3819
+ "correct": true
3820
+ },
3821
+ {
3822
+ "option": 2,
3823
+ "text": "Rome",
3824
+ "correct": false
3825
+ },
3826
+ {
3827
+ "option": 3,
3828
+ "text": "Berlin",
3829
+ "correct": false
3830
+ },
3831
+ {
3832
+ "option": 4,
3833
+ "text": "Madrid",
3834
+ "correct": false
3835
+ }
3836
+ ]
3837
+ }
3838
+ }
3839
+ ]
3840
+ },
3841
+ {
3842
+ "uuid": "q5",
3843
+ "original_language": "en",
3844
+ "mode": {
3845
+ "token": "multiplechoice"
3846
+ },
3847
+ "points": 1000,
3848
+ "duration": 30,
3849
+ "group": "3",
3850
+ "resources": [
3851
+ {
3852
+ "language": "en",
3853
+ "name": "What is the capital of France?",
3854
+ "options": {
3855
+ "items": [
3856
+ {
3857
+ "option": 1,
3858
+ "text": "Paris",
3859
+ "correct": true
3860
+ },
3861
+ {
3862
+ "option": 2,
3863
+ "text": "Rome",
3864
+ "correct": false
3865
+ },
3866
+ {
3867
+ "option": 3,
3868
+ "text": "Berlin",
3869
+ "correct": false
3870
+ },
3871
+ {
3872
+ "option": 4,
3873
+ "text": "Madrid",
3874
+ "correct": false
3875
+ }
3876
+ ]
3877
+ }
3878
+ }
3879
+ ]
3880
+ }
3881
+ ]
3882
+ }
3883
+ }
3884
+ },
3885
+ "expected": {
3886
+ "errors": [],
3887
+ "warnings": []
3888
+ }
3889
+ },
3083
3890
  {
3084
3891
  "name": "group-size-ungrouped-run",
3085
3892
  "input": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quizwitz-validator",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Evaluates the findings (validation issues and surprising-settings warnings) the QuizWitz game client's type manifests declare, for the quiz editor and the MCP server.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",