From bfb6dc8a2cb249ff896af3035a20558c12d9b665 Mon Sep 17 00:00:00 2001 From: mAi Date: Thu, 7 May 2026 20:11:37 +0200 Subject: [PATCH] feat(questions): boolean module (registry slot 1/7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First per-type module — establishes the file layout for the remaining six. - lib/questions/boolean.ts — schema (re-uses FeedbackQuestionBaseSchema + z.literal('boolean')), defaultStub, isAnswerEmpty, emptyStats, ingest, finalise, sanitizeForPublic, csvColumns, csvCellFor, adminCellSummary, plus the three .svelte component slots. - lib/questions/boolean.input.svelte — Yes/Nein radio pair, exactly the same markup the participant page renders today (will be the receiver when /f/[slug] flips to the registry dispatcher in commit 12). - lib/questions/boolean.builder.svelte — empty placeholder; boolean has no type-specific fields beyond the base. Slot exists so the registry shape stays uniform across all seven types. - lib/questions/boolean.results.svelte — count + percent bars, same as the current Results.svelte branch. - lib/questions/boolean.test.ts — 17 cases covering schema accept/reject, isAnswerEmpty (true/false/undefined/null/non-bool), ingest+finalise (yes/no counts, garbage ignored), CSV (single column, true/false/empty), adminCellSummary (Yes/No/em-dash). - lib/questions/_base.ts — FeedbackQuestionBaseSchema extracted for use by the per-type modules. (Currently duplicates the private schema in schemas.ts; commit 11 will flip schemas.ts to read from the registry and drop the duplication.) Registry: BooleanQuestion is the first entry in QUESTION_MODULES. The legacy `q.type === 'boolean'` strips in FormBuilder / participant page / Results.svelte / results.ts / submit / export still own dispatch — the wiring step at the end of Phase 2 flips them. 70 server tests pass (was 58). svelte-check + bun run build clean. --- package.json | 2 +- src/lib/questions/_base.ts | 20 +++++ src/lib/questions/boolean.builder.svelte | 8 ++ src/lib/questions/boolean.input.svelte | 26 +++++++ src/lib/questions/boolean.results.svelte | 29 ++++++++ src/lib/questions/boolean.test.ts | 94 ++++++++++++++++++++++++ src/lib/questions/boolean.ts | 69 +++++++++++++++++ src/lib/questions/registry.test.ts | 4 +- src/lib/questions/registry.ts | 17 +++-- 9 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 src/lib/questions/_base.ts create mode 100644 src/lib/questions/boolean.builder.svelte create mode 100644 src/lib/questions/boolean.input.svelte create mode 100644 src/lib/questions/boolean.results.svelte create mode 100644 src/lib/questions/boolean.test.ts create mode 100644 src/lib/questions/boolean.ts diff --git a/package.json b/package.json index 3a6a414..da5b4af 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "start": "node build/index.js", - "test:server": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts ./src/lib/server/admin-route.test.ts ./src/lib/server/feedback-pure.test.ts ./src/lib/questions/registry.test.ts", + "test:server": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts ./src/lib/server/admin-route.test.ts ./src/lib/server/feedback-pure.test.ts ./src/lib/questions/registry.test.ts ./src/lib/questions/boolean.test.ts", "test:components": "bun --bun vitest run --config vitest.config.ts", "test": "bun run test:server && bun run test:components" }, diff --git a/src/lib/questions/_base.ts b/src/lib/questions/_base.ts new file mode 100644 index 0000000..2acbaaa --- /dev/null +++ b/src/lib/questions/_base.ts @@ -0,0 +1,20 @@ +/** + * Shared base schema for every question type. Each per-type module extends + * this with its type-specific fields (placeholder for text types, options + * for choice types, etc.). + * + * Lives outside `types.ts` because it's a runtime zod schema, not just a + * type alias — keeping it in a sibling file avoids circular imports between + * `types.ts` (which the schemas.ts compiled union eventually reads) and + * per-type modules. + */ +import { z } from 'zod'; + +export const FeedbackQuestionBaseSchema = z.object({ + id: z.string().min(1).max(64), + label: z.string().min(1).max(200), + required: z.boolean().optional(), + help: z.string().max(500).optional(), +}); + +export type FeedbackQuestionBase = z.infer; diff --git a/src/lib/questions/boolean.builder.svelte b/src/lib/questions/boolean.builder.svelte new file mode 100644 index 0000000..32da17f --- /dev/null +++ b/src/lib/questions/boolean.builder.svelte @@ -0,0 +1,8 @@ + diff --git a/src/lib/questions/boolean.input.svelte b/src/lib/questions/boolean.input.svelte new file mode 100644 index 0000000..6935133 --- /dev/null +++ b/src/lib/questions/boolean.input.svelte @@ -0,0 +1,26 @@ + + +
+ + +
diff --git a/src/lib/questions/boolean.results.svelte b/src/lib/questions/boolean.results.svelte new file mode 100644 index 0000000..36257ad --- /dev/null +++ b/src/lib/questions/boolean.results.svelte @@ -0,0 +1,29 @@ + + +{#if stats.type === 'boolean'} +
+
+ Ja +
+
+
+ {stats.yes} ({pct(stats.yes, stats.count)}%) +
+
+ Nein +
+
+
+ {stats.no} ({pct(stats.no, stats.count)}%) +
+
+{/if} diff --git a/src/lib/questions/boolean.test.ts b/src/lib/questions/boolean.test.ts new file mode 100644 index 0000000..1b0c3af --- /dev/null +++ b/src/lib/questions/boolean.test.ts @@ -0,0 +1,94 @@ +import { describe, test, expect } from 'bun:test'; +import { BooleanQuestion, BooleanQuestionSchema } from './boolean'; + +describe('BooleanQuestion.schema', () => { + test('accepts a valid boolean question', () => { + const r = BooleanQuestionSchema.safeParse({ + id: 'q1', + label: 'Recommend?', + required: true, + type: 'boolean', + }); + expect(r.success).toBe(true); + }); + + test('rejects when type is wrong', () => { + const r = BooleanQuestionSchema.safeParse({ id: 'q1', label: 'X', type: 'scale' }); + expect(r.success).toBe(false); + }); + + test('rejects when label is missing', () => { + const r = BooleanQuestionSchema.safeParse({ id: 'q1', type: 'boolean' }); + expect(r.success).toBe(false); + }); +}); + +describe('BooleanQuestion.isAnswerEmpty', () => { + const q = BooleanQuestion.defaultStub(); + + test('undefined → empty', () => { + expect(BooleanQuestion.isAnswerEmpty(q, undefined)).toBe(true); + }); + + test('null → empty', () => { + expect(BooleanQuestion.isAnswerEmpty(q, null)).toBe(true); + }); + + test('true → not empty', () => { + expect(BooleanQuestion.isAnswerEmpty(q, true)).toBe(false); + }); + + test('false → not empty (Nein is a valid answer)', () => { + expect(BooleanQuestion.isAnswerEmpty(q, false)).toBe(false); + }); + + test('non-boolean → empty', () => { + expect(BooleanQuestion.isAnswerEmpty(q, 'true')).toBe(true); + expect(BooleanQuestion.isAnswerEmpty(q, 1)).toBe(true); + }); +}); + +describe('BooleanQuestion.ingest + finalise', () => { + const q = BooleanQuestion.defaultStub(); + + test('counts yes/no separately, ignores garbage', () => { + const stats = BooleanQuestion.emptyStats(q); + BooleanQuestion.ingest(stats, q, true, 'now'); + BooleanQuestion.ingest(stats, q, true, 'now'); + BooleanQuestion.ingest(stats, q, false, 'now'); + BooleanQuestion.ingest(stats, q, 'oops', 'now'); + BooleanQuestion.ingest(stats, q, null, 'now'); + BooleanQuestion.finalise(stats); + expect(stats.count).toBe(3); + expect(stats.yes).toBe(2); + expect(stats.no).toBe(1); + }); +}); + +describe('BooleanQuestion.csv', () => { + const q = BooleanQuestion.defaultStub(); + + test('one column per question', () => { + const cols = BooleanQuestion.csvColumns({ ...q, id: 'recommend' }); + expect(cols).toEqual([{ header: 'recommend', qid: 'recommend' }]); + }); + + test('cell renders true/false/empty literals', () => { + const [col] = BooleanQuestion.csvColumns(q); + expect(BooleanQuestion.csvCellFor(q, true, col)).toBe('true'); + expect(BooleanQuestion.csvCellFor(q, false, col)).toBe('false'); + expect(BooleanQuestion.csvCellFor(q, null, col)).toBe(''); + expect(BooleanQuestion.csvCellFor(q, undefined, col)).toBe(''); + }); +}); + +describe('BooleanQuestion.adminCellSummary', () => { + const q = BooleanQuestion.defaultStub(); + + test('formats Yes / No / em-dash', () => { + expect(BooleanQuestion.adminCellSummary(q, true)).toBe('Yes'); + expect(BooleanQuestion.adminCellSummary(q, false)).toBe('No'); + expect(BooleanQuestion.adminCellSummary(q, null)).toBe('—'); + expect(BooleanQuestion.adminCellSummary(q, undefined)).toBe('—'); + }); +}); diff --git a/src/lib/questions/boolean.ts b/src/lib/questions/boolean.ts new file mode 100644 index 0000000..7c457f8 --- /dev/null +++ b/src/lib/questions/boolean.ts @@ -0,0 +1,69 @@ +/** + * `boolean` question type — Yes/No radio pair on the participant side, count + * + percent bars in the results. + */ +import { z } from 'zod'; +import type { QuestionTypeModule, CsvColumn } from './types'; +import { FeedbackQuestionBaseSchema } from './_base'; +import BooleanInput from './boolean.input.svelte'; +import BooleanBuilder from './boolean.builder.svelte'; +import BooleanResults from './boolean.results.svelte'; + +export const BooleanQuestionSchema = FeedbackQuestionBaseSchema.extend({ + type: z.literal('boolean'), +}); + +type Q = z.infer; + +export const BooleanQuestion: QuestionTypeModule<'boolean'> = { + type: 'boolean', + label: 'Yes / No', + schema: BooleanQuestionSchema, + + defaultStub() { + return { id: 'q1', label: 'New question', required: false, type: 'boolean' }; + }, + + isAnswerEmpty(_q: Q, answer: unknown): boolean { + return answer !== true && answer !== false; + }, + + emptyStats() { + return { type: 'boolean', count: 0, yes: 0, no: 0 }; + }, + + ingest(stats, _q, answer) { + if (typeof answer !== 'boolean') return; + stats.count++; + if (answer) stats.yes++; + else stats.no++; + }, + + finalise() { + // boolean stats are complete after ingest — nothing to compute. + }, + + sanitizeForPublic(stats) { + return stats; + }, + + csvColumns(q: Q): CsvColumn[] { + return [{ header: q.id, qid: q.id }]; + }, + + csvCellFor(_q, answer) { + if (answer === true) return 'true'; + if (answer === false) return 'false'; + return ''; + }, + + adminCellSummary(_q, answer) { + if (answer === true) return 'Yes'; + if (answer === false) return 'No'; + return '—'; + }, + + ParticipantInput: BooleanInput, + BuilderEditor: BooleanBuilder, + ResultsBlock: BooleanResults, +}; diff --git a/src/lib/questions/registry.test.ts b/src/lib/questions/registry.test.ts index dafdf50..5287fdb 100644 --- a/src/lib/questions/registry.test.ts +++ b/src/lib/questions/registry.test.ts @@ -19,7 +19,9 @@ describe('registry shape', () => { }); test('getQuestion throws a helpful error for missing modules', () => { - expect(() => getQuestion('boolean')).toThrow(/lib\/questions\/\.ts/); + // Synthetic type literal that no module will ever register. Stays + // stable as the seven real types are added in subsequent commits. + expect(() => getQuestion('not_a_real_type' as never)).toThrow(/lib\/questions\/\.ts/); }); test('listQuestionTypes returns the array of registered type literals', () => { diff --git a/src/lib/questions/registry.ts b/src/lib/questions/registry.ts index cbdb5ea..2f8d1f9 100644 --- a/src/lib/questions/registry.ts +++ b/src/lib/questions/registry.ts @@ -11,15 +11,16 @@ */ import type { FeedbackQuestion } from '$lib/schemas'; import type { AnyQuestionTypeModule } from './types'; +import { BooleanQuestion } from './boolean'; -// Per-type modules will be imported here as they land in the next commits. -// QUESTION_MODULES intentionally starts empty — the wiring step (final -// commit of Phase 2) flips the legacy callers over to the registry once all -// seven types are in place. Until then, both code paths coexist: the legacy -// `q.type === '...'` strips in FormBuilder / participant page / Results.svelte -// / results.ts / submit / export keep working, and per-type tests exercise -// the modules' pure logic in isolation. -export const QUESTION_MODULES: readonly AnyQuestionTypeModule[] = []; +// Order matters — drives the FormBuilder "+ Add" picker layout. +// As the remaining six types land in subsequent commits they get appended +// here. The wiring step at the end of Phase 2 flips legacy `q.type === '...'` +// strips in FormBuilder / participant / Results.svelte / results.ts / +// submit / export over to `getQuestion(q.type).method(...)`. +export const QUESTION_MODULES: readonly AnyQuestionTypeModule[] = [ + BooleanQuestion as AnyQuestionTypeModule, +]; /** Look up the module for a question type. Throws on unknown — every type * in `FeedbackQuestion['type']` must have a module registered. */