UNPKG

meld

Version:

Meld: A template language for LLM prompts

455 lines (376 loc) 14.3 kB
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ResolutionService } from './ResolutionService.js'; import { IStateService } from '@services/state/StateService/IStateService.js'; import { IFileSystemService } from '@services/fs/FileSystemService/IFileSystemService.js'; import { IParserService } from '@services/pipeline/ParserService/IParserService.js'; import { ResolutionContext } from './IResolutionService.js'; import { ResolutionError } from './errors/ResolutionError.js'; import type { MeldNode, DirectiveNode, TextNode } from 'meld-spec'; // Import centralized syntax examples and helpers import { textDirectiveExamples, dataDirectiveExamples, defineDirectiveExamples, pathDirectiveExamples } from '@core/syntax/index.js'; // Import run examples directly import runDirectiveExamplesModule from '@core/syntax/run.js'; import { createExample, createInvalidExample, createNodeFromExample } from '@core/syntax/helpers'; // Use the correctly imported run directive examples const runDirectiveExamples = runDirectiveExamplesModule; // Mock the logger vi.mock('@core/utils/logger', () => ({ resolutionLogger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); describe('ResolutionService', () => { let service: ResolutionService; let stateService: IStateService; let fileSystemService: IFileSystemService; let parserService: IParserService; let context: ResolutionContext; beforeEach(() => { stateService = { getTextVar: vi.fn(), getDataVar: vi.fn(), getPathVar: vi.fn(), getCommand: vi.fn(), } as unknown as IStateService; fileSystemService = { exists: vi.fn(), readFile: vi.fn(), } as unknown as IFileSystemService; parserService = { parse: vi.fn(), } as unknown as IParserService; service = new ResolutionService( stateService, fileSystemService, parserService ); context = { currentFilePath: 'test.meld', allowedVariableTypes: { text: true, data: true, path: true, command: true }, state: stateService }; }); describe('resolveInContext', () => { it('should handle text nodes', async () => { const textNode: TextNode = { type: 'Text', content: 'simple text' }; vi.mocked(parserService.parse).mockResolvedValue([textNode]); const result = await service.resolveInContext('simple text', context); expect(result).toBe('simple text'); }); it('should resolve text variables', async () => { // Use centralized syntax example for text directive const example = textDirectiveExamples.atomic.simpleString; // Create a node matching what the parser would return for "{{greeting}}" const node: DirectiveNode = { type: 'Directive', directive: { kind: 'text', identifier: 'greeting', value: 'Hello' } }; vi.mocked(parserService.parse).mockResolvedValue([node]); vi.mocked(stateService.getTextVar).mockReturnValue('Hello World'); const result = await service.resolveInContext('{{greeting}}', context); expect(result).toBe('Hello World'); }); it('should resolve data variables', async () => { // Use centralized syntax example for data directive const example = dataDirectiveExamples.atomic.simpleObject; // Create a node matching what the parser would return for "{{config}}" const node: DirectiveNode = { type: 'Directive', directive: { kind: 'data', identifier: 'user', value: '{ "name": "Alice", "id": 123 }' } }; vi.mocked(parserService.parse).mockResolvedValue([node]); vi.mocked(stateService.getDataVar).mockReturnValue({ name: 'Alice', id: 123 }); const result = await service.resolveInContext('{{user}}', context); expect(result).toBe('{"name":"Alice","id":123}'); }); it('should resolve system path variables', async () => { // System path variables like $HOMEPATH are handled differently // than user-defined path variables const node: DirectiveNode = { type: 'Directive', directive: { kind: 'path', identifier: 'HOMEPATH' } }; vi.mocked(parserService.parse).mockResolvedValue([node]); vi.mocked(stateService.getPathVar).mockReturnValue('/home/user'); const result = await service.resolveInContext('$HOMEPATH', context); expect(result).toBe('/home/user'); }); it('should resolve user-defined path variables', async () => { // Use centralized syntax example for path directive const example = pathDirectiveExamples.atomic.homePath; // Create a node matching what the parser would return for "$home" const node: DirectiveNode = { type: 'Directive', directive: { kind: 'path', identifier: 'home', value: '$HOMEPATH/meld' } }; // Mock parser and path resolver vi.mocked(parserService.parse).mockResolvedValue([node]); vi.mocked(stateService.getPathVar).mockImplementation((name: string) => { if (name === 'home') return '/home/user/meld'; if (name === 'HOMEPATH') return '/home/user'; return undefined; }); // Use the exposed VariableReferenceResolver for more accurate path resolution testing const variableResolver = service.getVariableResolver(); const originalResolve = variableResolver.resolve; variableResolver.resolve = vi.fn().mockImplementation((text: string, ctx: ResolutionContext) => { if (text === '$home') { return Promise.resolve('/home/user/meld'); } return originalResolve.call(variableResolver, text, ctx); }); const result = await service.resolveInContext('$home', context); // After test, restore original method variableResolver.resolve = originalResolve; expect(result).toBe('/home/user/meld'); }); it('should resolve command references', async () => { // Use centralized syntax example for run directive const example = runDirectiveExamples.atomic.simple; // Create a node matching what the parser would return for "$echo(hello)" const node: DirectiveNode = { type: 'Directive', directive: { kind: 'run', identifier: 'echo', value: '$echo(test)', args: ['test'] } }; vi.mocked(parserService.parse).mockResolvedValue([node]); vi.mocked(stateService.getCommand).mockReturnValue({ command: '@run [echo ${text}]' }); const result = await service.resolveInContext('$echo(test)', context); expect(result).toBe('echo test'); }); it('should handle parsing failures by treating value as text', async () => { vi.mocked(parserService.parse).mockRejectedValue(new Error('Parse error')); const result = await service.resolveInContext('unparseable content', context); expect(result).toBe('unparseable content'); }); it('should concatenate multiple nodes', async () => { const nodes: MeldNode[] = [ { type: 'Text', content: 'Hello ' }, { type: 'Directive', directive: { kind: 'text', identifier: 'name', value: 'World' } } ]; vi.mocked(parserService.parse).mockResolvedValue(nodes); vi.mocked(stateService.getTextVar).mockReturnValue('World'); const result = await service.resolveInContext('Hello {{name}}', context); expect(result).toBe('Hello World'); }); }); describe('resolveContent', () => { it('should read file content', async () => { vi.mocked(fileSystemService.exists).mockResolvedValue(true); vi.mocked(fileSystemService.readFile).mockResolvedValue('file content'); const result = await service.resolveContent('/path/to/file'); expect(result).toBe('file content'); expect(fileSystemService.readFile).toHaveBeenCalledWith('/path/to/file'); }); it('should throw when file does not exist', async () => { vi.mocked(fileSystemService.exists).mockResolvedValue(false); await expect(service.resolveContent('/missing/file')) .rejects .toThrow('File not found: /missing/file'); }); }); describe('extractSection', () => { it('should extract section by heading', async () => { const content = `# Title Some content ## Section 1 Content 1 ## Section 2 Content 2`; const result = await service.extractSection(content, 'Section 1'); expect(result).toBe('## Section 1\n\nContent 1'); }); it('should include content until next heading of same or higher level', async () => { const content = `# Title Some content ## Section 1 Content 1 ### Subsection Subcontent ## Section 2 Content 2`; const result = await service.extractSection(content, 'Section 1'); expect(result).toBe('## Section 1\n\nContent 1\n\n### Subsection\n\nSubcontent'); }); it('should throw when section is not found', async () => { const content = '# Title\nContent'; await expect(service.extractSection(content, 'Missing Section')) .rejects .toThrow('Section not found: Missing Section'); }); }); describe('validateResolution', () => { it('should validate text variables are allowed', async () => { context.allowedVariableTypes.text = false; // Use centralized syntax example for text directive const example = textDirectiveExamples.atomic.simpleString; const node: DirectiveNode = { type: 'Directive', directive: { kind: 'text', identifier: 'greeting', value: 'Hello' } }; vi.mocked(parserService.parse).mockResolvedValue([node]); await expect(service.validateResolution('{{greeting}}', context)) .rejects .toThrow('Text variables are not allowed in this context'); }); it('should validate data variables are allowed', async () => { context.allowedVariableTypes.data = false; // Use centralized syntax example for data directive const example = dataDirectiveExamples.atomic.simpleObject; const node: DirectiveNode = { type: 'Directive', directive: { kind: 'data', identifier: 'user', value: '{ "name": "Alice", "id": 123 }' } }; vi.mocked(parserService.parse).mockResolvedValue([node]); await expect(service.validateResolution('{{user}}', context)) .rejects .toThrow('Data variables are not allowed in this context'); }); it('should validate path variables are allowed', async () => { context.allowedVariableTypes.path = false; // Use centralized syntax example for path directive const example = pathDirectiveExamples.atomic.homePath; const node: DirectiveNode = { type: 'Directive', directive: { kind: 'path', identifier: 'home' } }; vi.mocked(parserService.parse).mockResolvedValue([node]); await expect(service.validateResolution('$home', context)) .rejects .toThrow('Path variables are not allowed in this context'); }); it('should validate command references are allowed', async () => { context.allowedVariableTypes.command = false; // Use centralized syntax example for run directive with defined command const example = runDirectiveExamples.combinations.definedCommand; const node: DirectiveNode = { type: 'Directive', directive: { kind: 'run', identifier: 'greet', value: '$greet()', args: [] } }; vi.mocked(parserService.parse).mockResolvedValue([node]); await expect(service.validateResolution('$greet()', context)) .rejects .toThrow('Command references are not allowed in this context'); }); }); describe('detectCircularReferences', () => { it('should detect direct circular references', async () => { // For circular references, we need custom nodes // but we'll use naming consistent with the examples const nodeA: DirectiveNode = { type: 'Directive', directive: { kind: 'text', identifier: 'var1', value: '{{var2}}' } }; const nodeB: DirectiveNode = { type: 'Directive', directive: { kind: 'text', identifier: 'var2', value: '{{var1}}' } }; vi.mocked(parserService.parse) .mockImplementation((text) => { if (text === '{{var1}}') return [nodeA]; if (text === '{{var2}}') return [nodeB]; return []; }); vi.mocked(stateService.getTextVar) .mockImplementation((name) => { if (name === 'var1') return '{{var2}}'; if (name === 'var2') return '{{var1}}'; return undefined; }); await expect(service.detectCircularReferences('{{var1}}')) .rejects .toThrow('Circular reference detected: var1 -> var2 -> var1'); }); it('should handle non-circular references', async () => { // Use the basicInterpolation example which refers to other variables const example = textDirectiveExamples.combinations.basicInterpolation; const node: DirectiveNode = { type: 'Directive', directive: { kind: 'text', identifier: 'message', value: '`{{greeting}}, {{subject}}!`' } }; vi.mocked(parserService.parse).mockResolvedValue([node]); vi.mocked(stateService.getTextVar) .mockReturnValueOnce('`{{greeting}}, {{subject}}!`') .mockReturnValueOnce('Hello') .mockReturnValueOnce('World'); await expect(service.detectCircularReferences('{{message}}')) .resolves .not.toThrow(); }); }); });