meld
Version:
Meld: A template language for LLM prompts
380 lines (315 loc) • 13.9 kB
text/typescript
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TextDirectiveHandler } from './TextDirectiveHandler.js';
import { createMockStateService, createMockValidationService, createMockResolutionService } from '@tests/utils/testFactories.js';
import { DirectiveError } from '@services/pipeline/DirectiveService/errors/DirectiveError.js';
import type { DirectiveNode } from 'meld-spec';
import type { IStateService } from '@services/state/StateService/IStateService.js';
import { StringLiteralHandler } from '@services/resolution/ResolutionService/resolvers/StringLiteralHandler.js';
import { StringConcatenationHandler } from '@services/resolution/ResolutionService/resolvers/StringConcatenationHandler.js';
// Import the centralized syntax examples and helpers
import { textDirectiveExamples } from '@core/syntax/index.js';
import { ErrorSeverity } from '@core/errors';
/**
* Helper function to create real AST nodes using meld-ast
*/
const createNodeFromExample = async (code: string): Promise<DirectiveNode> => {
try {
const { parse } = await import('meld-ast');
const result = await parse(code, {
trackLocations: true,
validateNodes: true,
structuredPaths: true
});
return result.ast[0] as DirectiveNode;
} catch (error) {
console.error('Error parsing with meld-ast:', error);
throw error;
}
};
describe('TextDirectiveHandler', () => {
let handler: TextDirectiveHandler;
let stateService: ReturnType<typeof createMockStateService>;
let validationService: ReturnType<typeof createMockValidationService>;
let resolutionService: ReturnType<typeof createMockResolutionService>;
let clonedState: IStateService;
// Create real instances of the literal and concatenation handlers for testing
let realStringLiteralHandler: StringLiteralHandler;
let realStringConcatenationHandler: StringConcatenationHandler;
beforeEach(() => {
clonedState = {
setTextVar: vi.fn(),
getTextVar: vi.fn(),
getDataVar: vi.fn(),
clone: vi.fn(),
} as unknown as IStateService;
stateService = {
setTextVar: vi.fn(),
getTextVar: vi.fn(),
getDataVar: vi.fn(),
clone: vi.fn().mockReturnValue(clonedState)
} as unknown as IStateService;
validationService = createMockValidationService();
resolutionService = createMockResolutionService();
// Create real handlers to match actual implementation
realStringLiteralHandler = new StringLiteralHandler();
realStringConcatenationHandler = new StringConcatenationHandler(resolutionService);
// Set up better mocking for variable resolution
resolutionService.resolveInContext.mockImplementation(async (value: string, context: any) => {
// Use real string literal handler for string literals
if (realStringLiteralHandler.isStringLiteral(value)) {
return realStringLiteralHandler.parseLiteral(value);
}
// Handle common test case values - this simulates what the real ResolutionService would do
if (value.includes('{{name}}')) {
return value.replace(/\{\{name\}\}/g, 'World');
}
if (value.includes('{{user.name}}')) {
return value.replace(/\{\{user\.name\}\}/g, 'Alice');
}
if (value.includes('{{ENV_HOME}}')) {
return value.replace(/\{\{ENV_HOME\}\}/g, '/home/user');
}
if (value.includes('{{missing}}')) {
throw new Error('Variable not found: missing');
}
// Special case for pass-through directives test
if (value === '"@run echo \\"test\\""') {
return '@run echo "test"';
}
// For string concatenation tests
if (value === '"Hello" ++ " " ++ "World"') {
return 'Hello World';
}
if (value === '"Hello " ++ "{{name}}"') {
return 'Hello World';
}
if (value === '"Prefix: " ++ "Header" ++ "Footer"') {
return 'Prefix: HeaderFooter';
}
if (value === '"double" ++ \'single\' ++ `backtick`') {
return 'doublesinglebacktick';
}
return value;
});
// Mock validation service to fail for invalid nodes
validationService.validate.mockImplementation((node: any) => {
if (node.directive?.value === "'unclosed string") {
throw new Error('Invalid string literal: unclosed string');
}
if (node.directive?.value === '"no"++"spaces"') {
throw new Error('Invalid concatenation syntax');
}
return Promise.resolve();
});
handler = new TextDirectiveHandler(validationService, stateService, resolutionService);
});
describe('execute', () => {
it('should handle a simple text assignment with string literal', async () => {
// Arrange
const example = textDirectiveExamples.atomic.simpleString;
const node = await createNodeFromExample(example.code);
const context = {
state: stateService,
currentFilePath: 'test.meld'
};
const result = await handler.execute(node, context);
// The example uses 'greeting' as the identifier and "Hello" as the value
expect(clonedState.setTextVar).toHaveBeenCalledWith('greeting', 'Hello');
});
it('should handle text assignment with escaped characters', async () => {
// Arrange
const example = textDirectiveExamples.atomic.escapedCharacters;
const node = await createNodeFromExample(example.code);
// Special case - direct mock to handle expected behavior
resolutionService.resolveInContext.mockImplementation(async () => {
return 'Line 1\nLine 2\t"Quoted"';
});
const context = {
state: stateService,
currentFilePath: 'test.meld'
};
const result = await handler.execute(node, context);
expect(clonedState.setTextVar).toHaveBeenCalledWith('escaped', 'Line 1\nLine 2\t"Quoted"');
});
it('should handle a template literal in text directive', async () => {
// Arrange
const example = textDirectiveExamples.atomic.templateLiteral;
const node = await createNodeFromExample(example.code);
const context = {
state: stateService,
currentFilePath: 'test.meld'
};
const result = await handler.execute(node, context);
expect(clonedState.setTextVar).toHaveBeenCalledWith('message', 'Template content');
});
it('should handle object property interpolation in text value', async () => {
// Arrange
const example = textDirectiveExamples.combinations.objectInterpolation;
// For this test, we need a custom implementation
const mockResolveInContext = resolutionService.resolveInContext;
resolutionService.resolveInContext = vi.fn().mockImplementation(() => {
return 'Hello, Alice! Your ID is 123.';
});
const node = await createNodeFromExample(example.code.split('\n')[1]); // Get the second line with greeting directive
const context = {
state: stateService,
currentFilePath: 'test.meld'
};
const result = await handler.execute(node, context);
expect(clonedState.setTextVar).toHaveBeenCalledWith('greeting', 'Hello, Alice! Your ID is 123.');
// Restore the original mock
resolutionService.resolveInContext = mockResolveInContext;
});
it('should handle path referencing in text values', async () => {
// Arrange
const example = textDirectiveExamples.combinations.pathReferencing;
// For this test, we need a custom implementation
const mockResolveInContext = resolutionService.resolveInContext;
resolutionService.resolveInContext = vi.fn().mockImplementation(() => {
return 'Docs are at $PROJECTPATH/docs';
});
const node = await createNodeFromExample(example.code.split('\n')[5]); // Get the docsText line
const context = {
state: stateService,
currentFilePath: 'test.meld'
};
const result = await handler.execute(node, context);
expect(clonedState.setTextVar).toHaveBeenCalledWith('configText', 'Docs are at $PROJECTPATH/docs');
// Restore the original mock
resolutionService.resolveInContext = mockResolveInContext;
});
it('should return error if text interpolation contains undefined variables', async () => {
// Arrange
const example = textDirectiveExamples.invalid.undefinedVariable;
// For error testing, we need to create a custom implementation
// that throws an error for this specific test
const mockResolveInContext = resolutionService.resolveInContext;
resolutionService.resolveInContext = vi.fn().mockImplementation(() => {
throw new Error('Variable not found: undefined_var');
});
const node = await createNodeFromExample(example.code);
const context = {
state: stateService,
currentFilePath: 'test.meld'
};
await expect(handler.execute(node, context))
.rejects
.toThrow(DirectiveError);
// Restore the original mock
resolutionService.resolveInContext = mockResolveInContext;
});
it('should handle basic variable interpolation', async () => {
// Arrange
const example = textDirectiveExamples.combinations.basicInterpolation;
// For this test, we need a custom implementation
const mockResolveInContext = resolutionService.resolveInContext;
resolutionService.resolveInContext = vi.fn().mockImplementation(() => {
return 'Hello, World!';
});
const node = await createNodeFromExample(example.code.split('\n')[2]); // Get the third line with the message directive
const context = {
state: stateService,
currentFilePath: 'test.meld'
};
const result = await handler.execute(node, context);
expect(clonedState.setTextVar).toHaveBeenCalledWith('message', 'Hello, World!');
// Restore the original mock
resolutionService.resolveInContext = mockResolveInContext;
});
it('should register the node as a text directive in the registry', async () => {
// Arrange
const example = textDirectiveExamples.atomic.simpleString;
const node = await createNodeFromExample(example.code);
const context = {
state: stateService,
currentFilePath: 'test.meld'
};
const result = await handler.execute(node, context);
// The example uses 'greeting' as the identifier and "Hello" as the value
expect(clonedState.setTextVar).toHaveBeenCalledWith('greeting', 'Hello');
});
it('should report error for unclosed string', async () => {
// For invalid test cases, we'll need to manually create nodes
// since meld-ast would throw on these during parsing
const invalidExample = textDirectiveExamples.invalid.unclosedString;
// Create a mock node directly instead of parsing invalid syntax
const node = {
type: 'Directive',
directive: {
kind: 'text',
identifier: 'greeting',
value: '"unclosed string'
},
location: {
start: { line: 1, column: 1 },
end: { line: 1, column: 30 }
}
};
// Ensure our mock validation service rejects this
validationService.validate.mockRejectedValueOnce(new Error('Invalid string literal: unclosed string'));
await expect(handler.execute(node, {
state: stateService,
currentFilePath: 'test.meld'
}))
.rejects
.toThrow(DirectiveError);
});
});
/**
* This section demonstrates how to use the centralized syntax system
* once the import issues are fixed.
*
* NOTE: This section is commented out until the centralized system imports
* are working properly.
*/
/*
describe('centralized syntax examples (future implementation)', () => {
it('should handle atomic examples correctly', async () => {
// Using the centralized atomic examples
const example = getExample('text', 'atomic', 'simpleString');
const node = await createNodeFromExample(example.code);
const context = {
state: stateService,
currentFilePath: 'test.meld'
};
await handler.execute(node, context);
expect(clonedState.setTextVar).toHaveBeenCalledWith('greeting', 'Hello');
});
it('should reject invalid examples', async () => {
// Using the centralized invalid examples
// Note: For invalid syntax, we need to manually create nodes since parsing would fail
const invalidExample = getInvalidExample('text', 'unclosedString');
// Create a node that represents what the parser would have created
// if it didn't throw on invalid syntax
const node: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'text',
identifier: 'invalid',
value: invalidExample.code.split('=')[1]?.trim() || ''
},
location: {
start: { line: 1, column: 1 },
end: { line: 1, column: invalidExample.code.length }
}
};
const context = {
state: stateService,
currentFilePath: 'test.meld'
};
// Make the validation service reject this as expected by the invalid example
validationService.validate.mockRejectedValueOnce(
new Error(invalidExample.expectedError.message)
);
await expect(handler.execute(node, context))
.rejects
.toThrow(DirectiveError);
});
it('should test multiple examples in bulk', async () => {
// This is a demonstration of using testParserWithValidExamples
// to test multiple examples at once
testParserWithValidExamples(handler, 'text', 'atomic');
});
});
*/
});