UNPKG

meld

Version:

Meld: A template language for LLM prompts

644 lines (540 loc) 24.1 kB
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { OutputService } from './OutputService.js'; import { MeldOutputError } from '@core/errors/MeldOutputError.js'; import type { MeldNode } from 'meld-spec'; import type { IStateService } from '@services/state/StateService/IStateService.js'; import type { IResolutionService, ResolutionContext } from '@services/resolution/ResolutionService/IResolutionService.js'; import { createTextNode, createDirectiveNode, createCodeFenceNode, createLocation } from '../../../tests/utils/testFactories.js'; // Import centralized syntax examples import { textDirectiveExamples, dataDirectiveExamples, defineDirectiveExamples } from '@core/syntax/index.js'; // Import run examples directly import runDirectiveExamplesModule from '@core/syntax/run.js'; import { createNodeFromExample } from '@core/syntax/helpers'; // Use the correctly imported run directive examples const runDirectiveExamples = runDirectiveExamplesModule; // Mock StateService class MockStateService implements IStateService { private textVars = new Map<string, string>(); private dataVars = new Map<string, unknown>(); private pathVars = new Map<string, string>(); private commands = new Map<string, { command: string; options?: Record<string, unknown> }>(); private nodes: MeldNode[] = []; private transformationEnabled = false; private transformedNodes: MeldNode[] = []; private imports = new Set<string>(); private filePath: string | null = null; private _isImmutable = false; getAllTextVars(): Map<string, string> { return new Map(this.textVars); } getAllDataVars(): Map<string, unknown> { return new Map(this.dataVars); } getAllPathVars(): Map<string, string> { return new Map(this.pathVars); } getAllCommands(): Map<string, { command: string; options?: Record<string, unknown> }> { return new Map(this.commands); } setTextVar(name: string, value: string): void { this.textVars.set(name, value); } setDataVar(name: string, value: unknown): void { this.dataVars.set(name, value); } setPathVar(name: string, value: string): void { this.pathVars.set(name, value); } setCommand(name: string, command: string | { command: string; options?: Record<string, unknown> }): void { const cmdDef = typeof command === 'string' ? { command } : command; this.commands.set(name, cmdDef); } isTransformationEnabled(): boolean { return this.transformationEnabled; } enableTransformation(enable: boolean = true): void { this.transformationEnabled = enable; } getTransformedNodes(): MeldNode[] { if (this.transformationEnabled) { return this.transformedNodes.length > 0 ? [...this.transformedNodes] : [...this.nodes]; } return [...this.nodes]; } transformNode(original: MeldNode, transformed: MeldNode): void { const index = this.transformedNodes.indexOf(original); if (index >= 0) { this.transformedNodes[index] = transformed; } } setTransformedNodes(nodes: MeldNode[]): void { this.transformedNodes = [...nodes]; } getNodes(): MeldNode[] { return [...this.nodes]; } addNode(node: MeldNode): void { this.nodes.push(node); } appendContent(content: string): void { this.nodes.push({ type: 'Text', content } as TextNode); } addImport(path: string): void { this.imports.add(path); } removeImport(path: string): void { this.imports.delete(path); } hasImport(path: string): boolean { return this.imports.has(path); } getImports(): Set<string> { return new Set(this.imports); } getCurrentFilePath(): string | null { return this.filePath; } setCurrentFilePath(path: string): void { this.filePath = path; } hasLocalChanges(): boolean { return true; } getLocalChanges(): string[] { return ['state']; } setImmutable(): void { this._isImmutable = true; } get isImmutable(): boolean { return this._isImmutable; } createChildState(): IStateService { const child = new MockStateService(); child.textVars = new Map(this.textVars); child.dataVars = new Map(this.dataVars); child.pathVars = new Map(this.pathVars); child.commands = new Map(this.commands); child.nodes = [...this.nodes]; child.transformationEnabled = this.transformationEnabled; child.transformedNodes = [...this.transformedNodes]; child.imports = new Set(this.imports); child.filePath = this.filePath; child._isImmutable = this._isImmutable; return child; } mergeChildState(childState: IStateService): void { const child = childState as MockStateService; // Merge all state for (const [key, value] of child.textVars) { this.textVars.set(key, value); } for (const [key, value] of child.dataVars) { this.dataVars.set(key, value); } for (const [key, value] of child.pathVars) { this.pathVars.set(key, value); } for (const [key, value] of child.commands) { this.commands.set(key, value); } this.nodes.push(...child.nodes); if (child.transformationEnabled) { this.transformationEnabled = true; this.transformedNodes.push(...child.transformedNodes); } for (const imp of child.imports) { this.imports.add(imp); } } clone(): IStateService { const cloned = new MockStateService(); cloned.textVars = new Map(this.textVars); cloned.dataVars = new Map(this.dataVars); cloned.pathVars = new Map(this.pathVars); cloned.commands = new Map(this.commands); cloned.nodes = [...this.nodes]; cloned.transformationEnabled = this.transformationEnabled; cloned.transformedNodes = [...this.transformedNodes]; cloned.imports = new Set(this.imports); cloned.filePath = this.filePath; cloned._isImmutable = this._isImmutable; return cloned; } // Required interface methods getTextVar(name: string): string | undefined { return this.textVars.get(name); } getDataVar(name: string): unknown | undefined { return this.dataVars.get(name); } getCommand(name: string): { command: string; options?: Record<string, unknown> } | undefined { return this.commands.get(name); } getPathVar(name: string): string | undefined { return this.pathVars.get(name); } getLocalTextVars(): Map<string, string> { return new Map(this.textVars); } getLocalDataVars(): Map<string, unknown> { return new Map(this.dataVars); } } // Mock ResolutionService class MockResolutionService implements IResolutionService { async resolveInContext(value: string, context: ResolutionContext): Promise<string> { // For testing, just return the value as is return value; } // Add other required methods with empty implementations resolveText(): Promise<string> { return Promise.resolve(''); } resolveData(): Promise<any> { return Promise.resolve(null); } resolvePath(): Promise<string> { return Promise.resolve(''); } resolveCommand(): Promise<string> { return Promise.resolve(''); } resolveFile(): Promise<string> { return Promise.resolve(''); } resolveContent(): Promise<string> { return Promise.resolve(''); } validateResolution(): Promise<void> { return Promise.resolve(); } extractSection(): Promise<string> { return Promise.resolve(''); } detectCircularReferences(): Promise<void> { return Promise.resolve(); } } describe('OutputService', () => { let service: OutputService; let state: IStateService; let resolutionService: IResolutionService; beforeEach(() => { state = new MockStateService(); resolutionService = new MockResolutionService(); service = new OutputService(); service.initialize(state, resolutionService); }); describe('Format Registration', () => { it('should have default formats registered', () => { expect(service.supportsFormat('markdown')).toBe(true); expect(service.supportsFormat('xml')).toBe(true); }); it('should allow registering custom formats', async () => { const customConverter = async () => 'custom'; service.registerFormat('custom', customConverter); expect(service.supportsFormat('custom')).toBe(true); }); it('should throw on invalid format registration', () => { expect(() => service.registerFormat('', async () => '')).toThrow(); expect(() => service.registerFormat('test', null as any)).toThrow(); }); it('should list supported formats', () => { const formats = service.getSupportedFormats(); expect(formats).toContain('markdown'); expect(formats).toContain('xml'); }); }); describe('Markdown Output', () => { it('should convert text nodes to markdown', async () => { const nodes: MeldNode[] = [ createTextNode('Hello world\n', createLocation(1, 1)) ]; const output = await service.convert(nodes, state, 'markdown'); expect(output).toBe('Hello world\n'); }); it('should handle directive nodes according to type', async () => { // MIGRATION: Using centralized syntax examples instead of hardcoded examples // Definition directive - using @text example const textExample = textDirectiveExamples.atomic.simpleString; const textNode = await createNodeFromExample(textExample.code); let output = await service.convert([textNode], state, 'markdown'); expect(output).toBe(''); // Definition directives are omitted // Execution directive - using @run example const runExample = runDirectiveExamples.atomic.simple; const runNode = await createNodeFromExample(runExample.code); output = await service.convert([runNode], state, 'markdown'); expect(output).toBe('[run directive output placeholder]\n'); }); it('should include state variables when requested', async () => { state.setTextVar('greeting', 'hello'); state.setDataVar('count', 42); const nodes: MeldNode[] = [ createTextNode('Content', createLocation(1, 1)) ]; const output = await service.convert(nodes, state, 'markdown', { includeState: true }); expect(output).toContain('# Text Variables'); expect(output).toContain('@text greeting = "hello"'); expect(output).toContain('# Data Variables'); expect(output).toContain('@data count = 42'); expect(output).toContain('Content'); }); it('should respect preserveFormatting option', async () => { const nodes: MeldNode[] = [ createTextNode('\n Hello \n World \n', createLocation(1, 1)) ]; const preserved = await service.convert(nodes, state, 'markdown', { preserveFormatting: true }); expect(preserved).toBe('\n Hello \n World \n'); const cleaned = await service.convert(nodes, state, 'markdown', { preserveFormatting: false }); expect(cleaned).toBe('Hello \n World'); }); }); describe('XML Output', () => { it('should preserve text content', async () => { const nodes: MeldNode[] = [ createTextNode('Hello world', createLocation(1, 1)) ]; const output = await service.convert(nodes, state, 'xml'); expect(output).toContain('Hello world'); }); it('should preserve code fence content', async () => { // In our updated implementation, the code fence markers are already part of the content // so we need to include them in the test data const fenceContent = '```typescript\nconst x = 1;\n```'; const nodes: MeldNode[] = [ createCodeFenceNode(fenceContent, 'typescript', createLocation(1, 1)) ]; const output = await service.convert(nodes, state, 'xml'); expect(output).toContain('const x = 1;'); // The language is now included in the fence content itself, not added separately expect(output).toContain('```typescript'); }); it('should handle directives according to type', async () => { // MIGRATION: Using centralized syntax examples instead of hardcoded examples // Definition directive - using @text example const textExample = textDirectiveExamples.atomic.simpleString; const textNode = await createNodeFromExample(textExample.code); let output = await service.convert([textNode], state, 'xml'); expect(output).toBe(''); // Definition directives are omitted // Execution directive - using @run example const runExample = runDirectiveExamples.atomic.simple; const runNode = await createNodeFromExample(runExample.code); output = await service.convert([runNode], state, 'xml'); expect(output).toContain('[run directive output placeholder]'); }); it('should preserve state variables when requested', async () => { state.setTextVar('greeting', 'hello'); state.setDataVar('count', 42); const nodes: MeldNode[] = [ createTextNode('Content', createLocation(1, 1)) ]; const output = await service.convert(nodes, state, 'xml', { includeState: true }); expect(output).toContain('greeting'); expect(output).toContain('hello'); expect(output).toContain('count'); expect(output).toContain('42'); expect(output).toContain('Content'); }); }); describe('Transformation Mode', () => { it('should use transformed nodes when transformation is enabled', async () => { // MIGRATION: Using centralized syntax examples instead of hardcoded examples const originalNodes: MeldNode[] = [ // Using a run directive example await createNodeFromExample(runDirectiveExamples.atomic.simple.code) ]; const transformedNodes: MeldNode[] = [ createTextNode('test output\n', createLocation(1, 1)) ]; state.enableTransformation(); state.setTransformedNodes(transformedNodes); const output = await service.convert(originalNodes, state, 'markdown'); expect(output).toBe('test output\n'); }); it('should handle mixed content in transformation mode', async () => { // MIGRATION: Using centralized syntax examples instead of hardcoded examples const originalNodes: MeldNode[] = [ createTextNode('Before\n', createLocation(1, 1)), // Using a run directive example await createNodeFromExample(runDirectiveExamples.atomic.simple.code), createTextNode('After\n', createLocation(3, 1)) ]; const transformedNodes: MeldNode[] = [ createTextNode('Before\n', createLocation(1, 1)), createTextNode('test output\n', createLocation(2, 1)), createTextNode('After\n', createLocation(3, 1)) ]; state.enableTransformation(); state.setTransformedNodes(transformedNodes); const output = await service.convert(originalNodes, state, 'markdown'); expect(output).toBe('Before\ntest output\nAfter\n'); }); it('should handle definition directives in non-transformation mode', async () => { // MIGRATION: Using centralized syntax examples instead of hardcoded examples const nodes: MeldNode[] = [ createTextNode('Before\n', createLocation(1, 1)), // Using a text directive example from centralized examples await createNodeFromExample(textDirectiveExamples.atomic.simpleString.code), createTextNode('After\n', createLocation(3, 1)) ]; const output = await service.convert(nodes, state, 'markdown'); expect(output).toBe('Before\nAfter\n'); }); it('should show placeholders for execution directives in non-transformation mode', async () => { // MIGRATION: Using centralized syntax examples instead of hardcoded examples const nodes: MeldNode[] = [ createTextNode('Before\n', createLocation(1, 1)), // Using a run directive example from centralized examples await createNodeFromExample(runDirectiveExamples.atomic.simple.code), createTextNode('After\n', createLocation(3, 1)) ]; const output = await service.convert(nodes, state, 'markdown'); expect(output).toBe('Before\n[run directive output placeholder]\nAfter\n'); }); it('should preserve code fences in both modes', async () => { // In our updated implementation, the code fence markers are already part of the content // so we need to include them in the test data const fenceContent = '```js\nconst greeting = \'Hello, world!\';\nconsole.log(greeting);\n```'; // Create a code fence node using the proper factory function const codeFenceNode = createCodeFenceNode( fenceContent, 'js', createLocation(1, 1) ); const originalNodes = [ createTextNode('Before\n', createLocation(1, 1)), codeFenceNode, createTextNode('\nAfter', createLocation(3, 1)) ]; // Test non-transformation mode state.enableTransformation(false); let output = await service.convert(originalNodes, state, 'markdown'); expect(output).to.include('Before'); // The fence markers are now part of the content, not added by the converter expect(output).to.include('```js'); expect(output).to.include('const greeting = \'Hello, world!\';'); expect(output).to.include('console.log(greeting);'); expect(output).to.include('After'); // Test transformation mode state.enableTransformation(true); // Set the transformed nodes to be the same as the original nodes state.setTransformedNodes(originalNodes); output = await service.convert(originalNodes, state, 'markdown'); expect(output).to.include('Before'); // The fence markers are now part of the content, not added by the converter expect(output).to.include('```js'); expect(output).to.include('const greeting = \'Hello, world!\';'); expect(output).to.include('console.log(greeting);'); expect(output).to.include('After'); }); it('should handle XML output in both modes', async () => { // MIGRATION: Using centralized syntax examples instead of hardcoded examples const originalNodes: MeldNode[] = [ createTextNode('Before\n', createLocation(1, 1)), // Using a run directive example await createNodeFromExample(runDirectiveExamples.atomic.simple.code), createTextNode('After\n', createLocation(3, 1)) ]; // Non-transformation mode let output = await service.convert(originalNodes, state, 'xml'); expect(output).toContain('Before'); expect(output).toContain('[run directive output placeholder]'); expect(output).toContain('After'); // Transformation mode const transformedNodes: MeldNode[] = [ createTextNode('Before\n', createLocation(1, 1)), createTextNode('test output\n', createLocation(2, 1)), createTextNode('After\n', createLocation(3, 1)) ]; state.enableTransformation(); state.setTransformedNodes(transformedNodes); output = await service.convert(originalNodes, state, 'xml'); expect(output).toContain('Before'); expect(output).toContain('test output'); expect(output).toContain('After'); }); }); describe('Error Handling', () => { it('should throw MeldOutputError for unsupported formats', async () => { await expect(service.convert([], state, 'invalid' as any)) .rejects .toThrow(MeldOutputError); }); it('should throw MeldOutputError for unknown node types', async () => { const nodes = [{ type: 'unknown' }] as any[]; await expect(service.convert(nodes, state, 'markdown')) .rejects .toThrow(MeldOutputError); }); it('should wrap errors from format converters', async () => { service.registerFormat('error', async () => { throw new Error('Test error'); }); await expect(service.convert([], state, 'error')) .rejects .toThrow(MeldOutputError); }); it('should preserve MeldOutputError when thrown from converters', async () => { service.registerFormat('error', async () => { throw new MeldOutputError('Test error', 'error'); }); await expect(service.convert([], state, 'error')) .rejects .toThrow(MeldOutputError); }); }); it('should handle text directives', async () => { // Arrange const textExample = textDirectiveExamples.atomic.simpleString; // ... existing code ... }); it('should handle run directives', async () => { // Arrange const runExample = runDirectiveExamples.atomic.simple; // ... existing code ... }); describe('Regression Tests', () => { it('should not duplicate code fence markers in markdown output (regression #10.2.4)', async () => { // This tests the fix for the codefence duplication bug in version 10.2.4 // Arrange: Set up a code fence node with content that already includes the fence markers const content = '```javascript\nconst name = "Claude";\nconst greet = () => `Hello, ${name}`;\n```'; const nodes: MeldNode[] = [ createCodeFenceNode(content, 'javascript', createLocation(1, 1)) ]; // Act: Convert to markdown const output = await service.convert(nodes, state, 'markdown'); // Assert: Check that the output doesn't have duplicated fence markers // The output should contain the content exactly as-is, without adding extra ``` expect(output).toBe(content); // Make sure it contains the code inside expect(output).toContain('const name = "Claude";'); // Make sure it has exactly one opening and one closing fence marker const fenceMarkerCount = (output.match(/```/g) || []).length; expect(fenceMarkerCount).toBe(2); // Opening and closing, not 4 (which would indicate duplication) }); it('should not duplicate code fence markers in XML output (regression #10.2.4)', async () => { // This tests the fix for the codefence duplication bug in version 10.2.4 // Arrange: Set up a code fence node with content that already includes the fence markers const content = '```typescript\ninterface User { name: string; age: number; }\n```'; const nodes: MeldNode[] = [ createCodeFenceNode(content, 'typescript', createLocation(1, 1)) ]; // Act: Convert to XML const output = await service.convert(nodes, state, 'xml'); // Assert: Check that the output doesn't have duplicated fence markers // The output should contain the content exactly as-is, without adding extra ``` expect(output).toBe(content); // Make sure it contains the code inside expect(output).toContain('interface User'); // Make sure it has exactly one opening and one closing fence marker const fenceMarkerCount = (output.match(/```/g) || []).length; expect(fenceMarkerCount).toBe(2); // Opening and closing, not 4 (which would indicate duplication) }); it('should handle a document with mixed content and code fences (regression #10.2.4)', async () => { // This tests that code fence markers are not duplicated in a mixed document const codeFenceContent = '```javascript\nconst greeting = () => "Hello";\n```'; const nodes: MeldNode[] = [ createTextNode('Text before code\n', createLocation(1, 1)), createCodeFenceNode(codeFenceContent, 'javascript', createLocation(2, 1)), createTextNode('\nText after code', createLocation(4, 1)) ]; // Act: Convert to markdown const output = await service.convert(nodes, state, 'markdown'); // Assert: Check the output structure expect(output).toContain('Text before code\n'); expect(output).toContain(codeFenceContent); expect(output).toContain('\nText after code'); // Check for no duplication of fence markers const fenceMarkerCount = (output.match(/```/g) || []).length; expect(fenceMarkerCount).toBe(2); // Only the ones in the original content }); }); });