Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: form conditional logic, responses pipeline, PWA cache (#78, #79, #83)' (#139) from feat/form-logic-responses-pwa into main

scott ac6434af 93425b54

+1146
+219
src/forms/conditional-logic.ts
··· 1 + /** 2 + * Conditional Logic — show/hide questions based on answers. 3 + * 4 + * Pure logic module: rule definition, evaluation, question visibility. 5 + * DOM rendering handled by the forms UI layer. 6 + */ 7 + 8 + export type ConditionOperator = 9 + | 'equals' 10 + | 'not_equals' 11 + | 'contains' 12 + | 'not_contains' 13 + | 'greater_than' 14 + | 'less_than' 15 + | 'is_empty' 16 + | 'is_not_empty'; 17 + 18 + export interface Condition { 19 + /** Question ID to check */ 20 + sourceQuestionId: string; 21 + operator: ConditionOperator; 22 + /** Value to compare against (unused for is_empty/is_not_empty) */ 23 + value: string; 24 + } 25 + 26 + export type LogicAction = 'show' | 'hide' | 'skip_to'; 27 + 28 + export interface ConditionalRule { 29 + id: string; 30 + /** Question this rule applies to */ 31 + targetQuestionId: string; 32 + action: LogicAction; 33 + /** All conditions must be true (AND logic) */ 34 + conditions: Condition[]; 35 + /** For skip_to: the question to jump to */ 36 + skipToQuestionId?: string; 37 + } 38 + 39 + export interface ConditionalLogicState { 40 + rules: ConditionalRule[]; 41 + } 42 + 43 + let _ruleCounter = 0; 44 + 45 + /** 46 + * Create empty conditional logic state. 47 + */ 48 + export function createConditionalState(): ConditionalLogicState { 49 + return { rules: [] }; 50 + } 51 + 52 + /** 53 + * Add a conditional rule. 54 + */ 55 + export function addRule( 56 + state: ConditionalLogicState, 57 + targetQuestionId: string, 58 + action: LogicAction, 59 + conditions: Condition[], 60 + skipToQuestionId?: string, 61 + ): ConditionalLogicState { 62 + const rule: ConditionalRule = { 63 + id: `rule-${Date.now()}-${++_ruleCounter}`, 64 + targetQuestionId, 65 + action, 66 + conditions, 67 + skipToQuestionId, 68 + }; 69 + return { rules: [...state.rules, rule] }; 70 + } 71 + 72 + /** 73 + * Remove a rule by ID. 74 + */ 75 + export function removeRule( 76 + state: ConditionalLogicState, 77 + ruleId: string, 78 + ): ConditionalLogicState { 79 + return { rules: state.rules.filter(r => r.id !== ruleId) }; 80 + } 81 + 82 + /** 83 + * Evaluate a single condition against current answers. 84 + */ 85 + export function evaluateCondition( 86 + condition: Condition, 87 + answers: Map<string, unknown>, 88 + ): boolean { 89 + const answer = answers.get(condition.sourceQuestionId); 90 + const answerStr = answer !== null && answer !== undefined ? String(answer) : ''; 91 + 92 + switch (condition.operator) { 93 + case 'is_empty': 94 + return answerStr === ''; 95 + case 'is_not_empty': 96 + return answerStr !== ''; 97 + case 'equals': 98 + return answerStr.toLowerCase() === condition.value.toLowerCase(); 99 + case 'not_equals': 100 + return answerStr.toLowerCase() !== condition.value.toLowerCase(); 101 + case 'contains': 102 + return answerStr.toLowerCase().includes(condition.value.toLowerCase()); 103 + case 'not_contains': 104 + return !answerStr.toLowerCase().includes(condition.value.toLowerCase()); 105 + case 'greater_than': { 106 + const num = Number(answerStr); 107 + const target = Number(condition.value); 108 + return !isNaN(num) && !isNaN(target) && num > target; 109 + } 110 + case 'less_than': { 111 + const num = Number(answerStr); 112 + const target = Number(condition.value); 113 + return !isNaN(num) && !isNaN(target) && num < target; 114 + } 115 + default: 116 + return false; 117 + } 118 + } 119 + 120 + /** 121 + * Evaluate all conditions in a rule (AND logic). 122 + */ 123 + export function evaluateRule( 124 + rule: ConditionalRule, 125 + answers: Map<string, unknown>, 126 + ): boolean { 127 + if (rule.conditions.length === 0) return false; 128 + return rule.conditions.every(c => evaluateCondition(c, answers)); 129 + } 130 + 131 + /** 132 + * Determine which questions are visible given current answers. 133 + */ 134 + export function getVisibleQuestions( 135 + allQuestionIds: string[], 136 + state: ConditionalLogicState, 137 + answers: Map<string, unknown>, 138 + ): string[] { 139 + // Start with all visible 140 + const hidden = new Set<string>(); 141 + 142 + for (const rule of state.rules) { 143 + const triggered = evaluateRule(rule, answers); 144 + 145 + if (rule.action === 'show') { 146 + // Show action: question hidden by default, shown if triggered 147 + if (!triggered) { 148 + hidden.add(rule.targetQuestionId); 149 + } 150 + } else if (rule.action === 'hide') { 151 + // Hide action: question hidden if triggered 152 + if (triggered) { 153 + hidden.add(rule.targetQuestionId); 154 + } 155 + } 156 + } 157 + 158 + return allQuestionIds.filter(id => !hidden.has(id)); 159 + } 160 + 161 + /** 162 + * Get the skip-to target for the current state. 163 + * Returns the first active skip_to rule's target, or null. 164 + */ 165 + export function getSkipTarget( 166 + state: ConditionalLogicState, 167 + answers: Map<string, unknown>, 168 + ): string | null { 169 + for (const rule of state.rules) { 170 + if (rule.action === 'skip_to' && rule.skipToQuestionId && evaluateRule(rule, answers)) { 171 + return rule.skipToQuestionId; 172 + } 173 + } 174 + return null; 175 + } 176 + 177 + /** 178 + * Get rules that apply to a specific question. 179 + */ 180 + export function rulesForQuestion( 181 + state: ConditionalLogicState, 182 + questionId: string, 183 + ): ConditionalRule[] { 184 + return state.rules.filter(r => r.targetQuestionId === questionId); 185 + } 186 + 187 + /** 188 + * Get rules that depend on a specific source question. 189 + */ 190 + export function rulesDependingOn( 191 + state: ConditionalLogicState, 192 + sourceQuestionId: string, 193 + ): ConditionalRule[] { 194 + return state.rules.filter(r => 195 + r.conditions.some(c => c.sourceQuestionId === sourceQuestionId), 196 + ); 197 + } 198 + 199 + /** 200 + * Remove all rules targeting a deleted question. 201 + */ 202 + export function removeRulesForQuestion( 203 + state: ConditionalLogicState, 204 + questionId: string, 205 + ): ConditionalLogicState { 206 + return { 207 + rules: state.rules.filter( 208 + r => r.targetQuestionId !== questionId && 209 + !r.conditions.some(c => c.sourceQuestionId === questionId), 210 + ), 211 + }; 212 + } 213 + 214 + /** 215 + * Total rule count. 216 + */ 217 + export function ruleCount(state: ConditionalLogicState): number { 218 + return state.rules.length; 219 + }
+200
src/forms/responses.ts
··· 1 + /** 2 + * Form Responses Pipeline — collect and transform form submissions to sheet rows. 3 + * 4 + * Pure logic module: response storage, sheet mapping, aggregation. 5 + * Network and Yjs sync handled by the provider layer. 6 + */ 7 + 8 + export interface FormResponse { 9 + id: string; 10 + formId: string; 11 + answers: Map<string, unknown>; 12 + submittedAt: number; 13 + /** IP or user identifier (optional, for dedup) */ 14 + submitterId: string; 15 + } 16 + 17 + export interface ColumnMapping { 18 + questionId: string; 19 + columnIndex: number; 20 + columnName: string; 21 + } 22 + 23 + export interface ResponsePipelineConfig { 24 + formId: string; 25 + targetSheetId: string; 26 + /** Map question IDs to sheet columns */ 27 + mappings: ColumnMapping[]; 28 + /** Auto-add timestamp column */ 29 + includeTimestamp: boolean; 30 + /** Column index for timestamp */ 31 + timestampColumn: number; 32 + } 33 + 34 + let _responseCounter = 0; 35 + 36 + /** 37 + * Create a response from form answers. 38 + */ 39 + export function createResponse( 40 + formId: string, 41 + answers: Map<string, unknown>, 42 + submitterId = '', 43 + ): FormResponse { 44 + return { 45 + id: `resp-${Date.now()}-${++_responseCounter}`, 46 + formId, 47 + answers, 48 + submittedAt: Date.now(), 49 + submitterId, 50 + }; 51 + } 52 + 53 + /** 54 + * Create a pipeline config with auto-mapped columns. 55 + */ 56 + export function createPipelineConfig( 57 + formId: string, 58 + targetSheetId: string, 59 + questionIds: string[], 60 + questionLabels: string[], 61 + includeTimestamp = true, 62 + ): ResponsePipelineConfig { 63 + const mappings: ColumnMapping[] = questionIds.map((qId, i) => ({ 64 + questionId: qId, 65 + columnIndex: i, 66 + columnName: questionLabels[i] ?? `Column ${i + 1}`, 67 + })); 68 + 69 + return { 70 + formId, 71 + targetSheetId, 72 + mappings, 73 + includeTimestamp, 74 + timestampColumn: questionIds.length, 75 + }; 76 + } 77 + 78 + /** 79 + * Convert a form response to a sheet row (array of cell values). 80 + */ 81 + export function responseToRow( 82 + response: FormResponse, 83 + config: ResponsePipelineConfig, 84 + ): unknown[] { 85 + const row: unknown[] = new Array(config.mappings.length + (config.includeTimestamp ? 1 : 0)).fill(''); 86 + 87 + for (const mapping of config.mappings) { 88 + const value = response.answers.get(mapping.questionId); 89 + row[mapping.columnIndex] = value ?? ''; 90 + } 91 + 92 + if (config.includeTimestamp) { 93 + row[config.timestampColumn] = new Date(response.submittedAt).toISOString(); 94 + } 95 + 96 + return row; 97 + } 98 + 99 + /** 100 + * Convert multiple responses to rows. 101 + */ 102 + export function responsesToRows( 103 + responses: FormResponse[], 104 + config: ResponsePipelineConfig, 105 + ): unknown[][] { 106 + return responses.map(r => responseToRow(r, config)); 107 + } 108 + 109 + /** 110 + * Generate column headers from pipeline config. 111 + */ 112 + export function pipelineHeaders(config: ResponsePipelineConfig): string[] { 113 + const headers = config.mappings.map(m => m.columnName); 114 + if (config.includeTimestamp) { 115 + headers.push('Submitted At'); 116 + } 117 + return headers; 118 + } 119 + 120 + /** 121 + * Count responses per form. 122 + */ 123 + export function responseCount(responses: FormResponse[], formId: string): number { 124 + return responses.filter(r => r.formId === formId).length; 125 + } 126 + 127 + /** 128 + * Get responses sorted by submission time (newest first). 129 + */ 130 + export function sortByNewest(responses: FormResponse[]): FormResponse[] { 131 + return [...responses].sort((a, b) => b.submittedAt - a.submittedAt); 132 + } 133 + 134 + /** 135 + * Get responses sorted by submission time (oldest first). 136 + */ 137 + export function sortByOldest(responses: FormResponse[]): FormResponse[] { 138 + return [...responses].sort((a, b) => a.submittedAt - b.submittedAt); 139 + } 140 + 141 + /** 142 + * Filter responses by time range. 143 + */ 144 + export function filterByTimeRange( 145 + responses: FormResponse[], 146 + start: number, 147 + end: number, 148 + ): FormResponse[] { 149 + return responses.filter(r => r.submittedAt >= start && r.submittedAt <= end); 150 + } 151 + 152 + /** 153 + * Aggregate answers for a single question across responses. 154 + */ 155 + export function aggregateAnswers( 156 + responses: FormResponse[], 157 + questionId: string, 158 + ): Map<string, number> { 159 + const counts = new Map<string, number>(); 160 + for (const r of responses) { 161 + const val = String(r.answers.get(questionId) ?? ''); 162 + if (val) { 163 + counts.set(val, (counts.get(val) || 0) + 1); 164 + } 165 + } 166 + return counts; 167 + } 168 + 169 + /** 170 + * Check for duplicate submissions (same submitter within time window). 171 + */ 172 + export function isDuplicate( 173 + response: FormResponse, 174 + existing: FormResponse[], 175 + windowMs = 60000, 176 + ): boolean { 177 + if (!response.submitterId) return false; 178 + return existing.some( 179 + r => 180 + r.submitterId === response.submitterId && 181 + r.formId === response.formId && 182 + Math.abs(r.submittedAt - response.submittedAt) < windowMs, 183 + ); 184 + } 185 + 186 + /** 187 + * Remove duplicate responses. 188 + */ 189 + export function deduplicate( 190 + responses: FormResponse[], 191 + windowMs = 60000, 192 + ): FormResponse[] { 193 + const result: FormResponse[] = []; 194 + for (const r of responses) { 195 + if (!isDuplicate(r, result, windowMs)) { 196 + result.push(r); 197 + } 198 + } 199 + return result; 200 + }
+191
src/lib/pwa-cache.ts
··· 1 + /** 2 + * PWA Cache Strategy — service worker cache management. 3 + * 4 + * Pure logic module: cache strategy selection, URL matching, versioning. 5 + * Actual Service Worker registration and fetch interception handled separately. 6 + */ 7 + 8 + export type CacheStrategy = 'cache_first' | 'network_first' | 'stale_while_revalidate' | 'network_only' | 'cache_only'; 9 + 10 + export interface CacheRule { 11 + /** URL pattern (glob-like) */ 12 + pattern: string; 13 + strategy: CacheStrategy; 14 + /** Max age in seconds (for stale checks) */ 15 + maxAge: number; 16 + /** Cache name for grouping */ 17 + cacheName: string; 18 + } 19 + 20 + export interface PWACacheConfig { 21 + version: string; 22 + rules: CacheRule[]; 23 + /** URLs to precache on install */ 24 + precacheUrls: string[]; 25 + /** Max total cache size in bytes (0 = unlimited) */ 26 + maxCacheSize: number; 27 + } 28 + 29 + /** 30 + * Create default PWA cache config. 31 + */ 32 + export function createCacheConfig(version: string): PWACacheConfig { 33 + return { 34 + version, 35 + rules: [ 36 + { pattern: '*.html', strategy: 'network_first', maxAge: 3600, cacheName: 'pages' }, 37 + { pattern: '*.js', strategy: 'stale_while_revalidate', maxAge: 86400, cacheName: 'scripts' }, 38 + { pattern: '*.css', strategy: 'stale_while_revalidate', maxAge: 86400, cacheName: 'styles' }, 39 + { pattern: '*.{png,jpg,svg,ico}', strategy: 'cache_first', maxAge: 604800, cacheName: 'images' }, 40 + { pattern: '*.{woff,woff2}', strategy: 'cache_first', maxAge: 2592000, cacheName: 'fonts' }, 41 + { pattern: '/api/*', strategy: 'network_only', maxAge: 0, cacheName: 'api' }, 42 + ], 43 + precacheUrls: [], 44 + maxCacheSize: 50 * 1024 * 1024, // 50MB 45 + }; 46 + } 47 + 48 + /** 49 + * Match a URL against a glob-like pattern. 50 + */ 51 + export function matchPattern(url: string, pattern: string): boolean { 52 + // Convert glob to regex: handle braces first, then escape, then wildcards 53 + let transformed = pattern 54 + .replace(/\{([^}]+)\}/g, (_, alts: string) => `__ALTS__${alts}__ALTE__`); 55 + transformed = transformed 56 + .replace(/[.+^$()|[\]\\]/g, '\\$&') 57 + .replace(/\*/g, '.*') 58 + .replace(/__ALTS__([^_]+)__ALTE__/g, (_, alts: string) => 59 + `(?:${alts.split(',').map(a => a.replace(/[.+^$()|[\]\\]/g, '\\$&')).join('|')})`, 60 + ); 61 + const escaped = transformed; 62 + const regex = new RegExp(`^${escaped}$`, 'i'); 63 + 64 + // Match against pathname 65 + try { 66 + const pathname = new URL(url, 'http://localhost').pathname; 67 + return regex.test(pathname) || regex.test(url); 68 + } catch { 69 + return regex.test(url); 70 + } 71 + } 72 + 73 + /** 74 + * Find the matching cache rule for a URL. 75 + */ 76 + export function findMatchingRule( 77 + config: PWACacheConfig, 78 + url: string, 79 + ): CacheRule | null { 80 + for (const rule of config.rules) { 81 + if (matchPattern(url, rule.pattern)) { 82 + return rule; 83 + } 84 + } 85 + return null; 86 + } 87 + 88 + /** 89 + * Determine the cache strategy for a URL. 90 + */ 91 + export function strategyForUrl( 92 + config: PWACacheConfig, 93 + url: string, 94 + ): CacheStrategy { 95 + const rule = findMatchingRule(config, url); 96 + return rule?.strategy ?? 'network_first'; 97 + } 98 + 99 + /** 100 + * Check if a cached entry is stale. 101 + */ 102 + export function isStale( 103 + cachedAt: number, 104 + maxAge: number, 105 + now = Date.now(), 106 + ): boolean { 107 + if (maxAge <= 0) return true; 108 + return (now - cachedAt) / 1000 > maxAge; 109 + } 110 + 111 + /** 112 + * Add a URL to the precache list. 113 + */ 114 + export function addPrecacheUrl( 115 + config: PWACacheConfig, 116 + url: string, 117 + ): PWACacheConfig { 118 + if (config.precacheUrls.includes(url)) return config; 119 + return { ...config, precacheUrls: [...config.precacheUrls, url] }; 120 + } 121 + 122 + /** 123 + * Remove a URL from the precache list. 124 + */ 125 + export function removePrecacheUrl( 126 + config: PWACacheConfig, 127 + url: string, 128 + ): PWACacheConfig { 129 + return { ...config, precacheUrls: config.precacheUrls.filter(u => u !== url) }; 130 + } 131 + 132 + /** 133 + * Add a custom cache rule. 134 + */ 135 + export function addCacheRule( 136 + config: PWACacheConfig, 137 + rule: CacheRule, 138 + ): PWACacheConfig { 139 + return { ...config, rules: [...config.rules, rule] }; 140 + } 141 + 142 + /** 143 + * Get cache name for a URL. 144 + */ 145 + export function cacheNameForUrl( 146 + config: PWACacheConfig, 147 + url: string, 148 + ): string { 149 + const rule = findMatchingRule(config, url); 150 + return rule?.cacheName ?? 'default'; 151 + } 152 + 153 + /** 154 + * Get all unique cache names in the config. 155 + */ 156 + export function allCacheNames(config: PWACacheConfig): string[] { 157 + const names = new Set(config.rules.map(r => r.cacheName)); 158 + return [...names]; 159 + } 160 + 161 + /** 162 + * Generate versioned cache name. 163 + */ 164 + export function versionedCacheName(baseName: string, version: string): string { 165 + return `${baseName}-v${version}`; 166 + } 167 + 168 + /** 169 + * Determine which old caches should be cleaned up. 170 + */ 171 + export function staleCacheNames( 172 + existingCaches: string[], 173 + currentVersion: string, 174 + config: PWACacheConfig, 175 + ): string[] { 176 + const currentNames = new Set( 177 + allCacheNames(config).map(n => versionedCacheName(n, currentVersion)), 178 + ); 179 + return existingCaches.filter(name => !currentNames.has(name)); 180 + } 181 + 182 + /** 183 + * Check if a URL should bypass the cache entirely. 184 + */ 185 + export function shouldBypassCache( 186 + config: PWACacheConfig, 187 + url: string, 188 + ): boolean { 189 + const rule = findMatchingRule(config, url); 190 + return rule?.strategy === 'network_only'; 191 + }
+195
tests/conditional-logic.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createConditionalState, 4 + addRule, 5 + removeRule, 6 + evaluateCondition, 7 + evaluateRule, 8 + getVisibleQuestions, 9 + getSkipTarget, 10 + rulesForQuestion, 11 + rulesDependingOn, 12 + removeRulesForQuestion, 13 + ruleCount, 14 + type Condition, 15 + } from '../src/forms/conditional-logic.js'; 16 + 17 + const answers = new Map<string, unknown>([ 18 + ['q1', 'Yes'], 19 + ['q2', '25'], 20 + ['q3', ''], 21 + ['q4', 'Hello World'], 22 + ]); 23 + 24 + describe('evaluateCondition', () => { 25 + it('equals (case insensitive)', () => { 26 + expect(evaluateCondition({ sourceQuestionId: 'q1', operator: 'equals', value: 'yes' }, answers)).toBe(true); 27 + expect(evaluateCondition({ sourceQuestionId: 'q1', operator: 'equals', value: 'no' }, answers)).toBe(false); 28 + }); 29 + 30 + it('not_equals', () => { 31 + expect(evaluateCondition({ sourceQuestionId: 'q1', operator: 'not_equals', value: 'no' }, answers)).toBe(true); 32 + }); 33 + 34 + it('contains', () => { 35 + expect(evaluateCondition({ sourceQuestionId: 'q4', operator: 'contains', value: 'hello' }, answers)).toBe(true); 36 + expect(evaluateCondition({ sourceQuestionId: 'q4', operator: 'contains', value: 'xyz' }, answers)).toBe(false); 37 + }); 38 + 39 + it('not_contains', () => { 40 + expect(evaluateCondition({ sourceQuestionId: 'q4', operator: 'not_contains', value: 'xyz' }, answers)).toBe(true); 41 + }); 42 + 43 + it('greater_than', () => { 44 + expect(evaluateCondition({ sourceQuestionId: 'q2', operator: 'greater_than', value: '20' }, answers)).toBe(true); 45 + expect(evaluateCondition({ sourceQuestionId: 'q2', operator: 'greater_than', value: '30' }, answers)).toBe(false); 46 + }); 47 + 48 + it('less_than', () => { 49 + expect(evaluateCondition({ sourceQuestionId: 'q2', operator: 'less_than', value: '30' }, answers)).toBe(true); 50 + }); 51 + 52 + it('is_empty', () => { 53 + expect(evaluateCondition({ sourceQuestionId: 'q3', operator: 'is_empty', value: '' }, answers)).toBe(true); 54 + expect(evaluateCondition({ sourceQuestionId: 'q1', operator: 'is_empty', value: '' }, answers)).toBe(false); 55 + }); 56 + 57 + it('is_not_empty', () => { 58 + expect(evaluateCondition({ sourceQuestionId: 'q1', operator: 'is_not_empty', value: '' }, answers)).toBe(true); 59 + expect(evaluateCondition({ sourceQuestionId: 'q3', operator: 'is_not_empty', value: '' }, answers)).toBe(false); 60 + }); 61 + 62 + it('handles missing answer', () => { 63 + expect(evaluateCondition({ sourceQuestionId: 'missing', operator: 'is_empty', value: '' }, answers)).toBe(true); 64 + }); 65 + }); 66 + 67 + describe('evaluateRule', () => { 68 + it('returns true when all conditions met (AND)', () => { 69 + const conditions: Condition[] = [ 70 + { sourceQuestionId: 'q1', operator: 'equals', value: 'Yes' }, 71 + { sourceQuestionId: 'q2', operator: 'greater_than', value: '20' }, 72 + ]; 73 + let state = createConditionalState(); 74 + state = addRule(state, 'q5', 'show', conditions); 75 + expect(evaluateRule(state.rules[0], answers)).toBe(true); 76 + }); 77 + 78 + it('returns false when any condition fails', () => { 79 + const conditions: Condition[] = [ 80 + { sourceQuestionId: 'q1', operator: 'equals', value: 'Yes' }, 81 + { sourceQuestionId: 'q2', operator: 'greater_than', value: '100' }, 82 + ]; 83 + let state = createConditionalState(); 84 + state = addRule(state, 'q5', 'show', conditions); 85 + expect(evaluateRule(state.rules[0], answers)).toBe(false); 86 + }); 87 + 88 + it('returns false for empty conditions', () => { 89 + let state = createConditionalState(); 90 + state = addRule(state, 'q5', 'show', []); 91 + expect(evaluateRule(state.rules[0], answers)).toBe(false); 92 + }); 93 + }); 94 + 95 + describe('getVisibleQuestions', () => { 96 + const allIds = ['q1', 'q2', 'q3', 'q4', 'q5']; 97 + 98 + it('hides question when hide rule triggered', () => { 99 + let state = createConditionalState(); 100 + state = addRule(state, 'q5', 'hide', [ 101 + { sourceQuestionId: 'q1', operator: 'equals', value: 'Yes' }, 102 + ]); 103 + const visible = getVisibleQuestions(allIds, state, answers); 104 + expect(visible).not.toContain('q5'); 105 + }); 106 + 107 + it('shows question when show rule triggered', () => { 108 + let state = createConditionalState(); 109 + state = addRule(state, 'q5', 'show', [ 110 + { sourceQuestionId: 'q1', operator: 'equals', value: 'Yes' }, 111 + ]); 112 + const visible = getVisibleQuestions(allIds, state, answers); 113 + expect(visible).toContain('q5'); 114 + }); 115 + 116 + it('hides question when show rule not triggered', () => { 117 + let state = createConditionalState(); 118 + state = addRule(state, 'q5', 'show', [ 119 + { sourceQuestionId: 'q1', operator: 'equals', value: 'No' }, 120 + ]); 121 + const visible = getVisibleQuestions(allIds, state, answers); 122 + expect(visible).not.toContain('q5'); 123 + }); 124 + 125 + it('shows all when no rules', () => { 126 + const state = createConditionalState(); 127 + const visible = getVisibleQuestions(allIds, state, answers); 128 + expect(visible).toEqual(allIds); 129 + }); 130 + }); 131 + 132 + describe('getSkipTarget', () => { 133 + it('returns skip target when rule triggered', () => { 134 + let state = createConditionalState(); 135 + state = addRule(state, 'q3', 'skip_to', [ 136 + { sourceQuestionId: 'q1', operator: 'equals', value: 'Yes' }, 137 + ], 'q5'); 138 + expect(getSkipTarget(state, answers)).toBe('q5'); 139 + }); 140 + 141 + it('returns null when no skip rules triggered', () => { 142 + let state = createConditionalState(); 143 + state = addRule(state, 'q3', 'skip_to', [ 144 + { sourceQuestionId: 'q1', operator: 'equals', value: 'No' }, 145 + ], 'q5'); 146 + expect(getSkipTarget(state, answers)).toBeNull(); 147 + }); 148 + }); 149 + 150 + describe('addRule / removeRule', () => { 151 + it('adds and removes rules', () => { 152 + let state = createConditionalState(); 153 + state = addRule(state, 'q5', 'show', []); 154 + expect(ruleCount(state)).toBe(1); 155 + state = removeRule(state, state.rules[0].id); 156 + expect(ruleCount(state)).toBe(0); 157 + }); 158 + }); 159 + 160 + describe('rulesForQuestion', () => { 161 + it('returns rules targeting a question', () => { 162 + let state = createConditionalState(); 163 + state = addRule(state, 'q5', 'show', []); 164 + state = addRule(state, 'q3', 'hide', []); 165 + state = addRule(state, 'q5', 'hide', []); 166 + expect(rulesForQuestion(state, 'q5')).toHaveLength(2); 167 + }); 168 + }); 169 + 170 + describe('rulesDependingOn', () => { 171 + it('returns rules that depend on a source question', () => { 172 + let state = createConditionalState(); 173 + state = addRule(state, 'q5', 'show', [ 174 + { sourceQuestionId: 'q1', operator: 'equals', value: 'Yes' }, 175 + ]); 176 + state = addRule(state, 'q3', 'hide', [ 177 + { sourceQuestionId: 'q2', operator: 'greater_than', value: '10' }, 178 + ]); 179 + expect(rulesDependingOn(state, 'q1')).toHaveLength(1); 180 + }); 181 + }); 182 + 183 + describe('removeRulesForQuestion', () => { 184 + it('removes rules targeting or depending on a question', () => { 185 + let state = createConditionalState(); 186 + state = addRule(state, 'q5', 'show', [ 187 + { sourceQuestionId: 'q1', operator: 'equals', value: 'Yes' }, 188 + ]); 189 + state = addRule(state, 'q3', 'hide', [ 190 + { sourceQuestionId: 'q2', operator: 'equals', value: '25' }, 191 + ]); 192 + state = removeRulesForQuestion(state, 'q1'); 193 + expect(ruleCount(state)).toBe(1); 194 + }); 195 + });
+175
tests/pwa-cache.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createCacheConfig, 4 + matchPattern, 5 + findMatchingRule, 6 + strategyForUrl, 7 + isStale, 8 + addPrecacheUrl, 9 + removePrecacheUrl, 10 + addCacheRule, 11 + cacheNameForUrl, 12 + allCacheNames, 13 + versionedCacheName, 14 + staleCacheNames, 15 + shouldBypassCache, 16 + } from '../src/lib/pwa-cache.js'; 17 + 18 + describe('createCacheConfig', () => { 19 + it('creates config with default rules', () => { 20 + const config = createCacheConfig('1.0.0'); 21 + expect(config.version).toBe('1.0.0'); 22 + expect(config.rules.length).toBeGreaterThan(0); 23 + expect(config.precacheUrls).toEqual([]); 24 + expect(config.maxCacheSize).toBe(50 * 1024 * 1024); 25 + }); 26 + }); 27 + 28 + describe('matchPattern', () => { 29 + it('matches wildcard extension', () => { 30 + expect(matchPattern('/app.js', '*.js')).toBe(true); 31 + expect(matchPattern('/app.css', '*.js')).toBe(false); 32 + }); 33 + 34 + it('matches path prefix wildcard', () => { 35 + expect(matchPattern('/api/users', '/api/*')).toBe(true); 36 + expect(matchPattern('/app/home', '/api/*')).toBe(false); 37 + }); 38 + 39 + it('matches multi-extension pattern', () => { 40 + expect(matchPattern('/logo.png', '*.{png,jpg,svg,ico}')).toBe(true); 41 + expect(matchPattern('/logo.svg', '*.{png,jpg,svg,ico}')).toBe(true); 42 + expect(matchPattern('/logo.gif', '*.{png,jpg,svg,ico}')).toBe(false); 43 + }); 44 + 45 + it('matches HTML', () => { 46 + expect(matchPattern('/index.html', '*.html')).toBe(true); 47 + }); 48 + }); 49 + 50 + describe('findMatchingRule', () => { 51 + it('finds matching rule', () => { 52 + const config = createCacheConfig('1.0'); 53 + const rule = findMatchingRule(config, '/app.js'); 54 + expect(rule).not.toBeNull(); 55 + expect(rule!.strategy).toBe('stale_while_revalidate'); 56 + }); 57 + 58 + it('returns null for unmatched URL', () => { 59 + const config = createCacheConfig('1.0'); 60 + expect(findMatchingRule(config, '/data.json')).toBeNull(); 61 + }); 62 + }); 63 + 64 + describe('strategyForUrl', () => { 65 + it('returns strategy for matched URL', () => { 66 + const config = createCacheConfig('1.0'); 67 + expect(strategyForUrl(config, '/app.js')).toBe('stale_while_revalidate'); 68 + expect(strategyForUrl(config, '/index.html')).toBe('network_first'); 69 + expect(strategyForUrl(config, '/api/data')).toBe('network_only'); 70 + }); 71 + 72 + it('defaults to network_first for unknown URL', () => { 73 + const config = createCacheConfig('1.0'); 74 + expect(strategyForUrl(config, '/unknown.xyz')).toBe('network_first'); 75 + }); 76 + }); 77 + 78 + describe('isStale', () => { 79 + it('returns false for fresh cache', () => { 80 + const now = Date.now(); 81 + expect(isStale(now - 1000, 3600, now)).toBe(false); 82 + }); 83 + 84 + it('returns true for expired cache', () => { 85 + const now = Date.now(); 86 + expect(isStale(now - 7200_000, 3600, now)).toBe(true); 87 + }); 88 + 89 + it('returns true for zero maxAge', () => { 90 + expect(isStale(Date.now(), 0)).toBe(true); 91 + }); 92 + }); 93 + 94 + describe('addPrecacheUrl / removePrecacheUrl', () => { 95 + it('adds URL to precache', () => { 96 + let config = createCacheConfig('1.0'); 97 + config = addPrecacheUrl(config, '/index.html'); 98 + expect(config.precacheUrls).toContain('/index.html'); 99 + }); 100 + 101 + it('does not duplicate', () => { 102 + let config = createCacheConfig('1.0'); 103 + config = addPrecacheUrl(config, '/index.html'); 104 + config = addPrecacheUrl(config, '/index.html'); 105 + expect(config.precacheUrls).toHaveLength(1); 106 + }); 107 + 108 + it('removes URL', () => { 109 + let config = createCacheConfig('1.0'); 110 + config = addPrecacheUrl(config, '/index.html'); 111 + config = removePrecacheUrl(config, '/index.html'); 112 + expect(config.precacheUrls).toHaveLength(0); 113 + }); 114 + }); 115 + 116 + describe('addCacheRule', () => { 117 + it('adds custom rule', () => { 118 + let config = createCacheConfig('1.0'); 119 + const rulesBefore = config.rules.length; 120 + config = addCacheRule(config, { pattern: '*.json', strategy: 'network_first', maxAge: 300, cacheName: 'data' }); 121 + expect(config.rules.length).toBe(rulesBefore + 1); 122 + }); 123 + }); 124 + 125 + describe('cacheNameForUrl', () => { 126 + it('returns cache name for matched URL', () => { 127 + const config = createCacheConfig('1.0'); 128 + expect(cacheNameForUrl(config, '/app.js')).toBe('scripts'); 129 + expect(cacheNameForUrl(config, '/style.css')).toBe('styles'); 130 + }); 131 + 132 + it('returns default for unmatched', () => { 133 + const config = createCacheConfig('1.0'); 134 + expect(cacheNameForUrl(config, '/unknown')).toBe('default'); 135 + }); 136 + }); 137 + 138 + describe('allCacheNames', () => { 139 + it('returns unique cache names', () => { 140 + const config = createCacheConfig('1.0'); 141 + const names = allCacheNames(config); 142 + expect(names).toContain('pages'); 143 + expect(names).toContain('scripts'); 144 + expect(new Set(names).size).toBe(names.length); 145 + }); 146 + }); 147 + 148 + describe('versionedCacheName', () => { 149 + it('appends version', () => { 150 + expect(versionedCacheName('scripts', '2.0')).toBe('scripts-v2.0'); 151 + }); 152 + }); 153 + 154 + describe('staleCacheNames', () => { 155 + it('identifies old caches', () => { 156 + const config = createCacheConfig('2.0'); 157 + const existing = ['pages-v1.0', 'scripts-v1.0', 'pages-v2.0', 'scripts-v2.0']; 158 + const stale = staleCacheNames(existing, '2.0', config); 159 + expect(stale).toContain('pages-v1.0'); 160 + expect(stale).toContain('scripts-v1.0'); 161 + expect(stale).not.toContain('pages-v2.0'); 162 + }); 163 + }); 164 + 165 + describe('shouldBypassCache', () => { 166 + it('bypasses API calls', () => { 167 + const config = createCacheConfig('1.0'); 168 + expect(shouldBypassCache(config, '/api/data')).toBe(true); 169 + }); 170 + 171 + it('does not bypass static assets', () => { 172 + const config = createCacheConfig('1.0'); 173 + expect(shouldBypassCache(config, '/app.js')).toBe(false); 174 + }); 175 + });
+166
tests/responses.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createResponse, 4 + createPipelineConfig, 5 + responseToRow, 6 + responsesToRows, 7 + pipelineHeaders, 8 + responseCount, 9 + sortByNewest, 10 + sortByOldest, 11 + filterByTimeRange, 12 + aggregateAnswers, 13 + isDuplicate, 14 + deduplicate, 15 + } from '../src/forms/responses.js'; 16 + 17 + const answers1 = new Map<string, unknown>([['q1', 'Alice'], ['q2', 'alice@test.com']]); 18 + const answers2 = new Map<string, unknown>([['q1', 'Bob'], ['q2', 'bob@test.com']]); 19 + 20 + describe('createResponse', () => { 21 + it('creates response with unique ID', () => { 22 + const r1 = createResponse('f1', answers1); 23 + const r2 = createResponse('f1', answers2); 24 + expect(r1.id).not.toBe(r2.id); 25 + expect(r1.formId).toBe('f1'); 26 + expect(r1.answers).toBe(answers1); 27 + }); 28 + }); 29 + 30 + describe('createPipelineConfig', () => { 31 + it('creates config with auto-mapped columns', () => { 32 + const config = createPipelineConfig('f1', 's1', ['q1', 'q2'], ['Name', 'Email']); 33 + expect(config.mappings).toHaveLength(2); 34 + expect(config.mappings[0].columnIndex).toBe(0); 35 + expect(config.mappings[0].columnName).toBe('Name'); 36 + expect(config.includeTimestamp).toBe(true); 37 + expect(config.timestampColumn).toBe(2); 38 + }); 39 + }); 40 + 41 + describe('responseToRow', () => { 42 + it('converts response to row array', () => { 43 + const config = createPipelineConfig('f1', 's1', ['q1', 'q2'], ['Name', 'Email']); 44 + const response = createResponse('f1', answers1); 45 + const row = responseToRow(response, config); 46 + expect(row[0]).toBe('Alice'); 47 + expect(row[1]).toBe('alice@test.com'); 48 + expect(typeof row[2]).toBe('string'); // ISO timestamp 49 + }); 50 + 51 + it('handles missing answers', () => { 52 + const config = createPipelineConfig('f1', 's1', ['q1', 'q3'], ['Name', 'Phone']); 53 + const response = createResponse('f1', answers1); 54 + const row = responseToRow(response, config); 55 + expect(row[0]).toBe('Alice'); 56 + expect(row[1]).toBe(''); 57 + }); 58 + 59 + it('omits timestamp when disabled', () => { 60 + const config = createPipelineConfig('f1', 's1', ['q1'], ['Name'], false); 61 + const response = createResponse('f1', answers1); 62 + const row = responseToRow(response, config); 63 + expect(row).toHaveLength(1); 64 + }); 65 + }); 66 + 67 + describe('responsesToRows', () => { 68 + it('converts multiple responses', () => { 69 + const config = createPipelineConfig('f1', 's1', ['q1'], ['Name'], false); 70 + const responses = [createResponse('f1', answers1), createResponse('f1', answers2)]; 71 + const rows = responsesToRows(responses, config); 72 + expect(rows).toHaveLength(2); 73 + expect(rows[0][0]).toBe('Alice'); 74 + expect(rows[1][0]).toBe('Bob'); 75 + }); 76 + }); 77 + 78 + describe('pipelineHeaders', () => { 79 + it('returns column headers with timestamp', () => { 80 + const config = createPipelineConfig('f1', 's1', ['q1', 'q2'], ['Name', 'Email']); 81 + expect(pipelineHeaders(config)).toEqual(['Name', 'Email', 'Submitted At']); 82 + }); 83 + 84 + it('omits timestamp header when disabled', () => { 85 + const config = createPipelineConfig('f1', 's1', ['q1'], ['Name'], false); 86 + expect(pipelineHeaders(config)).toEqual(['Name']); 87 + }); 88 + }); 89 + 90 + describe('responseCount', () => { 91 + it('counts responses for a form', () => { 92 + const responses = [ 93 + createResponse('f1', answers1), 94 + createResponse('f2', answers2), 95 + createResponse('f1', answers2), 96 + ]; 97 + expect(responseCount(responses, 'f1')).toBe(2); 98 + expect(responseCount(responses, 'f2')).toBe(1); 99 + }); 100 + }); 101 + 102 + describe('sortByNewest / sortByOldest', () => { 103 + it('sorts by time', () => { 104 + const r1 = { ...createResponse('f1', answers1), submittedAt: 1000 }; 105 + const r2 = { ...createResponse('f1', answers2), submittedAt: 2000 }; 106 + const newest = sortByNewest([r1, r2]); 107 + expect(newest[0].submittedAt).toBe(2000); 108 + const oldest = sortByOldest([r1, r2]); 109 + expect(oldest[0].submittedAt).toBe(1000); 110 + }); 111 + }); 112 + 113 + describe('filterByTimeRange', () => { 114 + it('filters within range', () => { 115 + const r1 = { ...createResponse('f1', answers1), submittedAt: 1000 }; 116 + const r2 = { ...createResponse('f1', answers2), submittedAt: 5000 }; 117 + expect(filterByTimeRange([r1, r2], 0, 3000)).toHaveLength(1); 118 + expect(filterByTimeRange([r1, r2], 0, 10000)).toHaveLength(2); 119 + }); 120 + }); 121 + 122 + describe('aggregateAnswers', () => { 123 + it('counts answer values', () => { 124 + const responses = [ 125 + createResponse('f1', new Map([['q1', 'A']])), 126 + createResponse('f1', new Map([['q1', 'B']])), 127 + createResponse('f1', new Map([['q1', 'A']])), 128 + ]; 129 + const counts = aggregateAnswers(responses, 'q1'); 130 + expect(counts.get('A')).toBe(2); 131 + expect(counts.get('B')).toBe(1); 132 + }); 133 + }); 134 + 135 + describe('isDuplicate / deduplicate', () => { 136 + it('detects duplicate from same submitter', () => { 137 + const now = Date.now(); 138 + const r1 = { ...createResponse('f1', answers1, 'user1'), submittedAt: now }; 139 + const r2 = { ...createResponse('f1', answers2, 'user1'), submittedAt: now + 100 }; 140 + expect(isDuplicate(r2, [r1], 60000)).toBe(true); 141 + }); 142 + 143 + it('allows different submitters', () => { 144 + const now = Date.now(); 145 + const r1 = { ...createResponse('f1', answers1, 'user1'), submittedAt: now }; 146 + const r2 = { ...createResponse('f1', answers2, 'user2'), submittedAt: now + 100 }; 147 + expect(isDuplicate(r2, [r1])).toBe(false); 148 + }); 149 + 150 + it('allows submissions outside window', () => { 151 + const now = Date.now(); 152 + const r1 = { ...createResponse('f1', answers1, 'user1'), submittedAt: now }; 153 + const r2 = { ...createResponse('f1', answers2, 'user1'), submittedAt: now + 120000 }; 154 + expect(isDuplicate(r2, [r1], 60000)).toBe(false); 155 + }); 156 + 157 + it('deduplicates list', () => { 158 + const now = Date.now(); 159 + const responses = [ 160 + { ...createResponse('f1', answers1, 'user1'), submittedAt: now }, 161 + { ...createResponse('f1', answers2, 'user1'), submittedAt: now + 100 }, 162 + { ...createResponse('f1', answers1, 'user2'), submittedAt: now }, 163 + ]; 164 + expect(deduplicate(responses)).toHaveLength(2); 165 + }); 166 + });