@quizparts/core 0.0.1

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.
@@ -0,0 +1,37 @@
1
+ /** Session actions - pure, immutable updates */
2
+ import type { QuizSession } from './state.js';
3
+ /** Select a single choice (multiple_choice). Replaces current selection. */
4
+ export declare const selectChoice: (session: QuizSession, questionIndex: number, choiceId: string) => QuizSession;
5
+ /** Toggle a choice (multi_select). Adds or removes choiceId. */
6
+ export declare const toggleChoice: (session: QuizSession, questionIndex: number, choiceId: string) => QuizSession;
7
+ /** Set text input (text_input). */
8
+ export declare const setTextInput: (session: QuizSession, questionIndex: number, text: string) => QuizSession;
9
+ /** Set match pairs (match_pairs). */
10
+ export declare const setMatchPairs: (session: QuizSession, questionIndex: number, pairs: Array<[string, string]>) => QuizSession;
11
+ /** Set ordered ids (order_items). */
12
+ export declare const setOrderedIds: (session: QuizSession, questionIndex: number, orderedIds: string[]) => QuizSession;
13
+ /** Set sentence order (sentence_builder). */
14
+ export declare const setSentenceOrder: (session: QuizSession, questionIndex: number, sentenceOrder: string[]) => QuizSession;
15
+ /** Submit the current question. Returns new session and whether the answer was correct. */
16
+ export declare const submitAnswer: (session: QuizSession) => {
17
+ session: QuizSession;
18
+ correct: boolean;
19
+ };
20
+ /** Move to the next question. Marks current as complete and next as active. */
21
+ export declare const goToNextQuestion: (session: QuizSession) => QuizSession;
22
+ /** Move to the previous question. */
23
+ export declare const goToPreviousQuestion: (session: QuizSession) => QuizSession;
24
+ /** Progress info for the current session */
25
+ export interface Progress {
26
+ currentIndex: number;
27
+ total: number;
28
+ score: number;
29
+ attemptedCount: number;
30
+ isComplete: boolean;
31
+ canGoNext: boolean;
32
+ canGoPrevious: boolean;
33
+ }
34
+ export declare const getProgress: (session: QuizSession) => Progress;
35
+ /** Reset session to initial state. */
36
+ export declare const resetQuiz: (session: QuizSession) => QuizSession;
37
+ //# sourceMappingURL=actions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../src/actions.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAEhD,OAAO,KAAK,EAAE,WAAW,EAAiB,MAAM,YAAY,CAAC;AAM7D,4EAA4E;AAC5E,eAAO,MAAM,YAAY,GACvB,SAAS,WAAW,EACpB,eAAe,MAAM,EACrB,UAAU,MAAM,KACf,WAUF,CAAC;AAEF,gEAAgE;AAChE,eAAO,MAAM,YAAY,GACvB,SAAS,WAAW,EACpB,eAAe,MAAM,EACrB,UAAU,MAAM,KACf,WAcF,CAAC;AAEF,mCAAmC;AACnC,eAAO,MAAM,YAAY,GACvB,SAAS,WAAW,EACpB,eAAe,MAAM,EACrB,MAAM,MAAM,KACX,WAUF,CAAC;AAEF,qCAAqC;AACrC,eAAO,MAAM,aAAa,GACxB,SAAS,WAAW,EACpB,eAAe,MAAM,EACrB,OAAO,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,KAC7B,WAUF,CAAC;AAEF,qCAAqC;AACrC,eAAO,MAAM,aAAa,GACxB,SAAS,WAAW,EACpB,eAAe,MAAM,EACrB,YAAY,MAAM,EAAE,KACnB,WAUF,CAAC;AAEF,6CAA6C;AAC7C,eAAO,MAAM,gBAAgB,GAC3B,SAAS,WAAW,EACpB,eAAe,MAAM,EACrB,eAAe,MAAM,EAAE,KACtB,WAUF,CAAC;AAEF,2FAA2F;AAC3F,eAAO,MAAM,YAAY,GACvB,SAAS,WAAW,KACnB;IAAE,OAAO,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAqB1C,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,GAAI,SAAS,WAAW,KAAG,WAevD,CAAC;AAEF,qCAAqC;AACrC,eAAO,MAAM,oBAAoB,GAAI,SAAS,WAAW,KAAG,WAc3D,CAAC;AAEF,4CAA4C;AAC5C,MAAM,WAAW,QAAQ;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,OAAO,CAAC;IACnB,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,eAAO,MAAM,WAAW,GAAI,SAAS,WAAW,KAAG,QAalD,CAAC;AAEF,sCAAsC;AACtC,eAAO,MAAM,SAAS,GAAI,SAAS,WAAW,KAAG,WAiBhD,CAAC"}
@@ -0,0 +1,171 @@
1
+ /** Session actions - pure, immutable updates */
2
+ import { checkCorrectness } from './correctness.js';
3
+ const cloneStates = (states) => states.map((s) => ({ ...s, input: { ...s.input } }));
4
+ /** Select a single choice (multiple_choice). Replaces current selection. */
5
+ export const selectChoice = (session, questionIndex, choiceId) => {
6
+ if (questionIndex < 0 || questionIndex >= session.questionStates.length) {
7
+ return session;
8
+ }
9
+ const next = cloneStates(session.questionStates);
10
+ next[questionIndex] = {
11
+ ...next[questionIndex],
12
+ input: { ...next[questionIndex].input, selectedChoiceId: choiceId },
13
+ };
14
+ return { ...session, questionStates: next };
15
+ };
16
+ /** Toggle a choice (multi_select). Adds or removes choiceId. */
17
+ export const toggleChoice = (session, questionIndex, choiceId) => {
18
+ if (questionIndex < 0 || questionIndex >= session.questionStates.length) {
19
+ return session;
20
+ }
21
+ const curr = session.questionStates[questionIndex].input.selectedChoiceIds ?? [];
22
+ const set = new Set(curr);
23
+ if (set.has(choiceId))
24
+ set.delete(choiceId);
25
+ else
26
+ set.add(choiceId);
27
+ const next = cloneStates(session.questionStates);
28
+ next[questionIndex] = {
29
+ ...next[questionIndex],
30
+ input: { ...next[questionIndex].input, selectedChoiceIds: [...set] },
31
+ };
32
+ return { ...session, questionStates: next };
33
+ };
34
+ /** Set text input (text_input). */
35
+ export const setTextInput = (session, questionIndex, text) => {
36
+ if (questionIndex < 0 || questionIndex >= session.questionStates.length) {
37
+ return session;
38
+ }
39
+ const next = cloneStates(session.questionStates);
40
+ next[questionIndex] = {
41
+ ...next[questionIndex],
42
+ input: { ...next[questionIndex].input, text },
43
+ };
44
+ return { ...session, questionStates: next };
45
+ };
46
+ /** Set match pairs (match_pairs). */
47
+ export const setMatchPairs = (session, questionIndex, pairs) => {
48
+ if (questionIndex < 0 || questionIndex >= session.questionStates.length) {
49
+ return session;
50
+ }
51
+ const next = cloneStates(session.questionStates);
52
+ next[questionIndex] = {
53
+ ...next[questionIndex],
54
+ input: { ...next[questionIndex].input, matchPairs: pairs },
55
+ };
56
+ return { ...session, questionStates: next };
57
+ };
58
+ /** Set ordered ids (order_items). */
59
+ export const setOrderedIds = (session, questionIndex, orderedIds) => {
60
+ if (questionIndex < 0 || questionIndex >= session.questionStates.length) {
61
+ return session;
62
+ }
63
+ const next = cloneStates(session.questionStates);
64
+ next[questionIndex] = {
65
+ ...next[questionIndex],
66
+ input: { ...next[questionIndex].input, orderedIds },
67
+ };
68
+ return { ...session, questionStates: next };
69
+ };
70
+ /** Set sentence order (sentence_builder). */
71
+ export const setSentenceOrder = (session, questionIndex, sentenceOrder) => {
72
+ if (questionIndex < 0 || questionIndex >= session.questionStates.length) {
73
+ return session;
74
+ }
75
+ const next = cloneStates(session.questionStates);
76
+ next[questionIndex] = {
77
+ ...next[questionIndex],
78
+ input: { ...next[questionIndex].input, sentenceOrder },
79
+ };
80
+ return { ...session, questionStates: next };
81
+ };
82
+ /** Submit the current question. Returns new session and whether the answer was correct. */
83
+ export const submitAnswer = (session) => {
84
+ const idx = session.currentQuestionIndex;
85
+ if (idx < 0 || idx >= session.quiz.questions.length) {
86
+ return { session, correct: false };
87
+ }
88
+ const question = session.quiz.questions[idx];
89
+ const input = session.questionStates[idx].input;
90
+ const correct = checkCorrectness(question, input);
91
+ const next = cloneStates(session.questionStates);
92
+ next[idx] = {
93
+ ...next[idx],
94
+ status: correct ? 'correct' : 'incorrect',
95
+ isCorrect: correct,
96
+ };
97
+ const newSession = {
98
+ ...session,
99
+ questionStates: next,
100
+ score: session.score + (correct ? 1 : 0),
101
+ attemptedCount: session.attemptedCount + 1,
102
+ };
103
+ return { session: newSession, correct };
104
+ };
105
+ /** Move to the next question. Marks current as complete and next as active. */
106
+ export const goToNextQuestion = (session) => {
107
+ const idx = session.currentQuestionIndex;
108
+ const next = cloneStates(session.questionStates);
109
+ if (idx >= 0 && idx < next.length) {
110
+ next[idx] = { ...next[idx], status: 'complete' };
111
+ }
112
+ const nextIndex = Math.min(idx + 1, session.quiz.questions.length);
113
+ if (nextIndex < next.length) {
114
+ next[nextIndex] = { ...next[nextIndex], status: 'active' };
115
+ }
116
+ return {
117
+ ...session,
118
+ currentQuestionIndex: nextIndex,
119
+ questionStates: next,
120
+ };
121
+ };
122
+ /** Move to the previous question. */
123
+ export const goToPreviousQuestion = (session) => {
124
+ const idx = session.currentQuestionIndex;
125
+ if (idx <= 0)
126
+ return session;
127
+ const next = cloneStates(session.questionStates);
128
+ if (idx < next.length) {
129
+ next[idx] = { ...next[idx], status: 'idle' };
130
+ }
131
+ const prevIndex = idx - 1;
132
+ next[prevIndex] = { ...next[prevIndex], status: 'active' };
133
+ return {
134
+ ...session,
135
+ currentQuestionIndex: prevIndex,
136
+ questionStates: next,
137
+ };
138
+ };
139
+ export const getProgress = (session) => {
140
+ const total = session.quiz.questions.length;
141
+ const current = session.currentQuestionIndex;
142
+ const isComplete = total === 0 || (current >= total && session.attemptedCount >= total);
143
+ return {
144
+ currentIndex: current,
145
+ total,
146
+ score: session.score,
147
+ attemptedCount: session.attemptedCount,
148
+ isComplete,
149
+ canGoNext: current < total,
150
+ canGoPrevious: current > 0,
151
+ };
152
+ };
153
+ /** Reset session to initial state. */
154
+ export const resetQuiz = (session) => {
155
+ const s = {
156
+ ...session,
157
+ currentQuestionIndex: 0,
158
+ questionStates: session.quiz.questions.map(() => ({
159
+ status: 'idle',
160
+ input: {},
161
+ })),
162
+ score: 0,
163
+ attemptedCount: 0,
164
+ };
165
+ if (s.questionStates.length > 0) {
166
+ const next = cloneStates(s.questionStates);
167
+ next[0] = { ...next[0], status: 'active' };
168
+ return { ...s, questionStates: next };
169
+ }
170
+ return s;
171
+ };
@@ -0,0 +1,6 @@
1
+ /** Correctness checking per question type - no DOM/React */
2
+ import type { QuizQuestion } from '@quizparts/schema';
3
+ import type { QuestionInputState } from './state.js';
4
+ /** Check if the user's input matches the correct answer for the given question */
5
+ export declare const checkCorrectness: (question: QuizQuestion, input: QuestionInputState) => boolean;
6
+ //# sourceMappingURL=correctness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"correctness.d.ts","sourceRoot":"","sources":["../src/correctness.ts"],"names":[],"mappings":"AAAA,4DAA4D;AAE5D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAErD,kFAAkF;AAClF,eAAO,MAAM,gBAAgB,GAC3B,UAAU,YAAY,EACtB,OAAO,kBAAkB,KACxB,OAsCF,CAAC"}
@@ -0,0 +1,43 @@
1
+ /** Correctness checking per question type - no DOM/React */
2
+ /** Check if the user's input matches the correct answer for the given question */
3
+ export const checkCorrectness = (question, input) => {
4
+ switch (question.type) {
5
+ case 'multiple_choice':
6
+ return input.selectedChoiceId === question.answer;
7
+ case 'multi_select': {
8
+ const selected = input.selectedChoiceIds ?? [];
9
+ if (selected.length !== question.answer.length)
10
+ return false;
11
+ const set = new Set(question.answer);
12
+ return selected.every((id) => set.has(id));
13
+ }
14
+ case 'text_input': {
15
+ const text = (input.text ?? '').trim();
16
+ const answer = question.answer.trim();
17
+ return question.caseInsensitive
18
+ ? text.toLowerCase() === answer.toLowerCase()
19
+ : text === answer;
20
+ }
21
+ case 'match_pairs': {
22
+ const pairs = input.matchPairs ?? [];
23
+ if (pairs.length !== question.answer.length)
24
+ return false;
25
+ const normalizedAnswer = new Set(question.answer.map(([a, b]) => `${a}:${b}`));
26
+ return pairs.every(([l, r]) => normalizedAnswer.has(`${l}:${r}`));
27
+ }
28
+ case 'order_items': {
29
+ const ordered = input.orderedIds ?? [];
30
+ if (ordered.length !== question.answer.length)
31
+ return false;
32
+ return ordered.every((id, i) => id === question.answer[i]);
33
+ }
34
+ case 'sentence_builder': {
35
+ const order = input.sentenceOrder ?? [];
36
+ if (order.length !== question.answer.length)
37
+ return false;
38
+ return order.every((val, i) => val === question.answer[i]);
39
+ }
40
+ default:
41
+ return false;
42
+ }
43
+ };
@@ -0,0 +1,7 @@
1
+ /** @quizparts/core - Headless quiz engine */
2
+ export { SCHEMA_VERSION } from '@quizparts/schema';
3
+ export declare const CORE_VERSION = "0.0.1";
4
+ export { createQuizSession, createInitialQuestionState, type QuizSession, type QuestionState, type QuestionStatus, type QuestionInputState, } from './state.js';
5
+ export { checkCorrectness } from './correctness.js';
6
+ export { selectChoice, toggleChoice, setTextInput, setMatchPairs, setOrderedIds, setSentenceOrder, submitAnswer, goToNextQuestion, goToPreviousQuestion, getProgress, resetQuiz, type Progress, } from './actions.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAE7C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEnD,eAAO,MAAM,YAAY,UAAU,CAAC;AAEpC,OAAO,EACL,iBAAiB,EACjB,0BAA0B,EAC1B,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,kBAAkB,GACxB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEpD,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,aAAa,EACb,gBAAgB,EAChB,YAAY,EACZ,gBAAgB,EAChB,oBAAoB,EACpB,WAAW,EACX,SAAS,EACT,KAAK,QAAQ,GACd,MAAM,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ /** @quizparts/core - Headless quiz engine */
2
+ export { SCHEMA_VERSION } from '@quizparts/schema';
3
+ export const CORE_VERSION = '0.0.1';
4
+ export { createQuizSession, createInitialQuestionState, } from './state.js';
5
+ export { checkCorrectness } from './correctness.js';
6
+ export { selectChoice, toggleChoice, setTextInput, setMatchPairs, setOrderedIds, setSentenceOrder, submitAnswer, goToNextQuestion, goToPreviousQuestion, getProgress, resetQuiz, } from './actions.js';
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,207 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseQuiz } from '@quizparts/schema';
3
+ import { CORE_VERSION, createQuizSession, selectChoice, toggleChoice, setTextInput, setOrderedIds, submitAnswer, goToNextQuestion, goToPreviousQuestion, getProgress, resetQuiz, checkCorrectness, } from './index.js';
4
+ const validQuiz = parseQuiz({
5
+ id: 'test-quiz',
6
+ title: 'Test Quiz',
7
+ questions: [
8
+ {
9
+ id: 'q1',
10
+ type: 'multiple_choice',
11
+ prompt: 'What is 2+2?',
12
+ choices: [
13
+ { id: 'a', text: '3' },
14
+ { id: 'b', text: '4' },
15
+ { id: 'c', text: '5' },
16
+ ],
17
+ answer: 'b',
18
+ },
19
+ {
20
+ id: 'q2',
21
+ type: 'text_input',
22
+ prompt: 'Say "hello"',
23
+ answer: 'hello',
24
+ },
25
+ ],
26
+ });
27
+ describe('@quizparts/core', () => {
28
+ it('exports CORE_VERSION', () => {
29
+ expect(CORE_VERSION).toBe('0.0.1');
30
+ });
31
+ it('creates session from parsed quiz', () => {
32
+ if (!validQuiz.success)
33
+ throw new Error('parse failed');
34
+ const session = createQuizSession(validQuiz.data);
35
+ expect(session.quiz.questions).toHaveLength(2);
36
+ expect(session.currentQuestionIndex).toBe(0);
37
+ expect(session.score).toBe(0);
38
+ expect(session.questionStates[0].status).toBe('active');
39
+ expect(session.questionStates[1].status).toBe('idle');
40
+ });
41
+ it('selecting an answer updates state', () => {
42
+ if (!validQuiz.success)
43
+ throw new Error('parse failed');
44
+ const session = createQuizSession(validQuiz.data);
45
+ const next = selectChoice(session, 0, 'b');
46
+ expect(next.questionStates[0].input.selectedChoiceId).toBe('b');
47
+ });
48
+ it('submitting correct answer returns correct and updates score', () => {
49
+ if (!validQuiz.success)
50
+ throw new Error('parse failed');
51
+ let session = createQuizSession(validQuiz.data);
52
+ session = selectChoice(session, 0, 'b');
53
+ const { session: after, correct } = submitAnswer(session);
54
+ expect(correct).toBe(true);
55
+ expect(after.score).toBe(1);
56
+ expect(after.questionStates[0].status).toBe('correct');
57
+ });
58
+ it('submitting incorrect answer returns incorrect', () => {
59
+ if (!validQuiz.success)
60
+ throw new Error('parse failed');
61
+ let session = createQuizSession(validQuiz.data);
62
+ session = selectChoice(session, 0, 'a');
63
+ const { session: after, correct } = submitAnswer(session);
64
+ expect(correct).toBe(false);
65
+ expect(after.score).toBe(0);
66
+ expect(after.questionStates[0].status).toBe('incorrect');
67
+ });
68
+ it('progression: goToNextQuestion advances index and marks current complete', () => {
69
+ if (!validQuiz.success)
70
+ throw new Error('parse failed');
71
+ let session = createQuizSession(validQuiz.data);
72
+ session = selectChoice(session, 0, 'b');
73
+ const { session: afterSubmit } = submitAnswer(session);
74
+ const afterNext = goToNextQuestion(afterSubmit);
75
+ expect(afterNext.currentQuestionIndex).toBe(1);
76
+ expect(afterNext.questionStates[0].status).toBe('complete');
77
+ expect(afterNext.questionStates[1].status).toBe('active');
78
+ });
79
+ it('getProgress returns currentIndex, total, score, attemptedCount', () => {
80
+ if (!validQuiz.success)
81
+ throw new Error('parse failed');
82
+ let session = createQuizSession(validQuiz.data);
83
+ let progress = getProgress(session);
84
+ expect(progress.currentIndex).toBe(0);
85
+ expect(progress.total).toBe(2);
86
+ expect(progress.score).toBe(0);
87
+ expect(progress.attemptedCount).toBe(0);
88
+ expect(progress.canGoNext).toBe(true);
89
+ expect(progress.canGoPrevious).toBe(false);
90
+ session = selectChoice(session, 0, 'b');
91
+ const { session: after } = submitAnswer(session);
92
+ progress = getProgress(after);
93
+ expect(progress.score).toBe(1);
94
+ expect(progress.attemptedCount).toBe(1);
95
+ });
96
+ it('reset restores initial state', () => {
97
+ if (!validQuiz.success)
98
+ throw new Error('parse failed');
99
+ let session = createQuizSession(validQuiz.data);
100
+ session = selectChoice(session, 0, 'b');
101
+ const { session: afterSubmit } = submitAnswer(session);
102
+ const afterReset = resetQuiz(afterSubmit);
103
+ expect(afterReset.currentQuestionIndex).toBe(0);
104
+ expect(afterReset.score).toBe(0);
105
+ expect(afterReset.attemptedCount).toBe(0);
106
+ expect(afterReset.questionStates[0].status).toBe('active');
107
+ expect(afterReset.questionStates[0].input.selectedChoiceId).toBeUndefined();
108
+ });
109
+ it('text_input: setTextInput and submit correct answer', () => {
110
+ if (!validQuiz.success)
111
+ throw new Error('parse failed');
112
+ let session = createQuizSession(validQuiz.data);
113
+ session = goToNextQuestion(session);
114
+ session = setTextInput(session, 1, 'hello');
115
+ const { session: after, correct } = submitAnswer(session);
116
+ expect(correct).toBe(true);
117
+ expect(after.score).toBe(1);
118
+ });
119
+ it('goToPreviousQuestion goes back', () => {
120
+ if (!validQuiz.success)
121
+ throw new Error('parse failed');
122
+ let session = createQuizSession(validQuiz.data);
123
+ session = goToNextQuestion(session);
124
+ expect(session.currentQuestionIndex).toBe(1);
125
+ const back = goToPreviousQuestion(session);
126
+ expect(back.currentQuestionIndex).toBe(0);
127
+ expect(back.questionStates[0].status).toBe('active');
128
+ });
129
+ it('toggleChoice adds and removes selection for multi_select', () => {
130
+ const multiQuiz = parseQuiz({
131
+ id: 'm',
132
+ title: 'M',
133
+ questions: [
134
+ {
135
+ id: 'q1',
136
+ type: 'multi_select',
137
+ prompt: '?',
138
+ choices: [
139
+ { id: 'a', text: 'A' },
140
+ { id: 'b', text: 'B' },
141
+ ],
142
+ answer: ['a'],
143
+ },
144
+ ],
145
+ });
146
+ if (!multiQuiz.success)
147
+ throw new Error('parse failed');
148
+ let session = createQuizSession(multiQuiz.data);
149
+ session = toggleChoice(session, 0, 'a');
150
+ expect(session.questionStates[0].input.selectedChoiceIds).toEqual(['a']);
151
+ session = toggleChoice(session, 0, 'b');
152
+ expect(session.questionStates[0].input.selectedChoiceIds).toContain('a');
153
+ expect(session.questionStates[0].input.selectedChoiceIds).toContain('b');
154
+ session = toggleChoice(session, 0, 'a');
155
+ expect(session.questionStates[0].input.selectedChoiceIds).not.toContain('a');
156
+ expect(session.questionStates[0].input.selectedChoiceIds).toContain('b');
157
+ });
158
+ it('checkCorrectness: text_input caseInsensitive', () => {
159
+ const q = {
160
+ type: 'text_input',
161
+ id: 'q1',
162
+ prompt: '?',
163
+ answer: 'Hello',
164
+ caseInsensitive: true,
165
+ };
166
+ expect(checkCorrectness(q, { text: 'hello' })).toBe(true);
167
+ expect(checkCorrectness(q, { text: 'HELLO' })).toBe(true);
168
+ expect(checkCorrectness(q, { text: 'hi' })).toBe(false);
169
+ });
170
+ it('checkCorrectness: multi_select', () => {
171
+ const q = {
172
+ type: 'multi_select',
173
+ id: 'q1',
174
+ prompt: '?',
175
+ choices: [{ id: 'a', text: 'A' }, { id: 'b', text: 'B' }],
176
+ answer: ['a', 'b'],
177
+ };
178
+ expect(checkCorrectness(q, { selectedChoiceIds: ['a', 'b'] })).toBe(true);
179
+ expect(checkCorrectness(q, { selectedChoiceIds: ['b', 'a'] })).toBe(true);
180
+ expect(checkCorrectness(q, { selectedChoiceIds: ['a'] })).toBe(false);
181
+ expect(checkCorrectness(q, { selectedChoiceIds: ['a', 'b', 'a'] })).toBe(false);
182
+ });
183
+ it('setOrderedIds and submit correct order_items', () => {
184
+ const orderQuiz = parseQuiz({
185
+ id: 'o',
186
+ title: 'O',
187
+ questions: [
188
+ {
189
+ id: 'q1',
190
+ type: 'order_items',
191
+ prompt: 'Order',
192
+ items: [
193
+ { id: '1', text: 'One' },
194
+ { id: '2', text: 'Two' },
195
+ ],
196
+ answer: ['1', '2'],
197
+ },
198
+ ],
199
+ });
200
+ if (!orderQuiz.success)
201
+ throw new Error('parse failed');
202
+ let session = createQuizSession(orderQuiz.data);
203
+ session = setOrderedIds(session, 0, ['1', '2']);
204
+ const { correct } = submitAnswer(session);
205
+ expect(correct).toBe(true);
206
+ });
207
+ });
@@ -0,0 +1,41 @@
1
+ /** Quiz session state - serializable, no DOM/React */
2
+ import type { Quiz } from '@quizparts/schema';
3
+ /** Lifecycle status for a single question */
4
+ export type QuestionStatus = 'idle' | 'active' | 'answered' | 'correct' | 'incorrect' | 'complete';
5
+ /** User input state per question (by type) */
6
+ export interface QuestionInputState {
7
+ /** multiple_choice */
8
+ selectedChoiceId?: string | null;
9
+ /** multi_select */
10
+ selectedChoiceIds?: string[];
11
+ /** text_input */
12
+ text?: string;
13
+ /** match_pairs: user's pairing as [left, right][] */
14
+ matchPairs?: Array<[string, string]>;
15
+ /** order_items: user's ordered id list */
16
+ orderedIds?: string[];
17
+ /** sentence_builder: user's ordered tile values */
18
+ sentenceOrder?: string[];
19
+ }
20
+ /** State for one question in the session */
21
+ export interface QuestionState {
22
+ status: QuestionStatus;
23
+ input: QuestionInputState;
24
+ /** Set after submit: true if correct */
25
+ isCorrect?: boolean;
26
+ }
27
+ /** Full quiz session state */
28
+ export interface QuizSession {
29
+ quiz: Quiz;
30
+ currentQuestionIndex: number;
31
+ questionStates: QuestionState[];
32
+ /** Number of questions answered correctly */
33
+ score: number;
34
+ /** Number of questions that have been submitted */
35
+ attemptedCount: number;
36
+ }
37
+ /** Create initial question state */
38
+ export declare const createInitialQuestionState: () => QuestionState;
39
+ /** Build initial session from a parsed quiz */
40
+ export declare const createQuizSession: (quiz: Quiz) => QuizSession;
41
+ //# sourceMappingURL=state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAAA,sDAAsD;AAEtD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AAE9C,6CAA6C;AAC7C,MAAM,MAAM,cAAc,GACtB,MAAM,GACN,QAAQ,GACR,UAAU,GACV,SAAS,GACT,WAAW,GACX,UAAU,CAAC;AAEf,8CAA8C;AAC9C,MAAM,WAAW,kBAAkB;IACjC,sBAAsB;IACtB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,mBAAmB;IACnB,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC7B,iBAAiB;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qDAAqD;IACrD,UAAU,CAAC,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACrC,0CAA0C;IAC1C,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,mDAAmD;IACnD,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,4CAA4C;AAC5C,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,cAAc,CAAC;IACvB,KAAK,EAAE,kBAAkB,CAAC;IAC1B,wCAAwC;IACxC,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,8BAA8B;AAC9B,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,IAAI,CAAC;IACX,oBAAoB,EAAE,MAAM,CAAC;IAC7B,cAAc,EAAE,aAAa,EAAE,CAAC;IAChC,6CAA6C;IAC7C,KAAK,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,oCAAoC;AACpC,eAAO,MAAM,0BAA0B,QAAO,aAG5C,CAAC;AAEH,+CAA+C;AAC/C,eAAO,MAAM,iBAAiB,GAAI,MAAM,IAAI,KAAG,WAc9C,CAAC"}
package/dist/state.js ADDED
@@ -0,0 +1,20 @@
1
+ /** Quiz session state - serializable, no DOM/React */
2
+ /** Create initial question state */
3
+ export const createInitialQuestionState = () => ({
4
+ status: 'idle',
5
+ input: {},
6
+ });
7
+ /** Build initial session from a parsed quiz */
8
+ export const createQuizSession = (quiz) => {
9
+ const questionStates = quiz.questions.map(() => createInitialQuestionState());
10
+ if (questionStates.length > 0) {
11
+ questionStates[0].status = 'active';
12
+ }
13
+ return {
14
+ quiz,
15
+ currentQuestionIndex: 0,
16
+ questionStates,
17
+ score: 0,
18
+ attemptedCount: 0,
19
+ };
20
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@quizparts/core",
3
+ "version": "0.0.1",
4
+ "description": "Headless quiz engine for QuizParts (session, submit, progress)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/ovrpro/QuizParts.git"
9
+ },
10
+ "homepage": "https://github.com/ovrpro/QuizParts#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/ovrpro/QuizParts/issues"
13
+ },
14
+ "keywords": ["quiz", "headless", "engine", "learning", "typescript"],
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": ["dist"],
25
+ "publishConfig": { "access": "public" },
26
+ "scripts": {
27
+ "build": "tsc",
28
+ "lint": "eslint src --ext .ts",
29
+ "typecheck": "tsc --noEmit",
30
+ "test": "vitest run",
31
+ "clean": "rm -rf dist"
32
+ },
33
+ "dependencies": {
34
+ "@quizparts/schema": "0.0.1"
35
+ },
36
+ "devDependencies": {
37
+ "typescript": "^5.6.3",
38
+ "vitest": "^2.1.4"
39
+ }
40
+ }