test: Further improve test coverage (#2422)
This commit is contained in:
committed by
dermotduffy
parent
050c2f7c1a
commit
9384785d37
@@ -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) {}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+78
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user