diff --git a/web/src/app/routes.test.tsx b/web/src/app/routes.test.tsx
new file mode 100644
index 000000000..137428f73
--- /dev/null
+++ b/web/src/app/routes.test.tsx
@@ -0,0 +1,46 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import {
+ routeById,
+ routeFromLocation
+} from './routes';
+
+describe('app route matching', () => {
+ afterEach(() => {
+ window.history.replaceState(null, '', '/app');
+ });
+
+ it('returns one stable route object across sibling allowSubPaths locations', () => {
+ const playoutsRoute = routeById.get('playouts');
+
+ window.history.replaceState(null, '', '/app/playouts/20/alternate-schedules');
+ const alternateSchedules = routeFromLocation();
+
+ window.history.replaceState(null, '', '/app/playouts/20/templates');
+ const templates = routeFromLocation();
+
+ expect(alternateSchedules).toBe(playoutsRoute);
+ expect(templates).toBe(playoutsRoute);
+ expect(templates).toBe(alternateSchedules);
+ });
+
+ it('ignores the query string while preserving the route object', () => {
+ const searchRoute = routeById.get('search');
+
+ window.history.replaceState(null, '', '/app/search?query=alpha');
+ const firstQuery = routeFromLocation();
+
+ window.history.replaceState(null, '', '/app/search?query=beta');
+ const secondQuery = routeFromLocation();
+
+ expect(firstQuery).toBe(searchRoute);
+ expect(secondQuery).toBe(searchRoute);
+ });
+
+ it('normalizes a trailing slash and rejects an unowned path', () => {
+ window.history.replaceState(null, '', '/app/channels/');
+ expect(routeFromLocation()).toBe(routeById.get('channels'));
+
+ window.history.replaceState(null, '', '/app/channels/42');
+ expect(routeFromLocation()).toBeNull();
+ });
+});
diff --git a/web/src/screens/SchedulesScreen.test.tsx b/web/src/screens/SchedulesScreen.test.tsx
index b595b33f9..4734fde23 100644
--- a/web/src/screens/SchedulesScreen.test.tsx
+++ b/web/src/screens/SchedulesScreen.test.tsx
@@ -1,5 +1,6 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { PrimaryActionProvider, usePrimaryActionHandler } from '../primaryAction';
import { SchedulesScreen } from './SchedulesScreen';
function jsonResponse(body: unknown, status = 200): Response {
@@ -157,6 +158,11 @@ async function renderReady(options: MockOptions = {}) {
return handle;
}
+function SchedulesPrimaryActionButton() {
+ const handler = usePrimaryActionHandler('schedules');
+ return ;
+}
+
describe('SchedulesScreen — load', () => {
it('renders the lineup for the active schedule', async () => {
await renderReady();
@@ -438,10 +444,19 @@ describe('SchedulesScreen — schedule CRUD', () => {
await waitFor(() => expect(handle.requests.some((r) => r.url === '/api/v1/schedules/99/items')).toBe(true));
});
- it('responds to the TopBar primary-action event by opening create', async () => {
- await renderReady();
- window.dispatchEvent(new CustomEvent('ctv:primary-action', { detail: 'schedules' }));
- await waitFor(() => expect(screen.getByRole('dialog')).toBeTruthy());
+ it('registers the TopBar primary action to open create', async () => {
+ mockApi();
+ render(
+
+
+
+
+ );
+ await waitFor(() => expect(screen.getByLabelText('Schedule lineup')).toBeTruthy());
+
+ fireEvent.click(screen.getByRole('button', { name: 'TopBar action' }));
+
+ expect(screen.getByRole('dialog')).toBeTruthy();
expect(within(screen.getByRole('dialog')).getByLabelText('Name')).toBeTruthy();
});