Closes #2531. ## Summary The full `nunjucks` templating engine (~226KB) plus `ha-nunjucks` (~46KB) — roughly **272KB, ~13% of the eager entry chunk** — was statically imported and downloaded by every card on initial load, even though templates only apply when a config value contains a `{{ … }}` / `{% … %}` delimiter. Most cards use no templates and never need the engine. This defers the engine behind a dynamic `import('ha-nunjucks/dist')` so it ships in a separate, on-demand chunk instead of the eager `card-*.js`. ## Approach The render path (`TemplateRenderer.renderRecursively`) is **kept synchronous** — it is called from many synchronous hot paths (condition/trigger evaluators, picture-elements rendering, actions, folder matchers), and making it async would be a large, high-risk refactor of the evaluation core. Instead: - **New `src/card-controller/templates/engine.ts`** — a module-level singleton lazy loader (`loadTemplateEngine()` / `getTemplateEngine()`) shared across all `TemplateRenderer` instances, plus a `containsTemplate()` delimiter helper. - **Delimiter gating** — strings without a delimiter never touch the engine (the overwhelming majority of renders). - **Pre-warm at config time** — because every template string originates in the config, a new mandatory `TEMPLATE_ENGINE` initialization aspect loads the engine before first render whenever the config contains a delimiter. This **guarantees no raw `{{ … }}` flash**: content/condition rendering is blocked until the engine is present for template-using cards. Cards without templates never load it. ## Result - nunjucks + ha-nunjucks move out of the eager chunk into a separate chunk fetched only when a card actually uses templates. - No change to the synchronous public render API; condition/trigger evaluation core untouched. ## Tests - New `engine.ts` loader coverage (concurrent load, cached reuse, not-loaded fallback). - The 11 existing test files that render real (delimiter-bearing) templates declare their dependency explicitly via `beforeAll(loadTemplateEngine)` — no global/implicit setup hook. - Full suite green (4764 tests), lint and ts-prune clean, per-file 100% coverage maintained for the affected directories. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016ykWemdkZrgywvC71pHc6c --- _Generated by [Claude Code](https://claude.ai/code/session_016ykWemdkZrgywvC71pHc6c)_
379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { mock } from 'vitest-mock-extended';
|
|
|
|
import type { ActionsExecutionRequest } from '../../src/card-controller/actions/types.js';
|
|
import { AutomationsManager } from '../../src/card-controller/automations-manager.js';
|
|
import type { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js';
|
|
import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager.js';
|
|
import {
|
|
createCardAPI,
|
|
createHASS,
|
|
createHASSEvent,
|
|
flushPromises,
|
|
} from '../test-utils.js';
|
|
|
|
describe('AutomationsManager', () => {
|
|
const actions = [
|
|
{
|
|
action: 'fire-dom-event' as const,
|
|
advanced_camera_card_action: 'clips',
|
|
},
|
|
];
|
|
const triggers = [{ trigger: 'fullscreen' as const, fullscreen: true }];
|
|
const automation = {
|
|
triggers: triggers,
|
|
actions: actions,
|
|
};
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('should not execute actions', () => {
|
|
it('should do nothing without hass', () => {
|
|
const api = createCardAPI();
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([automation]);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
});
|
|
|
|
it('should do nothing without being initialized', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
|
|
false,
|
|
);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([automation]);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
});
|
|
|
|
it('should do nothing when an issue is present', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
|
|
true,
|
|
);
|
|
vi.mocked(
|
|
api.getIssueManager().getStateManager().hasFullCardIssue,
|
|
).mockReturnValue(true);
|
|
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([automation]);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
});
|
|
});
|
|
|
|
it('should execute actions when triggered', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
|
|
true,
|
|
);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([automation]);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).toBeCalledTimes(1);
|
|
|
|
// It does not re-trigger while its source stays in the same state.
|
|
stateManager.setState({ fullscreen: true });
|
|
expect(api.getActionsManager().executeActions).toBeCalledTimes(1);
|
|
|
|
stateManager.setState({ fullscreen: false });
|
|
expect(api.getActionsManager().executeActions).toBeCalledTimes(1);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
expect(api.getActionsManager().executeActions).toBeCalledTimes(2);
|
|
});
|
|
|
|
it('should subscribe automations registered before initialization', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
const isInitialized = vi.mocked(
|
|
api.getInitializationManager().isInitializedMandatory,
|
|
);
|
|
isInitialized.mockReturnValue(false);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([automation]);
|
|
|
|
// Registered before initialization: the triggers are not yet subscribed, so
|
|
// a matching change does nothing.
|
|
stateManager.setState({ fullscreen: true });
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
|
|
// Initialization completes and subscribes the dormant automations.
|
|
isInitialized.mockReturnValue(true);
|
|
automationsManager.subscribe();
|
|
|
|
stateManager.setState({ fullscreen: false });
|
|
stateManager.setState({ fullscreen: true });
|
|
expect(api.getActionsManager().executeActions).toBeCalledTimes(1);
|
|
});
|
|
|
|
it('should run actions when the ongoing conditions hold', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
|
|
true,
|
|
);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: triggers,
|
|
conditions: [{ condition: 'expand' as const, expand: true }],
|
|
actions: actions,
|
|
},
|
|
]);
|
|
|
|
// The ongoing condition holds when triggered, so `actions` run.
|
|
stateManager.setState({ expand: true });
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).toBeCalledWith({
|
|
actions: actions,
|
|
triggerData: { platform: 'acc', type: 'fullscreen' },
|
|
});
|
|
});
|
|
|
|
it('should do nothing when the ongoing conditions do not hold', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
|
|
true,
|
|
);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: triggers,
|
|
conditions: [{ condition: 'expand' as const, expand: true }],
|
|
actions: actions,
|
|
},
|
|
]);
|
|
|
|
// The automation is triggered but the ongoing condition does not hold, so
|
|
// nothing runs.
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
});
|
|
|
|
it('should do nothing when the actions are empty', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
|
|
true,
|
|
);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: triggers,
|
|
actions: [],
|
|
},
|
|
]);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
|
|
expect(api.getActionsManager().executeActions).not.toBeCalled();
|
|
});
|
|
|
|
it('should prevent automation loops', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
|
|
true,
|
|
);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: [{ trigger: 'camera' as const }],
|
|
actions: actions,
|
|
},
|
|
]);
|
|
|
|
// Create a setup where the automation's action re-triggers itself: the camera
|
|
// trigger responds to every camera change, and the action changes the camera,
|
|
// looping until the nested-execution guard trips.
|
|
let camera = 'one';
|
|
|
|
vi.mocked(api.getActionsManager().executeActions).mockImplementation(
|
|
async (
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
_request: ActionsExecutionRequest,
|
|
): Promise<void> => {
|
|
camera = camera === 'one' ? 'two' : 'one';
|
|
stateManager.setState({ camera: camera });
|
|
},
|
|
);
|
|
|
|
stateManager.setState({ camera: camera });
|
|
|
|
expect(api.getNotificationManager().setNotification).toBeCalledWith({
|
|
heading: {
|
|
text: 'Too many nested automation calls, please check your configuration for loops',
|
|
icon: 'mdi:alert',
|
|
severity: 'high',
|
|
},
|
|
});
|
|
|
|
expect(api.getActionsManager().executeActions).toBeCalledTimes(10);
|
|
});
|
|
|
|
it('should reset the nested-execution counter after an overflow', async () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
|
|
true,
|
|
);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: [{ trigger: 'camera' as const }],
|
|
actions: actions,
|
|
},
|
|
]);
|
|
|
|
// As in the loop test: the action changes the camera, re-triggering itself
|
|
// until the nested-execution guard trips.
|
|
let camera = 'one';
|
|
vi.mocked(api.getActionsManager().executeActions).mockImplementation(
|
|
async (): Promise<void> => {
|
|
camera = camera === 'one' ? 'two' : 'one';
|
|
stateManager.setState({ camera: camera });
|
|
},
|
|
);
|
|
|
|
stateManager.setState({ camera: camera });
|
|
expect(api.getActionsManager().executeActions).toBeCalledTimes(10);
|
|
|
|
// The counter is decremented on the microtasks that resume after each
|
|
// awaited execution, so let them drain before the next batch.
|
|
await flushPromises();
|
|
|
|
vi.mocked(api.getActionsManager().executeActions).mockClear();
|
|
|
|
// A second, independent change overflows afresh and reaches the full limit
|
|
// again -- only possible if the counter returned to zero. A leaked counter
|
|
// (overflow returning without decrementing) would cut this batch short.
|
|
stateManager.setState({ camera: 'three' });
|
|
expect(api.getActionsManager().executeActions).toBeCalledTimes(10);
|
|
});
|
|
|
|
it('should execute actions on a matching HA bus event trigger', () => {
|
|
const api = createCardAPI();
|
|
const eventWatcher = mock<EventWatcherSubscriptionInterface>();
|
|
vi.mocked(eventWatcher.subscribe).mockResolvedValue();
|
|
vi.mocked(eventWatcher.unsubscribe).mockResolvedValue();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
|
vi.mocked(api.getHASSManager().getEventWatcher).mockReturnValue(eventWatcher);
|
|
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
|
|
true,
|
|
);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: [{ trigger: 'event' as const, event_type: 'zha_event' }],
|
|
actions: actions,
|
|
},
|
|
]);
|
|
|
|
expect(eventWatcher.subscribe).toBeCalledTimes(1);
|
|
|
|
// Simulate an event arrival.
|
|
const event = createHASSEvent('zha_event', { command: 'press' });
|
|
vi.mocked(eventWatcher.subscribe).mock.calls[0][0].callback(event);
|
|
|
|
expect(api.getActionsManager().executeActions).toBeCalledTimes(1);
|
|
expect(
|
|
vi.mocked(api.getActionsManager().executeActions).mock.calls[0][0].triggerData,
|
|
).toEqual({ platform: 'event', event });
|
|
});
|
|
|
|
it('should delete automations', () => {
|
|
const api = createCardAPI();
|
|
vi.mocked(api.getHASSManager().hasHASS).mockReturnValue(true);
|
|
vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue(
|
|
true,
|
|
);
|
|
const stateManager = new ConditionStateManager();
|
|
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
|
|
|
const automationsManager = new AutomationsManager(api);
|
|
automationsManager.addAutomations([
|
|
{
|
|
triggers: [{ trigger: 'expand' as const, expand: true }],
|
|
actions: actions,
|
|
},
|
|
{
|
|
triggers: [{ trigger: 'fullscreen' as const, fullscreen: true }],
|
|
actions: actions,
|
|
tag: 'fullscreen',
|
|
},
|
|
]);
|
|
|
|
stateManager.setState({ fullscreen: true });
|
|
expect(api.getActionsManager().executeActions).toBeCalledTimes(1);
|
|
|
|
// Delete the fullscreen automation.
|
|
automationsManager.deleteAutomations('fullscreen');
|
|
|
|
stateManager.setState({ fullscreen: false });
|
|
stateManager.setState({ fullscreen: true });
|
|
expect(api.getActionsManager().executeActions).toBeCalledTimes(1);
|
|
|
|
stateManager.setState({ expand: true });
|
|
expect(api.getActionsManager().executeActions).toBeCalledTimes(2);
|
|
|
|
// Delete all automations.
|
|
automationsManager.deleteAutomations();
|
|
|
|
stateManager.setState({ expand: false });
|
|
stateManager.setState({ expand: true });
|
|
expect(api.getActionsManager().executeActions).toBeCalledTimes(2);
|
|
});
|
|
});
|