question-bank-framework 0.5.0 → 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/question-response.tsx +4 -4
- package/framework/components/quiz-page.tsx +12 -4
- 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
|
|
|
@@ -142,18 +142,18 @@ export function PairingResponse({
|
|
|
142
142
|
{matching && (
|
|
143
143
|
<div className="mt-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
|
144
144
|
<p className="m-0 text-xs font-semibold uppercase tracking-wide text-gray-500">Options</p>
|
|
145
|
-
<ol className="m-0 mt-2 grid list-none gap-x-6 gap-y-1 p-0 sm:grid-cols-2">
|
|
145
|
+
<ol className="m-0 mt-2 grid list-none grid-cols-[minmax(0,1fr)] gap-x-6 gap-y-1 p-0 sm:grid-cols-[repeat(2,minmax(0,1fr))]">
|
|
146
146
|
{(question.options ?? []).map((option, index) => (
|
|
147
147
|
<li key={option.id} className="flex items-start gap-2 text-sm text-gray-800">
|
|
148
148
|
<span className="w-5 shrink-0 font-semibold text-gray-500">{optionLetter(index)}.</span>
|
|
149
|
-
<MarkdownContent inline content={option.text} className="min-w-0 flex-1" />
|
|
149
|
+
<MarkdownContent inline content={option.text} className="min-w-0 flex-1 overflow-x-auto" />
|
|
150
150
|
</li>
|
|
151
151
|
))}
|
|
152
152
|
</ol>
|
|
153
153
|
</div>
|
|
154
154
|
)}
|
|
155
155
|
|
|
156
|
-
<ol aria-labelledby={`quiz-pairing-label-${question.id}`} className="m-0 mt-3 grid list-none gap-2 p-0">
|
|
156
|
+
<ol aria-labelledby={`quiz-pairing-label-${question.id}`} className="m-0 mt-3 grid list-none grid-cols-[minmax(0,1fr)] gap-2 p-0">
|
|
157
157
|
{slots.map((slot, index) => {
|
|
158
158
|
const chosen = pairings[slot.id];
|
|
159
159
|
const isCorrect = chosen === slot.correctId;
|
|
@@ -174,7 +174,7 @@ export function PairingResponse({
|
|
|
174
174
|
>
|
|
175
175
|
<div className="flex min-w-0 flex-1 items-start gap-2">
|
|
176
176
|
<span className="mt-0.5 w-6 shrink-0 text-sm font-semibold tabular-nums text-gray-500">{index + 1}.</span>
|
|
177
|
-
<label htmlFor={selectId} className="min-w-0 flex-1 text-base leading-7 text-gray-900">
|
|
177
|
+
<label htmlFor={selectId} className="min-w-0 flex-1 overflow-x-auto text-base leading-7 text-gray-900">
|
|
178
178
|
<MarkdownContent inline content={label} />
|
|
179
179
|
</label>
|
|
180
180
|
</div>
|
|
@@ -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];
|
|
@@ -358,7 +366,7 @@ export function QuizPage() {
|
|
|
358
366
|
<p id="quiz-choices-label" className="text-sm font-semibold text-gray-900">
|
|
359
367
|
{kind === 'single' ? 'Choose one answer' : 'Choose all that apply'}
|
|
360
368
|
</p>
|
|
361
|
-
<div role={kind === 'single' ? 'radiogroup' : 'group'} aria-labelledby="quiz-choices-label" className="mt-2 grid gap-2">
|
|
369
|
+
<div role={kind === 'single' ? 'radiogroup' : 'group'} aria-labelledby="quiz-choices-label" className="mt-2 grid grid-cols-[minmax(0,1fr)] gap-2">
|
|
362
370
|
{(activeQuestion.options ?? []).map((option, index) => {
|
|
363
371
|
const selected = selectedIds.includes(option.id);
|
|
364
372
|
const isCorrectOption = correctIds.includes(option.id);
|
|
@@ -398,7 +406,7 @@ export function QuizPage() {
|
|
|
398
406
|
>
|
|
399
407
|
{optionLetter(index)}
|
|
400
408
|
</span>
|
|
401
|
-
<MarkdownContent content={option.text} className="min-w-0 flex-1 text-base leading-7" />
|
|
409
|
+
<MarkdownContent content={option.text} className="min-w-0 flex-1 overflow-x-auto text-base leading-7" />
|
|
402
410
|
{showCorrectness && isCorrectOption && (
|
|
403
411
|
<Badge variant="outline" className="shrink-0 border-green-600 bg-white text-green-800">
|
|
404
412
|
Correct
|
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",
|