/** * Tests for the module types generator. * * These exercise pure codegen — no filesystem I/O. Each test builds a * ModuleManifest object in-memory and asserts on the generated string. */ import { describe, expect, test } from 'bun:test'; import type { ModuleManifest } from '../manifest/schema'; import { generateModuleTypes, moduleIdToPascalCase, variableTypeToTs, } from './module-types-generator'; function baseManifest(overrides: Partial = {}): ModuleManifest { return { celilo_contract: '1.0', id: 'test-module', name: 'Test Module', version: '1.0.0', requires: { capabilities: [] }, provides: { capabilities: [] }, variables: { owns: [], imports: [] }, ...overrides, }; } describe('moduleIdToPascalCase', () => { test('converts single-word IDs', () => { expect(moduleIdToPascalCase('lunacycle')).toBe('Lunacycle'); }); test('converts kebab-case IDs', () => { expect(moduleIdToPascalCase('dns-external')).toBe('DnsExternal'); expect(moduleIdToPascalCase('my-fancy-app')).toBe('MyFancyApp'); }); test('handles empty segments defensively', () => { expect(moduleIdToPascalCase('a--b')).toBe('AB'); }); test('preserves numeric characters', () => { expect(moduleIdToPascalCase('caddy-v2')).toBe('CaddyV2'); }); }); describe('variableTypeToTs', () => { test('maps primitives', () => { expect(variableTypeToTs('string')).toBe('string'); expect(variableTypeToTs('boolean')).toBe('boolean'); expect(variableTypeToTs('integer')).toBe('number'); expect(variableTypeToTs('number')).toBe('number'); }); test('maps array to unknown[]', () => { expect(variableTypeToTs('array')).toBe('unknown[]'); }); test('maps object to Record', () => { expect(variableTypeToTs('object')).toBe('Record'); }); }); describe('generateModuleTypes', () => { test('emits a file header and a no-keys alias for a manifest with no variables', () => { const out = generateModuleTypes(baseManifest({ id: 'empty', name: 'Empty Module' })); expect(out).toContain('// Generated from manifest.yml'); expect(out).toContain('Do not edit by hand'); // `= {}` means "any non-nullish value", which is the opposite of an empty // config surface — and biome bans it (lint/complexity/noBannedTypes). expect(out).toContain('export type EmptyConfig = Record;'); expect(out).not.toContain('export type EmptyConfig = {'); expect(out).toContain('(No variables declared — module has no typed config surface)'); }); test('produces the right type-alias name from a kebab-case module ID', () => { const out = generateModuleTypes(baseManifest({ id: 'dns-external', name: 'DNS External' })); expect(out).toContain('export type DnsExternalConfig ='); }); test('renders required fields as non-optional', () => { const out = generateModuleTypes( baseManifest({ id: 'test', name: 'Test', variables: { owns: [ { name: 'hostname', type: 'string', required: true, source: 'user', }, ], imports: [], }, }), ); expect(out).toContain('hostname: string;'); expect(out).not.toContain('hostname?: string;'); }); test('renders optional fields as optional when not required and no default', () => { const out = generateModuleTypes( baseManifest({ id: 'test', name: 'Test', variables: { owns: [ { name: 'hostname', type: 'string', required: false, source: 'user', }, ], imports: [], }, }), ); expect(out).toContain('hostname?: string;'); }); test('treats not-required + default as guaranteed (non-optional)', () => { const out = generateModuleTypes( baseManifest({ id: 'test', name: 'Test', variables: { owns: [ { name: 'app_port', type: 'integer', required: false, default: 3000, source: 'user', }, ], imports: [], }, }), ); expect(out).toContain('app_port: number;'); expect(out).not.toContain('app_port?: number;'); }); test('maps every primitive type correctly', () => { const out = generateModuleTypes( baseManifest({ id: 'test', name: 'Test', variables: { owns: [ { name: 'str', type: 'string', required: true, source: 'user' }, { name: 'num', type: 'number', required: true, source: 'user' }, { name: 'int', type: 'integer', required: true, source: 'user' }, { name: 'bool', type: 'boolean', required: true, source: 'user' }, { name: 'arr', type: 'array', required: true, source: 'user' }, { name: 'obj', type: 'object', required: true, source: 'user' }, ], imports: [], }, }), ); expect(out).toContain('str: string;'); expect(out).toContain('num: number;'); expect(out).toContain('int: number;'); expect(out).toContain('bool: boolean;'); expect(out).toContain('arr: unknown[];'); expect(out).toContain('obj: Record;'); }); test('emits JSDoc comments from variable descriptions', () => { const out = generateModuleTypes( baseManifest({ id: 'test', name: 'Test', variables: { owns: [ { name: 'hostname', type: 'string', required: true, source: 'user', description: 'Container hostname for DNS resolution', }, ], imports: [], }, }), ); expect(out).toContain('/** Container hostname for DNS resolution */'); }); test('renders imported capability variables as unknown with source-annotated JSDoc', () => { const out = generateModuleTypes( baseManifest({ id: 'test', name: 'Test', variables: { owns: [], imports: [ { name: 'dns_nameserver', source: 'capability', from: 'dns_external.nameserver', }, ], }, }), ); expect(out).toContain('Imported capability variables'); expect(out).toContain('dns_nameserver: unknown;'); expect(out).toContain('Imported from capability: dns_external.nameserver'); }); test('separates owns and imports sections with a blank line', () => { const out = generateModuleTypes( baseManifest({ id: 'test', name: 'Test', variables: { owns: [{ name: 'hostname', type: 'string', required: true, source: 'user' }], imports: [{ name: 'dns_ns', source: 'capability', from: 'dns_external.nameserver' }], }, }), ); expect(out).toContain('Module-owned variables'); expect(out).toContain('Imported capability variables'); }); test('includes the module name and description in the interface JSDoc', () => { const out = generateModuleTypes( baseManifest({ id: 'lunacycle', name: 'LunaCycle', description: 'Family task management', }), ); expect(out).toContain('Configuration shape for the LunaCycle module'); expect(out).toContain('Family task management'); }); test('escapes */ sequences in descriptions to avoid breaking the JSDoc', () => { const out = generateModuleTypes( baseManifest({ id: 'test', name: 'Test', variables: { owns: [ { name: 'weird', type: 'string', required: true, source: 'user', description: 'A field with */ in the description', }, ], imports: [], }, }), ); expect(out).not.toContain('*/ in the description'); expect(out).toContain('* / in the description'); }); test('output is deterministic across multiple runs', () => { const manifest = baseManifest({ id: 'lunacycle', name: 'LunaCycle', variables: { owns: [ { name: 'hostname', type: 'string', required: true, source: 'user' }, { name: 'app_port', type: 'integer', required: false, default: 3000, source: 'user' }, ], imports: [], }, }); const first = generateModuleTypes(manifest); const second = generateModuleTypes(manifest); const third = generateModuleTypes(manifest); expect(first).toBe(second); expect(second).toBe(third); }); test('output ends with a trailing newline', () => { const out = generateModuleTypes(baseManifest()); expect(out.endsWith('\n')).toBe(true); }); });