meld
Version:
Meld: A template language for LLM prompts
416 lines (369 loc) • 13.8 kB
text/typescript
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Mocked } from 'vitest';
import { InterpreterService } from './InterpreterService.js';
import { DirectiveService } from '@services/pipeline/DirectiveService/DirectiveService.js';
import { StateService } from '@services/state/StateService/StateService.js';
import { MeldInterpreterError } from '@core/errors/MeldInterpreterError.js';
import { MeldNode, DirectiveNode as MeldDirective, TextNode, SourceLocation } from 'meld-spec';
// Mock dependencies
vi.mock('../../DirectiveService/DirectiveService');
vi.mock('../../StateService/StateService');
describe('InterpreterService Unit', () => {
let service: InterpreterService;
let mockDirectiveService: Mocked<DirectiveService>;
let mockStateService: Mocked<StateService>;
let mockChildState: Mocked<StateService>;
beforeEach((): void => {
// Clear all mocks
vi.clearAllMocks();
// Create mock child state with immutable state support
mockChildState = {
setCurrentFilePath: vi.fn(),
getCurrentFilePath: vi.fn(),
addNode: vi.fn(),
mergeChildState: vi.fn(),
clone: vi.fn().mockReturnThis(),
getTextVar: vi.fn(),
getDataVar: vi.fn(),
getNodes: vi.fn().mockReturnValue([]),
setImmutable: vi.fn(),
setTextVar: vi.fn(),
createChildState: vi.fn().mockReturnThis(),
variables: {
text: new Map(),
data: new Map(),
path: new Map()
},
commands: new Map(),
imports: new Set(),
nodes: [],
filePath: undefined,
parentState: undefined
} as unknown as Mocked<StateService>;
// Create mock instances
mockDirectiveService = {
initialize: vi.fn(),
processDirective: vi.fn().mockResolvedValue(mockChildState),
handleDirective: vi.fn(),
validateDirective: vi.fn(),
createChildContext: vi.fn(),
processDirectives: vi.fn(),
supportsDirective: vi.fn(),
getSupportedDirectives: vi.fn(),
updateInterpreterService: vi.fn(),
registerHandler: vi.fn(),
hasHandler: vi.fn()
} as unknown as Mocked<DirectiveService>;
mockStateService = {
createChildState: vi.fn().mockReturnValue(mockChildState),
addNode: vi.fn(),
mergeStates: vi.fn(),
setCurrentFilePath: vi.fn(),
getCurrentFilePath: vi.fn(),
getTextVar: vi.fn(),
getDataVar: vi.fn(),
getNodes: vi.fn().mockReturnValue([]),
setImmutable: vi.fn(),
setTextVar: vi.fn(),
clone: vi.fn().mockReturnThis(),
mergeChildState: vi.fn(),
variables: {
text: new Map(),
data: new Map(),
path: new Map()
},
commands: new Map(),
imports: new Set(),
nodes: [],
filePath: undefined,
parentState: undefined
} as unknown as Mocked<StateService>;
// Initialize service
service = new InterpreterService();
service.initialize(mockDirectiveService, mockStateService);
});
describe('initialization', () => {
it('initializes with required services', (): void => {
expect(service).toBeDefined();
expect(service['directiveService']).toBe(mockDirectiveService);
expect(service['stateService']).toBe(mockStateService);
});
it('throws if initialized without required services', async (): Promise<void> => {
const uninitializedService = new InterpreterService();
await expect(() => uninitializedService.interpret([])).rejects.toThrow('InterpreterService must be initialized before use');
});
});
describe('node interpretation', () => {
it('processes text nodes directly', async (): Promise<void> => {
const textNode: TextNode = {
type: 'Text',
content: 'Hello world',
location: {
start: { line: 1, column: 1 },
end: { line: 1, column: 12 }
}
} as TextNode;
await service.interpret([textNode]);
expect(mockChildState.addNode).toHaveBeenCalledWith(textNode);
});
it('delegates directive nodes to directive service', async (): Promise<void> => {
const directiveNode: MeldDirective = {
type: 'Directive',
directive: {
kind: 'text',
identifier: 'test',
value: 'value'
},
location: { start: { line: 1, column: 1 }, end: { line: 1, column: 30 } }
};
mockChildState.getCurrentFilePath.mockReturnValue('test.meld');
await service.interpret([directiveNode]);
expect(mockDirectiveService.processDirective).toHaveBeenCalledWith(
directiveNode,
expect.objectContaining({
state: expect.any(Object),
currentFilePath: 'test.meld'
})
);
expect(directiveNode.type).toBe('Directive');
expect(directiveNode.directive.kind).toBe('text');
});
it('throws on unknown node types', async (): Promise<void> => {
const unknownNode = {
type: 'Unknown',
location: { start: { line: 1, column: 1 }, end: { line: 1, column: 30 } }
} as unknown as MeldNode;
await expect(service.interpret([unknownNode])).rejects.toThrow(/unknown node type/i);
});
});
describe('state management', () => {
it('creates new state for each interpretation', async (): Promise<void> => {
const nodes: MeldNode[] = [];
await service.interpret(nodes);
expect(mockStateService.createChildState).toHaveBeenCalled();
});
it('uses provided initial state when specified', async (): Promise<void> => {
const nodes: MeldNode[] = [];
const initialState = mockStateService;
await service.interpret(nodes, { initialState });
expect(mockStateService.createChildState).toHaveBeenCalled();
});
it('merges state when specified', async (): Promise<void> => {
const nodes: MeldNode[] = [];
const initialState = mockStateService;
await service.interpret(nodes, {
initialState,
mergeState: true
});
expect(mockStateService.mergeChildState).toHaveBeenCalled();
});
});
describe('error handling', () => {
it('wraps non-interpreter errors', async (): Promise<void> => {
const error = new Error('Test error');
mockDirectiveService.processDirective.mockRejectedValue(error);
const directiveNode: MeldDirective = {
type: 'Directive',
directive: {
kind: 'text',
identifier: 'test',
value: 'value'
},
location: { start: { line: 1, column: 1 }, end: { line: 1, column: 30 } }
};
await expect(service.interpret([directiveNode])).rejects.toBeInstanceOf(MeldInterpreterError);
});
it('preserves interpreter errors', async (): Promise<void> => {
const error = new MeldInterpreterError('Test error', 'test');
mockDirectiveService.processDirective.mockRejectedValue(error);
const directiveNode: MeldDirective = {
type: 'Directive',
directive: {
kind: 'text',
identifier: 'test',
value: 'value'
},
location: { start: { line: 1, column: 1 }, end: { line: 1, column: 30 } }
};
await expect(service.interpret([directiveNode])).rejects.toEqual(error);
});
it('includes node location in errors', async (): Promise<void> => {
const error = new Error('Test error');
mockDirectiveService.processDirective.mockRejectedValue(error);
const directiveNode: MeldDirective = {
type: 'Directive',
directive: {
kind: 'text',
identifier: 'test',
value: 'value'
},
location: { start: { line: 42, column: 10 }, end: { line: 42, column: 30 } }
};
try {
await service.interpret([directiveNode]);
expect.fail('Should have thrown error');
} catch (e) {
expect(e).toBeInstanceOf(MeldInterpreterError);
if (e instanceof MeldInterpreterError && directiveNode.location) {
expect(e.location).toEqual({
line: directiveNode.location.start.line,
column: directiveNode.location.start.column
});
}
}
});
});
describe('options handling', () => {
it('sets file path in state when provided', async (): Promise<void> => {
const nodes: MeldNode[] = [];
await service.interpret(nodes, {
filePath: 'test.meld'
});
expect(mockChildState.setCurrentFilePath).toHaveBeenCalledWith('test.meld');
});
it('passes options to directive service', async (): Promise<void> => {
const directiveNode: MeldDirective = {
type: 'Directive',
directive: {
kind: 'text',
identifier: 'test',
value: 'value'
},
location: { start: { line: 1, column: 1 }, end: { line: 1, column: 30 } }
};
const options = {
filePath: 'test.meld'
};
mockChildState.getCurrentFilePath.mockReturnValue('test.meld');
await service.interpret([directiveNode], options);
expect(mockDirectiveService.processDirective).toHaveBeenCalledWith(
directiveNode,
expect.objectContaining({
state: expect.any(Object),
currentFilePath: 'test.meld'
})
);
});
});
describe('child context creation', () => {
it('creates child context with parent state', async () => {
const parentState = mockStateService;
const childState = await service.createChildContext(parentState);
expect(mockStateService.createChildState).toHaveBeenCalled();
expect(childState).toBeDefined();
});
it('sets file path in child context when provided', async () => {
const parentState = mockStateService;
const filePath = 'test.meld';
const childState = await service.createChildContext(parentState, filePath);
expect(mockChildState.setCurrentFilePath).toHaveBeenCalledWith(filePath);
});
it('handles errors in child context creation', async () => {
const error = new Error('Test error');
mockStateService.createChildState.mockImplementation(() => {
throw error;
});
await expect(service.createChildContext(mockStateService))
.rejects.toBeInstanceOf(MeldInterpreterError);
});
});
describe('edge cases', () => {
it('handles empty node arrays', async () => {
const result = await service.interpret([]);
expect(result).toBeDefined();
expect(result.getNodes()).toHaveLength(0);
});
it('handles null/undefined nodes', async () => {
await expect(service.interpret(null as unknown as MeldNode[]))
.rejects.toThrow('No nodes provided for interpretation');
});
it('handles state initialization failures', async () => {
mockStateService.createChildState.mockReturnValue(null as unknown as StateService);
await expect(service.interpret([]))
.rejects.toThrow('Failed to initialize state for interpretation');
});
it('handles directive service initialization failures', async () => {
const directiveNode: MeldDirective = {
type: 'Directive',
directive: {
kind: 'text',
identifier: 'test',
value: 'value'
},
location: { start: { line: 1, column: 1 }, end: { line: 1, column: 30 } }
};
// Initialize with state service but no directive service
service = new InterpreterService();
service.initialize(mockDirectiveService, mockStateService);
service['directiveService'] = undefined;
await expect(service.interpret([directiveNode]))
.rejects.toThrow('InterpreterService must be initialized before use');
});
it('preserves node order in state', async () => {
const nodes: MeldNode[] = [
{
type: 'Text',
content: 'first',
location: {
start: { line: 1, column: 1 },
end: { line: 1, column: 6 }
}
} as TextNode,
{
type: 'Text',
content: 'second',
location: {
start: { line: 2, column: 1 },
end: { line: 2, column: 7 }
}
} as TextNode
];
mockChildState.getNodes.mockReturnValue(nodes);
const result = await service.interpret(nodes);
const resultNodes = result.getNodes();
expect(resultNodes).toHaveLength(2);
expect(resultNodes[0].type).toBe('Text');
expect((resultNodes[0] as TextNode).content).toBe('first');
expect(resultNodes[1].type).toBe('Text');
expect((resultNodes[1] as TextNode).content).toBe('second');
});
it('handles state rollback on partial failures', async () => {
const nodes: MeldNode[] = [
{
type: 'Text',
content: 'first',
location: {
start: { line: 1, column: 1 },
end: { line: 1, column: 6 }
}
} as TextNode,
{
type: 'Directive',
directive: {
kind: 'text',
identifier: 'test',
value: 'value'
},
location: { start: { line: 2, column: 1 }, end: { line: 2, column: 30 } }
} as MeldDirective,
{
type: 'Text',
content: 'third',
location: {
start: { line: 3, column: 1 },
end: { line: 3, column: 6 }
}
} as TextNode
];
mockDirectiveService.processDirective.mockRejectedValue(new Error('Test error'));
try {
await service.interpret(nodes);
expect.fail('Should have thrown error');
} catch (error) {
expect(error).toBeInstanceOf(MeldInterpreterError);
const state = mockStateService.createChildState();
expect(state.getNodes()).toHaveLength(0);
}
});
});
});