From 390bd7628786afb95cf05e6d04661b6bbda855cd Mon Sep 17 00:00:00 2001 From: mAi Date: Thu, 7 May 2026 20:06:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(questions):=20registry=20shape=20+=20types?= =?UTF-8?q?=20=E2=80=94=20empty,=20awaiting=20per-type=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes the contract every per-question-type module must satisfy: - lib/questions/types.ts — QuestionTypeModule interface (schema, defaultStub, isAnswerEmpty, emptyStats, ingest, finalise, sanitizeForPublic, csvColumns, csvCellFor, adminCellSummary, ParticipantInput, BuilderEditor, ResultsBlock). Plus the helper aliases QuestionForType and StatsForType (extract the discriminated-union variant for type T) and the shared Svelte component prop shapes. - lib/questions/registry.ts — QUESTION_MODULES (empty for now), getQuestion (throws on unknown), hasQuestion, listQuestionTypes. - lib/questions/registry.test.ts — locks the contract: getQuestion throws with a helpful error pointing at lib/questions/.ts when a module is missing, hasQuestion returns false for nonsense, listQuestionTypes matches the modules array. Registry is intentionally empty in this commit — the legacy `q.type === '...'` strips in FormBuilder, participant page, Results.svelte, results.ts, submit, and export keep working. Per-type modules land in the next commits; the final commit flips callers to use getQuestion(q.type) and the legacy strips disappear. Svelte component slots accept broadly-typed props and narrow internally on question.type. The dispatch is sound at the call site (caller looked up by matching type) but TypeScript can't prove the cross-component relationship without significant generic gymnastics — runtime narrowing inside each component is cheaper and keeps the registry literal simple. 58 server tests + 2 component tests pass. svelte-check clean. --- package.json | 2 +- src/lib/questions/registry.test.ts | 28 +++++++ src/lib/questions/registry.ts | 46 +++++++++++ src/lib/questions/types.ts | 121 +++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 src/lib/questions/registry.test.ts create mode 100644 src/lib/questions/registry.ts create mode 100644 src/lib/questions/types.ts diff --git a/package.json b/package.json index 9b5e319..3a6a414 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", + "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:components": "bun --bun vitest run --config vitest.config.ts", "test": "bun run test:server && bun run test:components" }, diff --git a/src/lib/questions/registry.test.ts b/src/lib/questions/registry.test.ts new file mode 100644 index 0000000..dafdf50 --- /dev/null +++ b/src/lib/questions/registry.test.ts @@ -0,0 +1,28 @@ +import { describe, test, expect } from 'bun:test'; +import { QUESTION_MODULES, getQuestion, hasQuestion, listQuestionTypes } from './registry'; + +// Once per-type modules land, this file's expectations grow to: +// - all 7 types present, in the documented picker order +// - schemas registry assembles a working discriminatedUnion +// - cross-type smoke (round-trip a tiny form through the registry) +// +// For now, the registry is empty by design (legacy paths still own dispatch). +// These cases lock the contract that getQuestion throws on unknown types. + +describe('registry shape', () => { + test('QUESTION_MODULES is a readonly array', () => { + expect(Array.isArray(QUESTION_MODULES)).toBe(true); + }); + + test('hasQuestion returns false for unknown types', () => { + expect(hasQuestion('definitely_not_a_real_type')).toBe(false); + }); + + test('getQuestion throws a helpful error for missing modules', () => { + expect(() => getQuestion('boolean')).toThrow(/lib\/questions\/\.ts/); + }); + + test('listQuestionTypes returns the array of registered type literals', () => { + expect(listQuestionTypes()).toEqual(QUESTION_MODULES.map((m) => m.type)); + }); +}); diff --git a/src/lib/questions/registry.ts b/src/lib/questions/registry.ts new file mode 100644 index 0000000..cbdb5ea --- /dev/null +++ b/src/lib/questions/registry.ts @@ -0,0 +1,46 @@ +/** + * Central question-type registry. + * + * One entry per type. The schemas index, the FormBuilder's "+ Add" picker, + * the participant input dispatcher, the results aggregator, and the CSV + * export all read from here. Adding a new question type = create + * `lib/questions/.ts` + add the import + push into QUESTION_MODULES. + * + * Order in the array matters for the FormBuilder picker — that's the order + * "+ Add" buttons render. + */ +import type { FeedbackQuestion } from '$lib/schemas'; +import type { AnyQuestionTypeModule } from './types'; + +// 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[] = []; + +/** Look up the module for a question type. Throws on unknown — every type + * in `FeedbackQuestion['type']` must have a module registered. */ +export function getQuestion(type: T): AnyQuestionTypeModule { + const mod = QUESTION_MODULES.find((m) => m.type === type); + if (!mod) { + throw new Error( + `Unknown question type: ${type}. Add a module under lib/questions/.ts and register it in lib/questions/registry.ts.`, + ); + } + return mod; +} + +/** Test if a question type has a registered module. Used by the wiring + * step's runtime sanity check. */ +export function hasQuestion(type: string): type is FeedbackQuestion['type'] { + return QUESTION_MODULES.some((m) => m.type === type); +} + +/** Ordered list of registered type literals — drives the FormBuilder's + * "+ Add" picker order. */ +export function listQuestionTypes(): FeedbackQuestion['type'][] { + return QUESTION_MODULES.map((m) => m.type); +} diff --git a/src/lib/questions/types.ts b/src/lib/questions/types.ts new file mode 100644 index 0000000..754da9f --- /dev/null +++ b/src/lib/questions/types.ts @@ -0,0 +1,121 @@ +/** + * Shape of a per-question-type module. One such module lives in + * `lib/questions/.ts` for each kind of question fdbck supports. + * + * The registry (`./registry.ts`) holds the seven concrete modules. Anywhere + * that used to dispatch on `q.type === '...'` now calls + * `getQuestion(q.type).method(q, ...)`. Adding a new type = creating one + * file + one line in the registry array. + * + * The Svelte component slots (`ParticipantInput`, `BuilderEditor`, + * `ResultsBlock`) accept broadly-typed props and narrow internally on + * `question.type`. The dispatch is always sound (callers look up via + * `getQuestion(q.type)` so the module already matches the question), but + * TypeScript can't prove the cross-component relationship without a lot of + * generic gymnastics — the runtime check inside each component is cheaper + * and it makes the registry literal stay simple. + */ +import type { ZodTypeAny } from 'zod'; +import type { Component } from 'svelte'; +import type { FeedbackQuestion } from '$lib/schemas'; +import type { QuestionStats, QuestionResult } from '$lib/server/results'; + +/** Pull the variant of FeedbackQuestion that has the given `type` literal. */ +export type QuestionForType = Extract< + FeedbackQuestion, + { type: T } +>; + +/** Pull the variant of QuestionStats that has the given `type` literal. */ +export type StatsForType = Extract< + QuestionStats, + { type: T } +>; + +export interface CsvColumn { + /** Column header in the exported CSV (e.g. `q1` or `kickoff_when[opt1]`). */ + header: string; + /** Question id this column belongs to. */ + qid: string; + /** Option id, only set for multi-column types like date_ranked_choice. */ + optId?: string; +} + +export interface ParticipantInputProps { + question: FeedbackQuestion; + answer: unknown; + setAnswer(value: unknown): void; +} + +export interface BuilderEditorProps { + question: FeedbackQuestion; + update(patch: Partial): void; +} + +export interface ResultsBlockProps { + question: QuestionResult; + stats: QuestionStats; +} + +export interface QuestionTypeModule { + /** Discriminator literal — matches the question's `type` field. */ + readonly type: T; + + /** Human-readable name for the FormBuilder type picker. */ + readonly label: string; + + /** Zod schema for this question's shape. The schemas registry assembles + * the discriminated union from all modules' schemas. */ + readonly schema: ZodTypeAny; + + /** Build a fresh question of this type for the FormBuilder "+ Add" button. + * The caller will overwrite `id` to a fresh uid. */ + defaultStub(): QuestionForType; + + /** Empty / required-violation answer test. The single source of truth for + * "is this answer missing?" — used by both the client-side validator on + * /f/[slug] AND the server-side gate in /api/.../submit. */ + isAnswerEmpty(question: QuestionForType, answer: unknown): boolean; + + /** Initial aggregator state for this question. */ + emptyStats(question: QuestionForType): StatsForType; + + /** Fold one answer into the aggregator. Mutates `stats` in place. */ + ingest( + stats: StatsForType, + question: QuestionForType, + answer: unknown, + createdAt: string, + ): void; + + /** Aggregator close-out: compute means, sort options, drop accumulators. */ + finalise(stats: StatsForType): void; + + /** Strip PII / contributor-identifying answer text for the public results + * endpoint that anonymous participants see after submitting. */ + sanitizeForPublic(stats: StatsForType): StatsForType; + + /** CSV column expansion. Most types return one column; date_ranked_choice + * returns one column per option. */ + csvColumns(question: QuestionForType): CsvColumn[]; + + /** CSV cell value for a given column (used after `csvColumns` to fill rows). */ + csvCellFor( + question: QuestionForType, + answer: unknown, + col: CsvColumn, + ): string; + + /** One-line cell summary for the admin /[id] submissions table. */ + adminCellSummary(question: QuestionForType, answer: unknown): string; + + /** Svelte components — see ParticipantInputProps / BuilderEditorProps / + * ResultsBlockProps for the shared shapes. */ + ParticipantInput: Component; + BuilderEditor: Component; + ResultsBlock: Component; +} + +/** Erased shape — the registry stores modules at this level since the + * per-type generic only matters at the call site. */ +export type AnyQuestionTypeModule = QuestionTypeModule;