@conform-ed/qti-react 0.0.12 → 0.0.13
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/index.js +4566 -212
- package/package.json +3 -1
- package/src/capability.ts +24 -0
- package/src/content-model.ts +104 -5
- package/src/graphic.ts +103 -0
- package/src/index.ts +139 -3
- package/src/interactions/associate.ts +22 -0
- package/src/interactions/drawing.ts +24 -0
- package/src/interactions/end-attempt.ts +19 -0
- package/src/interactions/extended-text.ts +21 -0
- package/src/interactions/gap-match.ts +22 -0
- package/src/interactions/graphic.ts +104 -0
- package/src/interactions/hottext.ts +21 -0
- package/src/interactions/index.ts +57 -2
- package/src/interactions/match.ts +27 -0
- package/src/interactions/media.ts +24 -0
- package/src/interactions/order.ts +21 -0
- package/src/interactions/slider.ts +24 -0
- package/src/interactions/upload.ts +19 -0
- package/src/normalized-item.ts +561 -0
- package/src/pci/index.ts +22 -0
- package/src/pci/interaction.ts +42 -0
- package/src/pci/markup.ts +102 -0
- package/src/pci/mount.ts +135 -0
- package/src/pci/registry.ts +240 -0
- package/src/pci/response.ts +138 -0
- package/src/pci/skin.ts +87 -0
- package/src/reference-skin/associate.ts +98 -0
- package/src/reference-skin/choice.ts +44 -0
- package/src/reference-skin/content.ts +30 -0
- package/src/reference-skin/drawing.ts +150 -0
- package/src/reference-skin/end-attempt.ts +27 -0
- package/src/reference-skin/extended-text.ts +35 -0
- package/src/reference-skin/gap-match.ts +69 -0
- package/src/reference-skin/graphic-associate.ts +123 -0
- package/src/reference-skin/graphic-base.ts +142 -0
- package/src/reference-skin/graphic-gap-match.ts +143 -0
- package/src/reference-skin/graphic-order.ts +76 -0
- package/src/reference-skin/hotspot.ts +43 -0
- package/src/reference-skin/hottext.ts +42 -0
- package/src/reference-skin/index.ts +75 -0
- package/src/reference-skin/inline-choice.ts +42 -0
- package/src/reference-skin/match.ts +80 -0
- package/src/reference-skin/media.ts +74 -0
- package/src/reference-skin/order.ts +79 -0
- package/src/reference-skin/position-object.ts +84 -0
- package/src/reference-skin/select-point.ts +87 -0
- package/src/reference-skin/slider.ts +41 -0
- package/src/reference-skin/text-entry.ts +31 -0
- package/src/reference-skin/upload.ts +46 -0
- package/src/response-processing.ts +178 -29
- package/src/rp/evaluate.ts +828 -0
- package/src/rp/index.ts +30 -0
- package/src/rp/interpreter.ts +251 -0
- package/src/rp/template-processing.ts +295 -0
- package/src/rp/templates.ts +190 -0
- package/src/rp/types.ts +161 -0
- package/src/rp/values.ts +198 -0
- package/src/runtime.ts +474 -28
- package/src/store.ts +155 -5
- package/src/test/controller.ts +806 -0
- package/src/test/index.ts +25 -0
- package/src/test/session-store.ts +244 -0
- package/src/test/types.ts +203 -0
- package/src/types.ts +27 -1
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The headless Test Controller (ADR-0005): given an assessmentTest view and a seed, it
|
|
3
|
+
* resolves the delivery plan (seeded selection + ordering) once, then answers every
|
|
4
|
+
* navigation, branching, and outcome-processing question as a pure transition over the
|
|
5
|
+
* consumer-persisted session state. It owns no storage and renders nothing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CapabilityIssue } from "../capability";
|
|
9
|
+
import {
|
|
10
|
+
RpUnsupportedError,
|
|
11
|
+
collectExpressionIssues,
|
|
12
|
+
deterministicExpressionKinds,
|
|
13
|
+
evaluateExpression,
|
|
14
|
+
type EvalEnv,
|
|
15
|
+
} from "../rp/evaluate";
|
|
16
|
+
import { mulberry32 } from "../rp/template-processing";
|
|
17
|
+
import type { OutcomeValue, RpExpressionView } from "../rp/types";
|
|
18
|
+
import {
|
|
19
|
+
coerceScalar,
|
|
20
|
+
floatValue,
|
|
21
|
+
fromFlatValue,
|
|
22
|
+
isNumericBaseType,
|
|
23
|
+
singleBoolean,
|
|
24
|
+
toOutcomeValue,
|
|
25
|
+
type MaybeRpValue,
|
|
26
|
+
type RpValue,
|
|
27
|
+
} from "../rp/values";
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
AssessmentItemRefView,
|
|
31
|
+
AssessmentSectionView,
|
|
32
|
+
AssessmentTestView,
|
|
33
|
+
ItemSessionControlView,
|
|
34
|
+
OutcomeConditionBranch,
|
|
35
|
+
OutcomeRuleView,
|
|
36
|
+
TestController,
|
|
37
|
+
TestItemResult,
|
|
38
|
+
TestPlan,
|
|
39
|
+
TestPlanItem,
|
|
40
|
+
TestSessionState,
|
|
41
|
+
} from "./types";
|
|
42
|
+
|
|
43
|
+
const supportedOutcomeRuleKinds = new Set(["outcomeCondition", "setOutcomeValue", "exitTest"]);
|
|
44
|
+
|
|
45
|
+
const testExpressionKinds = new Set([
|
|
46
|
+
...deterministicExpressionKinds,
|
|
47
|
+
"testVariables",
|
|
48
|
+
"numberCorrect",
|
|
49
|
+
"numberIncorrect",
|
|
50
|
+
"numberPresented",
|
|
51
|
+
"numberResponded",
|
|
52
|
+
"numberSelected",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
class ExitTestSignal extends Error {}
|
|
56
|
+
|
|
57
|
+
function inferBaseType(value: unknown): string | undefined {
|
|
58
|
+
if (typeof value === "number") {
|
|
59
|
+
return "float";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (typeof value === "boolean") {
|
|
63
|
+
return "boolean";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Lift a persisted flat value back into the typed model (cardinality inferred). */
|
|
70
|
+
function liftFlat(value: OutcomeValue): MaybeRpValue {
|
|
71
|
+
if (value === null || value === undefined) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
return fromFlatValue(value, "multiple", inferBaseType(value[0]));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return fromFlatValue(value, "single", inferBaseType(value));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------- Plan resolution (seeded selection + ordering) ----------
|
|
83
|
+
|
|
84
|
+
type SectionChild = AssessmentSectionView | AssessmentItemRefView;
|
|
85
|
+
|
|
86
|
+
/** QTI itemSessionControl defaults (spec): one attempt, skipping and review allowed. */
|
|
87
|
+
const specSessionControlDefaults: Required<ItemSessionControlView> = {
|
|
88
|
+
maxAttempts: 1,
|
|
89
|
+
showFeedback: false,
|
|
90
|
+
allowReview: true,
|
|
91
|
+
showSolution: false,
|
|
92
|
+
allowComment: false,
|
|
93
|
+
allowSkipping: true,
|
|
94
|
+
validateResponses: false,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Only explicitly-set fields cascade; undefined entries never mask an outer level. */
|
|
98
|
+
function definedControl(control: ItemSessionControlView | undefined): ItemSessionControlView {
|
|
99
|
+
return control ? Object.fromEntries(Object.entries(control).filter(([, value]) => value !== undefined)) : {};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function seededPick<T>(pool: readonly T[], count: number, random: () => number): T[] {
|
|
103
|
+
const indices = pool.map((_, index) => index);
|
|
104
|
+
|
|
105
|
+
for (let i = indices.length - 1; i > 0; i -= 1) {
|
|
106
|
+
const j = Math.floor(random() * (i + 1));
|
|
107
|
+
|
|
108
|
+
[indices[i], indices[j]] = [indices[j]!, indices[i]!];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return indices
|
|
112
|
+
.slice(0, Math.min(count, indices.length))
|
|
113
|
+
.sort((a, b) => a - b) // selected children keep document order; ordering shuffles separately
|
|
114
|
+
.map((index) => pool[index]!);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function applySelection(children: readonly SectionChild[], select: number, random: () => number): SectionChild[] {
|
|
118
|
+
const required = children.filter((child) => child.required === true);
|
|
119
|
+
const optional = children.filter((child) => child.required !== true);
|
|
120
|
+
const needed = Math.max(0, select - required.length);
|
|
121
|
+
const picked = new Set<SectionChild>([...required, ...seededPick(optional, needed, random)]);
|
|
122
|
+
|
|
123
|
+
return children.filter((child) => picked.has(child));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function applyOrdering(children: readonly SectionChild[], random: () => number): SectionChild[] {
|
|
127
|
+
// Fixed children keep their positions; the rest shuffle into the remaining slots.
|
|
128
|
+
const result: (SectionChild | null)[] = children.map((child) => (child.fixed === true ? child : null));
|
|
129
|
+
const movable = children.filter((child) => child.fixed !== true);
|
|
130
|
+
const shuffled = seededPick(movable, movable.length, random);
|
|
131
|
+
// seededPick preserves document order after picking; re-shuffle for ordering.
|
|
132
|
+
for (let i = shuffled.length - 1; i > 0; i -= 1) {
|
|
133
|
+
const j = Math.floor(random() * (i + 1));
|
|
134
|
+
|
|
135
|
+
[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let cursor = 0;
|
|
139
|
+
|
|
140
|
+
return result.map((slot) => slot ?? shuffled[cursor++]!);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolveSection(
|
|
144
|
+
section: AssessmentSectionView,
|
|
145
|
+
partIdentifier: string,
|
|
146
|
+
sectionPath: readonly string[],
|
|
147
|
+
inheritedPreConditions: readonly RpExpressionView[],
|
|
148
|
+
inheritedControl: ItemSessionControlView,
|
|
149
|
+
random: () => number,
|
|
150
|
+
): TestPlanItem[] {
|
|
151
|
+
const path = [...sectionPath, section.identifier];
|
|
152
|
+
const preConditions = [...inheritedPreConditions, ...(section.preConditions ?? [])];
|
|
153
|
+
const control = { ...inheritedControl, ...definedControl(section.itemSessionControl) };
|
|
154
|
+
|
|
155
|
+
let children: readonly SectionChild[] = section.children;
|
|
156
|
+
|
|
157
|
+
if (section.selection) {
|
|
158
|
+
children = applySelection(children, section.selection.select, random);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (section.ordering?.shuffle) {
|
|
162
|
+
children = applyOrdering(children, random);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const items: TestPlanItem[] = [];
|
|
166
|
+
|
|
167
|
+
for (const child of children) {
|
|
168
|
+
if (child.kind === "assessmentSection") {
|
|
169
|
+
items.push(...resolveSection(child, partIdentifier, path, preConditions, control, random));
|
|
170
|
+
} else {
|
|
171
|
+
items.push({
|
|
172
|
+
key: child.identifier,
|
|
173
|
+
ref: child,
|
|
174
|
+
partIdentifier,
|
|
175
|
+
sectionPath: path,
|
|
176
|
+
preConditions: [...preConditions, ...(child.preConditions ?? [])],
|
|
177
|
+
sessionControl: { ...specSessionControlDefaults, ...control, ...definedControl(child.itemSessionControl) },
|
|
178
|
+
...(child.timeLimits ? { timeLimits: child.timeLimits } : {}),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return items;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function resolvePlan(view: AssessmentTestView, seed: number): TestPlan {
|
|
187
|
+
const random = mulberry32(seed);
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
...(view.timeLimits ? { timeLimits: view.timeLimits } : {}),
|
|
191
|
+
parts: view.testParts.map((part) => ({
|
|
192
|
+
identifier: part.identifier,
|
|
193
|
+
navigationMode: part.navigationMode,
|
|
194
|
+
submissionMode: part.submissionMode,
|
|
195
|
+
...(part.timeLimits ? { timeLimits: part.timeLimits } : {}),
|
|
196
|
+
items: part.assessmentSections.flatMap((section) =>
|
|
197
|
+
resolveSection(section, part.identifier, [], [], definedControl(part.itemSessionControl), random),
|
|
198
|
+
),
|
|
199
|
+
})),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------- The controller ----------
|
|
204
|
+
|
|
205
|
+
export interface TestControllerOptions {
|
|
206
|
+
readonly seed: number;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function createTestController(view: AssessmentTestView, options: TestControllerOptions): TestController {
|
|
210
|
+
const plan = resolvePlan(view, options.seed);
|
|
211
|
+
const allItems: TestPlanItem[] = plan.parts.flatMap((part) => [...part.items]);
|
|
212
|
+
const partIndexByItemKey = new Map<string, number>();
|
|
213
|
+
const itemsByKey = new Map<string, TestPlanItem>();
|
|
214
|
+
|
|
215
|
+
plan.parts.forEach((part, partIndex) => {
|
|
216
|
+
for (const item of part.items) {
|
|
217
|
+
partIndexByItemKey.set(item.key, partIndex);
|
|
218
|
+
itemsByKey.set(item.key, item);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
function attemptsOf(state: TestSessionState, itemKey: string): number {
|
|
223
|
+
return (state.attemptCounts ?? {})[itemKey] ?? 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The plan items a subset-selecting expression addresses (section + categories). */
|
|
227
|
+
function subsetItems(expression: RpExpressionView): TestPlanItem[] {
|
|
228
|
+
const asList = (value: string | readonly string[] | undefined): readonly string[] | undefined =>
|
|
229
|
+
typeof value === "string" ? [value] : value;
|
|
230
|
+
const includeCategory = asList(expression.includeCategory);
|
|
231
|
+
const excludeCategory = asList(expression.excludeCategory);
|
|
232
|
+
|
|
233
|
+
return allItems.filter((item) => {
|
|
234
|
+
if (expression.sectionIdentifier !== undefined && !item.sectionPath.includes(expression.sectionIdentifier)) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const categories = item.ref.categories ?? [];
|
|
239
|
+
|
|
240
|
+
if (includeCategory !== undefined && !includeCategory.some((category) => categories.includes(category))) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return !(excludeCategory !== undefined && excludeCategory.some((category) => categories.includes(category)));
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function remainingAttempts(state: TestSessionState, itemKey: string): number {
|
|
249
|
+
const item = itemsByKey.get(itemKey);
|
|
250
|
+
|
|
251
|
+
if (!item) {
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const max = item.sessionControl.maxAttempts;
|
|
256
|
+
|
|
257
|
+
return max === 0 ? Number.POSITIVE_INFINITY : Math.max(0, max - attemptsOf(state, itemKey));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function defaultTestOutcomes(): Map<string, MaybeRpValue> {
|
|
261
|
+
const outcomes = new Map<string, MaybeRpValue>();
|
|
262
|
+
|
|
263
|
+
for (const declaration of view.outcomeDeclarations ?? []) {
|
|
264
|
+
if (declaration.defaultValue) {
|
|
265
|
+
outcomes.set(declaration.identifier, {
|
|
266
|
+
cardinality: declaration.cardinality,
|
|
267
|
+
baseType: declaration.baseType,
|
|
268
|
+
values: declaration.defaultValue.values.map((entry) => coerceScalar(entry.value, declaration.baseType)),
|
|
269
|
+
});
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
outcomes.set(declaration.identifier, isNumericBaseType(declaration.baseType) ? floatValue(0) : null);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return outcomes;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function makeEnv(state: TestSessionState, outcomes?: Map<string, MaybeRpValue>): EvalEnv {
|
|
280
|
+
return {
|
|
281
|
+
lookupVariable: (identifier) => {
|
|
282
|
+
const dot = identifier.indexOf(".");
|
|
283
|
+
|
|
284
|
+
if (dot !== -1) {
|
|
285
|
+
const itemKey = identifier.slice(0, dot);
|
|
286
|
+
const variableName = identifier.slice(dot + 1);
|
|
287
|
+
|
|
288
|
+
return liftFlat(state.itemOutcomes[itemKey]?.[variableName] ?? null);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (outcomes?.has(identifier)) {
|
|
292
|
+
return outcomes.get(identifier) ?? null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return liftFlat(state.testOutcomes[identifier] ?? null);
|
|
296
|
+
},
|
|
297
|
+
responseDeclaration: () => undefined,
|
|
298
|
+
responseValue: () => null,
|
|
299
|
+
testVariables: (expression) => {
|
|
300
|
+
// Contracts spell the variable `variableIdentifier`; the bare `identifier`
|
|
301
|
+
// form is accepted for hand-built views.
|
|
302
|
+
const variableName = expression.variableIdentifier ?? expression.identifier ?? "";
|
|
303
|
+
const weightIdentifier = expression.weightIdentifier;
|
|
304
|
+
const members: RpValue["values"][number][] = [];
|
|
305
|
+
let baseType = expression.baseType;
|
|
306
|
+
|
|
307
|
+
for (const item of subsetItems(expression)) {
|
|
308
|
+
const value = state.itemOutcomes[item.key]?.[variableName];
|
|
309
|
+
|
|
310
|
+
if (value === undefined || value === null) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const lifted = liftFlat(value);
|
|
315
|
+
|
|
316
|
+
if (lifted === null) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Weighted numeric values multiply by the item's named weight (missing
|
|
321
|
+
// names weigh 1) and the container becomes float (spec).
|
|
322
|
+
if (weightIdentifier !== undefined && isNumericBaseType(lifted.baseType)) {
|
|
323
|
+
const weight = item.ref.weights?.find((entry) => entry.identifier === weightIdentifier)?.value ?? 1;
|
|
324
|
+
|
|
325
|
+
baseType = "float";
|
|
326
|
+
members.push(...lifted.values.map((entry) => Number(entry) * weight));
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
baseType ??= lifted.baseType;
|
|
331
|
+
members.push(...lifted.values);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return members.length === 0 ? null : { cardinality: "multiple", baseType, values: members };
|
|
335
|
+
},
|
|
336
|
+
testAggregate: (expression) => {
|
|
337
|
+
const subset = subsetItems(expression);
|
|
338
|
+
const integer = (value: number): RpValue => ({
|
|
339
|
+
cardinality: "single",
|
|
340
|
+
baseType: "integer",
|
|
341
|
+
values: [value],
|
|
342
|
+
});
|
|
343
|
+
const countIn = (list: readonly string[] | undefined): number => {
|
|
344
|
+
const flagged = new Set(list ?? []);
|
|
345
|
+
|
|
346
|
+
return subset.filter((item) => flagged.has(item.key)).length;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
switch (expression.kind) {
|
|
350
|
+
case "numberSelected":
|
|
351
|
+
return integer(subset.length);
|
|
352
|
+
case "numberPresented":
|
|
353
|
+
return integer(countIn(state.presentedItems));
|
|
354
|
+
case "numberResponded":
|
|
355
|
+
return integer(countIn(state.respondedItems));
|
|
356
|
+
case "numberCorrect":
|
|
357
|
+
return integer(countIn(state.correctItems));
|
|
358
|
+
case "numberIncorrect":
|
|
359
|
+
return integer(countIn(state.incorrectItems));
|
|
360
|
+
default:
|
|
361
|
+
throw new RpUnsupportedError(expression.kind);
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function conditionPasses(expression: RpExpressionView, state: TestSessionState): boolean {
|
|
368
|
+
try {
|
|
369
|
+
return singleBoolean(evaluateExpression(expression, makeEnv(state))) === true;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (error instanceof RpUnsupportedError) {
|
|
372
|
+
return true; // unsupported preconditions never hide content; surfaced via issues
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function preConditionsPass(item: TestPlanItem, state: TestSessionState): boolean {
|
|
380
|
+
return item.preConditions.every((expression) => conditionPasses(expression, state));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function runOutcomeProcessing(state: TestSessionState): Readonly<Record<string, OutcomeValue>> {
|
|
384
|
+
let outcomes = defaultTestOutcomes();
|
|
385
|
+
const env = makeEnv(state, outcomes);
|
|
386
|
+
|
|
387
|
+
function branchTaken(branch: OutcomeConditionBranch): boolean {
|
|
388
|
+
if (singleBoolean(evaluateExpression(branch.expression, env)) !== true) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
executeRules(branch.rules);
|
|
393
|
+
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function executeRules(rules: readonly OutcomeRuleView[]): void {
|
|
398
|
+
for (const rule of rules) {
|
|
399
|
+
if (!supportedOutcomeRuleKinds.has(rule.kind)) {
|
|
400
|
+
throw new RpUnsupportedError(rule.kind);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (rule.kind === "exitTest") {
|
|
404
|
+
throw new ExitTestSignal();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (rule.kind === "setOutcomeValue") {
|
|
408
|
+
if (rule.identifier !== undefined && rule.expression !== undefined) {
|
|
409
|
+
outcomes.set(rule.identifier, evaluateExpression(rule.expression, env));
|
|
410
|
+
}
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// outcomeCondition
|
|
415
|
+
if (rule.outcomeIf && branchTaken(rule.outcomeIf)) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const elseIfTaken = (rule.outcomeElseIfs ?? []).some((branch) => branchTaken(branch));
|
|
420
|
+
|
|
421
|
+
if (!elseIfTaken && rule.outcomeElse) {
|
|
422
|
+
executeRules(rule.outcomeElse.rules);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
executeRules(view.outcomeProcessing?.rules ?? []);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (error instanceof RpUnsupportedError) {
|
|
431
|
+
outcomes = defaultTestOutcomes(); // abort, never partial scoring
|
|
432
|
+
} else if (!(error instanceof ExitTestSignal)) {
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return Object.fromEntries([...outcomes].map(([identifier, value]) => [identifier, toOutcomeValue(value)]));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** The first item at or after (partIndex, itemIndex) whose preconditions pass. */
|
|
441
|
+
function firstNavigable(state: TestSessionState, partIndex: number, itemIndex: number): TestPlanItem | null {
|
|
442
|
+
for (let p = partIndex; p < plan.parts.length; p += 1) {
|
|
443
|
+
const items = plan.parts[p]!.items;
|
|
444
|
+
|
|
445
|
+
for (let i = p === partIndex ? itemIndex : 0; i < items.length; i += 1) {
|
|
446
|
+
const item = items[i]!;
|
|
447
|
+
|
|
448
|
+
if (preConditionsPass(item, state)) {
|
|
449
|
+
return item;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function positionOf(itemKey: string): { partIndex: number; itemIndex: number } | null {
|
|
458
|
+
const partIndex = partIndexByItemKey.get(itemKey);
|
|
459
|
+
|
|
460
|
+
if (partIndex === undefined) {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const itemIndex = plan.parts[partIndex]!.items.findIndex((item) => item.key === itemKey);
|
|
465
|
+
|
|
466
|
+
return itemIndex === -1 ? null : { partIndex, itemIndex };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function withFlag(list: readonly string[] | undefined, itemKey: string, present: boolean): readonly string[] {
|
|
470
|
+
const existing = list ?? [];
|
|
471
|
+
|
|
472
|
+
if (existing.includes(itemKey) === present) {
|
|
473
|
+
return existing;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return present ? [...existing, itemKey] : existing.filter((entry) => entry !== itemKey);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Latest-attempt semantics: a re-attempt can flip correct ↔ incorrect ↔ neither. */
|
|
480
|
+
function applyResultFlags(state: TestSessionState, itemKey: string, result: TestItemResult): TestSessionState {
|
|
481
|
+
return {
|
|
482
|
+
...state,
|
|
483
|
+
respondedItems: withFlag(state.respondedItems, itemKey, result.responded === true),
|
|
484
|
+
correctItems: withFlag(state.correctItems, itemKey, result.correct === true),
|
|
485
|
+
incorrectItems: withFlag(state.incorrectItems, itemKey, result.correct === false),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function markPresented(state: TestSessionState, itemKey: string): TestSessionState {
|
|
490
|
+
return (state.presentedItems ?? []).includes(itemKey)
|
|
491
|
+
? state
|
|
492
|
+
: { ...state, presentedItems: [...(state.presentedItems ?? []), itemKey] };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Commit pending simultaneous results for one part (or all parts when null). */
|
|
496
|
+
function flushPending(state: TestSessionState, partIndex: number | null): TestSessionState {
|
|
497
|
+
const pending = state.pendingItemResults ?? {};
|
|
498
|
+
const keys = Object.keys(pending).filter((key) => partIndex === null || partIndexByItemKey.get(key) === partIndex);
|
|
499
|
+
|
|
500
|
+
if (keys.length === 0) {
|
|
501
|
+
return state;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const itemOutcomes = { ...state.itemOutcomes };
|
|
505
|
+
const attemptCounts = { ...(state.attemptCounts ?? {}) };
|
|
506
|
+
const remaining = { ...pending };
|
|
507
|
+
let flagged = state;
|
|
508
|
+
|
|
509
|
+
for (const key of keys) {
|
|
510
|
+
const result = pending[key]!;
|
|
511
|
+
|
|
512
|
+
itemOutcomes[key] = result.outcomes;
|
|
513
|
+
attemptCounts[key] = (attemptCounts[key] ?? 0) + 1; // the part's single attempt
|
|
514
|
+
delete remaining[key];
|
|
515
|
+
flagged = applyResultFlags(flagged, key, result);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return { ...flagged, itemOutcomes, attemptCounts, pendingItemResults: remaining };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function ended(state: TestSessionState): TestSessionState {
|
|
522
|
+
const flushed = flushPending(state, null);
|
|
523
|
+
|
|
524
|
+
return { ...flushed, status: "ended", currentItemKey: null, testOutcomes: runOutcomeProcessing(flushed) };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function moveToItem(state: TestSessionState, item: TestPlanItem | null): TestSessionState {
|
|
528
|
+
if (item === null) {
|
|
529
|
+
return ended(state);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Crossing a part boundary submits the departed part's pending results.
|
|
533
|
+
const fromPart = state.currentItemKey === null ? undefined : partIndexByItemKey.get(state.currentItemKey);
|
|
534
|
+
const toPart = partIndexByItemKey.get(item.key);
|
|
535
|
+
let next = state;
|
|
536
|
+
|
|
537
|
+
if (fromPart !== undefined && toPart !== fromPart) {
|
|
538
|
+
const flushed = flushPending(state, fromPart);
|
|
539
|
+
|
|
540
|
+
if (flushed !== state) {
|
|
541
|
+
next = { ...flushed, testOutcomes: runOutcomeProcessing(flushed) };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return markPresented({ ...next, currentItemKey: item.key }, item.key);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function nextState(state: TestSessionState): TestSessionState {
|
|
549
|
+
if (state.status === "ended" || state.currentItemKey === null) {
|
|
550
|
+
return state;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const current = positionOf(state.currentItemKey);
|
|
554
|
+
|
|
555
|
+
if (!current) {
|
|
556
|
+
return ended(state);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const part = plan.parts[current.partIndex]!;
|
|
560
|
+
const currentItem = part.items[current.itemIndex]!;
|
|
561
|
+
|
|
562
|
+
// allowSkipping=false in linear mode: the current item must be attempted before
|
|
563
|
+
// moving past it (branch rules cannot fire off an unattempted, unskippable item).
|
|
564
|
+
// Attempted = in attemptedItems, so pending simultaneous submissions count.
|
|
565
|
+
if (
|
|
566
|
+
part.navigationMode === "linear" &&
|
|
567
|
+
!currentItem.sessionControl.allowSkipping &&
|
|
568
|
+
!state.attemptedItems.includes(currentItem.key)
|
|
569
|
+
) {
|
|
570
|
+
return state;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Branch rules: first matching rule wins (author-explicit jumps bypass skip checks).
|
|
574
|
+
for (const branchRule of currentItem.ref.branchRules ?? []) {
|
|
575
|
+
if (!conditionPasses(branchRule.expression, state)) {
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (branchRule.target === "EXIT_TEST") {
|
|
580
|
+
return ended(state);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (branchRule.target === "EXIT_TESTPART") {
|
|
584
|
+
return moveToItem(state, firstNavigable(state, current.partIndex + 1, 0));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (branchRule.target === "EXIT_SECTION") {
|
|
588
|
+
const items = part.items;
|
|
589
|
+
const sectionKey = currentItem.sectionPath.join("/");
|
|
590
|
+
let index = current.itemIndex + 1;
|
|
591
|
+
|
|
592
|
+
while (index < items.length && items[index]!.sectionPath.join("/") === sectionKey) {
|
|
593
|
+
index += 1;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return moveToItem(state, firstNavigable(state, current.partIndex, index));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const target = positionOf(branchRule.target);
|
|
600
|
+
|
|
601
|
+
if (target && target.partIndex === current.partIndex) {
|
|
602
|
+
return moveToItem(state, firstNavigable(state, target.partIndex, target.itemIndex));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const destination = firstNavigable(state, current.partIndex, current.itemIndex + 1);
|
|
607
|
+
|
|
608
|
+
// allowSkipping=false in nonlinear mode bites at the part boundary: the part cannot
|
|
609
|
+
// be left while any reachable unskippable item is unattempted.
|
|
610
|
+
if (
|
|
611
|
+
part.navigationMode === "nonlinear" &&
|
|
612
|
+
(destination === null || partIndexByItemKey.get(destination.key) !== current.partIndex)
|
|
613
|
+
) {
|
|
614
|
+
const blocked = part.items.some(
|
|
615
|
+
(item) =>
|
|
616
|
+
!item.sessionControl.allowSkipping &&
|
|
617
|
+
!state.attemptedItems.includes(item.key) &&
|
|
618
|
+
preConditionsPass(item, state),
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
if (blocked) {
|
|
622
|
+
return state;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return moveToItem(state, destination);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---------- Static capability walk ----------
|
|
630
|
+
|
|
631
|
+
const issues: CapabilityIssue[] = [];
|
|
632
|
+
const seenIssues = new Set<string>();
|
|
633
|
+
|
|
634
|
+
function report(name: string): void {
|
|
635
|
+
if (!seenIssues.has(name)) {
|
|
636
|
+
seenIssues.add(name);
|
|
637
|
+
issues.push({ type: "unsupported-rp", name });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function walkOutcomeRules(rules: readonly OutcomeRuleView[]): void {
|
|
642
|
+
for (const rule of rules) {
|
|
643
|
+
if (!supportedOutcomeRuleKinds.has(rule.kind)) {
|
|
644
|
+
report(rule.kind);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (rule.expression) {
|
|
649
|
+
collectExpressionIssues(rule.expression, testExpressionKinds, report);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
for (const branch of [rule.outcomeIf, ...(rule.outcomeElseIfs ?? [])]) {
|
|
653
|
+
if (branch) {
|
|
654
|
+
collectExpressionIssues(branch.expression, testExpressionKinds, report);
|
|
655
|
+
walkOutcomeRules(branch.rules);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (rule.outcomeElse) {
|
|
660
|
+
walkOutcomeRules(rule.outcomeElse.rules);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
walkOutcomeRules(view.outcomeProcessing?.rules ?? []);
|
|
666
|
+
|
|
667
|
+
for (const item of allItems) {
|
|
668
|
+
for (const expression of item.preConditions) {
|
|
669
|
+
collectExpressionIssues(expression, testExpressionKinds, report);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
for (const branchRule of item.ref.branchRules ?? []) {
|
|
673
|
+
collectExpressionIssues(branchRule.expression, testExpressionKinds, report);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ---------- Public surface ----------
|
|
678
|
+
|
|
679
|
+
return {
|
|
680
|
+
plan,
|
|
681
|
+
issues,
|
|
682
|
+
|
|
683
|
+
start: () => {
|
|
684
|
+
const initial: TestSessionState = {
|
|
685
|
+
status: "in-progress",
|
|
686
|
+
currentItemKey: null,
|
|
687
|
+
itemOutcomes: {},
|
|
688
|
+
attemptedItems: [],
|
|
689
|
+
attemptCounts: {},
|
|
690
|
+
presentedItems: [],
|
|
691
|
+
respondedItems: [],
|
|
692
|
+
correctItems: [],
|
|
693
|
+
incorrectItems: [],
|
|
694
|
+
pendingItemResults: {},
|
|
695
|
+
testOutcomes: {},
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
return moveToItem({ ...initial, testOutcomes: runOutcomeProcessing(initial) }, firstNavigable(initial, 0, 0));
|
|
699
|
+
},
|
|
700
|
+
|
|
701
|
+
currentItem: (state) =>
|
|
702
|
+
state.currentItemKey === null ? null : (allItems.find((item) => item.key === state.currentItemKey) ?? null),
|
|
703
|
+
|
|
704
|
+
canMoveTo: (state, itemKey) => {
|
|
705
|
+
if (state.status === "ended" || state.currentItemKey === null) {
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const current = positionOf(state.currentItemKey);
|
|
710
|
+
const target = positionOf(itemKey);
|
|
711
|
+
|
|
712
|
+
if (!current || !target || target.partIndex !== current.partIndex) {
|
|
713
|
+
return false; // part boundaries are one-way; no cross-part jumps
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (plan.parts[current.partIndex]!.navigationMode !== "nonlinear") {
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return preConditionsPass(plan.parts[target.partIndex]!.items[target.itemIndex]!, state);
|
|
721
|
+
},
|
|
722
|
+
|
|
723
|
+
moveTo: (state, itemKey) => {
|
|
724
|
+
const current = positionOf(state.currentItemKey ?? "");
|
|
725
|
+
const target = positionOf(itemKey);
|
|
726
|
+
|
|
727
|
+
if (
|
|
728
|
+
state.status === "ended" ||
|
|
729
|
+
!current ||
|
|
730
|
+
!target ||
|
|
731
|
+
target.partIndex !== current.partIndex ||
|
|
732
|
+
plan.parts[current.partIndex]!.navigationMode !== "nonlinear"
|
|
733
|
+
) {
|
|
734
|
+
return state;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return markPresented({ ...state, currentItemKey: itemKey }, itemKey);
|
|
738
|
+
},
|
|
739
|
+
|
|
740
|
+
canNext: (state) => nextState(state) !== state,
|
|
741
|
+
|
|
742
|
+
next: nextState,
|
|
743
|
+
|
|
744
|
+
remainingAttempts,
|
|
745
|
+
|
|
746
|
+
canSubmitItem: (state, itemKey) => state.status !== "ended" && remainingAttempts(state, itemKey) > 0,
|
|
747
|
+
|
|
748
|
+
submitItem: (state, itemKey, result) => {
|
|
749
|
+
if (state.status === "ended") {
|
|
750
|
+
return state;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const partIndex = partIndexByItemKey.get(itemKey);
|
|
754
|
+
|
|
755
|
+
// Simultaneous parts hold results pending and allow revision until the part is
|
|
756
|
+
// left; the single attempt (spec) is only spent when the pending set flushes.
|
|
757
|
+
if (partIndex !== undefined && plan.parts[partIndex]!.submissionMode === "simultaneous") {
|
|
758
|
+
if (attemptsOf(state, itemKey) > 0) {
|
|
759
|
+
return state; // the part was already submitted
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return {
|
|
763
|
+
...state,
|
|
764
|
+
pendingItemResults: { ...(state.pendingItemResults ?? {}), [itemKey]: result },
|
|
765
|
+
attemptedItems: state.attemptedItems.includes(itemKey)
|
|
766
|
+
? state.attemptedItems
|
|
767
|
+
: [...state.attemptedItems, itemKey],
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Adaptive items run their own attempt lifecycle, so maxAttempts is ignored (spec).
|
|
772
|
+
if (result.adaptive !== true && remainingAttempts(state, itemKey) <= 0) {
|
|
773
|
+
return state;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const next: TestSessionState = {
|
|
777
|
+
...applyResultFlags(state, itemKey, result),
|
|
778
|
+
itemOutcomes: { ...state.itemOutcomes, [itemKey]: result.outcomes },
|
|
779
|
+
attemptedItems: state.attemptedItems.includes(itemKey)
|
|
780
|
+
? state.attemptedItems
|
|
781
|
+
: [...state.attemptedItems, itemKey],
|
|
782
|
+
attemptCounts: { ...(state.attemptCounts ?? {}), [itemKey]: attemptsOf(state, itemKey) + 1 },
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
return { ...next, testOutcomes: runOutcomeProcessing(next) };
|
|
786
|
+
},
|
|
787
|
+
|
|
788
|
+
end: (state) => (state.status === "ended" ? state : ended(state)),
|
|
789
|
+
|
|
790
|
+
visibleTestFeedbacks: (state) =>
|
|
791
|
+
(view.testFeedbacks ?? []).filter((feedback) => {
|
|
792
|
+
const accessOk = (feedback.access ?? "atEnd") === (state.status === "ended" ? "atEnd" : "during");
|
|
793
|
+
|
|
794
|
+
if (!accessOk) {
|
|
795
|
+
return false;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const outcome = state.testOutcomes[feedback.outcomeIdentifier] ?? null;
|
|
799
|
+
const matched = Array.isArray(outcome)
|
|
800
|
+
? outcome.map(String).includes(feedback.identifier)
|
|
801
|
+
: outcome !== null && String(outcome) === feedback.identifier;
|
|
802
|
+
|
|
803
|
+
return matched !== (feedback.showHide === "hide");
|
|
804
|
+
}),
|
|
805
|
+
};
|
|
806
|
+
}
|