Files
portal/src/lib/components/schedule/BlockModal.svelte.spec.ts
T
NeilClack 9a2888af36 Portal: per-system daily/weekly schedule editor
System picker lists the account's License Keys labelled by System Name
(fallback: key). Schedule type selector (Daily|Weekly) with a confirmation
before switching a populated schedule (daily->weekly prefills all seven
days; otherwise clears). Weekly shows a per-day editor. Blocks use
TimeInput, allow adjacent/midnight-crossing ranges (labelled 'ends next
day'), block empty blocks client-side, default dynamic audio on, and show
title/category/group only. Adds gap helper text and an informational
kiosk-timezone note (browser fallback; no external/paid dependency).
2026-09-29 10:28:05 -05:00

199 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
const { submitRef } = vi.hoisted(() => ({
submitRef: {
current: null as unknown as (opts: { formData: FormData; cancel?: () => void }) => unknown
}
}));
vi.mock('$app/forms', () => ({
enhance: (_node: unknown, submit: unknown) => {
submitRef.current = submit as typeof submitRef.current;
return () => {};
}
}));
import BlockModal from './BlockModal.svelte';
const categories = [{ id: 1, name: 'Physical' }];
const groups = [{ id: 1, name: 'Prayers' }];
const proclamations = [
{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(BlockModal, {
open: true,
mode: 'create',
categories,
groups,
proclamations,
existingBlocks: [],
initialStartMinutes: 6 * 60,
initialEndMinutes: 8 * 60,
initialProclamationIds: ['p1'],
onClose: () => {},
onSaved: () => {},
...overrides
});
}
function submit() {
const form = document.querySelector('form') as HTMLFormElement;
return submitRef.current({ formData: new FormData(form), cancel: () => {} });
}
async function typeTime(name: string, text: string) {
const input = (await page.getByRole('combobox', { name }).element()) as HTMLInputElement;
input.value = text;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
describe('BlockModal', () => {
it('uses a 12-hour TimeInput for start and end', async () => {
mount();
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: 'End time' })).toBeInTheDocument();
});
it('pre-fills the fields from the initial times', async () => {
mount({ initialStartMinutes: 7 * 60 + 30, initialEndMinutes: 9 * 60 + 15 });
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toHaveValue('7:30 AM');
await expect.element(page.getByRole('combobox', { name: 'End time' })).toHaveValue('9:15 AM');
});
it('shows a 12-hour clock with AM/PM', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 0 });
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toHaveValue('9:00 PM');
await expect.element(page.getByRole('combobox', { name: 'End time' })).toHaveValue('12:00 AM');
});
it('recomputes the time from typed input', async () => {
mount({ initialStartMinutes: 9 * 60, initialEndMinutes: 10 * 60 });
await typeTime('End time', '10:00 PM');
await expect.element(page.getByText('9:00 AM – 10:00 PM')).toBeInTheDocument();
});
it('shows validation feedback for unparseable input', async () => {
mount();
await typeTime('Start time', '25:00');
await expect.element(page.getByRole('alert')).toBeInTheDocument();
});
it('shows the review times and selected proclamation titles', async () => {
mount();
await expect.element(page.getByText('6:00 AM – 8:00 AM')).toBeInTheDocument();
await expect.element(page.getByText('Peace')).toBeInTheDocument();
});
it('marks an overnight block in the review', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 9 * 60 });
await expect.element(page.getByText('9:00 PM – 9:00 AM (ends next day)')).toBeInTheDocument();
});
it('rejects an overlapping time range with the specific error', async () => {
mount({
existingBlocks: [{ id: 'b1', startMinutes: 6 * 60, endMinutes: 8 * 60 }],
initialStartMinutes: 7 * 60,
initialEndMinutes: 9 * 60
});
submit();
await expect
.element(page.getByText(/This overlaps with your 6:00 AM–8:00 AM block\./))
.toBeInTheDocument();
});
it('requires at least one proclamation', async () => {
mount({ initialProclamationIds: [] });
submit();
await expect.element(page.getByText('Select at least one proclamation.')).toBeInTheDocument();
});
it('does not show an error for a valid selection', async () => {
mount();
const result = submit();
expect(typeof result).toBe('function');
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('shows Cancel with Accept in the browse step, and Accept returns to the details', async () => {
mount();
const browse = page.getByRole('button', { name: 'Browse proclamations' });
await expect.element(browse).toBeInTheDocument();
(browse.element() as HTMLElement).click();
await expect.element(page.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
const accept = page.getByRole('button', { name: 'Accept' });
await expect.element(accept).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Create Block' }))
.not.toBeInTheDocument();
(accept.element() as HTMLElement).click();
await expect.element(page.getByRole('button', { name: 'Create Block' })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Accept' })).not.toBeInTheDocument();
});
it('offers an Enable Dynamic Audio checkbox with a help tooltip', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
await expect
.element(page.getByText(/Dynamic audio will be enabled for this block/))
.toBeInTheDocument();
});
it('defaults dynamic audio on for new blocks', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
});
it('pre-checks dynamic audio when editing a block that has it enabled', async () => {
mount({ initialDynamicAudio: true });
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
});
it('leaves dynamic audio off when editing a block that has it disabled', async () => {
mount({ initialDynamicAudio: false });
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
expect((checkbox.element() as HTMLInputElement).checked).toBe(false);
});
it('toggles the dynamic audio flag in the submitted payload', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
(checkbox.element() as HTMLInputElement).click();
const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement;
await expect.poll(() => hidden.value).toBe('false');
});
});