chore(output): output formatter and tests
This commit is contained in:
parent
456e7e8feb
commit
d87e64ed7d
2 changed files with 419 additions and 0 deletions
276
src/lib/output.test.ts
Normal file
276
src/lib/output.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
143
src/lib/output.ts
Normal file
143
src/lib/output.ts
Normal file
|
|
@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue