test: Further improve test coverage (#2422)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 050c2f7c1a
commit 9384785d37
5 changed files with 522 additions and 53 deletions
+6 -16
View File
@@ -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) {}
},
+342
View File
@@ -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<string, unknown>): 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`<div ${actionHandler()}></div>`, 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`<div ${actionHandler()}></div>`, container);
render(html`<div ${actionHandler()}></div>`, container);
const handlers = document.body.querySelectorAll(
'action-handler-advanced-camera-card',
);
expect(handlers).toHaveLength(1);
});
});
+78
View File
@@ -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<string, number>(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<string, number>(new Map());
expect(cache.get('a')).toBeNull();
});
});
it('should set a value', () => {
const cache = new CacheBase<string, number>(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],
]);
});
});
+78
View File
@@ -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();
});
});
});
+18 -37
View File
@@ -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<string, Threshold> => {
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,
},
},
},
},