From 9384785d370fa594f888ab34a7f2678366182e39 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 15 Mar 2026 21:10:53 -0700 Subject: [PATCH] test: Further improve test coverage (#2422) --- src/action-handler-directive.ts | 22 +- tests/action-handler-directive.test.ts | 342 +++++++++++++++++++++++++ tests/cache/base.test.ts | 78 ++++++ tests/query-source.test.ts | 78 ++++++ vite.config.ts | 55 ++-- 5 files changed, 522 insertions(+), 53 deletions(-) create mode 100644 tests/action-handler-directive.test.ts create mode 100644 tests/cache/base.test.ts create mode 100644 tests/query-source.test.ts diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index cc924c34..fb1baa5f 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -10,8 +10,9 @@ import { ActionHandlerDetail, ActionHandlerOptions } from './ha/types.js'; import { stopEventFromActivatingCardWideActions } from './utils/action.js'; import { Timer } from './utils/timer.js'; -interface ActionHandlerInterface extends HTMLElement { +export interface ActionHandlerInterface extends HTMLElement { holdTime: number; + connectedCallback(): void; bind(element: Element, options): void; } interface ActionHandlerElement extends HTMLElement { @@ -55,16 +56,8 @@ class ActionHandler extends HTMLElement implements ActionHandlerInterface { element.actionHandlerOptions = options; element.addEventListener('contextmenu', (ev: Event) => { - const e = ev || window.event; - if (e.preventDefault) { - e.preventDefault(); - } - if (e.stopPropagation) { - e.stopPropagation(); - } - e.cancelBubble = true; - e.returnValue = false; - return false; + ev.preventDefault(); + ev.stopPropagation(); }); const start = (): void => { @@ -167,11 +160,7 @@ const actionHandlerBind = ( element: ActionHandlerElement, options?: AdvancedCameraCardActionHandlerOptions, ): void => { - const actionhandler: ActionHandler = getActionHandler(); - if (!actionhandler) { - return; - } - actionhandler.bind(element, options); + getActionHandler().bind(element, options); }; export const actionHandler = directive( @@ -181,6 +170,7 @@ export const actionHandler = directive( return noChange; } + // istanbul ignore next -- @preserve Required by Lit Directive API but never called (update() is used instead) // eslint-disable-next-line @typescript-eslint/no-unused-vars render(_options?: AdvancedCameraCardActionHandlerOptions) {} }, diff --git a/tests/action-handler-directive.test.ts b/tests/action-handler-directive.test.ts new file mode 100644 index 00000000..6d5aba3e --- /dev/null +++ b/tests/action-handler-directive.test.ts @@ -0,0 +1,342 @@ +import { html, render } from 'lit'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ActionHandlerInterface, actionHandler } from '../src/action-handler-directive'; +import { fireHASSEvent } from '../src/ha/fire-hass-event'; +import { ActionHandlerDetail } from '../src/ha/types'; +import { stopEventFromActivatingCardWideActions } from '../src/utils/action'; + +vi.mock('../src/ha/fire-hass-event.js'); +vi.mock('../src/utils/action.js'); + +// @vitest-environment jsdom +const getActionHandler = (): ActionHandlerInterface => { + const existing = document.body.querySelector('action-handler-advanced-camera-card'); + if (existing) { + return existing as ActionHandlerInterface; + } + const el = document.createElement('action-handler-advanced-camera-card'); + document.body.appendChild(el); + return el as ActionHandlerInterface; +}; + +const createBoundElement = (options?: Record): HTMLElement => { + const handler = getActionHandler(); + const element = document.createElement('div'); + handler.bind(element, options); + return element; +}; + +describe('ActionHandler', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + describe('connectedCallback', () => { + it('should stop hold timer on document mouse/touch events', () => { + vi.useFakeTimers(); + const handler = getActionHandler(); + handler.connectedCallback(); + + const element = createBoundElement({ hasHold: true }); + + // Start a hold via mousedown. + element.dispatchEvent(new MouseEvent('mousedown')); + + // A document-level mouseup should cancel the hold timer. + document.dispatchEvent(new MouseEvent('mouseup')); + + // Advance past hold time — hold should NOT have triggered. + vi.advanceTimersByTime(500); + + element.dispatchEvent(new MouseEvent('click')); + + expect(fireHASSEvent).toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'tap' }), + ); + }); + }); + + describe('bind', () => { + it('should update options on re-bind without re-registering listeners', () => { + const handler = getActionHandler(); + const element = document.createElement('div'); + + handler.bind(element, { hasHold: false }); + handler.bind(element, { hasHold: true }); + + expect( + (element as unknown as { actionHandlerOptions: unknown }).actionHandlerOptions, + ).toEqual({ + hasHold: true, + }); + }); + + it('should suppress contextmenu default behavior', () => { + const element = createBoundElement(); + const ev = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + const preventDefault = vi.spyOn(ev, 'preventDefault'); + const stopPropagation = vi.spyOn(ev, 'stopPropagation'); + + element.dispatchEvent(ev); + + expect(preventDefault).toHaveBeenCalled(); + expect(stopPropagation).toHaveBeenCalled(); + }); + }); + + describe('tap', () => { + it('should fire tap on click', () => { + const element = createBoundElement(); + element.dispatchEvent(new MouseEvent('click')); + + expect(fireHASSEvent).toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'tap' }), + ); + }); + + it('should fire start_tap on mousedown and end_tap on click', () => { + const element = createBoundElement(); + + element.dispatchEvent(new MouseEvent('mousedown')); + expect(fireHASSEvent).toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'start_tap' }), + ); + + element.dispatchEvent(new MouseEvent('click')); + expect(fireHASSEvent).toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'end_tap' }), + ); + }); + + it('should not duplicate start_tap from touchstart then mousedown', () => { + const element = createBoundElement(); + + element.dispatchEvent(new TouchEvent('touchstart')); + element.dispatchEvent(new MouseEvent('mousedown')); + + const calls = vi.mocked(fireHASSEvent).mock.calls; + const startTapCalls = calls.filter( + ([, , detail]) => (detail as ActionHandlerDetail)?.action === 'start_tap', + ); + expect(startTapCalls).toHaveLength(1); + }); + + it('should fire tap on Enter keyup', () => { + const element = createBoundElement(); + + element.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + + expect(fireHASSEvent).toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'tap' }), + ); + }); + + it('should not fire tap on non-Enter keyup', () => { + const element = createBoundElement(); + + element.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); + + expect(fireHASSEvent).not.toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'tap' }), + ); + }); + }); + + describe('hold', () => { + it('should fire hold after hold time', () => { + vi.useFakeTimers(); + const element = createBoundElement({ hasHold: true }); + + element.dispatchEvent(new MouseEvent('mousedown')); + vi.advanceTimersByTime(500); + element.dispatchEvent(new MouseEvent('click')); + + expect(fireHASSEvent).toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'hold' }), + ); + }); + + it('should fire tap when released before hold time', () => { + vi.useFakeTimers(); + const element = createBoundElement({ hasHold: true }); + + element.dispatchEvent(new MouseEvent('mousedown')); + vi.advanceTimersByTime(100); + element.dispatchEvent(new MouseEvent('click')); + + expect(fireHASSEvent).toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'tap' }), + ); + expect(fireHASSEvent).not.toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'hold' }), + ); + }); + }); + + describe('double click', () => { + it('should fire double_tap on rapid clicks', () => { + vi.useFakeTimers(); + const element = createBoundElement({ hasDoubleClick: true }); + + element.dispatchEvent(new MouseEvent('click', { detail: 1 })); + element.dispatchEvent(new MouseEvent('click', { detail: 2 })); + + expect(fireHASSEvent).toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'double_tap' }), + ); + }); + + it('should fire tap after double click timeout', () => { + vi.useFakeTimers(); + const element = createBoundElement({ hasDoubleClick: true }); + + element.dispatchEvent(new MouseEvent('click', { detail: 1 })); + vi.advanceTimersByTime(300); + + expect(fireHASSEvent).toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'tap' }), + ); + }); + }); + + describe('touch events', () => { + it('should not fire tap on touchend without hold', () => { + const element = createBoundElement(); + + element.dispatchEvent(new TouchEvent('touchstart')); + element.dispatchEvent(new TouchEvent('touchend')); + + expect(fireHASSEvent).not.toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'tap' }), + ); + }); + + it('should fire hold on touchend after hold time', () => { + vi.useFakeTimers(); + const element = createBoundElement({ hasHold: true }); + + element.dispatchEvent(new TouchEvent('touchstart')); + vi.advanceTimersByTime(500); + element.dispatchEvent(new TouchEvent('touchend')); + + expect(fireHASSEvent).toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'hold' }), + ); + }); + + it('should not fire tap on touchcancel without hold', () => { + const element = createBoundElement(); + + element.dispatchEvent(new TouchEvent('touchstart')); + element.dispatchEvent(new TouchEvent('touchcancel')); + + expect(fireHASSEvent).not.toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'tap' }), + ); + }); + }); + + describe('propagation', () => { + it('should stop propagation by default', () => { + const element = createBoundElement(); + element.dispatchEvent(new MouseEvent('click')); + + expect(stopEventFromActivatingCardWideActions).toHaveBeenCalled(); + }); + + it('should allow propagation when configured', () => { + const element = createBoundElement({ allowPropagation: true }); + element.dispatchEvent(new MouseEvent('click')); + + expect(stopEventFromActivatingCardWideActions).not.toHaveBeenCalled(); + }); + }); + + describe('mouseleave', () => { + it('should fire end_tap on mouseleave after mousedown', () => { + const element = createBoundElement(); + + element.dispatchEvent(new MouseEvent('mousedown')); + vi.mocked(fireHASSEvent).mockClear(); + + element.dispatchEvent(new MouseEvent('mouseleave')); + + expect(fireHASSEvent).toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'end_tap' }), + ); + }); + + it('should not fire end_tap on mouseleave without prior mousedown', () => { + const element = createBoundElement(); + + element.dispatchEvent(new MouseEvent('mouseleave')); + + expect(fireHASSEvent).not.toHaveBeenCalledWith( + element, + 'action', + expect.objectContaining({ action: 'end_tap' }), + ); + }); + }); +}); + +describe('actionHandler directive', () => { + it('should create action handler element and bind via Lit rendering', () => { + const existing = document.body.querySelector('action-handler-advanced-camera-card'); + if (existing) { + existing.remove(); + } + + const container = document.createElement('div'); + render(html`
`, container); + + expect( + document.body.querySelector('action-handler-advanced-camera-card'), + ).not.toBeNull(); + }); + + it('should reuse existing action handler element', () => { + const container = document.createElement('div'); + render(html`
`, container); + render(html`
`, container); + + const handlers = document.body.querySelectorAll( + 'action-handler-advanced-camera-card', + ); + expect(handlers).toHaveLength(1); + }); +}); diff --git a/tests/cache/base.test.ts b/tests/cache/base.test.ts new file mode 100644 index 00000000..40c211f3 --- /dev/null +++ b/tests/cache/base.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { CacheBase } from '../../src/cache/base'; + +describe('CacheBase', () => { + describe('has', () => { + it('should return true when key exists', () => { + const cache = new CacheBase(new Map([['a', 1]])); + expect(cache.has('a')).toBeTruthy(); + }); + + it('should return false when key is absent', () => { + const cache = new CacheBase(new Map()); + expect(cache.has('a')).toBeFalsy(); + }); + }); + + describe('get', () => { + it('should return value when key exists', () => { + const cache = new CacheBase(new Map([['a', 1]])); + expect(cache.get('a')).toBe(1); + }); + + it('should return null when key is absent', () => { + const cache = new CacheBase(new Map()); + expect(cache.get('a')).toBeNull(); + }); + }); + + it('should set a value', () => { + const cache = new CacheBase(new Map()); + cache.set('a', 1); + expect(cache.get('a')).toBe(1); + }); + + it('should delete a key', () => { + const cache = new CacheBase(new Map([['a', 1]])); + expect(cache.delete('a')).toBeTruthy(); + expect(cache.has('a')).toBeFalsy(); + }); + + it('should clear all entries', () => { + const cache = new CacheBase( + new Map([ + ['a', 1], + ['b', 2], + ]), + ); + + cache.clear(); + + expect(cache.has('a')).toBeFalsy(); + expect(cache.has('b')).toBeFalsy(); + }); + + it('should return matching entries', () => { + const cache = new CacheBase( + new Map([ + ['a', 1], + ['b', 2], + ['c', 3], + ]), + ); + expect(cache.getMatches((v) => v >= 2)).toEqual([2, 3]); + }); + + it('should iterate entries', () => { + const cache = new CacheBase( + new Map([ + ['a', 1], + ['b', 2], + ]), + ); + expect([...cache.entries()]).toEqual([ + ['a', 1], + ['b', 2], + ]); + }); +}); diff --git a/tests/query-source.test.ts b/tests/query-source.test.ts new file mode 100644 index 00000000..068db275 --- /dev/null +++ b/tests/query-source.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { hasUnsupportedFilters } from '../src/query-source'; + +describe('hasUnsupportedFilters', () => { + it('should return false for empty query', () => { + expect(hasUnsupportedFilters({})).toBeFalsy(); + }); + + it('should return false when all filters are supported', () => { + const result = hasUnsupportedFilters( + { + favorite: true, + tags: new Set(['tag']), + what: new Set(['person']), + where: new Set(['yard']), + reviewed: false, + severity: new Set(['high' as const]), + }, + { + favorite: true, + tags: true, + what: true, + where: true, + reviewed: true, + severity: true, + }, + ); + expect(result).toBeFalsy(); + }); + + describe('should detect unsupported filter', () => { + it('favorite', () => { + expect(hasUnsupportedFilters({ favorite: true })).toBeTruthy(); + }); + + it('tags', () => { + expect(hasUnsupportedFilters({ tags: new Set(['tag']) })).toBeTruthy(); + }); + + it('what', () => { + expect(hasUnsupportedFilters({ what: new Set(['person']) })).toBeTruthy(); + }); + + it('where', () => { + expect(hasUnsupportedFilters({ where: new Set(['yard']) })).toBeTruthy(); + }); + + it('reviewed', () => { + expect(hasUnsupportedFilters({ reviewed: false })).toBeTruthy(); + }); + + it('severity', () => { + expect( + hasUnsupportedFilters({ + severity: new Set(['high' as const]), + }), + ).toBeTruthy(); + }); + }); + + describe('should ignore empty sets', () => { + it('tags', () => { + expect(hasUnsupportedFilters({ tags: new Set() })).toBeFalsy(); + }); + + it('what', () => { + expect(hasUnsupportedFilters({ what: new Set() })).toBeFalsy(); + }); + + it('where', () => { + expect(hasUnsupportedFilters({ where: new Set() })).toBeFalsy(); + }); + + it('severity', () => { + expect(hasUnsupportedFilters({ severity: new Set() })).toBeFalsy(); + }); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 538c20a8..65b3cc80 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,49 +1,24 @@ import { defineConfig } from 'vitest/config'; -// These globs are expected to have 100% coverage. -const FULL_COVERAGE = [ - 'src/camera-manager/**/*.ts', - 'src/card-controller/**/*.ts', - 'src/components-lib/**/*.ts', - 'src/conditions/**/*.ts', - 'src/config/**/*.ts', - 'src/const.ts', - 'src/ha/**/*.ts', - 'src/localize/**/*.ts', - 'src/types.ts', - 'src/utils/**/*.ts', - 'src/view/*.ts', -]; - const EXCLUSIONS = [ '.eslintrc.cjs', 'docs/**', - 'src/components-lib/timeline/controller.ts', 'tests/**', + + // Web-components. + 'src/card.ts', + 'src/components/**/*.ts', + 'src/editor.ts', + + // Timeline controller (can be added later). + 'src/components-lib/timeline/controller.ts', + + // HA patches. + 'src/patches/**/*.ts', ]; const INCLUSIONS = ['tests/**/*.test.ts']; -interface Threshold { - statements: number; - branches: number; - functions: number; - lines: number; - perFile: boolean; -} - -const fullCoverage: Threshold = { - statements: 100, - branches: 100, - functions: 100, - lines: 100, - perFile: true, -}; - -const calculateFullCoverageThresholds = (): Record => { - return FULL_COVERAGE.reduce((a, v) => ({ ...a, [v]: fullCoverage }), {}); -}; - // ts-prune-ignore-next export default defineConfig({ test: { @@ -61,7 +36,13 @@ export default defineConfig({ // Favor istanbul for coverage over v8 due to better accuracy. provider: 'istanbul', thresholds: { - ...calculateFullCoverageThresholds(), + perFile: true, + 'src/**/*.ts': { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, }, }, },