From 56bbc3d4cbde89f319bc8de157e06b42192c5e95 Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Wed, 11 Nov 2026 17:19:58 -0500
Subject: [PATCH 1/2] test: cover the tutorial, schedule, sessions, speakers,
and account flows
---
e2e/account.spec.ts | 122 ++++++++++++++++
e2e/app.spec.ts | 16 ---
e2e/helpers.ts | 83 +++++++++++
e2e/navigation.spec.ts | 115 +++++++++++++++
e2e/schedule.spec.ts | 130 +++++++++++++++++
e2e/session-detail.spec.ts | 89 ++++++++++++
e2e/speakers.spec.ts | 49 +++++++
e2e/tutorial.spec.ts | 61 ++++++++
src/components/HomeOrTutorial.test.tsx | 28 ++++
src/components/Menu.test.tsx | 51 +++++++
src/data/AppContextProvider.tsx | 13 +-
src/data/actions.test.ts | 107 ++++++++++++++
src/data/dataApi.test.ts | 112 +++++++++++++++
src/data/selectors.test.ts | 191 +++++++++++++++++++++++++
src/data/state.test.ts | 108 ++++++++++++++
src/pages/SchedulePage.test.tsx | 73 ++++++++++
src/pages/SessionDetail.test.tsx | 58 ++++++++
src/pages/SpeakerList.test.tsx | 32 +++++
src/testing/fixtures.ts | 107 ++++++++++++++
src/testing/render.tsx | 35 +++++
20 files changed, 1562 insertions(+), 18 deletions(-)
create mode 100644 e2e/account.spec.ts
delete mode 100644 e2e/app.spec.ts
create mode 100644 e2e/helpers.ts
create mode 100644 e2e/navigation.spec.ts
create mode 100644 e2e/schedule.spec.ts
create mode 100644 e2e/session-detail.spec.ts
create mode 100644 e2e/speakers.spec.ts
create mode 100644 e2e/tutorial.spec.ts
create mode 100644 src/components/HomeOrTutorial.test.tsx
create mode 100644 src/components/Menu.test.tsx
create mode 100644 src/data/actions.test.ts
create mode 100644 src/data/dataApi.test.ts
create mode 100644 src/data/selectors.test.ts
create mode 100644 src/data/state.test.ts
create mode 100644 src/pages/SchedulePage.test.tsx
create mode 100644 src/pages/SessionDetail.test.tsx
create mode 100644 src/pages/SpeakerList.test.tsx
create mode 100644 src/testing/fixtures.ts
create mode 100644 src/testing/render.tsx
diff --git a/e2e/account.spec.ts b/e2e/account.spec.ts
new file mode 100644
index 00000000..84990701
--- /dev/null
+++ b/e2e/account.spec.ts
@@ -0,0 +1,122 @@
+import type { Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+
+import { APP_LOAD_TIMEOUT, navigateFromMenu, openMenu, openSchedule, submitCredentials, visiblePage } from './helpers';
+
+const expectMenuLinks = async (page: Page, shown: string[], hidden: string[]) => {
+ const menu = await openMenu(page);
+ for (const name of shown) {
+ await expect(menu.getByRole('link', { name, exact: true })).toBeVisible();
+ }
+ for (const name of hidden) {
+ await expect(menu.getByRole('link', { name, exact: true })).toHaveCount(0);
+ }
+ await page.keyboard.press('Escape');
+ await expect(menu).toBeHidden();
+};
+
+const expectLoggedIn = (page: Page) => expectMenuLinks(page, ['Account', 'Support', 'Logout'], ['Login', 'Signup']);
+const expectLoggedOut = (page: Page) => expectMenuLinks(page, ['Login', 'Support', 'Signup'], ['Account', 'Logout']);
+
+const login = async (page: Page, username: string) => {
+ await navigateFromMenu(page, 'Login');
+ await submitCredentials(page, 'login-page', 'Login', username);
+};
+
+test.describe('Account', () => {
+ test.beforeEach(async ({ page }) => {
+ await openSchedule(page);
+ });
+
+ test('starts logged out', async ({ page }) => {
+ await expectLoggedOut(page);
+ });
+
+ test('does not log in without a username and password', async ({ page }) => {
+ await navigateFromMenu(page, 'Login');
+ const login = visiblePage(page, 'login-page');
+ await login.getByRole('textbox', { name: 'Username' }).fill('ionitron');
+ await login.getByRole('button', { name: 'Login' }).click();
+
+ await expect(page).toHaveURL(/\/login$/);
+ await expectLoggedOut(page);
+ });
+
+ test('logging in marks the user as logged in', async ({ page }) => {
+ await login(page, 'ionitron');
+
+ await expectLoggedIn(page);
+ });
+
+ test('the account page shows the username and can change it', async ({ page }) => {
+ await login(page, 'ionitron');
+ await navigateFromMenu(page, 'Account');
+
+ const account = visiblePage(page, 'account-page');
+ await expect(account.getByRole('heading', { name: 'ionitron' })).toBeVisible();
+
+ // Typing into the alert before it has finished opening can be lost, so wait for it to open.
+ const opened = page.evaluate(
+ () =>
+ new Promise((resolve) =>
+ document.addEventListener('ionAlertDidPresent', () => resolve(), { once: true }),
+ ),
+ );
+ await account.getByText('Change Username').click();
+ await opened;
+ const alert = page.getByRole('alertdialog', { name: 'Change Username' });
+ const input = alert.getByRole('textbox', { name: 'username' });
+ await expect(input).toHaveValue('ionitron');
+ await input.fill('capacitor');
+ await alert.getByRole('button', { name: 'Ok' }).click();
+
+ await expect(account.getByRole('heading', { name: 'capacitor' })).toBeVisible();
+
+ // The new username is saved.
+ await page.reload();
+ await expect(visiblePage(page, 'account-page').getByRole('heading', { name: 'capacitor' })).toBeVisible({
+ timeout: APP_LOAD_TIMEOUT,
+ });
+ });
+
+ test('logging out returns to the logged out state', async ({ page }) => {
+ await login(page, 'ionitron');
+
+ await navigateFromMenu(page, 'Logout');
+
+ await expect(page).toHaveURL(/\/tabs\/schedule$/);
+ await expectLoggedOut(page);
+
+ // Logging out is saved too.
+ await page.reload();
+ await expect(page.getByRole('button', { name: 'menu' })).toBeVisible({ timeout: APP_LOAD_TIMEOUT });
+ await expectLoggedOut(page);
+ });
+
+ test('signing up logs the user in', async ({ page }) => {
+ await navigateFromMenu(page, 'Signup');
+ await submitCredentials(page, 'signup-page', 'Create', 'newbie');
+
+ await expectLoggedIn(page);
+ await navigateFromMenu(page, 'Account');
+ await expect(visiblePage(page, 'account-page').getByRole('heading', { name: 'newbie' })).toBeVisible();
+ });
+
+ test('the login page links to signup', async ({ page }) => {
+ await navigateFromMenu(page, 'Login');
+ await visiblePage(page, 'login-page').getByRole('button', { name: 'Signup' }).click();
+
+ await expect(page).toHaveURL(/\/signup$/);
+ });
+
+ test('stays logged in after a reload', async ({ page }) => {
+ await login(page, 'ionitron');
+
+ await page.reload();
+ await expect(page.getByRole('button', { name: 'menu' })).toBeVisible({ timeout: APP_LOAD_TIMEOUT });
+
+ await expectLoggedIn(page);
+ await navigateFromMenu(page, 'Account');
+ await expect(visiblePage(page, 'account-page').getByRole('heading', { name: 'ionitron' })).toBeVisible();
+ });
+});
diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts
deleted file mode 100644
index 2c5b2ffd..00000000
--- a/e2e/app.spec.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { expect, test } from '@playwright/test';
-
-test('shows the tutorial on the first visit', async ({ page }) => {
- await page.goto('/');
-
- await expect(page).toHaveURL(/\/tutorial$/);
- await expect(page.getByRole('button', { name: 'Skip' })).toBeVisible();
-});
-
-test('skipping the tutorial opens the schedule', async ({ page }) => {
- await page.goto('/');
- await page.getByRole('button', { name: 'Skip' }).click();
-
- await expect(page).toHaveURL(/\/schedule$/);
- await expect(page.locator('ion-segment-button', { hasText: 'Favorites' }).first()).toBeVisible();
-});
diff --git a/e2e/helpers.ts b/e2e/helpers.ts
new file mode 100644
index 00000000..71c6040f
--- /dev/null
+++ b/e2e/helpers.ts
@@ -0,0 +1,83 @@
+import type { Locator, Page } from '@playwright/test';
+import { expect } from '@playwright/test';
+
+// The app renders nothing until it has fetched the conference data, which can be slow on CI runners.
+export const APP_LOAD_TIMEOUT = 15_000;
+
+/**
+ * Opens the app for the first time and skips the tutorial, landing on the schedule.
+ */
+export const openSchedule = async (page: Page): Promise => {
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Skip' }).click({ timeout: APP_LOAD_TIMEOUT });
+ await expect(page).toHaveURL(/\/tabs\/schedule$/);
+ await expect(sessionLink(page, 'Breakfast')).toBeVisible();
+};
+
+/**
+ * Ionic keeps pages it has shown in the DOM and hides them, so scope to the page that is showing.
+ */
+export const visiblePage = (page: Page, id: string): Locator => page.locator(`#${id}:not(.ion-page-hidden)`);
+
+export const visibleTitle = (page: Page): Locator => page.locator('ion-title').filter({ visible: true }).first();
+
+// A session in the schedule list. Its accessible name starts with the session name, followed by its time and room.
+export const sessionLink = (page: Page, name: string): Locator =>
+ visiblePage(page, 'schedule-page').getByRole('link', { name: new RegExp(`^${escapeRegExp(name)} \\d`) });
+
+// The schedule's toolbar buttons only show an icon and have no accessible name, so find them by position.
+const scheduleEndButtons = (page: Page): Locator =>
+ visiblePage(page, 'schedule-page').locator('ion-header ion-buttons[slot="end"] ion-button');
+export const searchButton = (page: Page): Locator => scheduleEndButtons(page).first();
+export const filterButton = (page: Page): Locator => scheduleEndButtons(page).last();
+
+// The session detail's favorite button is the first icon-only button at the end of its toolbar.
+export const favoriteButton = (page: Page): Locator =>
+ visiblePage(page, 'session-detail-page').locator('ion-buttons[slot="end"] ion-button').first();
+
+export const openMenu = async (page: Page): Promise => {
+ // Wait for any page transition to finish so only the current page's menu button is showing.
+ const menuButton = page.getByRole('button', { name: 'menu', exact: true }).filter({ visible: true });
+ await expect(menuButton).toHaveCount(1);
+ await menuButton.click();
+ const menu = page.getByRole('navigation', { name: 'menu' });
+ await expect(menu).toBeVisible();
+ return menu;
+};
+
+export const navigateFromMenu = async (page: Page, name: string): Promise => {
+ const menu = await openMenu(page);
+ await menu.getByRole('link', { name, exact: true }).click();
+ await expect(menu).toBeHidden();
+};
+
+/**
+ * Switches the schedule between its All and Favorites segments.
+ */
+export const showSegment = async (page: Page, name: 'All' | 'Favorites'): Promise => {
+ const schedule = visiblePage(page, 'schedule-page');
+ // The segment button's host receives the pointer events, not its inner tab element.
+ await schedule.locator('ion-segment-button', { hasText: name }).click();
+ // Wait for the switch so the other segment's sessions aren't matched while they're still showing.
+ await expect(schedule.getByRole('tab', { name })).toHaveAttribute('aria-selected', 'true');
+};
+
+export const backButton = (page: Page, pageId: string): Locator =>
+ visiblePage(page, pageId).getByRole('button', { name: 'back', exact: true });
+
+export const submitCredentials = async (
+ page: Page,
+ pageId: string,
+ button: string,
+ username: string,
+): Promise => {
+ const form = visiblePage(page, pageId);
+ await form.getByRole('textbox', { name: 'Username' }).fill(username);
+ await form.getByRole('textbox', { name: 'Password' }).fill('secret');
+ await form.getByRole('button', { name: button }).click();
+ await expect(page).toHaveURL(/\/tabs\/schedule$/);
+};
+
+function escapeRegExp(text: string): string {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts
new file mode 100644
index 00000000..3bf9a762
--- /dev/null
+++ b/e2e/navigation.spec.ts
@@ -0,0 +1,115 @@
+import { expect, test } from '@playwright/test';
+
+import { navigateFromMenu, openMenu, openSchedule, visiblePage, visibleTitle } from './helpers';
+
+test.describe('Navigation', () => {
+ test.beforeEach(async ({ page }) => {
+ await openSchedule(page);
+ });
+
+ test('the tab bar reaches each tab', async ({ page }) => {
+ for (const tab of [
+ { name: 'Speakers', url: /\/tabs\/speakers$/, title: 'Speakers' },
+ { name: 'Map', url: /\/tabs\/map$/, title: 'Map' },
+ { name: 'Schedule', url: /\/tabs\/schedule$/, title: 'Schedule' },
+ ]) {
+ await page.getByRole('tab', { name: tab.name }).click();
+
+ await expect(page).toHaveURL(tab.url);
+ await expect(visibleTitle(page)).toHaveText(tab.title);
+ await expect(page.getByRole('tab', { name: tab.name })).toHaveAttribute('aria-selected', 'true');
+ }
+
+ // The About page has no title in its toolbar, just an About heading.
+ await page.getByRole('tab', { name: 'About' }).click();
+ await expect(page).toHaveURL(/\/tabs\/about$/);
+ await expect(visiblePage(page, 'about-page').getByRole('heading', { name: 'About' })).toBeVisible();
+ });
+
+ for (const item of [
+ { name: 'Speakers', url: /\/tabs\/speakers$/, title: 'Speakers' },
+ { name: 'Map', url: /\/tabs\/map$/, title: 'Map' },
+ { name: 'Support', url: /\/support$/, title: 'Support' },
+ { name: 'Login', url: /\/login$/, title: 'Login' },
+ { name: 'Signup', url: /\/signup$/, title: 'Signup' },
+ { name: 'Schedule', url: /\/tabs\/schedule$/, title: 'Schedule' },
+ ]) {
+ test(`the menu opens the ${item.name} page`, async ({ page }) => {
+ // Start somewhere else so that opening Schedule is a navigation too.
+ if (item.name === 'Schedule') {
+ await navigateFromMenu(page, 'Speakers');
+ await expect(page).toHaveURL(/\/tabs\/speakers$/);
+ }
+
+ await navigateFromMenu(page, item.name);
+
+ await expect(page).toHaveURL(item.url);
+ await expect(visibleTitle(page)).toHaveText(item.title);
+ });
+ }
+
+ test('the menu opens the About page', async ({ page }) => {
+ await navigateFromMenu(page, 'About');
+
+ await expect(page).toHaveURL(/\/tabs\/about$/);
+ await expect(visiblePage(page, 'about-page')).toContainText('The Ionic Conference is a one-day event');
+ });
+
+ test('the map page shows a marker for each location', async ({ page }) => {
+ // Keep the test independent of the network by not loading the map tiles.
+ await page.route(/tile\.openstreetmap\.org/, (route) => route.abort());
+
+ await page.getByRole('tab', { name: 'Map' }).click();
+
+ const map = page.locator('.map-canvas');
+ await expect(map).toHaveClass(/leaflet-container/);
+ await expect(map).toHaveClass(/show-map/);
+ await expect(map.locator('.leaflet-marker-icon')).toHaveCount(4);
+ });
+
+ test('the support page sends a message and clears the form', async ({ page }) => {
+ await navigateFromMenu(page, 'Support');
+ const support = visiblePage(page, 'support-page');
+ const notice = page.getByText('This does not actually send a support request.');
+ await expect(notice).toBeVisible();
+ // The page can only show one toast at a time and skips the confirmation while the notice is open (see the report).
+ await expect(notice).toBeHidden({ timeout: 10_000 });
+
+ const message = support.getByRole('textbox', { name: 'Enter your support message below' });
+ await message.fill('The app is great!');
+ await support.getByRole('button', { name: 'Submit' }).click();
+
+ await expect(page.getByText('Your support request has been sent.')).toBeVisible();
+ await expect(message).toHaveValue('');
+ });
+
+ test('the support page does not send an empty message', async ({ page }) => {
+ await navigateFromMenu(page, 'Support');
+ const support = visiblePage(page, 'support-page');
+
+ await support.getByRole('button', { name: 'Submit' }).click();
+
+ // The "Support message is required" error text isn't shown (see the report), so check nothing was sent.
+ await expect(support.getByRole('textbox', { name: 'Enter your support message below' })).toBeVisible();
+ await expect(page.getByText('Your support request has been sent.')).toHaveCount(0);
+ });
+
+ test('the dark mode toggle switches the dark palette', async ({ page }) => {
+ const app = page.locator('ion-app');
+ await expect(app).not.toHaveClass(/ion-palette-dark/);
+
+ const menu = await openMenu(page);
+ const toggle = menu.getByRole('switch', { name: 'Dark Mode' });
+ // The toggle's host element receives the pointer events, not its inner switch element.
+ const toggleHost = menu.locator('ion-toggle');
+ await toggleHost.click();
+
+ await expect(toggle).toBeChecked();
+ await expect(app).toHaveClass(/ion-palette-dark/);
+
+ await toggleHost.click();
+
+ await expect(toggle).not.toBeChecked();
+ await expect(app).not.toHaveClass(/ion-palette-dark/);
+ });
+});
diff --git a/e2e/schedule.spec.ts b/e2e/schedule.spec.ts
new file mode 100644
index 00000000..6e64fd59
--- /dev/null
+++ b/e2e/schedule.spec.ts
@@ -0,0 +1,130 @@
+import type { Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+
+import {
+ backButton,
+ favoriteButton,
+ filterButton,
+ openSchedule,
+ searchButton,
+ showSegment,
+ sessionLink,
+ visiblePage,
+} from './helpers';
+
+const schedule = (page: Page) => visiblePage(page, 'schedule-page');
+const timeGroups = (page: Page) => schedule(page).getByRole('group').filter({ visible: true });
+const timeGroup = (page: Page, time: string) => timeGroups(page).filter({ has: page.getByText(time, { exact: true }) });
+const visibleSessions = (page: Page) => schedule(page).getByRole('link').filter({ visible: true });
+// The modal's dialog role is on an element in its shadow root, which doesn't contain the slotted content.
+const filterModal = (page: Page) => page.locator('ion-modal');
+// The checkbox's host element receives the pointer events, not its inner checkbox element.
+const trackCheckbox = (page: Page, track: string) => filterModal(page).locator('ion-checkbox', { hasText: track });
+
+test.describe('Schedule', () => {
+ test.beforeEach(async ({ page }) => {
+ await openSchedule(page);
+ });
+
+ test('lists the sessions grouped by time', async ({ page }) => {
+ await expect(timeGroups(page)).toHaveCount(8);
+ await expect(visibleSessions(page)).toHaveCount(20);
+
+ // Groups are in time order, and so are the sessions within a group.
+ await expect(timeGroups(page).first()).toContainText('8:00 am');
+ await expect(timeGroups(page).last()).toContainText('3:00 pm');
+ await expect(timeGroup(page, '9:15 am').getByRole('heading')).toHaveText([
+ 'University of Ionic',
+ 'Getting Started with Ionic',
+ 'Ionic Tooling',
+ ]);
+ await expect(sessionLink(page, 'Ionic Tooling')).toHaveAccessibleName(
+ 'Ionic Tooling 9:45 am — 10:00 am: Executive Ballroom',
+ );
+ });
+
+ test('search filters the sessions by name and clearing it restores them', async ({ page }) => {
+ await searchButton(page).click();
+ const searchbox = schedule(page).getByRole('searchbox');
+ await searchbox.fill('react');
+
+ await expect(visibleSessions(page)).toHaveCount(2);
+ await expect(sessionLink(page, "What's New in React")).toBeVisible();
+ await expect(sessionLink(page, 'Building with React and Ionic')).toBeVisible();
+ await expect(timeGroups(page)).toHaveCount(2);
+
+ await schedule(page).getByRole('button', { name: 'reset', exact: true }).click();
+
+ await expect(searchbox).toHaveValue('');
+ await expect(visibleSessions(page)).toHaveCount(20);
+ await expect(timeGroups(page)).toHaveCount(8);
+ });
+
+ test('search shows an empty state when no session matches', async ({ page }) => {
+ await searchButton(page).click();
+ await schedule(page).getByRole('searchbox').fill('no such session');
+
+ await expect(schedule(page).getByText('No Sessions Found')).toBeVisible();
+ await expect(visibleSessions(page)).toHaveCount(0);
+ });
+
+ test('Favorites shows an empty state when nothing is favorited', async ({ page }) => {
+ await showSegment(page, 'Favorites');
+
+ await expect(schedule(page).getByText('No Sessions Found')).toBeVisible();
+ await expect(visibleSessions(page)).toHaveCount(0);
+ });
+
+ test('Favorites shows only the favorited sessions', async ({ page }) => {
+ await sessionLink(page, 'Ionic Tooling').click();
+ await favoriteButton(page).click();
+ await backButton(page, 'session-detail-page').click();
+
+ await showSegment(page, 'Favorites');
+
+ await expect(visibleSessions(page)).toHaveCount(1);
+ await expect(sessionLink(page, 'Ionic Tooling')).toBeVisible();
+ await expect(timeGroups(page)).toHaveCount(1);
+ await expect(timeGroups(page)).toContainText('9:15 am');
+
+ await showSegment(page, 'All');
+ await expect(visibleSessions(page)).toHaveCount(20);
+ });
+
+ test('the filter excludes a track and including it brings its sessions back', async ({ page }) => {
+ await filterButton(page).click();
+ await trackCheckbox(page, 'Food').click();
+ await expect(filterModal(page).getByRole('checkbox', { name: 'Food' })).not.toBeChecked();
+ await filterModal(page).getByRole('button', { name: 'Done' }).click();
+ await expect(filterModal(page)).toBeHidden();
+
+ await expect(sessionLink(page, 'Breakfast')).toBeHidden();
+ await expect(sessionLink(page, 'Lunch')).toBeHidden();
+ await expect(visibleSessions(page)).toHaveCount(18);
+ // The time groups that only had Food sessions are gone too.
+ await expect(timeGroups(page)).toHaveCount(6);
+
+ await filterButton(page).click();
+ await trackCheckbox(page, 'Food').click();
+ await expect(filterModal(page).getByRole('checkbox', { name: 'Food' })).toBeChecked();
+ await filterModal(page).getByRole('button', { name: 'Done' }).click();
+
+ await expect(sessionLink(page, 'Breakfast')).toBeVisible();
+ await expect(sessionLink(page, 'Lunch')).toBeVisible();
+ await expect(visibleSessions(page)).toHaveCount(20);
+ });
+
+ test('Reset in the filter deselects every track', async ({ page }) => {
+ await filterButton(page).click();
+ await filterModal(page).getByRole('button', { name: 'Reset' }).click();
+
+ await expect(filterModal(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
+ await expect(filterModal(page).getByRole('checkbox', { checked: false })).toHaveCount(10);
+
+ await trackCheckbox(page, 'Workshop').click();
+ await filterModal(page).getByRole('button', { name: 'Done' }).click();
+
+ await expect(visibleSessions(page)).toHaveCount(1);
+ await expect(sessionLink(page, 'Ionic Workshop')).toBeVisible();
+ });
+});
diff --git a/e2e/session-detail.spec.ts b/e2e/session-detail.spec.ts
new file mode 100644
index 00000000..7010e420
--- /dev/null
+++ b/e2e/session-detail.spec.ts
@@ -0,0 +1,89 @@
+import type { Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+
+import { backButton, favoriteButton, openSchedule, showSegment, sessionLink, visiblePage } from './helpers';
+
+const detail = (page: Page) => visiblePage(page, 'session-detail-page');
+const schedule = (page: Page) => visiblePage(page, 'schedule-page');
+
+const toggleFavoriteFromDetail = async (page: Page, name: string) => {
+ await showSegment(page, 'All');
+ await sessionLink(page, name).click();
+ await expect(detail(page).getByRole('heading', { level: 1 })).toHaveText(name);
+ await favoriteButton(page).click();
+ await backButton(page, 'session-detail-page').click();
+ await showSegment(page, 'Favorites');
+};
+
+// Drags a session's item to the left to reveal its options.
+const swipeOpen = async (page: Page, name: string) => {
+ const item = sessionLink(page, name);
+ await expect(item).toBeVisible();
+ const box = (await item.boundingBox())!;
+ const y = box.y + box.height / 2;
+ await page.mouse.move(box.x + box.width - 20, y);
+ await page.mouse.down();
+ await page.mouse.move(box.x + 40, y, { steps: 10 });
+ await page.mouse.up();
+};
+
+const itemOption = (page: Page, name: string) =>
+ schedule(page).getByRole('button', { name, exact: true }).filter({ visible: true });
+
+test.describe('Session detail', () => {
+ test.beforeEach(async ({ page }) => {
+ await openSchedule(page);
+ });
+
+ test('shows the session title, track, time and location', async ({ page }) => {
+ await sessionLink(page, 'Ionic Tooling').click();
+
+ await expect(page).toHaveURL(/\/tabs\/schedule\/3$/);
+ await expect(detail(page).getByRole('heading', { level: 1 })).toHaveText('Ionic Tooling');
+ await expect(detail(page)).toContainText('Tooling');
+ await expect(detail(page)).toContainText('9:45 am – 10:00 am');
+ await expect(detail(page)).toContainText('Executive Ballroom');
+ await expect(detail(page)).toContainText('we created Ionic');
+ });
+
+ test('favoriting from the detail page adds the session to Favorites', async ({ page }) => {
+ await toggleFavoriteFromDetail(page, 'Ionic Tooling');
+
+ await expect(sessionLink(page, 'Ionic Tooling')).toBeVisible();
+ });
+
+ test('unfavoriting from the detail page removes the session from Favorites', async ({ page }) => {
+ await toggleFavoriteFromDetail(page, 'Ionic Tooling');
+ await expect(sessionLink(page, 'Ionic Tooling')).toBeVisible();
+
+ await toggleFavoriteFromDetail(page, 'Ionic Tooling');
+
+ await expect(sessionLink(page, 'Ionic Tooling')).toBeHidden();
+ await expect(schedule(page).getByText('No Sessions Found')).toBeVisible();
+ });
+
+ test('favoriting from the sliding item adds the session to Favorites', async ({ page }) => {
+ await swipeOpen(page, 'Ionic Tooling');
+ await itemOption(page, 'Favorite').click();
+
+ await expect(page.getByText('Ionic Tooling was successfully added as a favorite.')).toBeVisible();
+
+ await showSegment(page, 'Favorites');
+ await expect(sessionLink(page, 'Ionic Tooling')).toBeVisible();
+ });
+
+ test('removing from Favorites with the sliding item unfavorites the session', async ({ page }) => {
+ await swipeOpen(page, 'Ionic Tooling');
+ await itemOption(page, 'Favorite').click();
+ await showSegment(page, 'Favorites');
+
+ await swipeOpen(page, 'Ionic Tooling');
+ await itemOption(page, 'Remove').click();
+ const alert = page.getByRole('alertdialog', { name: 'Remove Favorite' });
+ await expect(alert).toContainText('Would you like to remove this session from your favorites?');
+ await alert.getByRole('button', { name: 'Remove' }).click();
+
+ await expect(sessionLink(page, 'Ionic Tooling')).toBeHidden();
+ await expect(schedule(page).getByText('No Sessions Found')).toBeVisible();
+ });
+});
diff --git a/e2e/speakers.spec.ts b/e2e/speakers.spec.ts
new file mode 100644
index 00000000..3c166eb9
--- /dev/null
+++ b/e2e/speakers.spec.ts
@@ -0,0 +1,49 @@
+import type { Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+
+import { backButton, openSchedule, visiblePage } from './helpers';
+
+const speakerList = (page: Page) => visiblePage(page, 'speaker-list');
+const speakerCard = (page: Page, name: string) =>
+ speakerList(page)
+ .locator('ion-card')
+ .filter({ has: page.getByRole('heading', { name, exact: true }) });
+
+test.describe('Speakers', () => {
+ test.beforeEach(async ({ page }) => {
+ await openSchedule(page);
+ await page.getByRole('tab', { name: 'Speakers' }).click();
+ await expect(page).toHaveURL(/\/tabs\/speakers$/);
+ });
+
+ test('lists the speakers with their sessions', async ({ page }) => {
+ await expect(speakerList(page).locator('ion-card')).toHaveCount(13);
+
+ const burt = speakerCard(page, 'Burt Bear');
+ await expect(burt).toContainText('Software Engineer');
+ await expect(burt.getByRole('link', { name: 'The Ionic Package' })).toBeVisible();
+ await expect(burt.getByRole('link', { name: 'Push Notifications in Ionic' })).toBeVisible();
+ });
+
+ test('opens the speaker detail', async ({ page }) => {
+ await speakerCard(page, 'Burt Bear').getByRole('link', { name: 'About Burt Bear' }).click();
+
+ await expect(page).toHaveURL(/\/tabs\/speakers\/1$/);
+ const detail = visiblePage(page, 'speaker-detail');
+ await expect(detail.getByRole('heading', { name: 'Burt Bear' })).toBeVisible();
+ await expect(detail).toContainText('Burt is a Bear.');
+ });
+
+ test("opens a speaker's session from the speaker list", async ({ page }) => {
+ await speakerCard(page, 'Burt Bear').getByRole('link', { name: 'The Ionic Package' }).click();
+
+ await expect(page).toHaveURL(/\/tabs\/speakers\/sessions\/15$/);
+ const detail = visiblePage(page, 'session-detail-page');
+ await expect(detail.getByRole('heading', { level: 1 })).toHaveText('The Ionic Package');
+ await expect(detail).toContainText('1:30 pm – 2:00 pm');
+ await expect(detail).toContainText('Grand Ballroom B');
+
+ await backButton(page, 'session-detail-page').click();
+ await expect(page).toHaveURL(/\/tabs\/speakers$/);
+ });
+});
diff --git a/e2e/tutorial.spec.ts b/e2e/tutorial.spec.ts
new file mode 100644
index 00000000..0720761e
--- /dev/null
+++ b/e2e/tutorial.spec.ts
@@ -0,0 +1,61 @@
+import { expect, test } from '@playwright/test';
+
+import { APP_LOAD_TIMEOUT, openMenu, sessionLink, visiblePage } from './helpers';
+
+test.describe('Tutorial', () => {
+ test('shows the tutorial on the first visit', async ({ page }) => {
+ await page.goto('/');
+
+ await expect(page).toHaveURL(/\/tutorial$/, { timeout: APP_LOAD_TIMEOUT });
+ await expect(page.getByRole('heading', { name: 'Welcome to ICA' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Skip' })).toBeVisible();
+ });
+
+ test('skipping the tutorial opens the schedule', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Skip' }).click({ timeout: APP_LOAD_TIMEOUT });
+
+ await expect(page).toHaveURL(/\/tabs\/schedule$/);
+ await expect(sessionLink(page, 'Breakfast')).toBeVisible();
+ });
+
+ test('finishing the slides with Continue opens the schedule', async ({ page }) => {
+ await page.goto('/');
+ const tutorial = visiblePage(page, 'tutorial-page');
+
+ // Swipe through each slide by scrolling it into view.
+ for (const title of ['Welcome to ICA', 'What is Ionic?', 'What is Ionic Appflow?', 'Ready to Play?']) {
+ const heading = tutorial.getByRole('heading', { name: title });
+ await heading.scrollIntoViewIfNeeded({ timeout: APP_LOAD_TIMEOUT });
+ await expect(heading).toBeInViewport();
+ }
+ await tutorial.getByRole('button', { name: 'Continue' }).click();
+
+ await expect(page).toHaveURL(/\/tabs\/schedule$/);
+ await expect(sessionLink(page, 'Breakfast')).toBeVisible();
+ });
+
+ test('does not show the tutorial again after it is completed', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Skip' }).click({ timeout: APP_LOAD_TIMEOUT });
+ await expect(page).toHaveURL(/\/tabs\/schedule$/);
+
+ await page.goto('/');
+
+ await expect(page).toHaveURL(/\/tabs\/schedule$/, { timeout: APP_LOAD_TIMEOUT });
+ await expect(sessionLink(page, 'Breakfast')).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Skip' })).toBeHidden();
+ });
+
+ test('can be shown again from the menu', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Skip' }).click({ timeout: APP_LOAD_TIMEOUT });
+ await expect(page).toHaveURL(/\/tabs\/schedule$/);
+
+ const menu = await openMenu(page);
+ await menu.getByRole('button', { name: 'Show Tutorial' }).click();
+
+ await expect(page).toHaveURL(/\/tutorial$/);
+ await expect(page.getByRole('heading', { name: 'Welcome to ICA' })).toBeVisible();
+ });
+});
diff --git a/src/components/HomeOrTutorial.test.tsx b/src/components/HomeOrTutorial.test.tsx
new file mode 100644
index 00000000..355bc61e
--- /dev/null
+++ b/src/components/HomeOrTutorial.test.tsx
@@ -0,0 +1,28 @@
+import { screen } from '@testing-library/react';
+import { Route } from 'react-router-dom';
+import { describe, expect, it } from 'vitest';
+
+import { makeState } from '../testing/fixtures';
+import { renderWithState } from '../testing/render';
+
+import HomeOrTutorial from './HomeOrTutorial';
+
+const renderAtHome = (hasSeenTutorial: boolean) =>
+ renderWithState(, { state: makeState({ user: { hasSeenTutorial } }), path: '/' }, [
+ Tutorial page
} />,
+ Schedule page} />,
+ ]);
+
+describe('HomeOrTutorial', () => {
+ it('opens the tutorial when it has not been seen', () => {
+ renderAtHome(false);
+
+ expect(screen.getByText('Tutorial page')).toBeInTheDocument();
+ });
+
+ it('opens the schedule once the tutorial has been seen', () => {
+ renderAtHome(true);
+
+ expect(screen.getByText('Schedule page')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Menu.test.tsx b/src/components/Menu.test.tsx
new file mode 100644
index 00000000..c98aa488
--- /dev/null
+++ b/src/components/Menu.test.tsx
@@ -0,0 +1,51 @@
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { makeState } from '../testing/fixtures';
+import { renderWithState } from '../testing/render';
+
+import Menu from './Menu';
+
+describe('Menu', () => {
+ it('links to the conference pages', async () => {
+ renderWithState(, { state: makeState() });
+
+ expect(await screen.findByText('Schedule')).toBeInTheDocument();
+ for (const page of ['Schedule', 'Speakers', 'Map', 'About', 'Support']) {
+ expect(screen.getByText(page)).toBeInTheDocument();
+ }
+ expect(screen.getByText('Show Tutorial')).toBeInTheDocument();
+ });
+
+ it('offers login and signup when logged out', async () => {
+ renderWithState(, { state: makeState({ user: { isLoggedin: false } }) });
+
+ expect(await screen.findByText('Login')).toBeInTheDocument();
+ expect(screen.getByText('Signup')).toBeInTheDocument();
+ // Only the account section's heading says "Account".
+ expect(screen.getAllByText('Account')).toHaveLength(1);
+ expect(screen.queryByText('Logout')).not.toBeInTheDocument();
+ });
+
+ it('offers the account and logout when logged in', async () => {
+ renderWithState(, { state: makeState({ user: { isLoggedin: true, username: 'ionitron' } }) });
+
+ expect(await screen.findByText('Logout')).toBeInTheDocument();
+ // "Account" is also the heading of the account section.
+ expect(screen.getAllByText('Account')).toHaveLength(2);
+ expect(screen.queryByText('Login')).not.toBeInTheDocument();
+ expect(screen.queryByText('Signup')).not.toBeInTheDocument();
+ });
+
+ it('toggles dark mode', async () => {
+ renderWithState(, { state: makeState() });
+ const toggle = (await screen.findByText('Dark Mode')) as HTMLIonToggleElement;
+ expect(toggle.checked).toBe(false);
+
+ fireEvent.click(toggle);
+ await waitFor(() => expect(toggle.checked).toBe(true));
+
+ fireEvent.click(toggle);
+ await waitFor(() => expect(toggle.checked).toBe(false));
+ });
+});
diff --git a/src/data/AppContextProvider.tsx b/src/data/AppContextProvider.tsx
index edf56c5c..59e88287 100644
--- a/src/data/AppContextProvider.tsx
+++ b/src/data/AppContextProvider.tsx
@@ -2,9 +2,18 @@ import type { PropsWithChildren } from 'react';
import React, { useReducer } from 'react';
import { AppContext } from './AppContext';
-import { initialState, reducers } from './state';
+import type { AppState } from './state';
+import { initialState as defaultInitialState, reducers } from './state';
-export const AppContextProvider: React.FC = ({ children }) => {
+interface AppContextProviderProps {
+ // Lets tests render with preset state; the app always starts from the default.
+ initialState?: AppState;
+}
+
+export const AppContextProvider: React.FC> = ({
+ children,
+ initialState = defaultInitialState,
+}) => {
const [store, dispatch] = useReducer(reducers, initialState);
return (
diff --git a/src/data/actions.test.ts b/src/data/actions.test.ts
new file mode 100644
index 00000000..2a5b3ef3
--- /dev/null
+++ b/src/data/actions.test.ts
@@ -0,0 +1,107 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import * as dataApi from './dataApi';
+import { loadLocations } from './locations/locations.actions';
+import { loadConfData } from './sessions/sessions.actions';
+import { loadUserData, setHasSeenTutorial, setIsLoggedIn, setUsername } from './user/user.actions';
+
+vi.mock('./dataApi', () => ({
+ getConfData: vi.fn(),
+ getUserData: vi.fn(),
+ setIsLoggedInData: vi.fn(),
+ setHasSeenTutorialData: vi.fn(),
+ setUsernameData: vi.fn(),
+}));
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe('loadConfData', () => {
+ it('loads the conference data between loading flags', async () => {
+ const data = { allTracks: ['Ionic'] } as Awaited>;
+ vi.mocked(dataApi.getConfData).mockResolvedValue(data);
+ const dispatch = vi.fn();
+
+ await loadConfData()(dispatch);
+
+ expect(dispatch.mock.calls.map(([action]) => action)).toEqual([
+ { type: 'set-conf-loading', isLoading: true },
+ { type: 'set-conf-data', data },
+ { type: 'set-conf-loading', isLoading: false },
+ ]);
+ });
+});
+
+describe('loadUserData', () => {
+ it('loads the saved user data between loading flags', async () => {
+ const data = { isLoggedin: true, hasSeenTutorial: true, username: 'ionitron' };
+ vi.mocked(dataApi.getUserData).mockResolvedValue(data);
+ const dispatch = vi.fn();
+
+ await loadUserData()(dispatch);
+
+ expect(dispatch.mock.calls.map(([action]) => action)).toEqual([
+ { type: 'set-user-loading', isLoading: true },
+ { type: 'set-user-data', data },
+ { type: 'set-user-loading', isLoading: false },
+ ]);
+ });
+});
+
+describe('user actions that are saved', () => {
+ it('setIsLoggedIn saves the logged in state before returning its action', async () => {
+ expect(await setIsLoggedIn(true)(vi.fn())).toEqual({ type: 'set-is-loggedin', loggedIn: true });
+ expect(dataApi.setIsLoggedInData).toHaveBeenCalledWith(true);
+ });
+
+ it('setUsername saves the username before returning its action', async () => {
+ expect(await setUsername('ionitron')(vi.fn())).toEqual({ type: 'set-username', username: 'ionitron' });
+ expect(dataApi.setUsernameData).toHaveBeenCalledWith('ionitron');
+
+ expect(await setUsername()(vi.fn())).toEqual({ type: 'set-username', username: undefined });
+ expect(dataApi.setUsernameData).toHaveBeenLastCalledWith(undefined);
+ });
+
+ it('setHasSeenTutorial saves the tutorial flag before returning its action', async () => {
+ expect(await setHasSeenTutorial(true)(vi.fn())).toEqual({ type: 'set-has-seen-tutorial', hasSeenTutorial: true });
+ expect(dataApi.setHasSeenTutorialData).toHaveBeenCalledWith(true);
+ });
+
+ it('does not report the change until it has been saved', async () => {
+ vi.mocked(dataApi.setIsLoggedInData).mockRejectedValue(new Error('storage full'));
+
+ await expect(setIsLoggedIn(true)(vi.fn())).rejects.toThrow('storage full');
+ });
+});
+
+describe('loadLocations', () => {
+ it('loads the map locations', async () => {
+ const locations = [{ id: 1, name: 'Ionic HQ', lat: 1, lng: 2 }];
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(locations))));
+ const dispatch = vi.fn();
+
+ await loadLocations()(dispatch);
+
+ expect(fetch).toHaveBeenCalledWith('/assets/data/locations.json');
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_LOCATIONS', payload: locations });
+ });
+
+ it('falls back to the convention center when the locations cannot be loaded', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
+ const dispatch = vi.fn();
+
+ await loadLocations()(dispatch);
+
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'SET_LOCATIONS',
+ payload: [{ id: 1, name: 'Monona Terrace Convention Center', lat: 43.071584, lng: -89.38012, center: true }],
+ });
+ });
+});
diff --git a/src/data/dataApi.test.ts b/src/data/dataApi.test.ts
new file mode 100644
index 00000000..aa505ad9
--- /dev/null
+++ b/src/data/dataApi.test.ts
@@ -0,0 +1,112 @@
+import { Preferences } from '@capacitor/preferences';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { getConfData, getUserData, setHasSeenTutorialData, setIsLoggedInData, setUsernameData } from './dataApi';
+
+vi.mock('@capacitor/preferences', () => {
+ const store = new Map();
+ return {
+ Preferences: {
+ get: vi.fn(async ({ key }: { key: string }) => ({ value: store.has(key) ? store.get(key) : null })),
+ set: vi.fn(async ({ key, value }: { key: string; value: string }) => {
+ store.set(key, value);
+ }),
+ remove: vi.fn(async ({ key }: { key: string }) => {
+ store.delete(key);
+ }),
+ clear: vi.fn(async () => store.clear()),
+ },
+ };
+});
+
+const readData = (file: string) => readFileSync(join(import.meta.dirname, '../../public/assets/data', file), 'utf8');
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ await Preferences.clear();
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('getConfData', () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (url: string) => new Response(readData(url.replace('/assets/data/', '')))),
+ );
+ });
+
+ it('loads the schedule, sessions, speakers and locations', async () => {
+ const data = await getConfData();
+
+ expect(fetch).toHaveBeenCalledWith('/assets/data/data.json');
+ expect(fetch).toHaveBeenCalledWith('/assets/data/locations.json');
+ expect(data.schedule.date).toBe('2047-05-17');
+ expect(data.schedule.groups).toHaveLength(8);
+ expect(data.speakers).toHaveLength(13);
+ expect(data.locations.map((location) => location.name)).toContain('Ionic HQ');
+ });
+
+ it('flattens the schedule into a list of sessions', async () => {
+ const data = await getConfData();
+
+ expect(data.sessions).toHaveLength(20);
+ expect(data.sessions[0].name).toBe('Breakfast');
+ expect(data.sessions.at(-1)?.name).toBe('Mobile States');
+ });
+
+ it('collects each track once, sorted, and starts with every track shown', async () => {
+ const data = await getConfData();
+
+ expect(data.allTracks).toEqual([
+ 'Communication',
+ 'Design',
+ 'Documentation',
+ 'Food',
+ 'Ionic',
+ 'Navigation',
+ 'React',
+ 'Services',
+ 'Tooling',
+ 'Workshop',
+ ]);
+ expect(data.filteredTracks).toEqual(data.allTracks);
+ expect(data.filteredTracks).not.toBe(data.allTracks);
+ });
+});
+
+describe('user data', () => {
+ it('is logged out, before the tutorial and without a username when nothing is saved', async () => {
+ expect(await getUserData()).toEqual({ isLoggedin: false, hasSeenTutorial: false, username: undefined });
+ });
+
+ it('saves and reads back the logged in state, tutorial flag and username', async () => {
+ await setIsLoggedInData(true);
+ await setHasSeenTutorialData(true);
+ await setUsernameData('ionitron');
+
+ expect(Preferences.set).toHaveBeenCalledWith({ key: 'hasLoggedIn', value: 'true' });
+ expect(Preferences.set).toHaveBeenCalledWith({ key: 'hasSeenTutorial', value: 'true' });
+ expect(Preferences.set).toHaveBeenCalledWith({ key: 'username', value: 'ionitron' });
+ expect(await getUserData()).toEqual({ isLoggedin: true, hasSeenTutorial: true, username: 'ionitron' });
+ });
+
+ it('saves logging out', async () => {
+ await setIsLoggedInData(true);
+ await setIsLoggedInData(false);
+
+ expect((await getUserData()).isLoggedin).toBe(false);
+ });
+
+ it('removes the saved username when it is cleared', async () => {
+ await setUsernameData('ionitron');
+ await setUsernameData(undefined);
+
+ expect(Preferences.remove).toHaveBeenCalledWith({ key: 'username' });
+ expect((await getUserData()).username).toBeUndefined();
+ });
+});
diff --git a/src/data/selectors.test.ts b/src/data/selectors.test.ts
new file mode 100644
index 00000000..c4441069
--- /dev/null
+++ b/src/data/selectors.test.ts
@@ -0,0 +1,191 @@
+import { describe, expect, it } from 'vitest';
+
+import type { ScheduleGroup } from '../models/Schedule';
+import {
+ breakfast,
+ gettingStarted,
+ ionicTooling,
+ lunch,
+ makeSession,
+ makeState,
+ rachel,
+ reactSession,
+ ted,
+} from '../testing/fixtures';
+
+import {
+ getFilteredSchedule,
+ getGroupedFavorites,
+ getSearchedSchedule,
+ getSession,
+ getSpeaker,
+ getSpeakerSessions,
+ mapCenter,
+} from './selectors';
+
+const summarize = (schedule: { groups: { time: string; sessions: { name: string }[] }[] }) =>
+ schedule.groups.map((group) => [group.time, group.sessions.map((session) => session.name)]);
+
+describe('getFilteredSchedule', () => {
+ it('sorts the groups and their sessions by time', () => {
+ expect(summarize(getFilteredSchedule(makeState()))).toEqual([
+ ['8:00 am', ['Breakfast']],
+ ['9:00 am', ['Getting Started with Ionic', 'Ionic Tooling']],
+ ['12:00 pm', ['Lunch']],
+ ['1:00 pm', ["What's New in React"]],
+ ]);
+ });
+
+ it('keeps the schedule date', () => {
+ expect(getFilteredSchedule(makeState()).date).toBe('2047-05-17');
+ });
+
+ it('sorts 12 am before morning sessions and 12 pm after them', () => {
+ const midnight = makeSession({ id: 10, name: 'Midnight', timeStart: '12:00 am' });
+ const state = makeState({
+ data: {
+ schedule: {
+ date: '2047-05-17',
+ groups: [
+ { time: '12:30 pm', sessions: [lunch] },
+ { time: '11:00 am', sessions: [breakfast] },
+ { time: '12:00 am', sessions: [midnight] },
+ ],
+ },
+ },
+ });
+
+ expect(getFilteredSchedule(state).groups.map((group) => group.time)).toEqual(['12:00 am', '11:00 am', '12:30 pm']);
+ });
+
+ it('leaves out the sessions of excluded tracks and groups left empty', () => {
+ const state = makeState({ data: { filteredTracks: ['Ionic', 'React', 'Tooling'] } });
+
+ expect(summarize(getFilteredSchedule(state))).toEqual([
+ ['9:00 am', ['Getting Started with Ionic', 'Ionic Tooling']],
+ ['1:00 pm', ["What's New in React"]],
+ ]);
+ });
+
+ it('shows no sessions when every track is excluded', () => {
+ expect(getFilteredSchedule(makeState({ data: { filteredTracks: [] } })).groups).toEqual([]);
+ });
+
+ it('does not reorder the schedule in the state', () => {
+ const state = makeState();
+ getFilteredSchedule(state);
+
+ expect(state.data.schedule.groups.map((group: ScheduleGroup) => group.time)).toEqual([
+ '1:00 pm',
+ '8:00 am',
+ '12:00 pm',
+ '9:00 am',
+ ]);
+ });
+});
+
+describe('getSearchedSchedule', () => {
+ it('returns the filtered schedule when there is no search text', () => {
+ const state = makeState({ data: { searchText: '' } });
+
+ expect(getSearchedSchedule(state)).toBe(getFilteredSchedule(state));
+ });
+
+ it('keeps the sessions whose name contains the search text, ignoring case', () => {
+ const state = makeState({ data: { searchText: 'IONIC' } });
+
+ expect(summarize(getSearchedSchedule(state))).toEqual([
+ ['9:00 am', ['Getting Started with Ionic', 'Ionic Tooling']],
+ ]);
+ });
+
+ it('only matches session names', () => {
+ // "Dining Hall" is a location and "Food" a track, so neither matches.
+ expect(getSearchedSchedule(makeState({ data: { searchText: 'dining' } })).groups).toEqual([]);
+ expect(getSearchedSchedule(makeState({ data: { searchText: 'food' } })).groups).toEqual([]);
+ });
+
+ it('searches within the tracks that are not excluded', () => {
+ const state = makeState({ data: { searchText: 'ionic', filteredTracks: ['Tooling'] } });
+
+ expect(summarize(getSearchedSchedule(state))).toEqual([['9:00 am', ['Ionic Tooling']]]);
+ });
+});
+
+describe('getGroupedFavorites', () => {
+ it('shows only the favorited sessions, grouped by time', () => {
+ const state = makeState({ data: { favorites: [reactSession.id, ionicTooling.id] } });
+
+ expect(summarize(getGroupedFavorites(state))).toEqual([
+ ['9:00 am', ['Ionic Tooling']],
+ ['1:00 pm', ["What's New in React"]],
+ ]);
+ });
+
+ it('is empty when nothing is favorited', () => {
+ expect(getGroupedFavorites(makeState()).groups).toEqual([]);
+ });
+
+ it('applies the search and the track filter to the favorites', () => {
+ const favorites = [breakfast.id, ionicTooling.id, gettingStarted.id];
+
+ expect(summarize(getGroupedFavorites(makeState({ data: { favorites, searchText: 'tool' } })))).toEqual([
+ ['9:00 am', ['Ionic Tooling']],
+ ]);
+ expect(summarize(getGroupedFavorites(makeState({ data: { favorites, filteredTracks: ['Food'] } })))).toEqual([
+ ['8:00 am', ['Breakfast']],
+ ]);
+ });
+});
+
+describe('getSession', () => {
+ it('finds a session by id', () => {
+ expect(getSession(makeState(), { id: ionicTooling.id })).toBe(ionicTooling);
+ });
+
+ it('returns undefined for an unknown id', () => {
+ expect(getSession(makeState(), { id: 'missing' })).toBeUndefined();
+ });
+});
+
+describe('getSpeaker', () => {
+ it('finds a speaker by id', () => {
+ expect(getSpeaker(makeState(), { id: ted.id })).toBe(ted);
+ expect(getSpeaker(makeState(), { id: 'missing' })).toBeUndefined();
+ });
+});
+
+describe('getSpeakerSessions', () => {
+ it("maps each speaker's name to the sessions they speak at", () => {
+ expect(getSpeakerSessions(makeState())).toEqual({
+ [rachel.name]: [ionicTooling, reactSession],
+ [ted.name]: [gettingStarted, reactSession],
+ });
+ });
+
+ it('skips sessions without speakers', () => {
+ const state = makeState({ data: { sessions: [{ ...breakfast, speakerNames: undefined as unknown as string[] }] } });
+
+ expect(getSpeakerSessions(state)).toEqual({});
+ });
+});
+
+describe('mapCenter', () => {
+ const locations = [
+ { id: 1, name: 'Map Center', lat: 1, lng: 2 },
+ { id: 2, name: 'Ionic HQ', lat: 3, lng: 4 },
+ ];
+
+ it('returns the location chosen as the map center', () => {
+ expect(mapCenter(makeState({ data: { locations, mapCenterId: 2 } }))).toBe(locations[1]);
+ });
+
+ it('falls back to the conference center when no location matches', () => {
+ expect(mapCenter(makeState({ data: { locations, mapCenterId: 99 } }))).toEqual({
+ id: 1,
+ name: 'Map Center',
+ lat: 43.071584,
+ lng: -89.38012,
+ });
+ });
+});
diff --git a/src/data/state.test.ts b/src/data/state.test.ts
new file mode 100644
index 00000000..6c0e6342
--- /dev/null
+++ b/src/data/state.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest';
+
+import { makeState } from '../testing/fixtures';
+
+import { setLocations } from './locations/locations.actions';
+import {
+ addFavorite,
+ removeFavorite,
+ setData as setConfData,
+ setLoading as setConfLoading,
+ setMenuEnabled,
+ setSearchText,
+ updateFilteredTracks,
+} from './sessions/sessions.actions';
+import { initialState, reducers } from './state';
+import { setDarkMode, setData as setUserData, setLoading as setUserLoading } from './user/user.actions';
+
+describe('initialState', () => {
+ it('starts logged out, before the tutorial, with no conference data', () => {
+ expect(initialState.user).toEqual({ hasSeenTutorial: false, darkMode: false, isLoggedin: false, loading: false });
+ expect(initialState.data.schedule.groups).toEqual([]);
+ expect(initialState.data.favorites).toEqual([]);
+ expect(initialState.data.menuEnabled).toBe(true);
+ });
+});
+
+describe('sessions reducer', () => {
+ it('adds and removes favorites', () => {
+ let state = reducers(makeState(), addFavorite(4));
+ state = reducers(state, addFavorite(2));
+ expect(state.data.favorites).toEqual([4, 2]);
+
+ state = reducers(state, removeFavorite(4));
+ expect(state.data.favorites).toEqual([2]);
+ });
+
+ it('removes every copy of a favorite', () => {
+ const state = reducers(makeState({ data: { favorites: [2, 3, 2] } }), removeFavorite(2));
+
+ expect(state.data.favorites).toEqual([3]);
+ });
+
+ it('updates the filtered tracks and the search text', () => {
+ let state = reducers(makeState(), updateFilteredTracks(['Food']));
+ state = reducers(state, setSearchText('ionic'));
+
+ expect(state.data.filteredTracks).toEqual(['Food']);
+ expect(state.data.searchText).toBe('ionic');
+ });
+
+ it('merges loaded conference data and tracks loading', () => {
+ let state = reducers(initialState, setConfLoading(true));
+ expect(state.data.loading).toBe(true);
+
+ state = reducers(state, setConfData({ allTracks: ['Ionic'], filteredTracks: ['Ionic'] }));
+ expect(state.data.allTracks).toEqual(['Ionic']);
+ expect(state.data.favorites).toEqual([]);
+ });
+
+ it('enables and disables the menu', () => {
+ expect(reducers(initialState, setMenuEnabled(false)).data.menuEnabled).toBe(false);
+ });
+});
+
+describe('user reducer', () => {
+ it('handles the user actions', () => {
+ let state = reducers(initialState, setUserLoading(true));
+ expect(state.user.loading).toBe(true);
+
+ state = reducers(state, setUserData({ isLoggedin: true, username: 'ionitron', hasSeenTutorial: true }));
+ expect(state.user).toMatchObject({ isLoggedin: true, username: 'ionitron', hasSeenTutorial: true });
+
+ state = reducers(state, { type: 'set-username', username: 'capacitor' });
+ expect(state.user.username).toBe('capacitor');
+
+ state = reducers(state, { type: 'set-is-loggedin', loggedIn: false });
+ expect(state.user.isLoggedin).toBe(false);
+
+ state = reducers(state, { type: 'set-has-seen-tutorial', hasSeenTutorial: false });
+ expect(state.user.hasSeenTutorial).toBe(false);
+
+ expect(reducers(state, setDarkMode(true)).user.darkMode).toBe(true);
+ });
+});
+
+describe('locations reducer', () => {
+ it('stores the locations', () => {
+ const locations = [{ id: 1, name: 'Ionic HQ', lat: 1, lng: 2 }];
+
+ expect(reducers(initialState, setLocations(locations)).locations.locations).toBe(locations);
+ });
+});
+
+describe('combined reducers', () => {
+ it('keeps the other slices when one changes', () => {
+ const state = makeState();
+ const next = reducers(state, setDarkMode(true));
+
+ expect(next.data).toBe(state.data);
+ expect(next.locations).toBe(state.locations);
+ });
+
+ it('leaves the state unchanged for unknown actions', () => {
+ const state = makeState();
+
+ expect(reducers(state, { type: 'unknown' })).toEqual(state);
+ });
+});
diff --git a/src/pages/SchedulePage.test.tsx b/src/pages/SchedulePage.test.tsx
new file mode 100644
index 00000000..d275d43f
--- /dev/null
+++ b/src/pages/SchedulePage.test.tsx
@@ -0,0 +1,73 @@
+import { setupIonicReact } from '@ionic/react';
+import { screen, within } from '@testing-library/react';
+import { beforeAll, describe, expect, it } from 'vitest';
+
+import { ionicTooling, makeState, reactSession } from '../testing/fixtures';
+import { renderWithState } from '../testing/render';
+
+import SchedulePage from './SchedulePage';
+
+// The page reads the mode from Ionic's config.
+beforeAll(() => {
+ setupIonicReact({ mode: 'md' });
+});
+
+// The page renders the All and Favorites lists, and hides the one that isn't selected.
+const lists = (container: HTMLElement) => {
+ const [all, favorites] = Array.from(container.querySelectorAll('ion-content ion-list'));
+ return { all: all as HTMLElement, favorites: favorites as HTMLElement };
+};
+const sessionNames = (list: HTMLElement) => Array.from(list.querySelectorAll('h3'), (heading) => heading.textContent);
+const groupTimes = (list: HTMLElement) =>
+ Array.from(list.querySelectorAll('ion-item-divider')).map((divider) => divider.textContent);
+
+describe('SchedulePage', () => {
+ it('lists the sessions grouped by time', () => {
+ const { container } = renderWithState(, { state: makeState() });
+ const { all } = lists(container);
+
+ expect(groupTimes(all)).toEqual(['8:00 am', '9:00 am', '12:00 pm', '1:00 pm']);
+ expect(sessionNames(all)).toEqual([
+ 'Breakfast',
+ 'Getting Started with Ionic',
+ 'Ionic Tooling',
+ 'Lunch',
+ "What's New in React",
+ ]);
+ expect(within(all).getByText(/9:45 am — 10:00 am: Executive Ballroom/)).toBeInTheDocument();
+ });
+
+ it('lists only the sessions matching the search text', () => {
+ const { container } = renderWithState(, { state: makeState({ data: { searchText: 'ionic' } }) });
+
+ expect(groupTimes(lists(container).all)).toEqual(['9:00 am']);
+ expect(sessionNames(lists(container).all)).toEqual(['Getting Started with Ionic', 'Ionic Tooling']);
+ });
+
+ it('leaves out the sessions of excluded tracks', () => {
+ const { container } = renderWithState(, {
+ state: makeState({ data: { filteredTracks: ['Ionic', 'React', 'Tooling'] } }),
+ });
+
+ expect(sessionNames(lists(container).all)).toEqual([
+ 'Getting Started with Ionic',
+ 'Ionic Tooling',
+ "What's New in React",
+ ]);
+ });
+
+ it('shows the favorited sessions in the hidden favorites list', () => {
+ const { container } = renderWithState(, {
+ state: makeState({ data: { favorites: [reactSession.id, ionicTooling.id] } }),
+ });
+
+ expect(sessionNames(lists(container).favorites)).toEqual(['Ionic Tooling', "What's New in React"]);
+ expect(lists(container).favorites).toHaveStyle({ display: 'none' });
+ });
+
+ it('shows an empty state when no session matches', () => {
+ renderWithState(, { state: makeState({ data: { filteredTracks: [] } }) });
+
+ expect(screen.getByText('No Sessions Found')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/SessionDetail.test.tsx b/src/pages/SessionDetail.test.tsx
new file mode 100644
index 00000000..b420e8c6
--- /dev/null
+++ b/src/pages/SessionDetail.test.tsx
@@ -0,0 +1,58 @@
+import { fireEvent, screen } from '@testing-library/react';
+import { useContext } from 'react';
+import { describe, expect, it } from 'vitest';
+
+import { AppContext } from '../data/AppContext';
+import { ionicTooling, makeState } from '../testing/fixtures';
+import { renderWithState } from '../testing/render';
+
+import SessionDetail from './SessionDetail';
+
+// Shows the favorited session ids from the app state.
+const Favorites = () => ;
+
+const renderSession = (id: string, favorites: number[] = []) =>
+ renderWithState(
+ <>
+
+
+ >,
+ { state: makeState({ data: { favorites } }), url: `/tabs/schedule/${id}`, path: '/tabs/schedule/:id' },
+ );
+
+// The favorite button is the first icon-only button in the toolbar, before the share button.
+const favoriteButton = (container: HTMLElement) => container.querySelector('ion-header ion-button') as HTMLElement;
+
+describe('SessionDetail', () => {
+ it('shows the session from the URL', () => {
+ renderSession(String(ionicTooling.id));
+
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Ionic Tooling');
+ expect(screen.getByText('Tooling')).toBeInTheDocument();
+ expect(screen.getByText(/9:45 am – 10:00 am/)).toHaveTextContent('Executive Ballroom');
+ });
+
+ it('favorites and unfavorites the session', () => {
+ const { container } = renderSession(String(ionicTooling.id));
+
+ fireEvent.click(favoriteButton(container));
+ expect(screen.getByRole('status')).toHaveTextContent(String(ionicTooling.id));
+
+ fireEvent.click(favoriteButton(container));
+ expect(screen.getByRole('status')).toBeEmptyDOMElement();
+ });
+
+ it('unfavorites a session that is already a favorite', () => {
+ const { container } = renderSession(String(ionicTooling.id), [ionicTooling.id]);
+
+ fireEvent.click(favoriteButton(container));
+
+ expect(screen.getByRole('status')).toBeEmptyDOMElement();
+ });
+
+ it('shows a message for an unknown session', () => {
+ renderSession('999');
+
+ expect(screen.getByText('Session not found')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/SpeakerList.test.tsx b/src/pages/SpeakerList.test.tsx
new file mode 100644
index 00000000..a12f90d7
--- /dev/null
+++ b/src/pages/SpeakerList.test.tsx
@@ -0,0 +1,32 @@
+import { screen, within } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { makeState } from '../testing/fixtures';
+import { renderWithState } from '../testing/render';
+
+import SpeakerList from './SpeakerList';
+
+describe('SpeakerList', () => {
+ it('shows each speaker with their sessions', () => {
+ renderWithState(, { state: makeState() });
+
+ const rachel = screen.getByRole('heading', { name: 'Rachel Rabbit' }).closest('ion-card') as HTMLElement;
+ expect(within(rachel).getByText('Software Engineer')).toBeInTheDocument();
+ expect(
+ within(rachel)
+ .getAllByRole('heading', { level: 3 })
+ .map((h) => h.textContent),
+ ).toEqual(['Ionic Tooling', "What's New in React", 'About Rachel Rabbit']);
+
+ const ted = screen.getByRole('heading', { name: 'Ted Turtle' }).closest('ion-card') as HTMLElement;
+ expect(within(ted).getByText('Getting Started with Ionic')).toBeInTheDocument();
+ });
+
+ it('links each session to its detail page under the speakers tab', () => {
+ renderWithState(, { state: makeState() });
+
+ const rachel = screen.getByRole('heading', { name: 'Rachel Rabbit' }).closest('ion-card') as HTMLElement;
+ const session = within(rachel).getByText('Ionic Tooling').closest('ion-item');
+ expect(session).toHaveAttribute('router-link', '/tabs/speakers/sessions/2');
+ });
+});
diff --git a/src/testing/fixtures.ts b/src/testing/fixtures.ts
new file mode 100644
index 00000000..d948b3d4
--- /dev/null
+++ b/src/testing/fixtures.ts
@@ -0,0 +1,107 @@
+import type { AppState } from '../data/state';
+import { initialState } from '../data/state';
+import type { Session } from '../models/Schedule';
+import type { Speaker } from '../models/Speaker';
+
+// The data files store ids as strings, which is what the route params are matched against.
+const id = (value: string) => value as unknown as number;
+
+export const makeSession = (overrides: Partial & Pick): Session => ({
+ timeStart: '9:00 am',
+ timeEnd: '9:30 am',
+ location: 'Hall 1',
+ description: `About ${overrides.name}`,
+ speakerNames: [],
+ tracks: ['Ionic'],
+ ...overrides,
+});
+
+export const breakfast = makeSession({
+ id: id('1'),
+ name: 'Breakfast',
+ timeStart: '8:00 am',
+ timeEnd: '9:00 am',
+ location: 'Dining Hall',
+ tracks: ['Food'],
+});
+export const ionicTooling = makeSession({
+ id: id('2'),
+ name: 'Ionic Tooling',
+ timeStart: '9:45 am',
+ timeEnd: '10:00 am',
+ location: 'Executive Ballroom',
+ tracks: ['Tooling'],
+ speakerNames: ['Rachel Rabbit'],
+});
+export const gettingStarted = makeSession({
+ id: id('3'),
+ name: 'Getting Started with Ionic',
+ timeStart: '9:15 am',
+ timeEnd: '9:45 am',
+ location: 'Hall 2',
+ tracks: ['Ionic'],
+ speakerNames: ['Ted Turtle'],
+});
+export const reactSession = makeSession({
+ id: id('4'),
+ name: "What's New in React",
+ timeStart: '1:00 pm',
+ timeEnd: '1:30 pm',
+ location: 'Hall 3',
+ tracks: ['React'],
+ speakerNames: ['Rachel Rabbit', 'Ted Turtle'],
+});
+export const lunch = makeSession({
+ id: id('5'),
+ name: 'Lunch',
+ timeStart: '12:00 pm',
+ timeEnd: '1:00 pm',
+ location: 'Dining Hall',
+ tracks: ['Food'],
+});
+
+export const makeSpeaker = (overrides: Partial & Pick): Speaker => ({
+ profilePic: `/assets/img/speakers/${overrides.name}.jpg`,
+ twitter: 'ionicframework',
+ instagram: 'ionicframework',
+ about: `${overrides.name} is a speaker.`,
+ title: 'Software Engineer',
+ location: 'Everywhere',
+ email: 'speaker@example.com',
+ phone: '555-555-5555',
+ ...overrides,
+});
+
+export const rachel = makeSpeaker({ id: id('1'), name: 'Rachel Rabbit' });
+export const ted = makeSpeaker({ id: id('2'), name: 'Ted Turtle' });
+
+export const sessions = [breakfast, ionicTooling, gettingStarted, reactSession, lunch];
+
+/**
+ * App state with a small conference loaded. The groups are deliberately out of time order, and so are the
+ * sessions in the 9:00 am group, like the real data.
+ */
+export const makeState = ({
+ data,
+ user,
+}: { data?: Partial; user?: Partial } = {}): AppState => ({
+ ...initialState,
+ data: {
+ ...initialState.data,
+ schedule: {
+ date: '2047-05-17',
+ groups: [
+ { time: '1:00 pm', sessions: [reactSession] },
+ { time: '8:00 am', sessions: [breakfast] },
+ { time: '12:00 pm', sessions: [lunch] },
+ { time: '9:00 am', sessions: [ionicTooling, gettingStarted] },
+ ],
+ },
+ sessions,
+ speakers: [rachel, ted],
+ allTracks: ['Food', 'Ionic', 'React', 'Tooling'],
+ filteredTracks: ['Food', 'Ionic', 'React', 'Tooling'],
+ ...data,
+ },
+ user: { ...initialState.user, hasSeenTutorial: true, ...user },
+});
diff --git a/src/testing/render.tsx b/src/testing/render.tsx
new file mode 100644
index 00000000..faf54959
--- /dev/null
+++ b/src/testing/render.tsx
@@ -0,0 +1,35 @@
+import { render } from '@testing-library/react';
+import type { RenderResult } from '@testing-library/react';
+import type React from 'react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+
+import { AppContextProvider } from '../data/AppContextProvider';
+import type { AppState } from '../data/state';
+
+interface RenderOptions {
+ state: AppState;
+ // The URL to start at, and the route pattern the element is rendered at (for route params).
+ url?: string;
+ path?: string;
+}
+
+/**
+ * Renders a connected page or component with preset app state inside a router, without booting the whole app.
+ */
+export function renderWithState(
+ element: React.ReactElement,
+ { state, url = '/', path = '*' }: RenderOptions,
+ // Other routes to render, such as the pages the element navigates to.
+ otherRoutes: React.ReactElement[] = [],
+): RenderResult {
+ return render(
+
+
+
+
+ {otherRoutes}
+
+
+ ,
+ );
+}
From bb324ceb9253524e70de892a2d1f17036ecd617e Mon Sep 17 00:00:00 2001
From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
Date: Wed, 11 Nov 2026 17:46:05 -0500
Subject: [PATCH 2/2] chore(test): run the e2e tests against the production
build in ci
---
playwright.config.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/playwright.config.ts b/playwright.config.ts
index c8a8b83a..88248967 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -19,7 +19,11 @@ export default defineConfig({
},
],
webServer: {
- command: 'npm run dev -- --port 4314 --strictPort',
+ // CI tests the production build, which is what gets deployed, so build-tool
+ // updates are checked at runtime and not only by `npm run build` succeeding.
+ command: process.env.CI
+ ? 'npm run build && npm run preview -- --port 4314 --strictPort'
+ : 'npm run dev -- --port 4314 --strictPort',
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,