diff --git a/src/card-controller/actions/actions-manager.ts b/src/card-controller/actions/actions-manager.ts index e0ab5fc7..8ec48540 100644 --- a/src/card-controller/actions/actions-manager.ts +++ b/src/card-controller/actions/actions-manager.ts @@ -187,15 +187,14 @@ export class ActionsManager implements ActionsExecutor { triggerData?: TriggerData, ): ActionPrepareCallback { // 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 - // here as renderRecursively returns `unknown`. + // fixed trigger context, fresh card/HASS state per step. return (value: T): T => { const hass = this._api.getHASSManager().getHASS(); return hass - ? (renderer.renderRecursively(hass, value, { + ? renderer.renderRecursivelyAsType(hass, value, { conditionState: this._api.getConditionStateManager().getState(), triggerData, - }) as T) + }) : value; }; } diff --git a/src/card-controller/templates/index.ts b/src/card-controller/templates/index.ts index a423451a..7ba095c7 100644 --- a/src/card-controller/templates/index.ts +++ b/src/card-controller/templates/index.ts @@ -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 = ( + hass: HomeAssistant, + data: T, + options?: TemplateRenderOptions, + ): T => this.renderRecursively(hass, data, options) as T; + private _generateTemplateContext( options?: TemplateRenderOptions, ): TemplateContext | undefined { diff --git a/src/components/elements.ts b/src/components/elements.ts index 304c9de8..14262e43 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -136,9 +136,13 @@ export class AdvancedCameraCardElementsCore extends LitElement { return; } - const elements = this._templateRenderer.renderRecursively(this.hass, this.elements, { - conditionState: this.conditionStateManager?.getState(), - }) as PictureElements | undefined; + const elements = this._templateRenderer.renderRecursivelyAsType( + this.hass, + this.elements, + { + conditionState: this.conditionStateManager?.getState(), + }, + ); // Condition state changes won't change the actual rendered config unless // `elements` has a template, which is more likely does not. Avoid updating diff --git a/tests/card-controller/actions/actions-manager.test.ts b/tests/card-controller/actions/actions-manager.test.ts index a44562a4..865d8e8f 100644 --- a/tests/card-controller/actions/actions-manager.test.ts +++ b/tests/card-controller/actions/actions-manager.test.ts @@ -337,7 +337,7 @@ describe('ActionsManager', () => { const action = createLogAction('{{ acc.camera }}'); const templateRenderer = mock(); - templateRenderer.renderRecursively.mockReturnValue(action); + templateRenderer.renderRecursivelyAsType.mockReturnValue(action); const api = createAPI(); const hass = createHASS(); @@ -360,7 +360,7 @@ describe('ActionsManager', () => { await manager.executeActions({ actions: action, config, triggerData }); - expect(templateRenderer.renderRecursively).toBeCalledWith(hass, action, { + expect(templateRenderer.renderRecursivelyAsType).toBeCalledWith(hass, action, { conditionState, triggerData, }); @@ -377,7 +377,7 @@ describe('ActionsManager', () => { }); const templateRenderer = mock(); - templateRenderer.renderRecursively.mockReturnValue(allowedAction); + templateRenderer.renderRecursivelyAsType.mockReturnValue(allowedAction); const api = createAPI(); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); @@ -399,7 +399,7 @@ describe('ActionsManager', () => { const templateRenderer = mock(); // 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(); 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 // sees the camera the first action set. - expect(templateRenderer.renderRecursively).toHaveBeenNthCalledWith( + expect(templateRenderer.renderRecursivelyAsType).toHaveBeenNthCalledWith( 1, expect.anything(), expect.anything(), expect.objectContaining({ conditionState: { camera: 'first' } }), ); - expect(templateRenderer.renderRecursively).toHaveBeenNthCalledWith( + expect(templateRenderer.renderRecursivelyAsType).toHaveBeenNthCalledWith( 2, expect.anything(), expect.anything(), @@ -440,7 +440,7 @@ describe('ActionsManager', () => { const ran: string[] = []; const templateRenderer = mock(); - templateRenderer.renderRecursively.mockImplementation((_hass, data) => data); + templateRenderer.renderRecursivelyAsType.mockImplementation((_hass, data) => data); const api = createAPI(); // 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 // HASS, so HASS is read per action rather than captured once. 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 () => { const ran: string[] = []; const templateRenderer = mock(); - templateRenderer.renderRecursively + templateRenderer.renderRecursivelyAsType .mockImplementationOnce((_hass, data) => data) .mockImplementationOnce(() => { throw new Error('bad template'); diff --git a/tests/card-controller/templates/index.test.ts b/tests/card-controller/templates/index.test.ts index 00be0493..7bc569f7 100644 --- a/tests/card-controller/templates/index.test.ts +++ b/tests/card-controller/templates/index.test.ts @@ -178,4 +178,18 @@ describe('TemplateRenderer', () => { 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' }); + }); + }); });