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).
199 lines
6.5 KiB
TypeScript
199 lines
6.5 KiB
TypeScript
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');
|
||
});
|
||
});
|