@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,25 @@
|
|
|
1
|
+
export { createTestController, type TestControllerOptions } from "./controller";
|
|
2
|
+
export {
|
|
3
|
+
createTestSessionStore,
|
|
4
|
+
type TestSessionSnapshot,
|
|
5
|
+
type TestSessionStore,
|
|
6
|
+
type TestSessionStoreOptions,
|
|
7
|
+
} from "./session-store";
|
|
8
|
+
export type {
|
|
9
|
+
AssessmentItemRefView,
|
|
10
|
+
AssessmentSectionView,
|
|
11
|
+
AssessmentTestView,
|
|
12
|
+
BranchRuleView,
|
|
13
|
+
ItemSessionControlView,
|
|
14
|
+
OutcomeConditionBranch,
|
|
15
|
+
OutcomeRuleView,
|
|
16
|
+
TestController,
|
|
17
|
+
TestFeedbackView,
|
|
18
|
+
TestItemResult,
|
|
19
|
+
TestPartView,
|
|
20
|
+
TestPlan,
|
|
21
|
+
TestPlanItem,
|
|
22
|
+
TestPlanPart,
|
|
23
|
+
TestSessionState,
|
|
24
|
+
TimeLimitsView,
|
|
25
|
+
} from "./types";
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Test Session Store: glue between the headless Test Controller (ADR-0005) and
|
|
3
|
+
* per-item Attempt Stores. It holds the controller's JSON session state, creates item
|
|
4
|
+
* stores lazily with key-derived seeds (so every clone is replayable from the test
|
|
5
|
+
* seed alone), and feeds item submissions back into the controller so test outcome
|
|
6
|
+
* processing stays current. React-free — UI layers subscribe like any external store;
|
|
7
|
+
* persistence stays with the consumer (store the seed and `snapshot.state`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { CustomOperatorImplementation, ResponseNormalization, TemplateRuleView } from "../rp";
|
|
11
|
+
import type { AssessmentItemView } from "../runtime";
|
|
12
|
+
import { createAttemptStore, type AttemptSnapshot, type AttemptStore } from "../store";
|
|
13
|
+
import { isResponseRecord } from "../types";
|
|
14
|
+
import type { ResponseValue } from "../types";
|
|
15
|
+
|
|
16
|
+
import type { AssessmentItemRefView, TestController, TestFeedbackView, TestPlanItem, TestSessionState } from "./types";
|
|
17
|
+
|
|
18
|
+
export interface TestSessionStoreOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Resolve an item ref to its view. Synchronous by design: load the package's items
|
|
21
|
+
* before mounting the session; return null for refs that cannot be delivered.
|
|
22
|
+
*/
|
|
23
|
+
readonly resolveItem: (ref: AssessmentItemRefView) => AssessmentItemView | null;
|
|
24
|
+
/** The test seed: drives plan resolution replay and derives per-item clone seeds. */
|
|
25
|
+
readonly seed: number;
|
|
26
|
+
/** Resume a persisted session (item responses are session-local, not part of it). */
|
|
27
|
+
readonly initialState?: TestSessionState;
|
|
28
|
+
readonly normalization?: ResponseNormalization;
|
|
29
|
+
/** Registered vendor `customOperator` implementations by class (opt-in). */
|
|
30
|
+
readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TestSessionSnapshot {
|
|
34
|
+
readonly state: TestSessionState;
|
|
35
|
+
readonly currentItem: TestPlanItem | null;
|
|
36
|
+
/** The resolved view for the current item, or null when unresolvable. */
|
|
37
|
+
readonly currentItemView: AssessmentItemView | null;
|
|
38
|
+
readonly visibleFeedbacks: readonly TestFeedbackView[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TestSessionStore {
|
|
42
|
+
readonly controller: TestController;
|
|
43
|
+
readonly subscribe: (listener: () => void) => () => void;
|
|
44
|
+
readonly getSnapshot: () => TestSessionSnapshot;
|
|
45
|
+
/** The item's Attempt Store — created once per key, reused across navigation. */
|
|
46
|
+
readonly itemStore: (itemKey: string) => AttemptStore | null;
|
|
47
|
+
readonly itemView: (itemKey: string) => AssessmentItemView | null;
|
|
48
|
+
readonly next: () => void;
|
|
49
|
+
readonly canMoveTo: (itemKey: string) => boolean;
|
|
50
|
+
readonly moveTo: (itemKey: string) => void;
|
|
51
|
+
readonly end: () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Identifiers whose correct response arrives via `setCorrectResponse` in templates. */
|
|
55
|
+
function collectCorrectResponseTargets(rules: readonly TemplateRuleView[] | undefined, into: Set<string>): void {
|
|
56
|
+
for (const rule of rules ?? []) {
|
|
57
|
+
if (rule.kind === "setCorrectResponse" && rule.identifier !== undefined) {
|
|
58
|
+
into.add(rule.identifier);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const branch of [rule.templateIf, ...(rule.templateElseIfs ?? [])]) {
|
|
62
|
+
if (branch) {
|
|
63
|
+
collectCorrectResponseTargets(branch.rules, into);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (rule.templateElse) {
|
|
68
|
+
collectCorrectResponseTargets(rule.templateElse.rules, into);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The response declarations an attempt can be "correct" about (incl. templated ones). */
|
|
74
|
+
function scorableIdentifiers(view: AssessmentItemView): Set<string> {
|
|
75
|
+
const templated = new Set<string>();
|
|
76
|
+
|
|
77
|
+
collectCorrectResponseTargets(view.templateProcessing?.rules, templated);
|
|
78
|
+
|
|
79
|
+
return new Set(
|
|
80
|
+
view.responseDeclarations
|
|
81
|
+
.filter(
|
|
82
|
+
(declaration) =>
|
|
83
|
+
declaration.correctResponse !== undefined ||
|
|
84
|
+
declaration.mapping !== undefined ||
|
|
85
|
+
declaration.areaMapping !== undefined ||
|
|
86
|
+
templated.has(declaration.identifier),
|
|
87
|
+
)
|
|
88
|
+
.map((declaration) => declaration.identifier),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hasResponse(value: ResponseValue): boolean {
|
|
93
|
+
if (value === null || value === undefined || value === "") {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (isResponseRecord(value)) {
|
|
98
|
+
return Object.values(value).some((member) => member !== null && member !== "");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return !Array.isArray(value) || value.length > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Derive the controller-facing correctness flags from a submitted attempt. */
|
|
105
|
+
function resultFlags(
|
|
106
|
+
attempt: AttemptSnapshot,
|
|
107
|
+
scorable: ReadonlySet<string>,
|
|
108
|
+
): { correct?: boolean; responded: boolean } {
|
|
109
|
+
const relevant = attempt.scores.filter((score) => scorable.has(score.identifier));
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
...(relevant.length > 0 ? { correct: relevant.every((score) => score.correct) } : {}),
|
|
113
|
+
responded: Object.values(attempt.responses).some(hasResponse),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** FNV-1a over the item key, mixed with the test seed: stable per-item clone seeds. */
|
|
118
|
+
function deriveItemSeed(seed: number, itemKey: string): number {
|
|
119
|
+
let hash = (0x811c9dc5 ^ seed) >>> 0;
|
|
120
|
+
|
|
121
|
+
for (let index = 0; index < itemKey.length; index += 1) {
|
|
122
|
+
hash = Math.imul(hash ^ itemKey.charCodeAt(index), 0x01000193) >>> 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return hash;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createTestSessionStore(controller: TestController, options: TestSessionStoreOptions): TestSessionStore {
|
|
129
|
+
const listeners = new Set<() => void>();
|
|
130
|
+
const planItemsByKey = new Map<string, TestPlanItem>();
|
|
131
|
+
|
|
132
|
+
for (const part of controller.plan.parts) {
|
|
133
|
+
for (const item of part.items) {
|
|
134
|
+
planItemsByKey.set(item.key, item);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const itemViews = new Map<string, AssessmentItemView | null>();
|
|
139
|
+
const itemStores = new Map<string, AttemptStore | null>();
|
|
140
|
+
/** The last attempt snapshot forwarded per item — dedupes subscription firings. */
|
|
141
|
+
const forwardedAttempts = new Map<string, unknown>();
|
|
142
|
+
|
|
143
|
+
let state = options.initialState ?? controller.start();
|
|
144
|
+
let snapshot = buildSnapshot();
|
|
145
|
+
|
|
146
|
+
function buildSnapshot(): TestSessionSnapshot {
|
|
147
|
+
const currentItem = controller.currentItem(state);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
state,
|
|
151
|
+
currentItem,
|
|
152
|
+
currentItemView: currentItem === null ? null : itemView(currentItem.key),
|
|
153
|
+
visibleFeedbacks: controller.visibleTestFeedbacks(state),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function emit(next: TestSessionState): void {
|
|
158
|
+
state = next;
|
|
159
|
+
snapshot = buildSnapshot();
|
|
160
|
+
|
|
161
|
+
for (const listener of listeners) {
|
|
162
|
+
listener();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function itemView(itemKey: string): AssessmentItemView | null {
|
|
167
|
+
if (!itemViews.has(itemKey)) {
|
|
168
|
+
const planItem = planItemsByKey.get(itemKey);
|
|
169
|
+
itemViews.set(itemKey, planItem ? options.resolveItem(planItem.ref) : null);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return itemViews.get(itemKey) ?? null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function itemStore(itemKey: string): AttemptStore | null {
|
|
176
|
+
if (itemStores.has(itemKey)) {
|
|
177
|
+
return itemStores.get(itemKey) ?? null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const view = itemView(itemKey);
|
|
181
|
+
|
|
182
|
+
if (!view) {
|
|
183
|
+
itemStores.set(itemKey, null);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const store = createAttemptStore(
|
|
188
|
+
view.responseDeclarations,
|
|
189
|
+
{},
|
|
190
|
+
{
|
|
191
|
+
outcomeDeclarations: view.outcomeDeclarations,
|
|
192
|
+
responseProcessing: view.responseProcessing,
|
|
193
|
+
templateDeclarations: view.templateDeclarations,
|
|
194
|
+
templateProcessing: view.templateProcessing,
|
|
195
|
+
adaptive: view.adaptive,
|
|
196
|
+
seed: deriveItemSeed(options.seed, itemKey),
|
|
197
|
+
normalization: options.normalization,
|
|
198
|
+
customOperators: options.customOperators,
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const scorable = scorableIdentifiers(view);
|
|
203
|
+
|
|
204
|
+
// Every submitted snapshot flows into the controller, which decides what it means:
|
|
205
|
+
// an attempt (individual), a pending revision (simultaneous), or a refusal
|
|
206
|
+
// (maxAttempts spent) — refused results never reach session state.
|
|
207
|
+
store.subscribe(() => {
|
|
208
|
+
const attempt = store.getSnapshot();
|
|
209
|
+
|
|
210
|
+
if (attempt.submitted && forwardedAttempts.get(itemKey) !== attempt) {
|
|
211
|
+
forwardedAttempts.set(itemKey, attempt);
|
|
212
|
+
|
|
213
|
+
const next = controller.submitItem(state, itemKey, {
|
|
214
|
+
outcomes: attempt.outcomes,
|
|
215
|
+
...resultFlags(attempt, scorable),
|
|
216
|
+
...(view.adaptive === true ? { adaptive: true } : {}),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (next !== state) {
|
|
220
|
+
emit(next);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
itemStores.set(itemKey, store);
|
|
226
|
+
|
|
227
|
+
return store;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
controller,
|
|
232
|
+
subscribe: (listener) => {
|
|
233
|
+
listeners.add(listener);
|
|
234
|
+
return () => listeners.delete(listener);
|
|
235
|
+
},
|
|
236
|
+
getSnapshot: () => snapshot,
|
|
237
|
+
itemStore,
|
|
238
|
+
itemView,
|
|
239
|
+
next: () => emit(controller.next(state)),
|
|
240
|
+
canMoveTo: (itemKey) => controller.canMoveTo(state, itemKey),
|
|
241
|
+
moveTo: (itemKey) => emit(controller.moveTo(state, itemKey)),
|
|
242
|
+
end: () => emit(controller.end(state)),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural views of QTI 3 `assessmentTest` and the Test Controller's session state
|
|
3
|
+
* (ADR-0005). The controller owns the rules; everything in `TestSessionState` is plain
|
|
4
|
+
* JSON so the consumer owns persistence — store the seed and the state, replay the
|
|
5
|
+
* test.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CapabilityIssue } from "../capability";
|
|
9
|
+
import type { OutcomeDeclarationView, OutcomeValue, RpExpressionView } from "../rp";
|
|
10
|
+
import type { BodyNode } from "../runtime";
|
|
11
|
+
|
|
12
|
+
export interface BranchRuleView {
|
|
13
|
+
/** A target identifier in the same test part, or EXIT_TEST / EXIT_TESTPART / EXIT_SECTION. */
|
|
14
|
+
readonly target: string;
|
|
15
|
+
readonly expression: RpExpressionView;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* QTI `itemSessionControl`: per-level overrides cascading testPart → section → itemRef.
|
|
20
|
+
* The controller enforces `maxAttempts` and `allowSkipping`; the rest is surfaced for
|
|
21
|
+
* delivery chrome (review/solution/comment affordances are UI concerns).
|
|
22
|
+
*/
|
|
23
|
+
export interface ItemSessionControlView {
|
|
24
|
+
/** Attempts allowed per item; 0 means unlimited. Spec default: 1. */
|
|
25
|
+
readonly maxAttempts?: number;
|
|
26
|
+
readonly showFeedback?: boolean;
|
|
27
|
+
readonly allowReview?: boolean;
|
|
28
|
+
readonly showSolution?: boolean;
|
|
29
|
+
readonly allowComment?: boolean;
|
|
30
|
+
/** When false, the candidate must attempt the item before moving past it. */
|
|
31
|
+
readonly allowSkipping?: boolean;
|
|
32
|
+
readonly validateResponses?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* QTI `timeLimits` (seconds). The controller is clock-free by design (ADR-0005): these
|
|
37
|
+
* are data for the consumer's timers, which call `next()`/`end()` when time runs out.
|
|
38
|
+
*/
|
|
39
|
+
export interface TimeLimitsView {
|
|
40
|
+
readonly minTime?: number;
|
|
41
|
+
readonly maxTime?: number;
|
|
42
|
+
readonly allowLateSubmission?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AssessmentItemRefView {
|
|
46
|
+
readonly kind: "assessmentItemRef";
|
|
47
|
+
readonly identifier: string;
|
|
48
|
+
readonly href?: string;
|
|
49
|
+
readonly categories?: readonly string[];
|
|
50
|
+
readonly fixed?: boolean;
|
|
51
|
+
readonly required?: boolean;
|
|
52
|
+
readonly preConditions?: readonly RpExpressionView[];
|
|
53
|
+
readonly branchRules?: readonly BranchRuleView[];
|
|
54
|
+
readonly itemSessionControl?: ItemSessionControlView;
|
|
55
|
+
readonly timeLimits?: TimeLimitsView;
|
|
56
|
+
/** Named weights for `testVariables`/aggregate weighting (missing names weigh 1). */
|
|
57
|
+
readonly weights?: ReadonlyArray<{ readonly identifier: string; readonly value: number }>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AssessmentSectionView {
|
|
61
|
+
readonly kind: "assessmentSection";
|
|
62
|
+
readonly identifier: string;
|
|
63
|
+
readonly title?: string;
|
|
64
|
+
readonly visible?: boolean;
|
|
65
|
+
readonly fixed?: boolean;
|
|
66
|
+
readonly required?: boolean;
|
|
67
|
+
readonly selection?: { readonly select: number; readonly withReplacement?: boolean };
|
|
68
|
+
readonly ordering?: { readonly shuffle?: boolean };
|
|
69
|
+
readonly preConditions?: readonly RpExpressionView[];
|
|
70
|
+
readonly branchRules?: readonly BranchRuleView[];
|
|
71
|
+
readonly itemSessionControl?: ItemSessionControlView;
|
|
72
|
+
readonly timeLimits?: TimeLimitsView;
|
|
73
|
+
readonly children: ReadonlyArray<AssessmentSectionView | AssessmentItemRefView>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TestPartView {
|
|
77
|
+
readonly identifier: string;
|
|
78
|
+
readonly navigationMode: "linear" | "nonlinear";
|
|
79
|
+
readonly submissionMode: "individual" | "simultaneous";
|
|
80
|
+
readonly preConditions?: readonly RpExpressionView[];
|
|
81
|
+
readonly branchRules?: readonly BranchRuleView[];
|
|
82
|
+
readonly itemSessionControl?: ItemSessionControlView;
|
|
83
|
+
readonly timeLimits?: TimeLimitsView;
|
|
84
|
+
readonly assessmentSections: readonly AssessmentSectionView[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface TestFeedbackView {
|
|
88
|
+
readonly access?: "atEnd" | "during";
|
|
89
|
+
readonly outcomeIdentifier: string;
|
|
90
|
+
readonly identifier: string;
|
|
91
|
+
readonly showHide?: "show" | "hide";
|
|
92
|
+
readonly content?: readonly BodyNode[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface OutcomeConditionBranch {
|
|
96
|
+
readonly expression: RpExpressionView;
|
|
97
|
+
readonly rules: readonly OutcomeRuleView[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One outcome rule: outcomeCondition, setOutcomeValue, or exitTest. */
|
|
101
|
+
export interface OutcomeRuleView {
|
|
102
|
+
readonly kind: string;
|
|
103
|
+
readonly identifier?: string;
|
|
104
|
+
readonly expression?: RpExpressionView;
|
|
105
|
+
readonly outcomeIf?: OutcomeConditionBranch;
|
|
106
|
+
readonly outcomeElseIfs?: readonly OutcomeConditionBranch[];
|
|
107
|
+
readonly outcomeElse?: { readonly rules: readonly OutcomeRuleView[] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface AssessmentTestView {
|
|
111
|
+
readonly identifier: string;
|
|
112
|
+
readonly title?: string;
|
|
113
|
+
readonly outcomeDeclarations?: readonly OutcomeDeclarationView[];
|
|
114
|
+
readonly timeLimits?: TimeLimitsView;
|
|
115
|
+
readonly testParts: readonly TestPartView[];
|
|
116
|
+
readonly outcomeProcessing?: { readonly rules: readonly OutcomeRuleView[] };
|
|
117
|
+
readonly testFeedbacks?: readonly TestFeedbackView[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------- The resolved delivery plan (selection + ordering applied) ----------
|
|
121
|
+
|
|
122
|
+
export interface TestPlanItem {
|
|
123
|
+
/** The item ref identifier — unique within the test, used as the session key. */
|
|
124
|
+
readonly key: string;
|
|
125
|
+
readonly ref: AssessmentItemRefView;
|
|
126
|
+
readonly partIdentifier: string;
|
|
127
|
+
readonly sectionPath: readonly string[];
|
|
128
|
+
/** The item's own preconditions plus its ancestor sections' (all must pass). */
|
|
129
|
+
readonly preConditions: readonly RpExpressionView[];
|
|
130
|
+
/** Effective session control: part → section → itemRef cascade over spec defaults. */
|
|
131
|
+
readonly sessionControl: Required<ItemSessionControlView>;
|
|
132
|
+
/** The item ref's own time limits (part/test limits live on their own levels). */
|
|
133
|
+
readonly timeLimits?: TimeLimitsView;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface TestPlanPart {
|
|
137
|
+
readonly identifier: string;
|
|
138
|
+
readonly navigationMode: "linear" | "nonlinear";
|
|
139
|
+
readonly submissionMode: "individual" | "simultaneous";
|
|
140
|
+
readonly timeLimits?: TimeLimitsView;
|
|
141
|
+
readonly items: readonly TestPlanItem[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface TestPlan {
|
|
145
|
+
readonly timeLimits?: TimeLimitsView;
|
|
146
|
+
readonly parts: readonly TestPlanPart[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------- Session state (consumer-persisted, plain JSON) ----------
|
|
150
|
+
|
|
151
|
+
export interface TestItemResult {
|
|
152
|
+
readonly outcomes: Readonly<Record<string, OutcomeValue>>;
|
|
153
|
+
/**
|
|
154
|
+
* Whether every scorable response variable matched (feeds numberCorrect /
|
|
155
|
+
* numberIncorrect). Omit when the item has nothing to be correct about.
|
|
156
|
+
*/
|
|
157
|
+
readonly correct?: boolean;
|
|
158
|
+
/** The candidate gave at least one non-empty response (feeds numberResponded). */
|
|
159
|
+
readonly responded?: boolean;
|
|
160
|
+
/** Adaptive items manage their own attempt lifecycle, so maxAttempts is ignored (spec). */
|
|
161
|
+
readonly adaptive?: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface TestSessionState {
|
|
165
|
+
readonly status: "in-progress" | "ended";
|
|
166
|
+
readonly currentItemKey: string | null;
|
|
167
|
+
readonly itemOutcomes: Readonly<Record<string, Readonly<Record<string, OutcomeValue>>>>;
|
|
168
|
+
readonly attemptedItems: readonly string[];
|
|
169
|
+
readonly attemptCounts: Readonly<Record<string, number>>;
|
|
170
|
+
/** Items that have been the current item at least once (feeds numberPresented). */
|
|
171
|
+
readonly presentedItems: readonly string[];
|
|
172
|
+
/** Items whose latest attempt carried a response (feeds numberResponded). */
|
|
173
|
+
readonly respondedItems: readonly string[];
|
|
174
|
+
/** Items whose latest attempt was correct / incorrect (feeds numberCorrect/Incorrect). */
|
|
175
|
+
readonly correctItems: readonly string[];
|
|
176
|
+
readonly incorrectItems: readonly string[];
|
|
177
|
+
/**
|
|
178
|
+
* Results held back in simultaneous-submission parts (QTI: the part's responses are
|
|
179
|
+
* submitted together). They commit when the part is left or the test ends; until
|
|
180
|
+
* then they are invisible to outcome processing and feedback.
|
|
181
|
+
*/
|
|
182
|
+
readonly pendingItemResults: Readonly<Record<string, TestItemResult>>;
|
|
183
|
+
readonly testOutcomes: Readonly<Record<string, OutcomeValue>>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface TestController {
|
|
187
|
+
readonly plan: TestPlan;
|
|
188
|
+
/** Static capability issues found in outcome processing, preconditions, and branch rules. */
|
|
189
|
+
readonly issues: readonly CapabilityIssue[];
|
|
190
|
+
readonly start: () => TestSessionState;
|
|
191
|
+
readonly currentItem: (state: TestSessionState) => TestPlanItem | null;
|
|
192
|
+
readonly canMoveTo: (state: TestSessionState, itemKey: string) => boolean;
|
|
193
|
+
readonly moveTo: (state: TestSessionState, itemKey: string) => TestSessionState;
|
|
194
|
+
/** Whether `next()` would change state (false when allowSkipping blocks the move). */
|
|
195
|
+
readonly canNext: (state: TestSessionState) => boolean;
|
|
196
|
+
readonly next: (state: TestSessionState) => TestSessionState;
|
|
197
|
+
/** Attempts left for the item under its effective maxAttempts (Infinity when unlimited). */
|
|
198
|
+
readonly remainingAttempts: (state: TestSessionState, itemKey: string) => number;
|
|
199
|
+
readonly canSubmitItem: (state: TestSessionState, itemKey: string) => boolean;
|
|
200
|
+
readonly submitItem: (state: TestSessionState, itemKey: string, result: TestItemResult) => TestSessionState;
|
|
201
|
+
readonly end: (state: TestSessionState) => TestSessionState;
|
|
202
|
+
readonly visibleTestFeedbacks: (state: TestSessionState) => readonly TestFeedbackView[];
|
|
203
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -5,8 +5,19 @@
|
|
|
5
5
|
* schemas are `z.lazy` (statically `any`). No React or Mantine here.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/** One field of a record response; fields keep their runtime type (PCI JSON typing). */
|
|
9
|
+
export type ResponseFieldValue = string | number | boolean | null;
|
|
10
|
+
|
|
11
|
+
/** A record-cardinality response: named, individually-typed fields (PCI contracts). */
|
|
12
|
+
export type ResponseRecordValue = Readonly<Record<string, ResponseFieldValue>>;
|
|
13
|
+
|
|
8
14
|
/** A candidate response for one interaction, keyed in state by `responseIdentifier`. */
|
|
9
|
-
export type ResponseValue = string | readonly string[] | null;
|
|
15
|
+
export type ResponseValue = string | readonly string[] | ResponseRecordValue | null;
|
|
16
|
+
|
|
17
|
+
/** Narrow a ResponseValue to its record variant. */
|
|
18
|
+
export function isResponseRecord(value: ResponseValue): value is ResponseRecordValue {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
10
21
|
|
|
11
22
|
export type Cardinality = "single" | "multiple" | "ordered" | "record";
|
|
12
23
|
|
|
@@ -27,12 +38,27 @@ export interface MappingView {
|
|
|
27
38
|
readonly defaultValue?: number;
|
|
28
39
|
}
|
|
29
40
|
|
|
41
|
+
/** One scored area for point responses (QTI `areaMapEntry`). */
|
|
42
|
+
export interface AreaMapEntryView {
|
|
43
|
+
readonly shape: string;
|
|
44
|
+
readonly coords: readonly number[];
|
|
45
|
+
readonly mappedValue: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AreaMappingView {
|
|
49
|
+
readonly areaMapEntries: readonly AreaMapEntryView[];
|
|
50
|
+
readonly lowerBound?: number;
|
|
51
|
+
readonly upperBound?: number;
|
|
52
|
+
readonly defaultValue?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
export interface ResponseDeclarationView {
|
|
31
56
|
readonly identifier: string;
|
|
32
57
|
readonly cardinality: Cardinality;
|
|
33
58
|
readonly baseType?: string;
|
|
34
59
|
readonly correctResponse?: CorrectResponseView;
|
|
35
60
|
readonly mapping?: MappingView;
|
|
61
|
+
readonly areaMapping?: AreaMappingView;
|
|
36
62
|
}
|
|
37
63
|
|
|
38
64
|
/** The scored outcome for one response variable. */
|