refactor: Centralize template render type assertion (#2538)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent a31816c168
commit 5572ec728e
5 changed files with 44 additions and 16 deletions
@@ -187,15 +187,14 @@ export class ActionsManager implements ActionsExecutor {
triggerData?: TriggerData, triggerData?: TriggerData,
): ActionPrepareCallback { ): ActionPrepareCallback {
// Render against the state (incl. HASS) as it is *when the action runs* -- // Render against the state (incl. HASS) as it is *when the action runs* --
// fixed trigger context, fresh card/HASS state per step. The one cast lives // fixed trigger context, fresh card/HASS state per step.
// here as renderRecursively returns `unknown`.
return <T>(value: T): T => { return <T>(value: T): T => {
const hass = this._api.getHASSManager().getHASS(); const hass = this._api.getHASSManager().getHASS();
return hass return hass
? (renderer.renderRecursively(hass, value, { ? renderer.renderRecursivelyAsType(hass, value, {
conditionState: this._api.getConditionStateManager().getState(), conditionState: this._api.getConditionStateManager().getState(),
triggerData, triggerData,
}) as T) })
: value; : value;
}; };
} }
+11
View File
@@ -31,6 +31,17 @@ export class TemplateRenderer {
); );
}; };
// Structure-preserving variant of `renderRecursively`: arrays, records, and
// primitives keep their shape (only string leaves are rendered), so the
// caller's type is asserted back unchanged. Callers whose template renders to
// a *different* type than its input (e.g. a string that yields a boolean)
// must use `renderRecursively` and narrow the `unknown` result at runtime.
public renderRecursivelyAsType = <T>(
hass: HomeAssistant,
data: T,
options?: TemplateRenderOptions,
): T => this.renderRecursively(hass, data, options) as T;
private _generateTemplateContext( private _generateTemplateContext(
options?: TemplateRenderOptions, options?: TemplateRenderOptions,
): TemplateContext | undefined { ): TemplateContext | undefined {
+7 -3
View File
@@ -136,9 +136,13 @@ export class AdvancedCameraCardElementsCore extends LitElement {
return; return;
} }
const elements = this._templateRenderer.renderRecursively(this.hass, this.elements, { const elements = this._templateRenderer.renderRecursivelyAsType(
conditionState: this.conditionStateManager?.getState(), this.hass,
}) as PictureElements | undefined; this.elements,
{
conditionState: this.conditionStateManager?.getState(),
},
);
// Condition state changes won't change the actual rendered config unless // Condition state changes won't change the actual rendered config unless
// `elements` has a template, which is more likely does not. Avoid updating // `elements` has a template, which is more likely does not. Avoid updating
@@ -337,7 +337,7 @@ describe('ActionsManager', () => {
const action = createLogAction('{{ acc.camera }}'); const action = createLogAction('{{ acc.camera }}');
const templateRenderer = mock<TemplateRenderer>(); const templateRenderer = mock<TemplateRenderer>();
templateRenderer.renderRecursively.mockReturnValue(action); templateRenderer.renderRecursivelyAsType.mockReturnValue(action);
const api = createAPI(); const api = createAPI();
const hass = createHASS(); const hass = createHASS();
@@ -360,7 +360,7 @@ describe('ActionsManager', () => {
await manager.executeActions({ actions: action, config, triggerData }); await manager.executeActions({ actions: action, config, triggerData });
expect(templateRenderer.renderRecursively).toBeCalledWith(hass, action, { expect(templateRenderer.renderRecursivelyAsType).toBeCalledWith(hass, action, {
conditionState, conditionState,
triggerData, triggerData,
}); });
@@ -377,7 +377,7 @@ describe('ActionsManager', () => {
}); });
const templateRenderer = mock<TemplateRenderer>(); const templateRenderer = mock<TemplateRenderer>();
templateRenderer.renderRecursively.mockReturnValue(allowedAction); templateRenderer.renderRecursivelyAsType.mockReturnValue(allowedAction);
const api = createAPI(); const api = createAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
@@ -399,7 +399,7 @@ describe('ActionsManager', () => {
const templateRenderer = mock<TemplateRenderer>(); const templateRenderer = mock<TemplateRenderer>();
// Identity render -- assert on the render *inputs*, not a swapped output. // Identity render -- assert on the render *inputs*, not a swapped output.
templateRenderer.renderRecursively.mockImplementation((_hass, data) => data); templateRenderer.renderRecursivelyAsType.mockImplementation((_hass, data) => data);
const api = createAPI(); const api = createAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
@@ -422,13 +422,13 @@ describe('ActionsManager', () => {
// Each action renders with the state as it is at its turn: the second // Each action renders with the state as it is at its turn: the second
// sees the camera the first action set. // sees the camera the first action set.
expect(templateRenderer.renderRecursively).toHaveBeenNthCalledWith( expect(templateRenderer.renderRecursivelyAsType).toHaveBeenNthCalledWith(
1, 1,
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
expect.objectContaining({ conditionState: { camera: 'first' } }), expect.objectContaining({ conditionState: { camera: 'first' } }),
); );
expect(templateRenderer.renderRecursively).toHaveBeenNthCalledWith( expect(templateRenderer.renderRecursivelyAsType).toHaveBeenNthCalledWith(
2, 2,
expect.anything(), expect.anything(),
expect.anything(), expect.anything(),
@@ -440,7 +440,7 @@ describe('ActionsManager', () => {
const ran: string[] = []; const ran: string[] = [];
const templateRenderer = mock<TemplateRenderer>(); const templateRenderer = mock<TemplateRenderer>();
templateRenderer.renderRecursively.mockImplementation((_hass, data) => data); templateRenderer.renderRecursivelyAsType.mockImplementation((_hass, data) => data);
const api = createAPI(); const api = createAPI();
// No HASS for the first action's render; HASS thereafter. // No HASS for the first action's render; HASS thereafter.
@@ -464,14 +464,14 @@ describe('ActionsManager', () => {
// Both actions ran; only the second was rendered -- the first saw no // Both actions ran; only the second was rendered -- the first saw no
// HASS, so HASS is read per action rather than captured once. // HASS, so HASS is read per action rather than captured once.
expect(ran).toEqual(['one', 'two']); expect(ran).toEqual(['one', 'two']);
expect(templateRenderer.renderRecursively).toBeCalledTimes(1); expect(templateRenderer.renderRecursivelyAsType).toBeCalledTimes(1);
}); });
it('should abort the remaining actions when one fails to render', async () => { it('should abort the remaining actions when one fails to render', async () => {
const ran: string[] = []; const ran: string[] = [];
const templateRenderer = mock<TemplateRenderer>(); const templateRenderer = mock<TemplateRenderer>();
templateRenderer.renderRecursively templateRenderer.renderRecursivelyAsType
.mockImplementationOnce((_hass, data) => data) .mockImplementationOnce((_hass, data) => data)
.mockImplementationOnce(() => { .mockImplementationOnce(() => {
throw new Error('bad template'); throw new Error('bad template');
@@ -178,4 +178,18 @@ describe('TemplateRenderer', () => {
expect(result).toBe('Value:'); expect(result).toBe('Value:');
}); });
}); });
describe('renderRecursivelyAsType', () => {
it('should render in place while preserving the input structure', () => {
const renderer = new TemplateRenderer();
const hass = createHASS();
const result = renderer.renderRecursivelyAsType(
hass,
{ camera: '{{ acc.camera }}', static: 'value' },
{ conditionState: { camera: 'camera.office' } },
);
expect(result).toEqual({ camera: 'camera.office', static: 'value' });
});
});
}); });