302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { TemplateManager } from '../../../src/card-controller/templates/index';
|
|
import { createConfig } from '../../config/test-utils';
|
|
import {
|
|
createHASS,
|
|
createStateEntity,
|
|
stubConnectedHomeAssistant,
|
|
} from '../../test-utils';
|
|
|
|
// ha-nunjucks reads `window`/`document` at import and renders via a
|
|
// `window.haNunjucks` global, so the renderer needs a DOM environment.
|
|
// @vitest-environment jsdom
|
|
describe('TemplateManager', () => {
|
|
beforeAll(() => stubConnectedHomeAssistant());
|
|
|
|
describe('loadRenderer', () => {
|
|
it('should render a template raw before the engine is loaded', () => {
|
|
const manager = new TemplateManager();
|
|
const result = manager.renderRecursively(createHASS(), '{{ acc.camera }}', {
|
|
conditionState: { camera: 'camera.office' },
|
|
});
|
|
expect(result).toBe('{{ acc.camera }}');
|
|
});
|
|
|
|
it('should render a template after the engine is loaded', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
|
|
const result = manager.renderRecursively(createHASS(), '{{ acc.camera }}', {
|
|
conditionState: { camera: 'camera.office' },
|
|
});
|
|
expect(result).toBe('camera.office');
|
|
});
|
|
|
|
it('should be idempotent across repeat loads', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
await manager.loadRenderer();
|
|
|
|
const result = manager.renderRecursively(createHASS(), '{{ acc.camera }}', {
|
|
conditionState: { camera: 'camera.office' },
|
|
});
|
|
expect(result).toBe('camera.office');
|
|
});
|
|
|
|
it('should pass a non-template string through unchanged before loading', () => {
|
|
const manager = new TemplateManager();
|
|
expect(manager.renderRecursively(createHASS(), 'hello world')).toBe('hello world');
|
|
});
|
|
|
|
it('should prime the renderer on demand when a template is rendered unloaded', () => {
|
|
const manager = new TemplateManager();
|
|
const loadRenderer = vi.spyOn(manager, 'loadRenderer').mockResolvedValue();
|
|
|
|
manager.renderRecursively(createHASS(), '{{ acc.camera }}');
|
|
|
|
expect(loadRenderer).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should swallow a load failure when priming on an unloaded render', () => {
|
|
const manager = new TemplateManager();
|
|
vi.spyOn(manager, 'loadRenderer').mockRejectedValue(new Error('load failed'));
|
|
|
|
expect(() =>
|
|
manager.renderRecursively(createHASS(), '{{ acc.camera }}'),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('isLoaded', () => {
|
|
it('should report false before loading and true after', async () => {
|
|
const manager = new TemplateManager();
|
|
expect(manager.isLoaded()).toBe(false);
|
|
|
|
await manager.loadRenderer();
|
|
expect(manager.isLoaded()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('dataContainsTemplate', () => {
|
|
it.each([
|
|
['an expression marker', 'before {{ acc.camera }} after', true],
|
|
['a statement marker', '{% if acc.camera %}x{% endif %}', true],
|
|
['a template nested in an object', { a: { b: '{{ acc.view }}' } }, true],
|
|
['a template nested in an array', ['plain', '{{ acc.view }}'], true],
|
|
['a plain string', 'no templates here', false],
|
|
['an unmatched marker', 'an isolated {{ with no close', false],
|
|
['a non-string value', { count: 42, on: true }, false],
|
|
['an undefined value', undefined, false],
|
|
])('should detect %s', (_name, data, expected) => {
|
|
expect(TemplateManager.dataContainsTemplate(data)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe('renderRecursively', () => {
|
|
it('should render string templates with camera context', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
const result = manager.renderRecursively(hass, 'Camera: {{ acc.camera }}', {
|
|
conditionState: { camera: 'camera.office' },
|
|
});
|
|
expect(result).toBe('Camera: camera.office');
|
|
});
|
|
|
|
it('should render string templates with view context', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
const result = manager.renderRecursively(hass, 'View: {{ acc.view }}', {
|
|
conditionState: { view: 'live' },
|
|
});
|
|
expect(result).toBe('View: live');
|
|
});
|
|
|
|
it('should render string templates with the acc context', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
const result = manager.renderRecursively(
|
|
hass,
|
|
'{{ acc.camera }} - {{ acc.view }}',
|
|
{
|
|
conditionState: { camera: 'camera.front', view: 'clips' },
|
|
},
|
|
);
|
|
expect(result).toBe('camera.front - clips');
|
|
});
|
|
|
|
it('should render templates in arrays', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
const result = manager.renderRecursively(
|
|
hass,
|
|
['{{ acc.camera }}', 'static', '{{ acc.view }}'],
|
|
{ conditionState: { camera: 'camera.office', view: 'live' } },
|
|
);
|
|
expect(result).toEqual(['camera.office', 'static', 'live']);
|
|
});
|
|
|
|
it('should render templates in object values', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
const result = manager.renderRecursively(
|
|
hass,
|
|
{ camera: '{{ acc.camera }}', view: '{{ acc.view }}', static: 'value' },
|
|
{ conditionState: { camera: 'camera.office', view: 'live' } },
|
|
);
|
|
expect(result).toEqual({ camera: 'camera.office', view: 'live', static: 'value' });
|
|
});
|
|
|
|
it('should render templates in nested objects', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
const result = manager.renderRecursively(
|
|
hass,
|
|
{ outer: { inner: '{{ acc.camera }}' } },
|
|
{ conditionState: { camera: 'camera.office' } },
|
|
);
|
|
expect(result).toEqual({ outer: { inner: 'camera.office' } });
|
|
});
|
|
|
|
it('should return non-string/array/object values unchanged', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
expect(manager.renderRecursively(hass, 42)).toBe(42);
|
|
expect(manager.renderRecursively(hass, true)).toBe(true);
|
|
expect(manager.renderRecursively(hass, null)).toBe(null);
|
|
});
|
|
|
|
it('should render strings without templates unchanged', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
expect(manager.renderRecursively(hass, 'hello world')).toBe('hello world');
|
|
});
|
|
|
|
it('should render with a top-level stock trigger context', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
const result = manager.renderRecursively(hass, '{{ trigger.to_state.state }}', {
|
|
triggerData: {
|
|
platform: 'state',
|
|
entity_id: 'binary_sensor.door',
|
|
to_state: createStateEntity({ state: 'on' }),
|
|
},
|
|
});
|
|
expect(result).toBe('on');
|
|
});
|
|
|
|
it('should render with a top-level card trigger context', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
const result = manager.renderRecursively(hass, '{{ trigger.to_acc.camera }}', {
|
|
triggerData: {
|
|
platform: 'acc',
|
|
type: 'camera',
|
|
from_acc: { camera: 'camera.front' },
|
|
to_acc: { camera: 'camera.backyard' },
|
|
},
|
|
});
|
|
expect(result).toBe('camera.backyard');
|
|
});
|
|
|
|
it('should render with config context', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
const result = manager.renderRecursively(hass, '{{ acc.config.view.default }}', {
|
|
conditionState: { config: createConfig({ view: { default: 'clips' } }) },
|
|
});
|
|
expect(result).toBe('clips');
|
|
});
|
|
|
|
it('should render with mediaData context', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
const result = manager.renderRecursively(
|
|
hass,
|
|
'Title: {{ acc.media.title }}, Folder: {{ acc.media.is_folder }}',
|
|
{
|
|
mediaData: { title: 'Test Media', is_folder: false },
|
|
},
|
|
);
|
|
expect(result).toBe('Title: Test Media, Folder: false');
|
|
});
|
|
|
|
it('should render with combined context options', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
const result = manager.renderRecursively(
|
|
hass,
|
|
'{{ acc.camera }} - {{ acc.media.title }}',
|
|
{
|
|
conditionState: { camera: 'camera.office' },
|
|
mediaData: { title: 'My Video', is_folder: false },
|
|
},
|
|
);
|
|
expect(result).toBe('camera.office - My Video');
|
|
});
|
|
|
|
it('should return undefined context when no options provided', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
// Without options, templates referencing acc should render empty.
|
|
const result = manager.renderRecursively(hass, 'Value: {{ acc.camera }}');
|
|
expect(result).toBe('Value:');
|
|
});
|
|
|
|
it('should return undefined context when options have no relevant data', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
// Empty conditionState without camera or view should not create context.
|
|
const result = manager.renderRecursively(hass, 'Value: {{ acc.camera }}', {
|
|
conditionState: {},
|
|
});
|
|
expect(result).toBe('Value:');
|
|
});
|
|
});
|
|
|
|
describe('renderRecursivelyAsType', () => {
|
|
it('should render in place while preserving the input structure', async () => {
|
|
const manager = new TemplateManager();
|
|
await manager.loadRenderer();
|
|
const hass = createHASS();
|
|
|
|
const result = manager.renderRecursivelyAsType(
|
|
hass,
|
|
{ camera: '{{ acc.camera }}', static: 'value' },
|
|
{ conditionState: { camera: 'camera.office' } },
|
|
);
|
|
expect(result).toEqual({ camera: 'camera.office', static: 'value' });
|
|
});
|
|
});
|
|
});
|