From d87e64ed7d68ea24edb1579a22a300133da51556 Mon Sep 17 00:00:00 2001 From: lew Date: Mon, 23 Mar 2026 17:24:09 +0000 Subject: [PATCH] chore(output): output formatter and tests --- src/lib/output.test.ts | 276 +++++++++++++++++++++++++++++++++++++++++ src/lib/output.ts | 143 +++++++++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 src/lib/output.test.ts create mode 100644 src/lib/output.ts diff --git a/src/lib/output.test.ts b/src/lib/output.test.ts new file mode 100644 index 0000000..4b6ca19 --- /dev/null +++ b/src/lib/output.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect } from 'vitest'; +import { formatFieldOutput, generateRecord } from './output'; +import type { FieldDef, Template } from './types'; +import type { SpeciesData } from './data/types'; + +const stubSpecies: SpeciesData[] = [ + { + id: 'human', + name: 'Human', + description: '', + subspeciesLabel: 'Variant', + subspecies: [{ id: 'offworlder', name: 'Offworlder', description: '' }], + languages: ['tau-ceti-basic'], + citizenships: ['biesel'] + }, + { + id: 'tajara', + name: 'Tajara', + description: '', + subspeciesLabel: 'Ethnicity', + subspecies: [ + { id: 'hharar', name: 'Hharar', description: '' }, + { id: 'zhan-khazan', name: 'Zhan-Khazan', description: '' } + ], + languages: ['siik-maas'], + citizenships: ['pra'] + } +]; + +describe('formatFieldOutput', () => { + it('formats text fields', () => { + const field: FieldDef = { label: 'Pronouns', type: 'text' }; + expect(formatFieldOutput(field, 'she/her')).toBe('Pronouns: she/her'); + }); + + it('returns null for empty text', () => { + const field: FieldDef = { label: 'Pronouns', type: 'text' }; + expect(formatFieldOutput(field, '')).toBeNull(); + expect(formatFieldOutput(field, undefined)).toBeNull(); + }); + + it('formats textarea with header', () => { + const field: FieldDef = { label: 'Distinguishing Features', type: 'textarea' }; + expect(formatFieldOutput(field, 'Scar across left eye')).toBe( + 'Distinguishing Features:\nScar across left eye' + ); + }); + + it('returns null for empty textarea', () => { + const field: FieldDef = { label: 'Distinguishing Features', type: 'textarea' }; + expect(formatFieldOutput(field, '')).toBeNull(); + }); + + it('formats list as bullet points', () => { + const field: FieldDef = { label: 'Employment History', type: 'list' }; + expect(formatFieldOutput(field, 'Shaft Miner')).toBe( + 'Employment History:\n - Shaft Miner' + ); + }); + + it('returns null for empty list', () => { + const field: FieldDef = { label: 'Employment History', type: 'list' }; + expect(formatFieldOutput(field, '')).toBeNull(); + }); + + it('filters blank lines from list', () => { + const field: FieldDef = { label: 'Employment History', type: 'list' }; + expect(formatFieldOutput(field, 'Line 1\n\nLine 2\n')).toBe( + 'Employment History:\n - Line 1\n - Line 2' + ); + }); + + it('formats height with conversion', () => { + const field: FieldDef = { label: 'Height', type: 'height' }; + expect(formatFieldOutput(field, 180)).toBe('Height: 180 cm (5\'11")'); + }); + + it('returns null for zero/undefined height', () => { + const field: FieldDef = { label: 'Height', type: 'height' }; + expect(formatFieldOutput(field, 0)).toBeNull(); + expect(formatFieldOutput(field, undefined)).toBeNull(); + }); + + it('formats weight with conversion', () => { + const field: FieldDef = { label: 'Weight', type: 'weight' }; + expect(formatFieldOutput(field, 75)).toBe('Weight: 75 kg (165 lb)'); + }); + + it('returns null for zero/undefined weight', () => { + const field: FieldDef = { label: 'Weight', type: 'weight' }; + expect(formatFieldOutput(field, 0)).toBeNull(); + }); + + it('formats species with display name', () => { + const field: FieldDef = { label: 'Species', type: 'species' }; + expect(formatFieldOutput(field, 'tajara', stubSpecies)).toBe('Species: Tajara'); + }); + + it('formats subspecies with dynamic label', () => { + const field: FieldDef = { label: 'Subspecies', type: 'subspecies' }; + expect(formatFieldOutput(field, 'hharar', stubSpecies, 'tajara')).toBe('Ethnicity: Hharar'); + }); + + it('returns null for empty subspecies', () => { + const field: FieldDef = { label: 'Subspecies', type: 'subspecies' }; + expect(formatFieldOutput(field, '', stubSpecies, 'tajara')).toBeNull(); + }); + + it('formats languages as comma list', () => { + const field: FieldDef = { label: 'Spoken Languages', type: 'languages' }; + expect(formatFieldOutput(field, ['Tau Ceti Basic', 'Siik\'maas'])).toBe( + 'Spoken Languages: Tau Ceti Basic, Siik\'maas' + ); + }); + + it('returns null for empty languages', () => { + const field: FieldDef = { label: 'Spoken Languages', type: 'languages' }; + expect(formatFieldOutput(field, [])).toBeNull(); + }); + + it('formats checkbox as bullet list of selected', () => { + const field: FieldDef = { + label: 'Opt-Outs', + type: 'checkbox', + options: [ + { value: 'no-borg', label: 'Do Not Borgify' }, + { value: 'no-revive', label: 'Do Not Revive' }, + { value: 'no-prosthetic', label: 'Do Not Prostheticize' } + ] + }; + expect(formatFieldOutput(field, ['no-borg', 'no-revive'])).toBe( + 'Opt-Outs:\n - Do Not Borgify\n - Do Not Revive' + ); + }); + + it('returns null for empty checkbox', () => { + const field: FieldDef = { + label: 'Opt-Outs', + type: 'checkbox', + options: [{ value: 'no-borg', label: 'Do Not Borgify' }] + }; + expect(formatFieldOutput(field, [])).toBeNull(); + }); + + it('formats select fields', () => { + const field: FieldDef = { + label: 'Citizenship', + type: 'select', + options: [{ value: 'biesel', label: 'Republic of Biesel' }] + }; + expect(formatFieldOutput(field, 'biesel')).toBe('Citizenship: Republic of Biesel'); + }); + + it('formats date fields', () => { + const field: FieldDef = { label: 'Date of Birth', type: 'date' }; + expect(formatFieldOutput(field, 'March 15th, 2438')).toBe('Date of Birth: March 15th, 2438'); + }); + + it('formats number fields', () => { + const field: FieldDef = { label: 'Age', type: 'number' }; + expect(formatFieldOutput(field, 30)).toBe('Age: 30'); + }); + + it('formats citizenship type', () => { + const field: FieldDef = { label: 'Citizenship', type: 'citizenship' }; + expect(formatFieldOutput(field, 'Republic of Biesel')).toBe('Citizenship: Republic of Biesel'); + }); + + it('formats multi-select as comma list', () => { + const field: FieldDef = { + label: 'Other Skills', + type: 'multi-select', + options: [ + { value: 'engineering', label: 'Engineering' }, + { value: 'medical', label: 'Medical' } + ] + }; + expect(formatFieldOutput(field, ['engineering', 'medical'])).toBe( + 'Other Skills: Engineering, Medical' + ); + }); +}); + +const testTemplate: Template = { + id: 'test', + name: 'Test Template', + description: '', + schemaVersion: 1, + records: [ + { + type: 'public', + expanded: true, + fields: [ + { label: 'Name', type: 'text' }, + { label: 'Species', type: 'species' }, + { label: 'Pronouns', type: 'text' } + ] + }, + { + type: 'employment', + expanded: false, + preamble: 'This information has been verified by employment agents.', + fields: [ + { label: 'Employment History', type: 'list' }, + { label: 'Formal Education', type: 'list' } + ] + }, + { + type: 'medical', + expanded: false, + preamble: 'Protected by doctor-patient confidentiality.', + fields: [ + { + label: 'Opt-Outs', + type: 'checkbox', + options: [{ value: 'no-borg', label: 'Do Not Borgify' }] + }, + { label: 'Allergies', type: 'list' } + ] + }, + { + type: 'security', + expanded: false, + preamble: 'This information has been verified by employment agents.', + fields: [ + { label: 'Attitude Towards SCC', type: 'textarea' }, + { label: 'Arrest History', type: 'list' } + ] + } + ] +}; + +describe('generateRecord', () => { + it('includes public header and employment body', () => { + const data = { + name: 'Yury Zakharov', + species: 'tajara', + 'employment-history': 'Janitor' + }; + const out = generateRecord(testTemplate, data, 'employment', stubSpecies); + expect(out).toContain('/// PUBLIC RECORD ///'); + expect(out).toContain('Name: Yury Zakharov'); + expect(out).toContain('Species: Tajara'); + expect(out).toContain('/// EMPLOYMENT RECORD ///'); + expect(out).toContain('This information has been verified by employment agents.'); + expect(out).toContain(' - Janitor'); + expect(out).toContain('LAST UPDATED:'); + }); + + it('shows NO RECORD FOUND when body is empty', () => { + const data = { name: 'Yury Zakharov' }; + const out = generateRecord(testTemplate, data, 'medical', stubSpecies); + expect(out).toContain('/// NO MEDICAL RECORD FOUND ///'); + }); + + it('includes preamble in medical record', () => { + const data = { + name: 'Yury Zakharov', + allergies: 'Peanuts' + }; + const out = generateRecord(testTemplate, data, 'medical', stubSpecies); + expect(out).toContain('Protected by doctor-patient confidentiality.'); + expect(out).toContain(' - Peanuts'); + }); + + it('includes preamble in security record', () => { + const data = { + name: 'Yury Zakharov', + 'attitude-towards-scc': 'Loyal employee' + }; + const out = generateRecord(testTemplate, data, 'security', stubSpecies); + expect(out).toContain('/// SECURITY RECORD ///'); + expect(out).toContain('This information has been verified by employment agents.'); + expect(out).toContain('Loyal employee'); + }); +}); diff --git a/src/lib/output.ts b/src/lib/output.ts new file mode 100644 index 0000000..f64ed37 --- /dev/null +++ b/src/lib/output.ts @@ -0,0 +1,143 @@ +import type { FieldDef, Template } from './types'; +import type { SpeciesData } from './data/types'; +import { cmToFeetInches, kgToLb } from './utils/conversions'; +import { formatICDate } from './utils/dates'; +import { slugify } from './utils/slugify'; + +export function formatFieldOutput( + field: FieldDef, + value: unknown, + speciesData?: SpeciesData[], + currentSpecies?: string +): string | null { + switch (field.type) { + case 'text': + case 'date': + case 'citizenship': + return value ? `${field.label}: ${value}` : null; + + case 'textarea': + return value ? `${field.label}:\n${value}` : null; + + case 'list': { + const lines = splitLines(value as string); + return lines.length ? `${field.label}:\n${formatBullets(lines)}` : null; + } + + case 'number': + return value != null && value !== 0 ? `${field.label}: ${value}` : null; + + case 'height': + return value ? `${field.label}: ${value} cm (${cmToFeetInches(value as number)})` : null; + + case 'weight': { + if (!value) return null; + const lb = Math.round(kgToLb(value as number)); + return `${field.label}: ${value} kg (${lb} lb)`; + } + + case 'species': { + if (!value || !speciesData) return null; + const sp = speciesData.find((s) => s.id === value); + return sp ? `${field.label}: ${sp.name}` : `${field.label}: ${value}`; + } + + case 'subspecies': { + if (!value || !speciesData || !currentSpecies) return null; + const sp = speciesData.find((s) => s.id === currentSpecies); + if (!sp) return null; + const sub = sp.subspecies.find((s) => s.id === value); + return sub ? `${sp.subspeciesLabel}: ${sub.name}` : null; + } + + case 'languages': { + const arr = value as string[] | undefined; + return arr?.length ? `${field.label}: ${arr.join(', ')}` : null; + } + + case 'checkbox': { + const selected = value as string[] | undefined; + if (!selected?.length) return null; + const labels = selected + .map((v) => field.options.find((o) => o.value === v)?.label ?? v) + return `${field.label}:\n${formatBullets(labels)}`; + } + + case 'select': { + if (!value) return null; + const opt = field.options.find((o) => o.value === value); + return `${field.label}: ${opt?.label ?? value}`; + } + + case 'multi-select': { + const vals = value as string[] | undefined; + if (!vals?.length) return null; + const labels = vals.map((v) => field.options.find((o) => o.value === v)?.label ?? v); + return `${field.label}: ${labels.join(', ')}`; + } + } +} + +export function generateRecord( + template: Template, + data: Record, + recordType: string, + speciesData?: SpeciesData[] +): string { + const publicRecord = template.records.find((r) => r.type === 'public'); + const targetRecord = template.records.find((r) => r.type === recordType); + const currentSpecies = data['species'] as string | undefined; + + const parts: string[] = []; + + // Public section + if (publicRecord) { + const publicLines = renderFields(publicRecord.fields, data, speciesData, currentSpecies); + if (publicLines.length) { + parts.push('/// PUBLIC RECORD ///'); + parts.push(publicLines.join('\n')); + } + } + + // Target record section + if (targetRecord) { + const bodyLines = renderFields(targetRecord.fields, data, speciesData, currentSpecies); + const typeLabel = recordType.toUpperCase(); + + if (!bodyLines.length) { + parts.push(`/// NO ${typeLabel} RECORD FOUND ///`); + } else { + parts.push(`/// ${typeLabel} RECORD ///`); + if (targetRecord.preamble) { + parts.push(targetRecord.preamble); + } + parts.push(bodyLines.join('\n\n')); + } + } + + parts.push(`LAST UPDATED: ${formatICDate(new Date())}`); + return parts.join('\n\n'); +} + +function renderFields( + fields: FieldDef[], + data: Record, + speciesData?: SpeciesData[], + currentSpecies?: string +): string[] { + const lines: string[] = []; + for (const field of fields) { + const out = formatFieldOutput(field, data[slugify(field.label)], speciesData, currentSpecies); + if (out) lines.push(out); + } + return lines; +} + +function splitLines(text: string | undefined): string[] { + if (!text) return []; + return text.split('\n').map((l) => l.trim()).filter(Boolean); +} + +function formatBullets(items: string[]): string { + return items.map((item) => ` - ${item}`).join('\n'); +}