mirror of
https://github.com/GeorgeSG/lovelace-time-picker-card.git
synced 2026-11-04 20:08:43 +00:00
Modernize tech stack
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
import { computeEntityName, supportsEntityNameSelector } from '../src/entity-name';
|
||||
|
||||
const entity: HassEntity = {
|
||||
entity_id: 'input_datetime.alarm',
|
||||
state: '07:30:00',
|
||||
attributes: { friendly_name: 'Alarm' },
|
||||
last_changed: '',
|
||||
last_updated: '',
|
||||
context: { id: '', user_id: null, parent_id: null },
|
||||
};
|
||||
|
||||
const hassWithVersion = (
|
||||
version: string | undefined,
|
||||
extra: Record<string, unknown> = {},
|
||||
): HomeAssistant =>
|
||||
({ config: version ? { version } : undefined, ...extra }) as unknown as HomeAssistant;
|
||||
|
||||
describe('supportsEntityNameSelector', () => {
|
||||
it('is false before Home Assistant 2025.11', () => {
|
||||
expect(supportsEntityNameSelector(hassWithVersion('2025.10.3'))).toBe(false);
|
||||
expect(supportsEntityNameSelector(hassWithVersion('2024.12.0'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is true from Home Assistant 2025.11', () => {
|
||||
expect(supportsEntityNameSelector(hassWithVersion('2025.11.0'))).toBe(true);
|
||||
expect(supportsEntityNameSelector(hassWithVersion('2026.1.0'))).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the version is unknown', () => {
|
||||
expect(supportsEntityNameSelector(hassWithVersion(undefined))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeEntityName', () => {
|
||||
it('returns the configured string when there is no entity', () => {
|
||||
expect(computeEntityName(hassWithVersion('2026.9.0'), undefined, 'Wake up')).toEqual('Wake up');
|
||||
expect(computeEntityName(hassWithVersion('2026.9.0'), undefined, undefined)).toBeUndefined();
|
||||
expect(
|
||||
computeEntityName(hassWithVersion('2026.9.0'), undefined, [{ type: 'area' }]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delegates to hass.formatEntityName from Home Assistant 2026.4', () => {
|
||||
const formatEntityName = vi.fn().mockReturnValue('Bedroom Alarm');
|
||||
const hass = hassWithVersion('2026.4.0', { formatEntityName });
|
||||
const name = [{ type: 'area' as const }, { type: 'entity' as const }];
|
||||
|
||||
expect(computeEntityName(hass, entity, name)).toEqual('Bedroom Alarm');
|
||||
expect(formatEntityName).toHaveBeenCalledWith(entity, name);
|
||||
});
|
||||
|
||||
it('passes an undefined name through to hass.formatEntityName', () => {
|
||||
const formatEntityName = vi.fn().mockReturnValue('Alarm');
|
||||
computeEntityName(hassWithVersion('2026.4.0', { formatEntityName }), entity, undefined);
|
||||
expect(formatEntityName).toHaveBeenCalledWith(entity, undefined);
|
||||
});
|
||||
|
||||
it('falls back to the friendly name before Home Assistant 2026.4', () => {
|
||||
const formatEntityName = vi.fn();
|
||||
const hass = hassWithVersion('2026.3.2', { formatEntityName });
|
||||
|
||||
expect(computeEntityName(hass, entity, [{ type: 'area' }])).toEqual('Alarm');
|
||||
expect(computeEntityName(hass, entity, undefined)).toEqual('Alarm');
|
||||
expect(formatEntityName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers a configured string over the friendly name on older versions', () => {
|
||||
expect(computeEntityName(hassWithVersion('2026.3.2'), entity, 'Wake up')).toEqual('Wake up');
|
||||
});
|
||||
|
||||
it('falls back when a recent hass does not carry formatEntityName', () => {
|
||||
expect(computeEntityName(hassWithVersion('2026.9.0'), entity, 'Wake up')).toEqual('Wake up');
|
||||
expect(computeEntityName(hassWithVersion('2026.9.0'), entity, undefined)).toEqual('Alarm');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Hour } from '../../src/models/hour';
|
||||
import { Direction } from '../../src/types';
|
||||
import { DEFAULT_HOUR_STEP } from '../../src/const';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Minute } from '../../src/models/minute';
|
||||
import { Direction } from '../../src/types';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Second } from '../../src/models/second';
|
||||
import { Direction } from '../../src/types';
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Hour } from '../../src/models/hour';
|
||||
import { Minute } from '../../src/models/minute';
|
||||
import { Second } from '../../src/models/second';
|
||||
import { Time } from '../../src/models/time';
|
||||
import { Direction } from '../../src/types';
|
||||
|
||||
const time = (h: number, m: number, s: number, linkValues = false): Time =>
|
||||
new Time(new Hour(h), new Minute(m, 5), new Second(s, 5), linkValues);
|
||||
|
||||
describe('Time', () => {
|
||||
describe('value', () => {
|
||||
it('joins the units with colons', () => {
|
||||
expect(time(7, 5, 0).value).toEqual('7:5:0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hourStep', () => {
|
||||
it('steps the hour without touching minutes', () => {
|
||||
const t = time(23, 30, 0);
|
||||
t.hourStep(Direction.UP);
|
||||
expect([t.hour.value, t.minute.value]).toEqual([0, 30]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('minuteStep', () => {
|
||||
it('does not change the hour when values are not linked', () => {
|
||||
const t = time(11, 55, 0);
|
||||
t.minuteStep(Direction.UP);
|
||||
expect([t.hour.value, t.minute.value]).toEqual([11, 0]);
|
||||
});
|
||||
|
||||
it('carries an overflow into the next hour when values are linked', () => {
|
||||
const t = time(11, 55, 0, true);
|
||||
t.minuteStep(Direction.UP);
|
||||
expect([t.hour.value, t.minute.value]).toEqual([12, 0]);
|
||||
});
|
||||
|
||||
it('borrows from the previous hour when values are linked', () => {
|
||||
const t = time(0, 0, 0, true);
|
||||
t.minuteStep(Direction.DOWN);
|
||||
expect([t.hour.value, t.minute.value]).toEqual([23, 55]);
|
||||
});
|
||||
|
||||
it('carries by one hour regardless of the hour step', () => {
|
||||
const t = new Time(new Hour(10, 3), new Minute(55, 5), new Second(0), true);
|
||||
t.minuteStep(Direction.UP);
|
||||
expect(t.hour.value).toEqual(11);
|
||||
});
|
||||
|
||||
it('does not carry when the step stays within the hour', () => {
|
||||
const t = time(10, 30, 0, true);
|
||||
t.minuteStep(Direction.UP);
|
||||
expect([t.hour.value, t.minute.value]).toEqual([10, 35]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondStep', () => {
|
||||
it('does not change the minute when values are not linked', () => {
|
||||
const t = time(10, 30, 55);
|
||||
t.secondStep(Direction.UP);
|
||||
expect([t.minute.value, t.second.value]).toEqual([30, 0]);
|
||||
});
|
||||
|
||||
it('carries an overflow into the next minute when values are linked', () => {
|
||||
const t = time(10, 30, 55, true);
|
||||
t.secondStep(Direction.UP);
|
||||
expect([t.minute.value, t.second.value]).toEqual([31, 0]);
|
||||
});
|
||||
|
||||
it('borrows from the previous minute when values are linked', () => {
|
||||
const t = time(10, 30, 0, true);
|
||||
t.secondStep(Direction.DOWN);
|
||||
expect([t.minute.value, t.second.value]).toEqual([29, 55]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { LitElement } from 'lit';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
import '../src/time-picker-card';
|
||||
import { TimePickerCard } from '../src/time-picker-card';
|
||||
import { TimePickerCardConfig } from '../src/types';
|
||||
|
||||
// The card renders Home Assistant's error card for invalid configs; stub the
|
||||
// element so the test can read the error it was given.
|
||||
class ErrorCardStub extends HTMLElement {
|
||||
config?: { error: string };
|
||||
setConfig(config: { error: string }): void {
|
||||
this.config = config;
|
||||
}
|
||||
}
|
||||
customElements.define('hui-error-card', ErrorCardStub);
|
||||
|
||||
const ENTITY_ID = 'input_datetime.alarm';
|
||||
|
||||
/** YAML is untyped at runtime, so setConfig has to cope with shapes the type forbids. */
|
||||
const invalidConfig = (config: Record<string, unknown>): TimePickerCardConfig =>
|
||||
config as unknown as TimePickerCardConfig;
|
||||
|
||||
const alarm = (hour: number, minute = 30, second = 0): HassEntity => ({
|
||||
entity_id: ENTITY_ID,
|
||||
state: `${hour}:${minute}:${second}`,
|
||||
attributes: { friendly_name: 'Alarm', has_time: true, has_date: false, hour, minute, second },
|
||||
last_changed: '',
|
||||
last_updated: '',
|
||||
context: { id: '', user_id: null, parent_id: null },
|
||||
});
|
||||
|
||||
const createHass = (entity: HassEntity = alarm(7)): HomeAssistant =>
|
||||
({
|
||||
states: { [entity.entity_id]: entity },
|
||||
config: { version: '2026.9.2' },
|
||||
callService: vi.fn().mockResolvedValue(undefined),
|
||||
}) as unknown as HomeAssistant;
|
||||
|
||||
const renderCard = async (
|
||||
config: Partial<TimePickerCardConfig>,
|
||||
hass: HomeAssistant = createHass(),
|
||||
): Promise<TimePickerCard> => {
|
||||
const card = document.createElement('time-picker-card') as TimePickerCard;
|
||||
card.setConfig({ type: 'custom:time-picker-card', entity: ENTITY_ID, ...config });
|
||||
card.hass = hass;
|
||||
document.body.appendChild(card);
|
||||
await settle(card);
|
||||
return card;
|
||||
};
|
||||
|
||||
/** Waits for the card and its nested Lit children to finish rendering. */
|
||||
const settle = async (card: TimePickerCard): Promise<void> => {
|
||||
await card.updateComplete;
|
||||
const children = card.shadowRoot!.querySelectorAll('time-unit, time-period');
|
||||
await Promise.all(Array.from(children).map((el) => (el as LitElement).updateComplete));
|
||||
};
|
||||
|
||||
const inputValues = (card: TimePickerCard): string[] =>
|
||||
Array.from(card.shadowRoot!.querySelectorAll('time-unit')).map(
|
||||
(unit) => unit.shadowRoot!.querySelector<HTMLInputElement>('input')!.value,
|
||||
);
|
||||
|
||||
describe('time-picker-card', () => {
|
||||
let card: TimePickerCard;
|
||||
|
||||
afterEach(() => {
|
||||
card?.remove();
|
||||
});
|
||||
|
||||
describe('setConfig', () => {
|
||||
beforeEach(() => {
|
||||
card = document.createElement('time-picker-card') as TimePickerCard;
|
||||
});
|
||||
|
||||
it('rejects a missing entity', () => {
|
||||
expect(() => card.setConfig(invalidConfig({ type: 'custom:time-picker-card' }))).toThrow(
|
||||
'You must set an entity',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown hour mode', () => {
|
||||
expect(() =>
|
||||
card.setConfig(
|
||||
invalidConfig({ type: 'custom:time-picker-card', entity: ENTITY_ID, hour_mode: 10 }),
|
||||
),
|
||||
).toThrow('Invalid hour_mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('shows hour and minute inputs with the entity time', async () => {
|
||||
card = await renderCard({});
|
||||
expect(inputValues(card)).toEqual(['07', '30']);
|
||||
expect(card.shadowRoot!.querySelector('time-period')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the entity name in the header by default', async () => {
|
||||
card = await renderCard({});
|
||||
expect(card.shadowRoot!.querySelector('.time-picker-header')!.textContent!.trim()).toEqual(
|
||||
'Alarm',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows seconds and an AM/PM toggle when configured', async () => {
|
||||
card = await renderCard({ hour_mode: 12, hide: { seconds: false } });
|
||||
expect(inputValues(card)).toEqual(['07', '30', '00']);
|
||||
expect(card.shadowRoot!.querySelector('time-period')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('re-renders when hass delivers a new entity state', async () => {
|
||||
card = await renderCard({});
|
||||
card.hass = createHass(alarm(9, 45));
|
||||
await settle(card);
|
||||
expect(inputValues(card)).toEqual(['09', '45']);
|
||||
});
|
||||
|
||||
it('renders an error card when the entity is missing', async () => {
|
||||
card = await renderCard({ entity: 'input_datetime.missing' });
|
||||
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub;
|
||||
expect(error.config?.error).toEqual('Entity not found');
|
||||
});
|
||||
|
||||
it('renders an error card when the entity has no time', async () => {
|
||||
const dateOnly = { ...alarm(7), attributes: { friendly_name: 'Date', has_time: false } };
|
||||
card = await renderCard({}, createHass(dateOnly));
|
||||
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub;
|
||||
expect(error.config?.error).toContain('has_time: true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('interaction', () => {
|
||||
it('calls set_datetime when an hour arrow is clicked', async () => {
|
||||
const hass = createHass();
|
||||
card = await renderCard({}, hass);
|
||||
const hourUnit = card.shadowRoot!.querySelector('time-unit')!;
|
||||
hourUnit.shadowRoot!.querySelector<HTMLElement>('.time-picker-icon')!.click();
|
||||
|
||||
expect(hass.callService).toHaveBeenCalledWith('input_datetime', 'set_datetime', {
|
||||
entity_id: ENTITY_ID,
|
||||
time: '8:30:0',
|
||||
});
|
||||
});
|
||||
|
||||
it('debounces the service call when a delay is configured', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const hass = createHass();
|
||||
card = await renderCard({ delay: 500 }, hass);
|
||||
const [up] = Array.from(
|
||||
card
|
||||
.shadowRoot!.querySelector('time-unit')!
|
||||
.shadowRoot!.querySelectorAll<HTMLElement>('.time-picker-icon'),
|
||||
);
|
||||
up.click();
|
||||
up.click();
|
||||
expect(hass.callService).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(hass.callService).toHaveBeenCalledTimes(1);
|
||||
expect(hass.callService).toHaveBeenCalledWith('input_datetime', 'set_datetime', {
|
||||
entity_id: ENTITY_ID,
|
||||
time: '9:30:0',
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user