@pie-players/pie-players-shared 0.3.66 → 0.3.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/dist/formative/index.d.ts +16 -0
- package/dist/formative/index.js +5 -0
- package/dist/formative/mastery.d.ts +16 -0
- package/dist/formative/mastery.js +43 -0
- package/dist/formative/outcome.d.ts +19 -0
- package/dist/formative/outcome.js +90 -0
- package/dist/formative/policy.d.ts +37 -0
- package/dist/formative/policy.js +86 -0
- package/dist/formative/session.d.ts +19 -0
- package/dist/formative/session.js +113 -0
- package/dist/formative/state.d.ts +60 -0
- package/dist/formative/state.js +140 -0
- package/dist/formative/types.d.ts +164 -0
- package/dist/formative/types.js +13 -0
- package/dist/i18n/catalogs.d.ts +38 -0
- package/dist/i18n/catalogs.js +47 -0
- package/dist/i18n/index.d.ts +28 -4
- package/dist/i18n/index.js +34 -3
- package/dist/i18n/language-tags.d.ts +51 -0
- package/dist/i18n/language-tags.js +84 -0
- package/dist/i18n/messages/en-US.d.ts +601 -0
- package/dist/i18n/messages/en-US.js +617 -0
- package/dist/i18n/messages/nl-NL.d.ts +516 -0
- package/dist/i18n/messages/nl-NL.js +516 -0
- package/dist/i18n/provider.d.ts +168 -0
- package/dist/i18n/provider.js +472 -0
- package/dist/i18n/types.d.ts +177 -28
- package/dist/i18n/types.js +6 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/pie/custom-element-define.d.ts +24 -0
- package/dist/pie/custom-element-define.js +36 -6
- package/dist/pie/index.d.ts +1 -1
- package/dist/pie/index.js +1 -1
- package/dist/pie/versioned-tag.js +3 -2
- package/dist/security/sanitize-forbidden-lists.d.ts +8 -0
- package/dist/security/sanitize-forbidden-lists.js +41 -0
- package/dist/security/sanitize-item-markup.js +3 -36
- package/dist/security/sanitize-svg-icon.js +3 -25
- package/dist/security/wrap-overwide-images.d.ts +5 -10
- package/dist/security/wrap-overwide-images.js +13 -62
- package/dist/security/wrap-overwide-tables.d.ts +5 -10
- package/dist/security/wrap-overwide-tables.js +13 -62
- package/dist/security/wrap-overwide.d.ts +51 -0
- package/dist/security/wrap-overwide.js +91 -0
- package/dist/server/npm-auth-env.d.ts +13 -0
- package/dist/server/npm-auth-env.js +64 -0
- package/dist/timed-media/index.d.ts +17 -0
- package/dist/timed-media/index.js +16 -0
- package/dist/timed-media/media-element-source.d.ts +36 -0
- package/dist/timed-media/media-element-source.js +121 -0
- package/dist/timed-media/policy.d.ts +36 -0
- package/dist/timed-media/policy.js +297 -0
- package/dist/timed-media/session.d.ts +21 -0
- package/dist/timed-media/session.js +85 -0
- package/dist/timed-media/state.d.ts +66 -0
- package/dist/timed-media/state.js +357 -0
- package/dist/timed-media/types.d.ts +343 -0
- package/dist/timed-media/types.js +12 -0
- package/dist/tools/term-lookup.d.ts +189 -0
- package/dist/tools/term-lookup.js +284 -0
- package/dist/types/index.d.ts +105 -1
- package/dist/ui/attribute-coercion.d.ts +1 -0
- package/dist/ui/attribute-coercion.js +19 -0
- package/dist/ui/content-styles.d.ts +11 -3
- package/dist/ui/content-styles.js +42 -13
- package/dist/ui/first-focusable.d.ts +22 -0
- package/dist/ui/first-focusable.js +47 -0
- package/dist/ui/focus-trap.js +7 -2
- package/dist/ui/pointer-drag.d.ts +30 -0
- package/dist/ui/pointer-drag.js +34 -0
- package/package.json +35 -3
- package/dist/i18n/loader.d.ts +0 -35
- package/dist/i18n/loader.js +0 -132
- package/dist/i18n/simple-i18n.d.ts +0 -68
- package/dist/i18n/simple-i18n.js +0 -198
- package/dist/i18n/translations/ar/common.json +0 -36
- package/dist/i18n/translations/ar/toolkit.json +0 -48
- package/dist/i18n/translations/ar/tools.json +0 -103
- package/dist/i18n/translations/en/common.json +0 -36
- package/dist/i18n/translations/en/toolkit.json +0 -48
- package/dist/i18n/translations/en/tools.json +0 -103
- package/dist/i18n/translations/es/common.json +0 -36
- package/dist/i18n/translations/es/toolkit.json +0 -48
- package/dist/i18n/translations/es/tools.json +0 -103
- package/dist/i18n/translations/zh/common.json +0 -36
- package/dist/i18n/translations/zh/toolkit.json +0 -48
- package/dist/i18n/translations/zh/tools.json +0 -103
package/README.md
CHANGED
|
@@ -19,12 +19,71 @@ Supported subpaths are declared in `package.json`:
|
|
|
19
19
|
- `@pie-players/pie-players-shared/security`
|
|
20
20
|
- `@pie-players/pie-players-shared/object`
|
|
21
21
|
- `@pie-players/pie-players-shared/types`
|
|
22
|
+
- `@pie-players/pie-players-shared/formative`
|
|
23
|
+
- `@pie-players/pie-players-shared/timed-media`
|
|
22
24
|
- `@pie-players/pie-players-shared/pie`
|
|
23
25
|
- `@pie-players/pie-players-shared/pie/tag-names`
|
|
24
26
|
- `@pie-players/pie-players-shared/loaders`
|
|
25
27
|
- `@pie-players/pie-players-shared/server/npm-registry`
|
|
26
28
|
- `@pie-players/pie-players-shared/i18n`
|
|
27
29
|
|
|
30
|
+
## Formative Delivery
|
|
31
|
+
|
|
32
|
+
`@pie-players/pie-players-shared/formative` holds the policy resolution, outcome
|
|
33
|
+
aggregation, Try-state reducer, item view, and mastery rollup that
|
|
34
|
+
`pie-section-player` drives. It is pure — no DOM, no timers, no element registry
|
|
35
|
+
— so it is testable without a browser and importable by an adapter that has no
|
|
36
|
+
player.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import {
|
|
40
|
+
aggregateFormativeOutcome,
|
|
41
|
+
resolveFormativePolicy,
|
|
42
|
+
resolveFormativeItemView,
|
|
43
|
+
rollupFormativeMastery,
|
|
44
|
+
} from "@pie-players/pie-players-shared/formative";
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The authored half — `FormativeDeliveryPolicy` on `AssessmentSection` and
|
|
48
|
+
`FormativeItemPolicy` on `AssessmentItemRef` — is re-exported from the package
|
|
49
|
+
root and from `/types`, beside the section types it annotates.
|
|
50
|
+
|
|
51
|
+
The contract is [`docs/prds/formative-delivery-contract.md`](../../docs/prds/formative-delivery-contract.md),
|
|
52
|
+
including the QTI 3 mapping and why PIE says **Try** rather than "attempt".
|
|
53
|
+
|
|
54
|
+
## Timed Media
|
|
55
|
+
|
|
56
|
+
`@pie-players/pie-players-shared/timed-media` holds the timed-media section
|
|
57
|
+
vocabulary, its validation, the cue reduction, the session slice, and the **Media
|
|
58
|
+
Time Source** port that a section reaches media through. Pure apart from
|
|
59
|
+
`createMediaElementTimeSource`, which is inert until called, so the contract stays
|
|
60
|
+
importable in Node beside the rest.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import {
|
|
64
|
+
createMediaElementTimeSource,
|
|
65
|
+
normalizeTimedMediaSectionData,
|
|
66
|
+
reduceTimedMediaState,
|
|
67
|
+
resolveTimedMediaProjection,
|
|
68
|
+
type MediaTimeSource,
|
|
69
|
+
} from "@pie-players/pie-players-shared/timed-media";
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The port is shaped after `HTMLMediaElement` on purpose — a native `<video>`
|
|
73
|
+
satisfies it through the adapter above — with two departures: seeking is
|
|
74
|
+
`seekTo(seconds)` rather than a writable `currentTime`, and `capabilities` declares
|
|
75
|
+
`canPause` / `canRestrictSeeking`, because a source that cannot control playback
|
|
76
|
+
needs a way to say so. A host implementing the port directly needs no PIE element to
|
|
77
|
+
deliver timed media.
|
|
78
|
+
|
|
79
|
+
The authored half — `sectionType` and `timedMedia` on `AssessmentSection` — is
|
|
80
|
+
re-exported from the package root and from `/types`, beside the section types it
|
|
81
|
+
annotates.
|
|
82
|
+
|
|
83
|
+
The contract is [`docs/prds/timed-media-section-contract.md`](../../docs/prds/timed-media-section-contract.md),
|
|
84
|
+
including why cue gate conditions name the formative vocabulary and why an
|
|
85
|
+
unenforceable policy degrades to advisory rather than failing closed.
|
|
86
|
+
|
|
28
87
|
## Browser ESM Element Contract
|
|
29
88
|
|
|
30
89
|
The canonical producer-side contract lives with the `pie-elements-ng` package
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formative delivery: Try state, feedback reveal, and section mastery.
|
|
3
|
+
*
|
|
4
|
+
* Pure and Node-safe by design — no DOM, no timers, no element registry — so
|
|
5
|
+
* the contract is testable without a browser and an adapter can import it
|
|
6
|
+
* without pulling in a player. Everything that touches the DOM stays in
|
|
7
|
+
* `@pie-players/pie-section-player`.
|
|
8
|
+
*
|
|
9
|
+
* Contract: `docs/prds/formative-delivery-contract.md`.
|
|
10
|
+
*/
|
|
11
|
+
export type { FormativeAction, FormativeCorrectness, FormativeDeliveryPolicy, FormativeEnvOverride, FormativeFeedbackReveal, FormativeItemPolicy, FormativeItemState, FormativeItemView, FormativeMasteryRollup, FormativeRevealTiming, FormativeScoredOutcome, FormativeSectionProjection, FormativeSectionSlice, FormativeTryLimit, FormativeTryOutcome, ResolvedFormativePolicy, } from "./types.js";
|
|
12
|
+
export { FORMATIVE_POLICY_DEFAULTS, isFormativeSectionEnabled, resolveFormativePolicies, resolveFormativePolicy, } from "./policy.js";
|
|
13
|
+
export { aggregateFormativeOutcome } from "./outcome.js";
|
|
14
|
+
export { FORMATIVE_ITEM_STATE_VERSION, createFormativeItemState, hideFormativeItem, recordFormativeTry, resolveFormativeItemView, retryFormativeItem, revealFormativeItem, } from "./state.js";
|
|
15
|
+
export { FORMATIVE_MASTERY_VERSION, rollupFormativeMastery } from "./mastery.js";
|
|
16
|
+
export { FORMATIVE_SLICE_VERSION, normalizeFormativeSectionSlice, toFormativeSectionSlice, } from "./session.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { FORMATIVE_POLICY_DEFAULTS, isFormativeSectionEnabled, resolveFormativePolicies, resolveFormativePolicy, } from "./policy.js";
|
|
2
|
+
export { aggregateFormativeOutcome } from "./outcome.js";
|
|
3
|
+
export { FORMATIVE_ITEM_STATE_VERSION, createFormativeItemState, hideFormativeItem, recordFormativeTry, resolveFormativeItemView, retryFormativeItem, revealFormativeItem, } from "./state.js";
|
|
4
|
+
export { FORMATIVE_MASTERY_VERSION, rollupFormativeMastery } from "./mastery.js";
|
|
5
|
+
export { FORMATIVE_SLICE_VERSION, normalizeFormativeSectionSlice, toFormativeSectionSlice, } from "./session.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FormativeItemState, FormativeMasteryRollup } from "./types.js";
|
|
2
|
+
export declare const FORMATIVE_MASTERY_VERSION: 1;
|
|
3
|
+
/**
|
|
4
|
+
* Roll Try outcomes up to the section.
|
|
5
|
+
*
|
|
6
|
+
* The denominator is the load-bearing decision. An item whose last outcome was
|
|
7
|
+
* `"unknown"` — a rubric item, or one whose element ships no controller — is
|
|
8
|
+
* excluded rather than counted wrong, because counting it wrong would report a
|
|
9
|
+
* false negative to whatever consumes the rollup. An *untried* item is not
|
|
10
|
+
* excluded: nothing yet says it cannot be scored, so it keeps the section from
|
|
11
|
+
* reading as complete after one correct answer.
|
|
12
|
+
*/
|
|
13
|
+
export declare function rollupFormativeMastery(args: {
|
|
14
|
+
itemIdentifiers: readonly string[];
|
|
15
|
+
states: Record<string, FormativeItemState>;
|
|
16
|
+
}): FormativeMasteryRollup;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const FORMATIVE_MASTERY_VERSION = 1;
|
|
2
|
+
/**
|
|
3
|
+
* Roll Try outcomes up to the section.
|
|
4
|
+
*
|
|
5
|
+
* The denominator is the load-bearing decision. An item whose last outcome was
|
|
6
|
+
* `"unknown"` — a rubric item, or one whose element ships no controller — is
|
|
7
|
+
* excluded rather than counted wrong, because counting it wrong would report a
|
|
8
|
+
* false negative to whatever consumes the rollup. An *untried* item is not
|
|
9
|
+
* excluded: nothing yet says it cannot be scored, so it keeps the section from
|
|
10
|
+
* reading as complete after one correct answer.
|
|
11
|
+
*/
|
|
12
|
+
export function rollupFormativeMastery(args) {
|
|
13
|
+
const identifiers = args.itemIdentifiers ?? [];
|
|
14
|
+
const totalItems = identifiers.length;
|
|
15
|
+
let notScorableItems = 0;
|
|
16
|
+
let masteredItems = 0;
|
|
17
|
+
let triedItems = 0;
|
|
18
|
+
let triesToMasteryTotal = 0;
|
|
19
|
+
for (const identifier of identifiers) {
|
|
20
|
+
const state = args.states?.[identifier];
|
|
21
|
+
if (!state)
|
|
22
|
+
continue;
|
|
23
|
+
if (state.tryCount > 0)
|
|
24
|
+
triedItems += 1;
|
|
25
|
+
if (state.lastOutcome?.correctness === "unknown")
|
|
26
|
+
notScorableItems += 1;
|
|
27
|
+
if (typeof state.firstCorrectTry === "number") {
|
|
28
|
+
masteredItems += 1;
|
|
29
|
+
triesToMasteryTotal += state.firstCorrectTry;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const scorableItems = Math.max(0, totalItems - notScorableItems);
|
|
33
|
+
return {
|
|
34
|
+
version: FORMATIVE_MASTERY_VERSION,
|
|
35
|
+
totalItems,
|
|
36
|
+
scorableItems,
|
|
37
|
+
masteredItems,
|
|
38
|
+
triedItems,
|
|
39
|
+
averageTriesToMastery: masteredItems > 0 ? triesToMasteryTotal / masteredItems : undefined,
|
|
40
|
+
// A section with nothing scorable is never vacuously complete.
|
|
41
|
+
complete: scorableItems > 0 && masteredItems === scorableItems,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FormativeScoredOutcome, FormativeTryOutcome } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reduce the array `pie-item-player.provideScore()` returns to one Try outcome.
|
|
4
|
+
*
|
|
5
|
+
* The aggregation mirrors the policy the persisted API scoring path already
|
|
6
|
+
* documents (`docs/item-player/scoring-and-rubrics.md`), so a browser-derived
|
|
7
|
+
* formative result and a server-derived score do not disagree about what a
|
|
8
|
+
* multi-element item is worth:
|
|
9
|
+
*
|
|
10
|
+
* - one scored outcome: `points = score`, `max = max ?? 1`;
|
|
11
|
+
* - several: the mean of normalized fractions, with `max = 1`;
|
|
12
|
+
* - none scored: `"unknown"`, no points.
|
|
13
|
+
*
|
|
14
|
+
* `provideScore()` returns `undefined` in the slot of any model whose element or
|
|
15
|
+
* controller was missing, which is exactly the rubric and no-controller case, so
|
|
16
|
+
* those slots count toward `totalElementCount` and not toward
|
|
17
|
+
* `scoredElementCount`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function aggregateFormativeOutcome(outcomes: ReadonlyArray<FormativeScoredOutcome | null | undefined> | null): FormativeTryOutcome;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full credit is a float comparison, so it needs a tolerance. Averaging
|
|
3
|
+
* normalized fractions ("1/3 + 1/3 + 1/3") lands a hair under 1 in binary
|
|
4
|
+
* floating point, and reporting that as partial credit would be a defect
|
|
5
|
+
* visible to a learner.
|
|
6
|
+
*/
|
|
7
|
+
const FULL_CREDIT_EPSILON = 1e-9;
|
|
8
|
+
function isScored(outcome) {
|
|
9
|
+
return (!!outcome &&
|
|
10
|
+
typeof outcome.score === "number" &&
|
|
11
|
+
Number.isFinite(outcome.score));
|
|
12
|
+
}
|
|
13
|
+
/** A denominator of 0 or a missing `max` both mean "normalized to 1". */
|
|
14
|
+
function denominatorOf(outcome) {
|
|
15
|
+
const max = outcome.max;
|
|
16
|
+
return typeof max === "number" && Number.isFinite(max) && max > 0 ? max : 1;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Retained verbatim for a host rendering its own feedback. Empty slots are
|
|
20
|
+
* dropped rather than preserved as holes: every real entry identifies its own
|
|
21
|
+
* model, and `null` in a persisted array carries no information a host could
|
|
22
|
+
* use.
|
|
23
|
+
*/
|
|
24
|
+
function definedOutcomes(outcomes) {
|
|
25
|
+
const defined = outcomes.filter((outcome) => !!outcome && typeof outcome === "object");
|
|
26
|
+
return defined.length > 0 ? defined : undefined;
|
|
27
|
+
}
|
|
28
|
+
function classify(points, max) {
|
|
29
|
+
if (max <= 0)
|
|
30
|
+
return "unknown";
|
|
31
|
+
if (points + FULL_CREDIT_EPSILON >= max)
|
|
32
|
+
return "correct";
|
|
33
|
+
if (points <= 0)
|
|
34
|
+
return "incorrect";
|
|
35
|
+
return "partial";
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Reduce the array `pie-item-player.provideScore()` returns to one Try outcome.
|
|
39
|
+
*
|
|
40
|
+
* The aggregation mirrors the policy the persisted API scoring path already
|
|
41
|
+
* documents (`docs/item-player/scoring-and-rubrics.md`), so a browser-derived
|
|
42
|
+
* formative result and a server-derived score do not disagree about what a
|
|
43
|
+
* multi-element item is worth:
|
|
44
|
+
*
|
|
45
|
+
* - one scored outcome: `points = score`, `max = max ?? 1`;
|
|
46
|
+
* - several: the mean of normalized fractions, with `max = 1`;
|
|
47
|
+
* - none scored: `"unknown"`, no points.
|
|
48
|
+
*
|
|
49
|
+
* `provideScore()` returns `undefined` in the slot of any model whose element or
|
|
50
|
+
* controller was missing, which is exactly the rubric and no-controller case, so
|
|
51
|
+
* those slots count toward `totalElementCount` and not toward
|
|
52
|
+
* `scoredElementCount`.
|
|
53
|
+
*/
|
|
54
|
+
export function aggregateFormativeOutcome(outcomes) {
|
|
55
|
+
const all = Array.isArray(outcomes) ? outcomes : [];
|
|
56
|
+
const scored = all.filter(isScored);
|
|
57
|
+
const totalElementCount = all.length;
|
|
58
|
+
if (scored.length === 0) {
|
|
59
|
+
return {
|
|
60
|
+
correctness: "unknown",
|
|
61
|
+
scoredElementCount: 0,
|
|
62
|
+
totalElementCount,
|
|
63
|
+
elementOutcomes: definedOutcomes(all),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
let points;
|
|
67
|
+
let max;
|
|
68
|
+
if (scored.length === 1) {
|
|
69
|
+
const only = scored[0];
|
|
70
|
+
max = denominatorOf(only);
|
|
71
|
+
points = only.score;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
max = 1;
|
|
75
|
+
points =
|
|
76
|
+
scored.reduce((total, outcome) => total + outcome.score / denominatorOf(outcome), 0) / scored.length;
|
|
77
|
+
}
|
|
78
|
+
// An element reporting more than its own maximum is an element defect. The
|
|
79
|
+
// aggregate is a ratio, so an out-of-range input has no meaning here; clamp
|
|
80
|
+
// rather than let it read as full credit on a partially correct response.
|
|
81
|
+
const clamped = Math.min(Math.max(points, 0), max);
|
|
82
|
+
return {
|
|
83
|
+
correctness: classify(clamped, max),
|
|
84
|
+
points: clamped,
|
|
85
|
+
max,
|
|
86
|
+
scoredElementCount: scored.length,
|
|
87
|
+
totalElementCount,
|
|
88
|
+
elementOutcomes: definedOutcomes(all),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { FormativeDeliveryPolicy, FormativeItemPolicy, ResolvedFormativePolicy } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Built-in defaults. `enabled: false` is what makes this contract invisible to
|
|
4
|
+
* every section authored before it: an absent `formative` field resolves to a
|
|
5
|
+
* disabled policy, and a disabled policy produces no state, no projection and
|
|
6
|
+
* no control.
|
|
7
|
+
*
|
|
8
|
+
* `maxTries: 1` matches QTI 3's `qti-item-session-control@max-attempts` default.
|
|
9
|
+
*/
|
|
10
|
+
export declare const FORMATIVE_POLICY_DEFAULTS: ResolvedFormativePolicy;
|
|
11
|
+
/**
|
|
12
|
+
* Merge built-in defaults, the section policy, and one item ref's override, in
|
|
13
|
+
* that order. Each field overrides independently, which is the order QTI 3 uses
|
|
14
|
+
* for `qti-item-session-control` on a section and on an item ref.
|
|
15
|
+
*
|
|
16
|
+
* Unrecognized values fall through to the layer beneath rather than failing the
|
|
17
|
+
* section: authored policy is host-supplied data, and a typo in one field should
|
|
18
|
+
* not cost the learner the whole item.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveFormativePolicy(sectionPolicy?: FormativeDeliveryPolicy | null, itemPolicy?: FormativeItemPolicy | null): ResolvedFormativePolicy;
|
|
21
|
+
/**
|
|
22
|
+
* Resolve one policy per item in section order.
|
|
23
|
+
*
|
|
24
|
+
* Keyed by the identifier the caller supplies, which in the section runtime is
|
|
25
|
+
* the canonical item id — the same key `itemSessions` and the completion map
|
|
26
|
+
* use, so a formative state and an item session are always addressable by one
|
|
27
|
+
* id.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveFormativePolicies(args: {
|
|
30
|
+
sectionPolicy?: FormativeDeliveryPolicy | null;
|
|
31
|
+
items: ReadonlyArray<{
|
|
32
|
+
identifier: string;
|
|
33
|
+
policy?: FormativeItemPolicy | null;
|
|
34
|
+
}>;
|
|
35
|
+
}): Record<string, ResolvedFormativePolicy>;
|
|
36
|
+
/** True when at least one item in the section delivers formatively. */
|
|
37
|
+
export declare function isFormativeSectionEnabled(policies: Record<string, ResolvedFormativePolicy>): boolean;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in defaults. `enabled: false` is what makes this contract invisible to
|
|
3
|
+
* every section authored before it: an absent `formative` field resolves to a
|
|
4
|
+
* disabled policy, and a disabled policy produces no state, no projection and
|
|
5
|
+
* no control.
|
|
6
|
+
*
|
|
7
|
+
* `maxTries: 1` matches QTI 3's `qti-item-session-control@max-attempts` default.
|
|
8
|
+
*/
|
|
9
|
+
export const FORMATIVE_POLICY_DEFAULTS = {
|
|
10
|
+
enabled: false,
|
|
11
|
+
maxTries: 1,
|
|
12
|
+
feedback: "correctness",
|
|
13
|
+
revealOn: "on-try",
|
|
14
|
+
};
|
|
15
|
+
function normalizeTryLimit(value) {
|
|
16
|
+
if (value === "unlimited")
|
|
17
|
+
return "unlimited";
|
|
18
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
19
|
+
return undefined;
|
|
20
|
+
const rounded = Math.trunc(value);
|
|
21
|
+
// Zero and negatives are meaningless here rather than shorthand for
|
|
22
|
+
// unlimited. QTI spells unlimited as `max-attempts="0"`; the adapter
|
|
23
|
+
// translates, this contract does not overload the number.
|
|
24
|
+
return rounded >= 1 ? rounded : undefined;
|
|
25
|
+
}
|
|
26
|
+
function normalizeFeedback(value) {
|
|
27
|
+
return value === "none" || value === "correctness" || value === "solution"
|
|
28
|
+
? value
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|
|
31
|
+
function normalizeRevealTiming(value) {
|
|
32
|
+
return value === "on-try" || value === "on-final-try" ? value : undefined;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Merge built-in defaults, the section policy, and one item ref's override, in
|
|
36
|
+
* that order. Each field overrides independently, which is the order QTI 3 uses
|
|
37
|
+
* for `qti-item-session-control` on a section and on an item ref.
|
|
38
|
+
*
|
|
39
|
+
* Unrecognized values fall through to the layer beneath rather than failing the
|
|
40
|
+
* section: authored policy is host-supplied data, and a typo in one field should
|
|
41
|
+
* not cost the learner the whole item.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveFormativePolicy(sectionPolicy, itemPolicy) {
|
|
44
|
+
const enabled = typeof itemPolicy?.enabled === "boolean"
|
|
45
|
+
? itemPolicy.enabled
|
|
46
|
+
: typeof sectionPolicy?.enabled === "boolean"
|
|
47
|
+
? sectionPolicy.enabled
|
|
48
|
+
: FORMATIVE_POLICY_DEFAULTS.enabled;
|
|
49
|
+
const maxTries = normalizeTryLimit(itemPolicy?.maxTries) ??
|
|
50
|
+
normalizeTryLimit(sectionPolicy?.maxTries) ??
|
|
51
|
+
FORMATIVE_POLICY_DEFAULTS.maxTries;
|
|
52
|
+
const feedback = normalizeFeedback(itemPolicy?.feedback) ??
|
|
53
|
+
normalizeFeedback(sectionPolicy?.feedback) ??
|
|
54
|
+
FORMATIVE_POLICY_DEFAULTS.feedback;
|
|
55
|
+
const requestedRevealOn = normalizeRevealTiming(itemPolicy?.revealOn) ??
|
|
56
|
+
normalizeRevealTiming(sectionPolicy?.revealOn) ??
|
|
57
|
+
FORMATIVE_POLICY_DEFAULTS.revealOn;
|
|
58
|
+
// `"on-final-try"` has no referent under unlimited Tries — there is no final
|
|
59
|
+
// Try to reveal on — so it resolves to the only reading that keeps feedback
|
|
60
|
+
// reachable.
|
|
61
|
+
const revealOn = requestedRevealOn === "on-final-try" && maxTries === "unlimited"
|
|
62
|
+
? "on-try"
|
|
63
|
+
: requestedRevealOn;
|
|
64
|
+
return { enabled, maxTries, feedback, revealOn };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve one policy per item in section order.
|
|
68
|
+
*
|
|
69
|
+
* Keyed by the identifier the caller supplies, which in the section runtime is
|
|
70
|
+
* the canonical item id — the same key `itemSessions` and the completion map
|
|
71
|
+
* use, so a formative state and an item session are always addressable by one
|
|
72
|
+
* id.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveFormativePolicies(args) {
|
|
75
|
+
const resolved = {};
|
|
76
|
+
for (const item of args.items) {
|
|
77
|
+
if (!item?.identifier)
|
|
78
|
+
continue;
|
|
79
|
+
resolved[item.identifier] = resolveFormativePolicy(args.sectionPolicy, item.policy);
|
|
80
|
+
}
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
/** True when at least one item in the section delivers formatively. */
|
|
84
|
+
export function isFormativeSectionEnabled(policies) {
|
|
85
|
+
return Object.values(policies).some((policy) => policy.enabled);
|
|
86
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FormativeItemState, FormativeSectionSlice } from "./types.js";
|
|
2
|
+
export declare const FORMATIVE_SLICE_VERSION: 1;
|
|
3
|
+
export declare function toFormativeSectionSlice(states: Record<string, FormativeItemState>): FormativeSectionSlice;
|
|
4
|
+
/**
|
|
5
|
+
* Validate a persisted slice against the section it is being restored into.
|
|
6
|
+
*
|
|
7
|
+
* Returns `null` for a slice this build cannot read, which the caller treats as
|
|
8
|
+
* "start formative state clean". Rejection is deliberately narrow: it discards
|
|
9
|
+
* formative progress and nothing else, so a version bump here never costs a
|
|
10
|
+
* learner their responses — item sessions travel in the same snapshot and are
|
|
11
|
+
* normalized separately.
|
|
12
|
+
*
|
|
13
|
+
* An absent slice is `null` too, and indistinguishable from a pre-formative
|
|
14
|
+
* save, which is what keeps existing snapshots valid.
|
|
15
|
+
*/
|
|
16
|
+
export declare function normalizeFormativeSectionSlice(args: {
|
|
17
|
+
slice: unknown;
|
|
18
|
+
allowedItemIdentifiers: readonly string[];
|
|
19
|
+
}): Record<string, FormativeItemState> | null;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { FORMATIVE_ITEM_STATE_VERSION } from "./state.js";
|
|
2
|
+
export const FORMATIVE_SLICE_VERSION = 1;
|
|
3
|
+
const CORRECTNESS_VALUES = [
|
|
4
|
+
"correct",
|
|
5
|
+
"partial",
|
|
6
|
+
"incorrect",
|
|
7
|
+
"unknown",
|
|
8
|
+
];
|
|
9
|
+
function readFiniteNumber(value) {
|
|
10
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
11
|
+
? value
|
|
12
|
+
: undefined;
|
|
13
|
+
}
|
|
14
|
+
function readNonNegativeInteger(value) {
|
|
15
|
+
const numeric = readFiniteNumber(value);
|
|
16
|
+
if (numeric === undefined)
|
|
17
|
+
return undefined;
|
|
18
|
+
const rounded = Math.trunc(numeric);
|
|
19
|
+
return rounded >= 0 ? rounded : undefined;
|
|
20
|
+
}
|
|
21
|
+
function normalizeOutcome(value) {
|
|
22
|
+
if (!value || typeof value !== "object")
|
|
23
|
+
return undefined;
|
|
24
|
+
const raw = value;
|
|
25
|
+
const correctness = CORRECTNESS_VALUES.find((candidate) => candidate === raw.correctness);
|
|
26
|
+
if (!correctness)
|
|
27
|
+
return undefined;
|
|
28
|
+
return {
|
|
29
|
+
correctness,
|
|
30
|
+
points: readFiniteNumber(raw.points),
|
|
31
|
+
max: readFiniteNumber(raw.max),
|
|
32
|
+
scoredElementCount: readNonNegativeInteger(raw.scoredElementCount) ?? 0,
|
|
33
|
+
totalElementCount: readNonNegativeInteger(raw.totalElementCount) ?? 0,
|
|
34
|
+
elementOutcomes: normalizeElementOutcomes(raw.elementOutcomes),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Element outcomes are element-shaped by contract, so validation stops at "an
|
|
39
|
+
* array of objects". Reaching further would mean this module deciding what an
|
|
40
|
+
* element's outcome may contain, which is the element's business.
|
|
41
|
+
*/
|
|
42
|
+
function normalizeElementOutcomes(value) {
|
|
43
|
+
if (!Array.isArray(value))
|
|
44
|
+
return undefined;
|
|
45
|
+
const entries = value.filter((entry) => !!entry && typeof entry === "object" && !Array.isArray(entry));
|
|
46
|
+
return entries.length > 0 ? entries : undefined;
|
|
47
|
+
}
|
|
48
|
+
function normalizeRevealOverride(value) {
|
|
49
|
+
return value === "correctness" || value === "solution" || value === "none"
|
|
50
|
+
? value
|
|
51
|
+
: undefined;
|
|
52
|
+
}
|
|
53
|
+
function normalizeItemState(itemIdentifier, value) {
|
|
54
|
+
if (!value || typeof value !== "object")
|
|
55
|
+
return null;
|
|
56
|
+
const raw = value;
|
|
57
|
+
if (raw.version !== FORMATIVE_ITEM_STATE_VERSION)
|
|
58
|
+
return null;
|
|
59
|
+
const tryCount = readNonNegativeInteger(raw.tryCount);
|
|
60
|
+
if (tryCount === undefined)
|
|
61
|
+
return null;
|
|
62
|
+
const firstCorrectTry = readNonNegativeInteger(raw.firstCorrectTry);
|
|
63
|
+
return {
|
|
64
|
+
version: FORMATIVE_ITEM_STATE_VERSION,
|
|
65
|
+
itemIdentifier,
|
|
66
|
+
tryCount,
|
|
67
|
+
revealed: raw.revealed === true,
|
|
68
|
+
lastOutcome: normalizeOutcome(raw.lastOutcome),
|
|
69
|
+
firstCorrectTry: firstCorrectTry !== undefined && firstCorrectTry >= 1
|
|
70
|
+
? firstCorrectTry
|
|
71
|
+
: undefined,
|
|
72
|
+
revealOverride: normalizeRevealOverride(raw.revealOverride),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function toFormativeSectionSlice(states) {
|
|
76
|
+
return {
|
|
77
|
+
version: FORMATIVE_SLICE_VERSION,
|
|
78
|
+
items: { ...states },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Validate a persisted slice against the section it is being restored into.
|
|
83
|
+
*
|
|
84
|
+
* Returns `null` for a slice this build cannot read, which the caller treats as
|
|
85
|
+
* "start formative state clean". Rejection is deliberately narrow: it discards
|
|
86
|
+
* formative progress and nothing else, so a version bump here never costs a
|
|
87
|
+
* learner their responses — item sessions travel in the same snapshot and are
|
|
88
|
+
* normalized separately.
|
|
89
|
+
*
|
|
90
|
+
* An absent slice is `null` too, and indistinguishable from a pre-formative
|
|
91
|
+
* save, which is what keeps existing snapshots valid.
|
|
92
|
+
*/
|
|
93
|
+
export function normalizeFormativeSectionSlice(args) {
|
|
94
|
+
const { slice } = args;
|
|
95
|
+
if (!slice || typeof slice !== "object")
|
|
96
|
+
return null;
|
|
97
|
+
const raw = slice;
|
|
98
|
+
if (raw.version !== FORMATIVE_SLICE_VERSION)
|
|
99
|
+
return null;
|
|
100
|
+
if (!raw.items || typeof raw.items !== "object")
|
|
101
|
+
return null;
|
|
102
|
+
const allowed = new Set(args.allowedItemIdentifiers ?? []);
|
|
103
|
+
const normalized = {};
|
|
104
|
+
for (const [itemIdentifier, entry] of Object.entries(raw.items)) {
|
|
105
|
+
if (!allowed.has(itemIdentifier))
|
|
106
|
+
continue;
|
|
107
|
+
const state = normalizeItemState(itemIdentifier, entry);
|
|
108
|
+
if (!state)
|
|
109
|
+
continue;
|
|
110
|
+
normalized[itemIdentifier] = state;
|
|
111
|
+
}
|
|
112
|
+
return normalized;
|
|
113
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { FormativeFeedbackReveal, FormativeItemState, FormativeItemView, FormativeTryOutcome, ResolvedFormativePolicy } from "./types.js";
|
|
2
|
+
export declare const FORMATIVE_ITEM_STATE_VERSION: 1;
|
|
3
|
+
export declare function createFormativeItemState(itemIdentifier: string): FormativeItemState;
|
|
4
|
+
/**
|
|
5
|
+
* Record one Try.
|
|
6
|
+
*
|
|
7
|
+
* Idempotent against a double submit: a Try is only counted when the current
|
|
8
|
+
* state can be checked, so a second click landing before the projection
|
|
9
|
+
* round-trips is dropped rather than spending a Try.
|
|
10
|
+
*/
|
|
11
|
+
export declare function recordFormativeTry(args: {
|
|
12
|
+
state?: FormativeItemState;
|
|
13
|
+
itemIdentifier: string;
|
|
14
|
+
policy: ResolvedFormativePolicy;
|
|
15
|
+
outcome: FormativeTryOutcome;
|
|
16
|
+
}): FormativeItemState;
|
|
17
|
+
/**
|
|
18
|
+
* Dismiss a reveal and reopen the item for editing. Withdrawing `revealed` is
|
|
19
|
+
* what withdraws the env override, so the item returns to the section's own
|
|
20
|
+
* mode.
|
|
21
|
+
*
|
|
22
|
+
* A learner action, so it respects the Try budget: an item with none left keeps
|
|
23
|
+
* its feedback on screen. `hideFormativeItem` is the host-authority version.
|
|
24
|
+
*/
|
|
25
|
+
export declare function retryFormativeItem(args: {
|
|
26
|
+
state?: FormativeItemState;
|
|
27
|
+
policy: ResolvedFormativePolicy;
|
|
28
|
+
}): FormativeItemState | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Reveal on host authority — a teacher-driven "show the answer".
|
|
31
|
+
*
|
|
32
|
+
* Spends no Try and ignores the Try budget and `revealOn`, because none of those
|
|
33
|
+
* bound a decision the host has already taken. `feedback` is required rather
|
|
34
|
+
* than defaulted from the policy: a forced reveal under `feedback: "none"` would
|
|
35
|
+
* project nothing, so the caller states what to show.
|
|
36
|
+
*
|
|
37
|
+
* Works on an item with no Try yet. The element renders evaluate mode over
|
|
38
|
+
* whatever response is there, including none, which is what "show the answer"
|
|
39
|
+
* means before the learner has answered.
|
|
40
|
+
*/
|
|
41
|
+
export declare function revealFormativeItem(args: {
|
|
42
|
+
state?: FormativeItemState;
|
|
43
|
+
itemIdentifier: string;
|
|
44
|
+
policy: ResolvedFormativePolicy;
|
|
45
|
+
feedback: Exclude<FormativeFeedbackReveal, "none">;
|
|
46
|
+
}): FormativeItemState;
|
|
47
|
+
/**
|
|
48
|
+
* Withdraw a reveal on host authority. Unlike a learner retry this ignores the
|
|
49
|
+
* Try budget: a host that revealed an item with no Tries left must be able to
|
|
50
|
+
* put it back.
|
|
51
|
+
*/
|
|
52
|
+
export declare function hideFormativeItem(args: {
|
|
53
|
+
state?: FormativeItemState;
|
|
54
|
+
policy: ResolvedFormativePolicy;
|
|
55
|
+
}): FormativeItemState | undefined;
|
|
56
|
+
/** Derive everything a rendering component needs from policy plus state. */
|
|
57
|
+
export declare function resolveFormativeItemView(args: {
|
|
58
|
+
policy: ResolvedFormativePolicy;
|
|
59
|
+
state?: FormativeItemState;
|
|
60
|
+
}): FormativeItemView;
|