question-bank-framework 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/core/config.tsx +1 -0
- package/core/index.ts +1 -0
- package/core/types.ts +78 -0
- package/framework/components/quiz-page.tsx +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,6 +126,14 @@ item is right. `defineBank` validates each question's fields against its kind
|
|
|
126
126
|
and throws on mismatches (e.g. a `correctId` that isn't one of the options).
|
|
127
127
|
Don't repeat the option list or answer table in `prompt`; the UI renders them.
|
|
128
128
|
|
|
129
|
+
Quizzes shuffle option order (choice options, the shared matching options and
|
|
130
|
+
each dropdown blank's options), seeded by the quiz id so the order is stable
|
|
131
|
+
across reloads but differs between quizzes. The bank grid keeps authored
|
|
132
|
+
order, so explanations should name options by content, not by letter. Opt out
|
|
133
|
+
with `shuffle: false` on the question, or on a single dropdown blank, when
|
|
134
|
+
order matters ("All of the above", ordered scales, options that reference
|
|
135
|
+
each other).
|
|
136
|
+
|
|
129
137
|
## Dev
|
|
130
138
|
|
|
131
139
|
```sh
|
package/core/config.tsx
CHANGED
package/core/index.ts
CHANGED
package/core/types.ts
CHANGED
|
@@ -56,6 +56,8 @@ export interface DropdownBlank {
|
|
|
56
56
|
options: QuestionOption[];
|
|
57
57
|
/** Id of the correct option in this blank's `options`. */
|
|
58
58
|
correctId: string;
|
|
59
|
+
/** Shuffle this blank's options in quizzes. Defaults to the question's `shuffle` (true). */
|
|
60
|
+
shuffle?: boolean;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
/** Expected answer of a numerical question. */
|
|
@@ -92,6 +94,14 @@ export interface BaseQuestion {
|
|
|
92
94
|
blanks?: DropdownBlank[];
|
|
93
95
|
/** Expected number (numerical). */
|
|
94
96
|
numeric?: NumericAnswer;
|
|
97
|
+
/**
|
|
98
|
+
* Shuffle option order in quizzes (single/multiple/matching options and
|
|
99
|
+
* dropdown blank options). Defaults to true. Set false when option order is
|
|
100
|
+
* meaningful, e.g. "All of the above" or options that reference each other.
|
|
101
|
+
* The bank grid always shows authored order, so explanations should refer
|
|
102
|
+
* to options by content, not by letter.
|
|
103
|
+
*/
|
|
104
|
+
shuffle?: boolean;
|
|
95
105
|
// Subjects extend this interface with their own fields; see template/bank.config.ts.
|
|
96
106
|
}
|
|
97
107
|
|
|
@@ -247,6 +257,14 @@ function validateQuestion(question: BaseQuestion): void {
|
|
|
247
257
|
throw new Error(`Question "${question.id}" is ${kind} and must not define ${field}.`);
|
|
248
258
|
}
|
|
249
259
|
}
|
|
260
|
+
if (question.shuffle !== undefined) {
|
|
261
|
+
if (typeof question.shuffle !== 'boolean') {
|
|
262
|
+
throw new Error(`Question "${question.id}" has a non-boolean shuffle.`);
|
|
263
|
+
}
|
|
264
|
+
if (kind === 'written' || kind === 'numerical') {
|
|
265
|
+
throw new Error(`Question "${question.id}" is ${kind} and must not define shuffle.`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
250
268
|
|
|
251
269
|
switch (kind) {
|
|
252
270
|
case 'written':
|
|
@@ -305,6 +323,9 @@ function validateQuestion(question: BaseQuestion): void {
|
|
|
305
323
|
if (!seen.has(blank.correctId)) {
|
|
306
324
|
throw new Error(`Question "${question.id}" blank "${blank.id}" has unknown correctId "${blank.correctId}".`);
|
|
307
325
|
}
|
|
326
|
+
if (blank.shuffle !== undefined && typeof blank.shuffle !== 'boolean') {
|
|
327
|
+
throw new Error(`Question "${question.id}" blank "${blank.id}" has a non-boolean shuffle.`);
|
|
328
|
+
}
|
|
308
329
|
}
|
|
309
330
|
return;
|
|
310
331
|
}
|
|
@@ -369,6 +390,63 @@ export function getPairingSlots(question: BaseQuestion): PairingSlot[] {
|
|
|
369
390
|
return [];
|
|
370
391
|
}
|
|
371
392
|
|
|
393
|
+
// FNV-1a string hash → 32-bit seed.
|
|
394
|
+
function hashSeed(text: string): number {
|
|
395
|
+
let hash = 0x811c9dc5;
|
|
396
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
397
|
+
hash ^= text.charCodeAt(i);
|
|
398
|
+
hash = Math.imul(hash, 0x01000193);
|
|
399
|
+
}
|
|
400
|
+
return hash >>> 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// mulberry32 PRNG: deterministic floats in [0, 1).
|
|
404
|
+
function seededRandom(seed: number): () => number {
|
|
405
|
+
let state = seed;
|
|
406
|
+
return () => {
|
|
407
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
408
|
+
let t = state;
|
|
409
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
410
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
411
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function seededShuffle<T>(list: readonly T[], seed: string): T[] {
|
|
416
|
+
const out = [...list];
|
|
417
|
+
const random = seededRandom(hashSeed(seed));
|
|
418
|
+
for (let i = out.length - 1; i > 0; i -= 1) {
|
|
419
|
+
const j = Math.floor(random() * (i + 1));
|
|
420
|
+
[out[i], out[j]] = [out[j]!, out[i]!];
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Presentation copy of a question with its options shuffled, deterministically
|
|
427
|
+
* for `seed` (e.g. a quiz session id) so the order is stable across reloads
|
|
428
|
+
* but differs between quizzes. Answers are stored as option ids, so grading is
|
|
429
|
+
* unaffected. Questions with `shuffle: false` (and blanks with
|
|
430
|
+
* `shuffle: false`) keep their authored order.
|
|
431
|
+
*/
|
|
432
|
+
export function shuffleQuestionOptions<Q extends BaseQuestion>(question: Q, seed: string): Q {
|
|
433
|
+
const enabled = question.shuffle !== false;
|
|
434
|
+
const base = `${seed}:${question.id}`;
|
|
435
|
+
const kind = questionKind(question);
|
|
436
|
+
if ((kind === 'single' || kind === 'multiple' || kind === 'matching') && enabled && question.options) {
|
|
437
|
+
return { ...question, options: seededShuffle(question.options, `${base}:options`) };
|
|
438
|
+
}
|
|
439
|
+
if (kind === 'dropdown' && question.blanks) {
|
|
440
|
+
return {
|
|
441
|
+
...question,
|
|
442
|
+
blanks: question.blanks.map((blank) =>
|
|
443
|
+
(blank.shuffle ?? enabled) ? { ...blank, options: seededShuffle(blank.options, `${base}:${blank.id}`) } : blank,
|
|
444
|
+
),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
return question;
|
|
448
|
+
}
|
|
449
|
+
|
|
372
450
|
const isRecordAnswer = (answer: QuizAnswer | undefined): answer is Record<string, string> =>
|
|
373
451
|
typeof answer === 'object' && answer !== null && !Array.isArray(answer);
|
|
374
452
|
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
questionKind,
|
|
17
17
|
questionKindLabels,
|
|
18
18
|
scoreAnswers,
|
|
19
|
+
shuffleQuestionOptions,
|
|
19
20
|
toSelectedIds,
|
|
20
21
|
} from '../../core/types.ts';
|
|
21
22
|
import { Badge } from './ui/badge';
|
|
@@ -52,10 +53,17 @@ export function QuizPage() {
|
|
|
52
53
|
const [titleDraft, setTitleDraft] = useState('');
|
|
53
54
|
const [statusMap, setStatusMap] = useState<QuestionStatusMap>({});
|
|
54
55
|
const titleInputRef = useRef<HTMLInputElement>(null);
|
|
56
|
+
// Options are shuffled per quiz (seeded by the quiz id), so order is stable
|
|
57
|
+
// across reloads but differs between quizzes. Answers store option ids.
|
|
58
|
+
const sessionId = session?.id;
|
|
59
|
+
const questionIds = session?.questionIds;
|
|
55
60
|
const quizQuestions = useMemo(
|
|
56
61
|
() =>
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
questionIds
|
|
63
|
+
?.map((id) => bank.questionById.get(id))
|
|
64
|
+
.filter((question) => question !== undefined)
|
|
65
|
+
.map((question) => shuffleQuestionOptions(question, sessionId ?? '')) ?? [],
|
|
66
|
+
[questionIds, sessionId, bank],
|
|
59
67
|
);
|
|
60
68
|
const activeIndex = Math.min(session?.currentQuestion ?? 0, Math.max(quizQuestions.length - 1, 0));
|
|
61
69
|
const activeQuestion = quizQuestions[activeIndex];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "question-bank-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Framework-style question bank: TanStack Start + TanStack Table grid, quizzes, and filter DSL. Bring your own bank.config.ts + content/. Eject any framework/ file with `qb reveal` (Vike-style).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|