feat: Add festive visual effects (#2252)
<!-- CURSOR_SUMMARY --> > [!NOTE] > Introduces an effects system (fireworks, ghost, hearts, shamrocks, snow), a new `effect` action, and optional date-based loading effects, integrated into the card with docs, schema, editor, and tests. > > - **Effects System**: > - Add `EffectsController` and `advanced-camera-card-effects` host with lazy-loaded effect modules (`fireworks`, `ghost`, `hearts`, `shamrocks`, `snow`). > - New base effect component with fade-in/out; SCSS and z-index updates. > - **Actions**: > - New custom action `advanced_camera_card_action: effect` with `effect_action` (`start`|`stop`|`toggle`). > - Wire into `ActionFactory` and add `EffectAction` executor. > - **Card Integration**: > - Mount effects host in `advanced-camera-card` and expose `getEffectsControllerAPI()` via `CardController`. > - Loading component can trigger date-based effects (e.g., New Year fireworks) when enabled. > - **Configuration & Editor**: > - Add `performance.features.card_loading_effects` (default `true`) alongside `card_loading_indicator`. > - Update schemas, defaults, low-performance profile (disables effects), and editor toggles. > - **Docs & Examples**: > - Document `effect` action parameters and add menu example. > - Update performance docs with new option. > - **Localization & Tests**: > - Update i18n strings for new performance option. > - Add comprehensive tests for effects controller, action, factory, config defaults/profiles, and utilities. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cffd4304fe3bd22340316f9cec53e341379b1756. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { EffectAction } from '../../../../src/card-controller/actions/actions/effect';
|
||||
import { createEffectAction } from '../../../../src/utils/action';
|
||||
import { createCardAPI } from '../../../test-utils';
|
||||
|
||||
describe('EffectAction', () => {
|
||||
it('should call startEffect when action is start', async () => {
|
||||
const api = createCardAPI();
|
||||
const actionConfig = createEffectAction('snow', 'start');
|
||||
const action = new EffectAction({}, actionConfig);
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getEffectsControllerAPI()?.startEffect).toHaveBeenCalledWith('snow');
|
||||
});
|
||||
|
||||
it('should call stopEffect when action is stop', async () => {
|
||||
const api = createCardAPI();
|
||||
const actionConfig = createEffectAction('snow', 'stop');
|
||||
const action = new EffectAction({}, actionConfig);
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getEffectsControllerAPI()?.stopEffect).toHaveBeenCalledWith('snow');
|
||||
});
|
||||
|
||||
it('should call toggleEffect when action is toggle', async () => {
|
||||
const api = createCardAPI();
|
||||
const actionConfig = createEffectAction('snow', 'toggle');
|
||||
const action = new EffectAction({}, actionConfig);
|
||||
|
||||
await action.execute(api);
|
||||
|
||||
expect(api.getEffectsControllerAPI()?.toggleEffect).toHaveBeenCalledWith('snow');
|
||||
});
|
||||
|
||||
it('should not throw when effectsControllerAPI is null', async () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getEffectsControllerAPI).mockReturnValue(null);
|
||||
const actionConfig = createEffectAction('snow', 'start');
|
||||
const action = new EffectAction({}, actionConfig);
|
||||
|
||||
await expect(action.execute(api)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should have a no-op stop method', async () => {
|
||||
const actionConfig = createEffectAction('snow', 'stop');
|
||||
const action = new EffectAction({}, actionConfig);
|
||||
|
||||
await expect(action.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ import { ViewAction } from '../../../src/card-controller/actions/actions/view';
|
||||
import { ActionFactory } from '../../../src/card-controller/actions/factory';
|
||||
import { INTERNAL_CALLBACK_ACTION } from '../../../src/config/schema/actions/custom/internal';
|
||||
import { ActionConfig } from '../../../src/config/schema/actions/types';
|
||||
import { EffectAction } from '../../../src/card-controller/actions/actions/effect';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ActionFactory', () => {
|
||||
@@ -188,6 +189,7 @@ describe('ActionFactory', () => {
|
||||
InternalCallbackAction,
|
||||
],
|
||||
[{ advanced_camera_card_action: 'reload' as const }, ReloadAction],
|
||||
[{ advanced_camera_card_action: 'effect' as const }, EffectAction],
|
||||
])(
|
||||
'advanced_camera_card_action: $advanced_camera_card_action',
|
||||
(action: Partial<ActionConfig>, classObject: object) => {
|
||||
|
||||
@@ -152,6 +152,7 @@ describe('ConfigManager', () => {
|
||||
features: {
|
||||
animated_progress_indicator: true,
|
||||
card_loading_indicator: true,
|
||||
card_loading_effects: true,
|
||||
media_chunk_size: 50,
|
||||
},
|
||||
style: {
|
||||
|
||||
@@ -32,6 +32,8 @@ import { AdvancedCameraCardEditor } from '../../src/editor';
|
||||
import { DeviceRegistryManager } from '../../src/ha/registry/device';
|
||||
import { EntityRegistryManagerLive } from '../../src/ha/registry/entity';
|
||||
import { ResolvedMediaCache } from '../../src/ha/resolved-media';
|
||||
import { EffectsControllerAPI } from '../../src/types';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
vi.mock('../../src/camera-manager/manager');
|
||||
vi.mock('../../src/card-controller/actions/actions-manager');
|
||||
@@ -70,7 +72,7 @@ const createCardElement = (): CardHTMLElement => {
|
||||
};
|
||||
|
||||
const createController = (): CardController => {
|
||||
return new CardController(createCardElement(), vi.fn(), vi.fn());
|
||||
return new CardController(createCardElement(), vi.fn(), vi.fn(), vi.fn());
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
@@ -83,15 +85,23 @@ describe('CardController', () => {
|
||||
const element = createCardElement();
|
||||
const scrollCallback = vi.fn();
|
||||
const menuToggleCallback = vi.fn();
|
||||
const effectsControllerAPI = mock<EffectsControllerAPI>();
|
||||
const effectsControllerAPICallback = vi.fn().mockReturnValue(effectsControllerAPI);
|
||||
|
||||
const manager = new CardController(element, scrollCallback, menuToggleCallback);
|
||||
const controller = new CardController(
|
||||
element,
|
||||
scrollCallback,
|
||||
menuToggleCallback,
|
||||
effectsControllerAPICallback,
|
||||
);
|
||||
|
||||
expect(CardElementManager).toBeCalledWith(
|
||||
manager,
|
||||
controller,
|
||||
element,
|
||||
scrollCallback,
|
||||
menuToggleCallback,
|
||||
);
|
||||
expect(controller.getEffectsControllerAPI()).toBe(effectsControllerAPI);
|
||||
});
|
||||
|
||||
describe('accessors', () => {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EffectsController } from '../../../src/components-lib/effects/effects-controller';
|
||||
import { EffectComponent } from '../../../src/components-lib/effects/types';
|
||||
import { EffectName } from '../../../src/types';
|
||||
|
||||
vi.mock('../../../src/components/effects/fireworks', () => ({
|
||||
AdvancedCameraCardEffectFireworks: vi.fn(() => createMockEffectComponent()),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/components/effects/ghost', () => ({
|
||||
AdvancedCameraCardEffectGhost: vi.fn(() => createMockEffectComponent()),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/components/effects/hearts', () => ({
|
||||
AdvancedCameraCardEffectHearts: vi.fn(() => createMockEffectComponent()),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/components/effects/shamrocks', () => ({
|
||||
AdvancedCameraCardEffectShamrocks: vi.fn(() => createMockEffectComponent()),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/components/effects/snow', () => ({
|
||||
AdvancedCameraCardEffectSnow: vi.fn(() => createMockEffectComponent()),
|
||||
}));
|
||||
|
||||
const createMockEffectComponent = (): EffectComponent => {
|
||||
const element = document.createElement('div') as unknown as EffectComponent;
|
||||
element.fadeIn = true;
|
||||
element.startFadeOut = vi.fn().mockResolvedValue(undefined);
|
||||
return element;
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('EffectsController', () => {
|
||||
let container: HTMLElement;
|
||||
let controller: EffectsController;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
controller = new EffectsController();
|
||||
controller.setContainer(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('startEffect', () => {
|
||||
it('should load and start snow effect', async () => {
|
||||
expect(container.children.length).toBe(0);
|
||||
|
||||
await controller.startEffect('snow');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should load and start hearts effect', async () => {
|
||||
expect(container.children.length).toBe(0);
|
||||
|
||||
await controller.startEffect('hearts');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should load and start shamrocks effect', async () => {
|
||||
expect(container.children.length).toBe(0);
|
||||
|
||||
await controller.startEffect('shamrocks');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should load and start fireworks effect', async () => {
|
||||
expect(container.children.length).toBe(0);
|
||||
|
||||
await controller.startEffect('fireworks');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should load and start ghost effect', async () => {
|
||||
expect(container.children.length).toBe(0);
|
||||
|
||||
await controller.startEffect('ghost');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should set fadeIn to true by default', async () => {
|
||||
await controller.startEffect('snow');
|
||||
|
||||
const effectElement = container.children[0] as EffectComponent;
|
||||
expect(effectElement.fadeIn).toBe(true);
|
||||
});
|
||||
|
||||
it('should set fadeIn from options', async () => {
|
||||
await controller.startEffect('snow', { fadeIn: false });
|
||||
|
||||
const effectElement = container.children[0] as EffectComponent;
|
||||
expect(effectElement.fadeIn).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start effect if container is null', async () => {
|
||||
controller.setContainer(null);
|
||||
|
||||
await controller.startEffect('snow');
|
||||
|
||||
expect(container.children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not start effect if already active', async () => {
|
||||
await controller.startEffect('snow');
|
||||
await controller.startEffect('snow');
|
||||
|
||||
// Should only have one child since the second call is ignored.
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should allow starting different effects simultaneously', async () => {
|
||||
await controller.startEffect('snow');
|
||||
await controller.startEffect('hearts');
|
||||
|
||||
expect(container.children.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should return early if effect module cannot be loaded', async () => {
|
||||
// Use an effect name that doesn't exist in the registry
|
||||
await controller.startEffect('unknown' as EffectName);
|
||||
|
||||
expect(container.children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopEffect', () => {
|
||||
it('should stop an active effect', async () => {
|
||||
await controller.startEffect('snow');
|
||||
const effectElement = container.children[0] as EffectComponent;
|
||||
|
||||
await controller.stopEffect('snow');
|
||||
|
||||
expect(effectElement.startFadeOut).toHaveBeenCalled();
|
||||
expect(container.children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should do nothing if effect is not active', async () => {
|
||||
expect(container.children.length).toBe(0);
|
||||
|
||||
await controller.stopEffect('snow');
|
||||
|
||||
expect(container.children.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow starting effect again after stopping', async () => {
|
||||
await controller.startEffect('snow');
|
||||
await controller.stopEffect('snow');
|
||||
await controller.startEffect('snow');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should cancel effect when stopped during loading', async () => {
|
||||
// Start the effect but don't await it - simulates the loading phase.
|
||||
const startPromise = controller.startEffect('snow');
|
||||
|
||||
// Stop the effect immediately while it's still loading.
|
||||
await controller.stopEffect('snow');
|
||||
|
||||
// Wait for start to complete.
|
||||
await startPromise;
|
||||
|
||||
// The effect should not appear since it was stopped during loading.
|
||||
expect(container.children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleEffect', () => {
|
||||
it('should start effect when not active', async () => {
|
||||
await controller.toggleEffect('snow');
|
||||
|
||||
expect(container.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should stop effect when active', async () => {
|
||||
await controller.startEffect('snow');
|
||||
const effectElement = container.children[0] as EffectComponent;
|
||||
|
||||
await controller.toggleEffect('snow');
|
||||
|
||||
expect(effectElement.startFadeOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass options when starting effect', async () => {
|
||||
await controller.toggleEffect('snow', { fadeIn: false });
|
||||
|
||||
const effectElement = container.children[0] as EffectComponent;
|
||||
expect(effectElement.fadeIn).toBe(false);
|
||||
});
|
||||
|
||||
it('should cancel effect when toggled during loading', async () => {
|
||||
// Start the effect but don't await it - simulates the loading phase.
|
||||
const startPromise = controller.startEffect('snow');
|
||||
|
||||
// Toggle the effect immediately while it's still loading.
|
||||
await controller.toggleEffect('snow');
|
||||
|
||||
// Wait for start to complete.
|
||||
await startPromise;
|
||||
|
||||
// The effect should not appear since it was toggled (stopped) during loading.
|
||||
expect(container.children.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ it('should contain expected defaults', () => {
|
||||
'menu.style': 'outside',
|
||||
'performance.features.animated_progress_indicator': false,
|
||||
'performance.features.card_loading_indicator': false,
|
||||
'performance.features.card_loading_effects': false,
|
||||
'performance.features.max_simultaneous_engine_requests': 1,
|
||||
'performance.features.media_chunk_size': 10,
|
||||
'performance.style.border_radius': false,
|
||||
|
||||
@@ -287,6 +287,7 @@ describe('config defaults', () => {
|
||||
performance: {
|
||||
features: {
|
||||
animated_progress_indicator: true,
|
||||
card_loading_effects: true,
|
||||
card_loading_indicator: true,
|
||||
media_chunk_size: 50,
|
||||
},
|
||||
|
||||
+7
-1
@@ -57,7 +57,12 @@ import {
|
||||
import { Device } from '../src/ha/registry/device/types';
|
||||
import { Entity, EntityRegistryManager } from '../src/ha/registry/entity/types';
|
||||
import { CurrentUser, HassStateDifference, HomeAssistant } from '../src/ha/types';
|
||||
import { CapabilitiesRaw, Interaction, MediaLoadedInfo } from '../src/types';
|
||||
import {
|
||||
CapabilitiesRaw,
|
||||
EffectsControllerAPI,
|
||||
Interaction,
|
||||
MediaLoadedInfo,
|
||||
} from '../src/types';
|
||||
import { EventViewMedia, ViewMedia, ViewMediaType } from '../src/view/item';
|
||||
import { QueryResults } from '../src/view/query-results';
|
||||
import { ViewItemCapabilities } from '../src/view/types';
|
||||
@@ -580,6 +585,7 @@ export const createCardAPI = (): CardController => {
|
||||
api.getCardElementManager.mockReturnValue(mock<CardElementManager>());
|
||||
api.getConditionStateManager.mockReturnValue(mock<ConditionStateManager>());
|
||||
api.getConfigManager.mockReturnValue(mock<ConfigManager>());
|
||||
api.getEffectsControllerAPI.mockReturnValue(mock<EffectsControllerAPI>());
|
||||
api.getEntityRegistryManager.mockReturnValue(mock<EntityRegistryManager>());
|
||||
api.getExpandManager.mockReturnValue(mock<ExpandManager>());
|
||||
api.getFoldersManager.mockReturnValue(mock<FoldersManager>());
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ActionConfig } from '../../src/config/schema/actions/types.js';
|
||||
import {
|
||||
createCameraAction,
|
||||
createDisplayModeAction,
|
||||
createEffectAction,
|
||||
createGeneralAction,
|
||||
createInternalCallbackAction,
|
||||
createLogAction,
|
||||
@@ -300,6 +301,22 @@ describe('createSelectOptionAction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEffectAction', () => {
|
||||
it('should create effect action', () => {
|
||||
expect(
|
||||
createEffectAction('snow', 'start', {
|
||||
cardID: 'card_id',
|
||||
}),
|
||||
).toEqual({
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'effect',
|
||||
effect: 'snow',
|
||||
effect_action: 'start',
|
||||
card_id: 'card_id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionConfigGivenAction', () => {
|
||||
const action = createViewAction('clips');
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
dayToDate,
|
||||
desparsifyArrays,
|
||||
errorToConsole,
|
||||
forceReflow,
|
||||
formatDate,
|
||||
formatDateAndTime,
|
||||
generateFloatApproximatelyEqualsCustomizer,
|
||||
@@ -535,3 +536,16 @@ describe('convertHTTPAdressToWebsocket', () => {
|
||||
expect(convertHTTPAdressToWebsocket('HtTp://example.com')).toBe('ws://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceReflow', () => {
|
||||
it('should access offsetHeight to trigger reflow', () => {
|
||||
const element = document.createElement('div');
|
||||
const offsetHeightSpy = vi
|
||||
.spyOn(element, 'offsetHeight', 'get')
|
||||
.mockReturnValue(100);
|
||||
|
||||
forceReflow(element);
|
||||
|
||||
expect(offsetHeightSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user