From c4400194f5a5ee37aeb1c479341ad366c8fdf7a2 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 24 May 2025 16:14:09 -0700 Subject: [PATCH] fix: Allow multiple overrides to operate independently (#2063) - Closes #1954 --- src/card-controller/config/config-manager.ts | 34 +- .../config/get-overridden-config.ts | 77 ---- .../config/overrides-manager.ts | 102 +++++ src/config/schema/overrides.ts | 19 +- .../config/config-manager.test.ts | 372 ++++++++--------- .../config/get-overridden-config.test.ts | 305 -------------- .../config/overrides-manager.test.ts | 395 ++++++++++++++++++ 7 files changed, 704 insertions(+), 600 deletions(-) delete mode 100644 src/card-controller/config/get-overridden-config.ts create mode 100644 src/card-controller/config/overrides-manager.ts delete mode 100644 tests/card-controller/config/get-overridden-config.test.ts create mode 100644 tests/card-controller/config/overrides-manager.test.ts diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index b99d96cc..7f566a07 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -1,5 +1,4 @@ import { isEqual } from 'lodash-es'; -import { ConditionsManager } from '../../conditions/conditions-manager.js'; import { isConfigUpgradeable } from '../../config/management.js'; import { setProfiles } from '../../config/profiles/set-profiles.js'; import { @@ -12,11 +11,11 @@ import { localize } from '../../localize/localize.js'; import { getParseErrorPaths } from '../../utils/zod.js'; import { InitializationAspect } from '../initialization-manager.js'; import { CardConfigAPI } from '../types.js'; -import { getOverriddenConfig } from './get-overridden-config.js'; import { setAutomationsFromConfig } from './load-automations.js'; import { setRemoteControlEntityFromConfig } from './load-control-entities.js'; import { setFoldersFromConfig } from './load-folders.js'; import { setKeyboardShortcutsFromConfig } from './load-keyboard-shortcuts.js'; +import { OverridesManager } from './overrides-manager.js'; export class ConfigManager { protected _api: CardConfigAPI; @@ -29,7 +28,9 @@ export class ConfigManager { protected _overriddenConfig: AdvancedCameraCardConfig | null = null; protected _rawConfig: RawAdvancedCameraCardConfig | null = null; protected _cardWideConfig: CardWideConfig | null = null; - protected _overridesConditionsManager: ConditionsManager | null = null; + protected _overridesManager = new OverridesManager(() => + this._processOverrideConfig(), + ); constructor(api: CardConfigAPI) { this._api = api; @@ -89,14 +90,10 @@ export class ConfigManager { debug: config.debug, }; - this._overridesConditionsManager?.destroy(); - this._overridesConditionsManager = this._config.overrides?.length - ? new ConditionsManager( - this._config.overrides.map((override) => override.conditions).flat(), - this._api.getConditionStateManager(), - ) - : null; - this._overridesConditionsManager?.addListener(() => this._processOverrideConfig()); + this._overridesManager.set( + this._api.getConditionStateManager(), + this._config.overrides, + ); this._api.getConditionStateManager().setState({ view: undefined, @@ -121,11 +118,6 @@ export class ConfigManager { } protected _processOverrideConfig(): void { - /* istanbul ignore if: No (current) way to reach this code -- @preserve */ - if (!this._config) { - return; - } - const overriddenConfig = this._getOverriddenConfig(); // Save on Lit re-rendering costs by only updating the configuration if it @@ -163,15 +155,13 @@ export class ConfigManager { } protected _getOverriddenConfig(): AdvancedCameraCardConfig | null { - if (!this._overridesConditionsManager || !this._config) { - return this._config; + /* istanbul ignore if: No (current) way to reach this code -- @preserve */ + if (!this._config) { + return null; } try { - return getOverriddenConfig(this._overridesConditionsManager, this._config, { - configOverrides: this._config.overrides, - schema: advancedCameraCardConfigSchema, - }); + return this._overridesManager.getConfig(this._config); } catch (ev) { this._api.getMessageManager().setErrorIfHigherPriority(ev); return null; diff --git a/src/card-controller/config/get-overridden-config.ts b/src/card-controller/config/get-overridden-config.ts deleted file mode 100644 index 0a273fb7..00000000 --- a/src/card-controller/config/get-overridden-config.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { merge } from 'lodash-es'; -import { ZodType as ZodSchema } from 'zod'; -import { ConditionsManagerReadonlyInterface } from '../../conditions/types'; -import { - copyConfig, - deleteConfigValue, - getConfigValue, - setConfigValue, -} from '../../config/management'; -import { Overrides } from '../../config/schema/overrides'; -import { RawAdvancedCameraCardConfig } from '../../config/types'; -import { localize } from '../../localize/localize'; -import { AdvancedCameraCardError } from '../../types'; -import { desparsifyArrays } from '../../utils/basic'; - -class OverrideConfigurationError extends AdvancedCameraCardError {} - -export function getOverriddenConfig( - manager: ConditionsManagerReadonlyInterface, - config: Readonly, - options?: { - configOverrides?: Readonly; - schema?: ZodSchema; - }, -): RT { - if (!options?.configOverrides) { - return config; - } - - let output = copyConfig(config); - let overridden = false; - for (const override of options.configOverrides) { - if (manager.getEvaluation()?.result) { - override.delete?.forEach((deletionKey) => { - deleteConfigValue(output, deletionKey); - }); - - Object.keys(override.set ?? {}).forEach((setKey) => { - setConfigValue(output, setKey, override.set?.[setKey]); - }); - - Object.keys(override.merge ?? {}).forEach((mergeKey) => { - setConfigValue( - output, - mergeKey, - merge({}, getConfigValue(output, mergeKey), override.merge?.[mergeKey]), - ); - }); - - overridden = true; - } - } - - if (!overridden) { - // Return the same configuration object if it has not been overridden (to - // reduce re-renders for a configuration that has not changed). - return config; - } - - if (options?.configOverrides?.some((override) => override.delete?.length)) { - // If anything was deleted during this override, empty undefined slots may - // be left in arrays where values were unset. Desparsify them. - output = desparsifyArrays(output); - } - - if (options?.schema) { - const parseResult = options.schema.safeParse(output); - if (!parseResult.success) { - throw new OverrideConfigurationError( - localize('error.invalid_configuration_override'), - [parseResult.error.errors, output], - ); - } - return parseResult.data; - } - return output; -} diff --git a/src/card-controller/config/overrides-manager.ts b/src/card-controller/config/overrides-manager.ts new file mode 100644 index 00000000..8bddbfcc --- /dev/null +++ b/src/card-controller/config/overrides-manager.ts @@ -0,0 +1,102 @@ +import { merge } from 'lodash-es'; +import { ConditionsManager } from '../../conditions/conditions-manager'; +import { ConditionStateManagerReadonlyInterface } from '../../conditions/types'; +import { + copyConfig, + deleteConfigValue, + getConfigValue, + setConfigValue, +} from '../../config/management'; +import { Override } from '../../config/schema/overrides'; +import { + AdvancedCameraCardConfig, + advancedCameraCardConfigSchema, +} from '../../config/schema/types'; +import { localize } from '../../localize/localize'; +import { AdvancedCameraCardError } from '../../types'; +import { desparsifyArrays } from '../../utils/basic.js'; + +type OverridesCallback = () => void; + +class OverrideConfigurationError extends AdvancedCameraCardError {} + +export class OverridesManager { + private _overrides = new Map(); + private _callback: OverridesCallback; + + constructor(callback: OverridesCallback) { + this._callback = callback; + } + + private _clear(): void { + this._overrides.forEach((manager) => manager.destroy()); + this._overrides.clear(); + } + + public hasOverrides(): boolean { + return !!this._overrides.size; + } + + public set( + stateManager: ConditionStateManagerReadonlyInterface, + overrides?: Override[], + ): void { + this._clear(); + + overrides?.forEach((override) => { + const manager = new ConditionsManager(override.conditions, stateManager); + manager.addListener(this._callback); + this._overrides.set(override, manager); + }); + } + + public getConfig(base: AdvancedCameraCardConfig): AdvancedCameraCardConfig { + let output = copyConfig(base); + let overridden = false; + let desparsify = false; + + for (const [override, manager] of this._overrides.entries()) { + if (manager.getEvaluation()?.result) { + override.delete?.forEach((deletionKey) => { + deleteConfigValue(output, deletionKey); + desparsify = true; + }); + + Object.keys(override.set ?? {}).forEach((setKey) => { + setConfigValue(output, setKey, override.set?.[setKey]); + }); + + Object.keys(override.merge ?? {}).forEach((mergeKey) => { + setConfigValue( + output, + mergeKey, + merge({}, getConfigValue(output, mergeKey), override.merge?.[mergeKey]), + ); + }); + + overridden = true; + } + } + + if (!overridden) { + // Return the same configuration object if it has not been overridden (to + // reduce re-renders for a configuration that has not changed). + return base; + } + + if (desparsify) { + // If anything was deleted during this override, empty undefined slots may + // be left in arrays where values were unset. Desparsify them. + output = desparsifyArrays(output); + } + + const parseResult = advancedCameraCardConfigSchema.safeParse(output); + if (!parseResult.success) { + throw new OverrideConfigurationError( + localize('error.invalid_configuration_override'), + [parseResult.error.errors, output], + ); + } + return parseResult.data; + } +} diff --git a/src/config/schema/overrides.ts b/src/config/schema/overrides.ts index 0a31d9f9..2e2c12b5 100644 --- a/src/config/schema/overrides.ts +++ b/src/config/schema/overrides.ts @@ -1,13 +1,12 @@ import { z } from 'zod'; import { advancedCameraCardConditionSchema } from './conditions/types'; -export const overridesSchema = z - .object({ - conditions: advancedCameraCardConditionSchema.array(), - merge: z.object({}).passthrough().optional(), - set: z.object({}).passthrough().optional(), - delete: z.string().array().optional(), - }) - .array() - .optional(); -export type Overrides = z.infer; +const overrideSchema = z.object({ + conditions: advancedCameraCardConditionSchema.array(), + merge: z.object({}).passthrough().optional(), + set: z.object({}).passthrough().optional(), + delete: z.string().array().optional(), +}); +export type Override = z.infer; + +export const overridesSchema = overrideSchema.array().optional(); diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index d75033d2..7c28644c 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -4,7 +4,7 @@ import { ConfigManager } from '../../../src/card-controller/config/config-manage import { InitializationAspect } from '../../../src/card-controller/initialization-manager'; import { ConditionStateManager } from '../../../src/conditions/state-manager'; import { advancedCameraCardConfigSchema } from '../../../src/config/schema/types'; -import { createCardAPI, flushPromises } from '../../test-utils'; +import { createCardAPI, createConfig, flushPromises } from '../../test-utils'; describe('ConfigManager', () => { beforeEach(() => { @@ -162,93 +162,64 @@ describe('ConfigManager', () => { }); }); - it('should ignore overrides with same config', () => { - const api = createCardAPI(); - const stateManager = new ConditionStateManager(); - vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + describe('should override', () => { + it('should ignore overrides with same config', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - const manager = new ConfigManager(api); - const cameras = [{ camera_entity: 'camera.office' }]; - const config = { - type: 'custom:advanced-camera-card', - cameras: cameras, - overrides: [ - { - conditions: [{ condition: 'fullscreen', fullscreen: true }], - set: { - // Override with the same. - cameras: cameras, + const manager = new ConfigManager(api); + const cameras = [{ camera_entity: 'camera.office' }]; + const config = { + type: 'custom:advanced-camera-card', + cameras: cameras, + overrides: [ + { + conditions: [{ condition: 'fullscreen', fullscreen: true }], + set: { + // Override with the same. + cameras: cameras, + }, }, + ], + }; + + manager.setConfig(config); + + expect(api.getStyleManager().updateFromConfig).toBeCalledTimes(1); + + stateManager.setState({ fullscreen: true }); + + expect(api.getStyleManager().updateFromConfig).toBeCalledTimes(1); + }); + + it('should honor override', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = new ConfigManager(api); + const config = createConfig({ + menu: { + style: 'hidden', }, - ], - }; + overrides: [ + { + conditions: [{ condition: 'fullscreen', fullscreen: true }], + set: { 'menu.style': 'none' }, + }, + ], + }); - manager.setConfig(config); + manager.setConfig(config); + expect(manager.getConfig()?.menu?.style).toBe('hidden'); - expect(api.getStyleManager().updateFromConfig).toBeCalledTimes(1); + stateManager.setState({ fullscreen: true }); + expect(manager.getConfig()?.menu?.style).toBe('none'); + expect(manager.getConfig()).not.toEqual(manager.getNonOverriddenConfig()); + }); - stateManager.setState({ fullscreen: true }); - - expect(api.getStyleManager().updateFromConfig).toBeCalledTimes(1); - }); - - it('should override', () => { - const api = createCardAPI(); - const stateManager = new ConditionStateManager(); - vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - - const manager = new ConfigManager(api); - const config = { - type: 'custom:advanced-camera-card', - cameras: [{ camera_entity: 'camera.office' }], - menu: { - style: 'hidden', - }, - overrides: [ - { - conditions: [{ condition: 'fullscreen', fullscreen: true }], - set: { 'menu.style': 'none' }, - }, - ], - }; - - manager.setConfig(config); - expect(manager.getConfig()?.menu?.style).toBe('hidden'); - - stateManager.setState({ fullscreen: true }); - expect(manager.getConfig()?.menu?.style).toBe('none'); - expect(manager.getConfig()).not.toEqual(manager.getNonOverriddenConfig()); - }); - - it('should set error on invalid override', () => { - const api = createCardAPI(); - const stateManager = new ConditionStateManager(); - vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - - const manager = new ConfigManager(api); - const config = { - type: 'custom:advanced-camera-card', - cameras: [{ camera_entity: 'camera.office' }], - overrides: [ - { - conditions: [{ condition: 'fullscreen', fullscreen: true }], - delete: ['cameras'], - }, - ], - }; - - manager.setConfig(config); - expect(manager.getConfig()).not.toBeNull(); - - stateManager.setState({ fullscreen: true }); - expect(manager.getConfig()).not.toBeNull(); - expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith( - expect.objectContaining({ message: 'Invalid override configuration' }), - ); - }); - - describe('should uninitialize on override', () => { - it('cameras', () => { + it('should set error on invalid override', () => { const api = createCardAPI(); const stateManager = new ConditionStateManager(); vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); @@ -260,137 +231,166 @@ describe('ConfigManager', () => { overrides: [ { conditions: [{ condition: 'fullscreen', fullscreen: true }], - set: { - cameras: [{ camera_entity: 'camera.kitchen' }], - }, + delete: ['cameras'], }, ], }; manager.setConfig(config); - - expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalledWith( - InitializationAspect.CAMERAS, - ); + expect(manager.getConfig()).not.toBeNull(); stateManager.setState({ fullscreen: true }); - - expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith( - InitializationAspect.CAMERAS, + expect(manager.getConfig()).not.toBeNull(); + expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith( + expect.objectContaining({ message: 'Invalid override configuration' }), ); }); - it('cameras_global', () => { - const api = createCardAPI(); - const stateManager = new ConditionStateManager(); - vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + describe('should uninitialize on override', () => { + it('cameras', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - const manager = new ConfigManager(api); - const config = { - type: 'custom:advanced-camera-card', - cameras: [{ camera_entity: 'camera.office' }], - overrides: [ - { - conditions: [{ condition: 'fullscreen', fullscreen: true }], - set: { - cameras_global: { live_provider: 'jsmpeg' }, + const manager = new ConfigManager(api); + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [{ condition: 'fullscreen', fullscreen: true }], + set: { + cameras: [{ camera_entity: 'camera.kitchen' }], + }, }, - }, - ], - }; + ], + }; - manager.setConfig(config); + manager.setConfig(config); - expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalledWith( - InitializationAspect.CAMERAS, - ); + expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalledWith( + InitializationAspect.CAMERAS, + ); - stateManager.setState({ fullscreen: true }); + stateManager.setState({ fullscreen: true }); - expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith( - InitializationAspect.CAMERAS, - ); + expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith( + InitializationAspect.CAMERAS, + ); + }); + + it('cameras_global', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = new ConfigManager(api); + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [{ condition: 'fullscreen', fullscreen: true }], + set: { + cameras_global: { live_provider: 'jsmpeg' }, + }, + }, + ], + }; + + manager.setConfig(config); + + expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalledWith( + InitializationAspect.CAMERAS, + ); + + stateManager.setState({ fullscreen: true }); + + expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith( + InitializationAspect.CAMERAS, + ); + }); + + it('live.microphone.always_connected', () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + + const manager = new ConfigManager(api); + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [{ condition: 'fullscreen', fullscreen: true }], + set: { + 'live.microphone.always_connected': true, + }, + }, + ], + }; + + manager.setConfig(config); + + expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalledWith( + InitializationAspect.MICROPHONE_CONNECT, + ); + + stateManager.setState({ fullscreen: true }); + + expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith( + InitializationAspect.MICROPHONE_CONNECT, + ); + }); }); - it('live.microphone.always_connected', () => { - const api = createCardAPI(); - const stateManager = new ConditionStateManager(); - vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + describe('should initialize on override', () => { + it('should initialize background items', async () => { + const api = createCardAPI(); + const stateManager = new ConditionStateManager(); + const listener = vi.fn(); + stateManager.addListener(listener); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); - const manager = new ConfigManager(api); - const config = { - type: 'custom:advanced-camera-card', - cameras: [{ camera_entity: 'camera.office' }], - overrides: [ - { - conditions: [{ condition: 'fullscreen', fullscreen: true }], - set: { - 'live.microphone.always_connected': true, + const manager = new ConfigManager(api); + const config = { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + overrides: [ + { + conditions: [{ condition: 'fullscreen', fullscreen: true }], + set: { + cameras: [{ camera_entity: 'camera.kitchen' }], + }, }, - }, - ], - }; + ], + }; - manager.setConfig(config); + manager.setConfig(config); - expect(api.getInitializationManager().uninitialize).not.toHaveBeenCalledWith( - InitializationAspect.MICROPHONE_CONNECT, - ); + await flushPromises(); - stateManager.setState({ fullscreen: true }); + expect(api.getDefaultManager().initializeIfNecessary).toBeCalledTimes(1); + expect(api.getMediaPlayerManager().initializeIfNecessary).toBeCalledTimes(1); + expect(listener).not.toBeCalledWith( + expect.objectContaining({ change: { config: expect.anything() } }), + ); - expect(api.getInitializationManager().uninitialize).toHaveBeenCalledWith( - InitializationAspect.MICROPHONE_CONNECT, - ); - }); - }); + vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( + true, + ); + stateManager.setState({ fullscreen: true }); - describe('should initialize on override', () => { - it('should initialize background items', async () => { - const api = createCardAPI(); - const stateManager = new ConditionStateManager(); - const listener = vi.fn(); - stateManager.addListener(listener); - vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); + await flushPromises(); - const manager = new ConfigManager(api); - const config = { - type: 'custom:advanced-camera-card', - cameras: [{ camera_entity: 'camera.office' }], - overrides: [ - { - conditions: [{ condition: 'fullscreen', fullscreen: true }], - set: { - cameras: [{ camera_entity: 'camera.kitchen' }], - }, - }, - ], - }; + expect(api.getDefaultManager().initializeIfNecessary).toBeCalledTimes(2); + expect(api.getMediaPlayerManager().initializeIfNecessary).toBeCalledTimes(2); - manager.setConfig(config); - - await flushPromises(); - - expect(api.getDefaultManager().initializeIfNecessary).toBeCalledTimes(1); - expect(api.getMediaPlayerManager().initializeIfNecessary).toBeCalledTimes(1); - expect(listener).not.toBeCalledWith( - expect.objectContaining({ change: { config: expect.anything() } }), - ); - - vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( - true, - ); - stateManager.setState({ fullscreen: true }); - - await flushPromises(); - - expect(api.getDefaultManager().initializeIfNecessary).toBeCalledTimes(2); - expect(api.getMediaPlayerManager().initializeIfNecessary).toBeCalledTimes(2); - - // Should set the config condition state. - expect(listener).toBeCalledWith( - expect.objectContaining({ change: { config: expect.anything() } }), - ); + // Should set the config condition state. + expect(listener).toBeCalledWith( + expect.objectContaining({ change: { config: expect.anything() } }), + ); + }); }); }); }); diff --git a/tests/card-controller/config/get-overridden-config.test.ts b/tests/card-controller/config/get-overridden-config.test.ts deleted file mode 100644 index 3d79c08a..00000000 --- a/tests/card-controller/config/get-overridden-config.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import { z } from 'zod'; -import { getOverriddenConfig } from '../../../src/card-controller/config/get-overridden-config'; -import { ConditionsManagerReadonlyInterface } from '../../../src/conditions/types'; - -describe('getOverriddenConfig', () => { - const config = { - menu: { - style: 'none', - }, - }; - - it('should not override without overrides', () => { - const manager = mock(); - manager.getEvaluation.mockReturnValue({ result: true }); - - expect(getOverriddenConfig(manager, config)).toBe(config); - }); - - it('should not override when conditions do not match', () => { - const manager = mock(); - manager.getEvaluation.mockReturnValue({ result: false }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - merge: { - menu: { - style: 'hidden', - }, - }, - delete: ['menu.style'], - set: { - 'menu.style': 'overlay', - }, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toBe(config); - }); - - describe('should merge', () => { - it('with path', () => { - const manager = mock(); - manager.getEvaluation.mockReturnValue({ result: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - merge: { - 'live.controls.thumbnails': { - mode: 'none', - }, - }, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({ - menu: { - style: 'none', - }, - live: { - controls: { - thumbnails: { - mode: 'none', - }, - }, - }, - }); - }); - - it('without path', () => { - const manager = mock(); - manager.getEvaluation.mockReturnValue({ result: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - merge: { - menu: { - style: 'hidden', - }, - }, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({ - menu: { - style: 'hidden', - }, - }); - }); - - it('with invalid merge', () => { - const manager = mock(); - manager.getEvaluation.mockReturnValue({ result: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - merge: 6 as unknown as Record, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({ - menu: { - style: 'none', - }, - }); - }); - }); - - describe('should set', () => { - it('leaf node', () => { - const manager = mock(); - manager.getEvaluation.mockReturnValue({ result: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - set: { - 'menu.style': 'hidden', - }, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({ - menu: { - style: 'hidden', - }, - }); - }); - - it('root node', () => { - const manager = mock(); - manager.getEvaluation.mockReturnValue({ result: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - set: { - menu: { - style: 'hidden', - }, - }, - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({ - menu: { - style: 'hidden', - }, - }); - }); - }); - - describe('should delete', () => { - it('leaf node', () => { - const manager = mock(); - manager.getEvaluation.mockReturnValue({ result: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - delete: ['menu.style' as const], - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({ - menu: {}, - }); - }); - - it('root node', () => { - const manager = mock(); - manager.getEvaluation.mockReturnValue({ result: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - delete: ['menu' as const], - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - }, - ], - }), - ).toEqual({}); - }); - }); - - describe('should validate schema', () => { - const testSchema = z.object({ - menu: z.object({ - style: z.enum(['none', 'hidden']), - }), - }); - - it('passing', () => { - const manager = mock(); - manager.getEvaluation.mockReturnValue({ result: true }); - - expect( - getOverriddenConfig(manager, config, { - configOverrides: [ - { - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - set: { - 'menu.style': 'hidden', - }, - }, - ], - schema: testSchema, - }), - ).toEqual({ - menu: { - style: 'hidden', - }, - }); - }); - - it('failing', () => { - const manager = mock(); - manager.getEvaluation.mockReturnValue({ result: true }); - - expect(() => - getOverriddenConfig(manager, config, { - configOverrides: [ - { - conditions: [ - { - condition: 'fullscreen' as const, - fullscreen: true, - }, - ], - set: { - 'menu.style': 'NOT_A_STYLE', - }, - }, - ], - schema: testSchema, - }), - ).toThrowError(/Invalid override configuration/); - }); - }); -}); diff --git a/tests/card-controller/config/overrides-manager.test.ts b/tests/card-controller/config/overrides-manager.test.ts new file mode 100644 index 00000000..66e40ec6 --- /dev/null +++ b/tests/card-controller/config/overrides-manager.test.ts @@ -0,0 +1,395 @@ +import { describe, expect, it, vi } from 'vitest'; +import { OverridesManager } from '../../../src/card-controller/config/overrides-manager'; +import { ConditionStateManager } from '../../../src/conditions/state-manager'; +import { createConfig } from '../../test-utils'; + +describe('OverridesManager', () => { + it('should add overrides', () => { + const config = createConfig({ + overrides: [ + { + set: { + 'menu.style': 'overlay', + }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }); + + const manager = new OverridesManager(vi.fn()); + manager.set(new ConditionStateManager(), config.overrides); + + expect(manager.hasOverrides()).toBe(true); + }); + + it('should clear overrides', () => { + const config = createConfig({ + overrides: [ + { + set: { + 'menu.style': 'overlay', + }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }); + + const stateManager = new ConditionStateManager(); + const manager = new OverridesManager(vi.fn()); + manager.set(stateManager, config.overrides); + + expect(manager.getConfig(config).menu?.style).toBe('hidden'); + + manager.set(stateManager, []); + + stateManager.setState({ fullscreen: true }); + expect(manager.getConfig(config).menu?.style).toBe('hidden'); + + expect(manager.hasOverrides()).toBe(false); + }); + + it('should not override when conditions do not match', () => { + const config = createConfig({ + overrides: [ + { + set: { + 'menu.style': 'overlay', + }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }); + + const manager = new OverridesManager(vi.fn()); + manager.set(new ConditionStateManager(), config.overrides); + + expect(manager.getConfig(config)).toBe(config); + }); + + it('should callback on change', () => { + const config = createConfig({ + overrides: [ + { + set: { + 'menu.style': 'overlay', + }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }); + + const callback = vi.fn(); + const stateManager = new ConditionStateManager(); + const manager = new OverridesManager(callback); + manager.set(stateManager, config.overrides); + + expect(manager.getConfig(config).menu?.style).toBe('hidden'); + + expect(callback).not.toBeCalled(); + + stateManager.setState({ fullscreen: true }); + + expect(callback).toBeCalledTimes(1); + }); + + describe('should handle override merge', () => { + it('with path', () => { + const config = createConfig({ + overrides: [ + { + merge: { + 'live.controls.thumbnails': { + mode: 'none', + }, + }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }); + + const stateManager = new ConditionStateManager(); + stateManager.setState({ fullscreen: true }); + + const manager = new OverridesManager(vi.fn()); + manager.set(stateManager, config.overrides); + + const overriddenConfig = manager.getConfig(config); + + expect(config.live.controls.thumbnails.mode).toBe('right'); + expect(overriddenConfig.live.controls.thumbnails.mode).toBe('none'); + }); + + it('without path', () => { + const config = createConfig({ + overrides: [ + { + merge: { + live: { + controls: { + thumbnails: { + mode: 'none', + }, + }, + }, + }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }); + + const stateManager = new ConditionStateManager(); + stateManager.setState({ fullscreen: true }); + + const manager = new OverridesManager(vi.fn()); + manager.set(stateManager, config.overrides); + + const overriddenConfig = manager.getConfig(config); + + expect(config.live.controls.thumbnails.mode).toBe('right'); + expect(overriddenConfig.live.controls.thumbnails.mode).toBe('none'); + }); + }); + + describe('should handle override set', () => { + it('leaf node', () => { + const config = createConfig({ + overrides: [ + { + set: { + 'live.controls.thumbnails.mode': 'none', + }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }); + + const stateManager = new ConditionStateManager(); + stateManager.setState({ fullscreen: true }); + + const manager = new OverridesManager(vi.fn()); + manager.set(stateManager, config.overrides); + + const overriddenConfig = manager.getConfig(config); + + expect(config.live.controls.thumbnails.mode).toBe('right'); + expect(overriddenConfig.live.controls.thumbnails.mode).toBe('none'); + }); + + it('root node', () => { + const config = createConfig({ + overrides: [ + { + set: { + live: { + controls: { + thumbnails: { + mode: 'none', + }, + }, + }, + }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }); + + const stateManager = new ConditionStateManager(); + stateManager.setState({ fullscreen: true }); + + const manager = new OverridesManager(vi.fn()); + manager.set(stateManager, config.overrides); + + const overriddenConfig = manager.getConfig(config); + + expect(config.live.controls.thumbnails.mode).toBe('right'); + expect(overriddenConfig.live.controls.thumbnails.mode).toBe('none'); + }); + }); + + describe('should handle override delete', () => { + it('leaf node', () => { + const config = createConfig({ + live: { + controls: { + thumbnails: { + mode: 'left', + }, + }, + }, + overrides: [ + { + delete: ['live.controls.thumbnails.mode' as const], + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }); + + const stateManager = new ConditionStateManager(); + stateManager.setState({ fullscreen: true }); + + const manager = new OverridesManager(vi.fn()); + manager.set(stateManager, config.overrides); + + const overriddenConfig = manager.getConfig(config); + + expect(config.live.controls.thumbnails.mode).toBe('left'); + expect(overriddenConfig.live.controls.thumbnails.mode).toBe('right'); + }); + + it('root node', () => { + const config = createConfig({ + live: { + controls: { + thumbnails: { + mode: 'left', + }, + }, + }, + overrides: [ + { + delete: ['live' as const], + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }); + + const stateManager = new ConditionStateManager(); + stateManager.setState({ fullscreen: true }); + + const manager = new OverridesManager(vi.fn()); + manager.set(stateManager, config.overrides); + + const overriddenConfig = manager.getConfig(config); + + expect(config.live.controls.thumbnails.mode).toBe('left'); + expect(overriddenConfig.live.controls.thumbnails.mode).toBe('right'); + }); + }); + + it('should throw on invalid schema', () => { + const config = createConfig({ + overrides: [ + { + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + ], + }); + config.overrides![0].merge = 6 as unknown as Record; + + const stateManager = new ConditionStateManager(); + stateManager.setState({ fullscreen: true }); + + const manager = new OverridesManager(vi.fn()); + manager.set(stateManager, config.overrides); + + expect(() => manager.getConfig(config)).toThrowError( + /Invalid override configuration/, + ); + }); + + // See: https://github.com/dermotduffy/advanced-camera-card/issues/1954 + it('should handle overrides separately', () => { + const config = createConfig({ + live: { + controls: { + thumbnails: { + mode: 'right', + }, + }, + }, + overrides: [ + { + set: { 'live.controls.thumbnails.mode': 'left' }, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + ], + }, + { + set: { 'live.controls.thumbnails.mode': 'none' }, + conditions: [ + { + condition: 'expand' as const, + expand: true, + }, + ], + }, + ], + }); + + const stateManager = new ConditionStateManager(); + const manager = new OverridesManager(vi.fn()); + manager.set(stateManager, config.overrides); + + expect(manager.getConfig(config).live.controls.thumbnails.mode).toBe('right'); + + stateManager.setState({ fullscreen: true }); + expect(manager.getConfig(config).live.controls.thumbnails.mode).toBe('left'); + + stateManager.setState({ expand: true }); + expect(manager.getConfig(config).live.controls.thumbnails.mode).toBe('none'); + + stateManager.setState({ fullscreen: false }); + expect(manager.getConfig(config).live.controls.thumbnails.mode).toBe('none'); + + stateManager.setState({ expand: false }); + expect(manager.getConfig(config).live.controls.thumbnails.mode).toBe('right'); + }); +});