feat(questions): boolean module (registry slot 1/7)
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.
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
20
src/lib/questions/_base.ts
Normal file
20
src/lib/questions/_base.ts
Normal file
@@ -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<typeof FeedbackQuestionBaseSchema>;
|
||||
8
src/lib/questions/boolean.builder.svelte
Normal file
8
src/lib/questions/boolean.builder.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
// boolean has no type-specific fields beyond the base (label / required /
|
||||
// help) — the parent FormBuilder card handles those. This component
|
||||
// renders nothing of its own; it exists so the registry slot is
|
||||
// consistent across all seven types.
|
||||
import type { BuilderEditorProps } from './types';
|
||||
let { question: _q, update: _u }: BuilderEditorProps = $props();
|
||||
</script>
|
||||
26
src/lib/questions/boolean.input.svelte
Normal file
26
src/lib/questions/boolean.input.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { ParticipantInputProps } from './types';
|
||||
|
||||
let { question, answer, setAnswer }: ParticipantInputProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="fb-options">
|
||||
<label class="fb-option-row">
|
||||
<input
|
||||
type="radio"
|
||||
name={`q-${question.id}`}
|
||||
checked={answer === true}
|
||||
onchange={() => setAnswer(true)}
|
||||
/>
|
||||
<span>Ja</span>
|
||||
</label>
|
||||
<label class="fb-option-row">
|
||||
<input
|
||||
type="radio"
|
||||
name={`q-${question.id}`}
|
||||
checked={answer === false}
|
||||
onchange={() => setAnswer(false)}
|
||||
/>
|
||||
<span>Nein</span>
|
||||
</label>
|
||||
</div>
|
||||
29
src/lib/questions/boolean.results.svelte
Normal file
29
src/lib/questions/boolean.results.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { ResultsBlockProps } from './types';
|
||||
|
||||
let { stats }: ResultsBlockProps = $props();
|
||||
|
||||
function pct(part: number, whole: number): number {
|
||||
if (whole === 0) return 0;
|
||||
return Math.round((part / whole) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if stats.type === 'boolean'}
|
||||
<div class="fb-results__bars">
|
||||
<div class="fb-results__row">
|
||||
<span class="fb-results__row-label">Ja</span>
|
||||
<div class="fb-results__bar-track">
|
||||
<div class="fb-results__bar-fill" style="width: {pct(stats.yes, stats.count)}%;"></div>
|
||||
</div>
|
||||
<span class="fb-results__row-count">{stats.yes} ({pct(stats.yes, stats.count)}%)</span>
|
||||
</div>
|
||||
<div class="fb-results__row">
|
||||
<span class="fb-results__row-label">Nein</span>
|
||||
<div class="fb-results__bar-track">
|
||||
<div class="fb-results__bar-fill" style="width: {pct(stats.no, stats.count)}%;"></div>
|
||||
</div>
|
||||
<span class="fb-results__row-count">{stats.no} ({pct(stats.no, stats.count)}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
94
src/lib/questions/boolean.test.ts
Normal file
94
src/lib/questions/boolean.test.ts
Normal file
@@ -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('—');
|
||||
});
|
||||
});
|
||||
69
src/lib/questions/boolean.ts
Normal file
69
src/lib/questions/boolean.ts
Normal file
@@ -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<typeof BooleanQuestionSchema>;
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -19,7 +19,9 @@ describe('registry shape', () => {
|
||||
});
|
||||
|
||||
test('getQuestion throws a helpful error for missing modules', () => {
|
||||
expect(() => getQuestion('boolean')).toThrow(/lib\/questions\/<type>\.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\/<type>\.ts/);
|
||||
});
|
||||
|
||||
test('listQuestionTypes returns the array of registered type literals', () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user