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 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
@@ -56,6 +56,7 @@ export {
56
56
  questionKindLabels,
57
57
  questionKinds,
58
58
  scoreAnswers,
59
+ shuffleQuestionOptions,
59
60
  toPairings,
60
61
  toSelectedIds,
61
62
  } from './types.ts';
package/core/index.ts CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  questionKinds,
21
21
  questionStatuses,
22
22
  scoreAnswers,
23
+ shuffleQuestionOptions,
23
24
  toPairings,
24
25
  toSelectedIds,
25
26
  } from './config.tsx';
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
- session?.questionIds.map((id) => bank.questionById.get(id)).filter((question) => question !== undefined) ?? [],
58
- [session, bank],
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.5.1",
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",