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/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, 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 = () => {useContext(AppContext).state.data.favorites.join(',')}; + +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} + + + , + ); +}