diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index f897e0b..4294dd5 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -1,15 +1,64 @@ +import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; +// Ionic keeps tab pages it has shown in the DOM (hidden), so only look at visible elements. +const visibleTitle = (page: Page) => page.locator('ion-title').filter({ visible: true }).first(); +// The outgoing page can still be visible mid-transition, so match the content by its page name. +const visibleContent = (page: Page, pageName: string) => + page.locator('ion-content').filter({ visible: true, hasText: pageName }); + +const tabs = { + tab1: { name: 'Tab 1', url: /\/tab1$/, title: 'Tab 1' }, + photos: { name: 'Photos', url: /\/tab2$/, title: 'Photo Gallery' }, + tab3: { name: 'Tab 3', url: /\/tab3$/, title: 'Tab 3' }, +}; + test('shows the first tab on load', async ({ page }) => { await page.goto('/'); - await expect(page.locator('ion-content').getByText('Tab 1 page')).toBeVisible(); + await expect(page).toHaveURL(tabs.tab1.url); + await expect(visibleTitle(page)).toHaveText(tabs.tab1.title); + await expect(page.getByRole('tab', { name: tabs.tab1.name })).toHaveAttribute('aria-selected', 'true'); +}); + +test('switches tabs from the tab bar', async ({ page }) => { + await page.goto('/'); + + for (const tab of [tabs.photos, tabs.tab3, tabs.tab1]) { + 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'); + } }); -test('opens the photo gallery from the tab bar', async ({ page }) => { +test('shows an icon on each tab', async ({ page }) => { await page.goto('/'); - await page.getByRole('tab', { name: 'Photos' }).click(); - await expect(page).toHaveURL(/\/tab2$/); - await expect(page.locator('ion-title', { hasText: 'Photo Gallery' }).first()).toBeVisible(); + for (const tab of [tabs.tab1, tabs.photos, tabs.tab3]) { + // ion-icon draws its SVG in its shadow root, which Playwright's locators can see. + const tabButton = page.locator('ion-tab-button').filter({ hasText: tab.name }); + await expect(tabButton.locator('ion-icon svg')).toBeVisible(); + } }); + +for (const tab of [ + { ...tabs.tab1, pageName: 'Tab 1 page' }, + { ...tabs.tab3, pageName: 'Tab 3 page' }, +]) { + test(`${tab.name} links to the Ionic UI components docs`, async ({ page }) => { + await page.goto('/'); + await page.getByRole('tab', { name: tab.name }).click(); + await expect(page).toHaveURL(tab.url); + + const content = visibleContent(page, tab.pageName); + await expect(content.getByText(tab.pageName, { exact: true })).toBeVisible(); + await expect(content).toContainText('Explore UI Components'); + + const link = content.getByRole('link', { name: 'UI Components' }); + await expect(link).toHaveAttribute('href', 'https://ionicframework.com/docs/components'); + await expect(link).toHaveAttribute('target', '_blank'); + await expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); +} diff --git a/e2e/gallery.spec.ts b/e2e/gallery.spec.ts new file mode 100644 index 0000000..f6a8333 --- /dev/null +++ b/e2e/gallery.spec.ts @@ -0,0 +1,93 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +// Chromium's fake camera stands in for a real one, so these tests run the actual +// Capacitor Camera web flow (the pwa-elements camera modal). +test.use({ + permissions: ['camera'], + launchOptions: { args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'] }, +}); + +const takePhoto = async (page: Page): Promise => { + await page.locator('ion-fab-button').click(); + await page.locator('pwa-camera .shutter').click(); + await page.locator('pwa-camera .accept-use').click(); +}; + +const photo = (page: Page, position: number) => page.getByRole('button', { name: `Photo ${position}` }); + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.getByRole('tab', { name: 'Photos' }).click(); +}); + +test('takes a photo and adds it to the gallery', async ({ page }) => { + await takePhoto(page); + + await expect(photo(page, 1)).toBeVisible(); + await expect(photo(page, 2)).toHaveCount(0); +}); + +test('keeps photos after the app reloads', async ({ page }) => { + await takePhoto(page); + await expect(photo(page, 1)).toBeVisible(); + + await page.reload(); + + await expect(page.getByRole('img', { name: 'Photo 1' })).toHaveAttribute('src', /^data:image\/jpeg;base64,/); +}); + +test('deletes a photo from the action sheet', async ({ page }) => { + await takePhoto(page); + await takePhoto(page); + await expect(photo(page, 2)).toBeVisible(); + + await photo(page, 1).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(photo(page, 2)).toHaveCount(0); + await expect(photo(page, 1)).toBeVisible(); + + await page.reload(); + + // Wait for the remaining photo to load from storage before checking the deleted one stayed gone. + await expect(page.getByRole('img', { name: 'Photo 1' })).toHaveAttribute('src', /^data:image\/jpeg;base64,/); + await expect(photo(page, 2)).toHaveCount(0); +}); + +test('adds a new photo after deleting one', async ({ page }) => { + await takePhoto(page); + await photo(page, 1).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + await expect(photo(page, 1)).toHaveCount(0); + + await takePhoto(page); + + await expect(photo(page, 1)).toBeVisible(); + await expect(photo(page, 2)).toHaveCount(0); + + await page.reload(); + + // Wait for the saved photo to load from storage before checking it's the only one. + await expect(page.getByRole('img', { name: 'Photo 1' })).toHaveAttribute('src', /^data:image\/jpeg;base64,/); + await expect(photo(page, 2)).toHaveCount(0); +}); + +test('keeps the photo when the action sheet is cancelled', async ({ page }) => { + await takePhoto(page); + await photo(page, 1).click(); + await page.getByRole('button', { name: 'Cancel' }).click(); + + await expect(page.locator('ion-action-sheet')).toBeHidden(); + await expect(photo(page, 1)).toBeVisible(); + + // The photo's actions open again after a cancel. + await photo(page, 1).click(); + await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible(); +}); + +test('shows a camera icon on the camera button', async ({ page }) => { + // ion-icon draws its SVG in its shadow root, which Playwright's locators can see. Only match + // the button's own icon, not the close icon ion-fab-button keeps in its shadow root. + await expect(page.locator('ion-fab-button > ion-icon').locator('svg')).toBeVisible(); +}); diff --git a/src/app/explore-container/explore-container.component.spec.ts b/src/app/explore-container/explore-container.component.spec.ts index 376df07..0892872 100644 --- a/src/app/explore-container/explore-container.component.spec.ts +++ b/src/app/explore-container/explore-container.component.spec.ts @@ -5,7 +5,6 @@ import { TestBed } from '@angular/core/testing'; import { ExploreContainerComponent } from './explore-container.component'; describe('ExploreContainerComponent', () => { - let component: ExploreContainerComponent; let fixture: ComponentFixture; beforeEach(async () => { @@ -15,11 +14,20 @@ describe('ExploreContainerComponent', () => { }).compileComponents(); fixture = TestBed.createComponent(ExploreContainerComponent); - component = fixture.componentInstance; + }); + + it('shows the page name', () => { + fixture.componentRef.setInput('name', 'Tab 1 page'); fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('strong').textContent).toBe('Tab 1 page'); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('links to the Ionic UI components docs', () => { + fixture.detectChanges(); + + const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + expect(link.href).toBe('https://ionicframework.com/docs/components'); + expect(link.target).toBe('_blank'); }); }); diff --git a/src/app/services/photo.service.spec.ts b/src/app/services/photo.service.spec.ts index e60c04f..cb24a7a 100644 --- a/src/app/services/photo.service.spec.ts +++ b/src/app/services/photo.service.spec.ts @@ -1,20 +1,182 @@ import { provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { provideIonicAngular } from '@ionic/angular'; +import { Camera } from '@capacitor/camera'; +import { Filesystem } from '@capacitor/filesystem'; +import { Preferences } from '@capacitor/preferences'; +import { Platform, provideIonicAngular } from '@ionic/angular'; +import type { UserPhoto } from './photo.service'; import { PhotoService } from './photo.service'; +vi.mock('@capacitor/camera', () => ({ + Camera: { getPhoto: vi.fn() }, + CameraResultType: { Uri: 'uri' }, + CameraSource: { Camera: 'CAMERA' }, +})); + +vi.mock('@capacitor/filesystem', () => ({ + Filesystem: { readFile: vi.fn(), writeFile: vi.fn(), deleteFile: vi.fn() }, + Directory: { Data: 'DATA' }, +})); + +vi.mock('@capacitor/preferences', () => ({ + Preferences: { get: vi.fn(), set: vi.fn() }, +})); + +vi.mock('@capacitor/core', () => ({ + Capacitor: { convertFileSrc: (uri: string) => `converted:${uri}` }, +})); + +const NOW = 1700000000000; + describe('PhotoService', () => { let service: PhotoService; + let isHybrid: boolean; beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(Date, 'now').mockReturnValue(NOW); + vi.mocked(Filesystem.writeFile).mockResolvedValue({ uri: `file:///data/${NOW}.jpeg` }); + TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection(), provideIonicAngular()], }); service = TestBed.inject(PhotoService); + + isHybrid = false; + vi.spyOn(TestBed.inject(Platform), 'is').mockImplementation((platform) => platform === 'hybrid' && isHybrid); }); - it('should be created', () => { - expect(service).toBeTruthy(); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + describe('addNewToGallery', () => { + it('saves a web photo and adds it to the front of the gallery', async () => { + const existing: UserPhoto = { filepath: 'older.jpeg', webviewPath: 'blob:older' }; + service.photos.set([existing]); + vi.mocked(Camera.getPhoto).mockResolvedValue({ webPath: 'blob:new-photo', format: 'jpeg', saved: false }); + // Return the jsdom Blob directly: Node's Response would stringify it. + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ blob: async () => new Blob(['image-bytes'], { type: 'image/jpeg' }) }), + ); + + await service.addNewToGallery(); + + expect(Camera.getPhoto).toHaveBeenCalledWith({ resultType: 'uri', source: 'CAMERA', quality: 100 }); + expect(Filesystem.writeFile).toHaveBeenCalledWith({ + path: `${NOW}.jpeg`, + data: `data:image/jpeg;base64,${btoa('image-bytes')}`, + directory: 'DATA', + }); + expect(service.photos()).toEqual([{ filepath: `${NOW}.jpeg`, webviewPath: 'blob:new-photo' }, existing]); + expect(Preferences.set).toHaveBeenCalledWith({ key: 'photos', value: JSON.stringify(service.photos()) }); + }); + + it.each([ + ['the photo cannot be read', 'error' as const], + ['the photo is not read as a data URL', new ArrayBuffer(1)], + ])('does not save a web photo when %s', async (_, outcome) => { + vi.mocked(Camera.getPhoto).mockResolvedValue({ webPath: 'blob:new-photo', format: 'jpeg', saved: false }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ blob: async () => new Blob(['image-bytes']) })); + vi.stubGlobal( + 'FileReader', + class { + result: unknown = null; + onerror: ((error: unknown) => void) | null = null; + onload: (() => void) | null = null; + + readAsDataURL(): void { + if (outcome === 'error') { + this.onerror?.(new Error('read failed')); + } else { + this.result = outcome; + this.onload?.(); + } + } + }, + ); + + await expect(service.addNewToGallery()).rejects.toBeDefined(); + + expect(Filesystem.writeFile).not.toHaveBeenCalled(); + expect(service.photos()).toEqual([]); + expect(Preferences.set).not.toHaveBeenCalled(); + }); + + it('reads a native photo from its file path and converts the saved URI', async () => { + isHybrid = true; + vi.mocked(Camera.getPhoto).mockResolvedValue({ + path: 'file:///tmp/photo.jpeg', + webPath: 'capacitor://localhost/_capacitor_file_/tmp/photo.jpeg', + format: 'jpeg', + saved: false, + }); + vi.mocked(Filesystem.readFile).mockResolvedValue({ data: 'BASE64DATA' }); + + await service.addNewToGallery(); + + expect(Filesystem.readFile).toHaveBeenCalledWith({ path: 'file:///tmp/photo.jpeg' }); + expect(Filesystem.writeFile).toHaveBeenCalledWith({ path: `${NOW}.jpeg`, data: 'BASE64DATA', directory: 'DATA' }); + expect(service.photos()).toEqual([ + { filepath: `file:///data/${NOW}.jpeg`, webviewPath: `converted:file:///data/${NOW}.jpeg` }, + ]); + }); + }); + + describe('loadSaved', () => { + it('loads saved photos and reads each file as base64 on the web', async () => { + vi.mocked(Preferences.get).mockResolvedValue({ + value: JSON.stringify([{ filepath: 'a.jpeg' }, { filepath: 'b.jpeg' }]), + }); + vi.mocked(Filesystem.readFile).mockImplementation(async ({ path }) => ({ data: `data-for-${path}` })); + + await service.loadSaved(); + + expect(Preferences.get).toHaveBeenCalledWith({ key: 'photos' }); + expect(Filesystem.readFile).toHaveBeenCalledWith({ path: 'a.jpeg', directory: 'DATA' }); + expect(service.photos()).toEqual([ + { filepath: 'a.jpeg', webviewPath: 'data:image/jpeg;base64,data-for-a.jpeg' }, + { filepath: 'b.jpeg', webviewPath: 'data:image/jpeg;base64,data-for-b.jpeg' }, + ]); + }); + + it('keeps the stored paths on native without reading the files', async () => { + isHybrid = true; + const saved: UserPhoto[] = [{ filepath: 'file:///data/a.jpeg', webviewPath: 'converted:file:///data/a.jpeg' }]; + vi.mocked(Preferences.get).mockResolvedValue({ value: JSON.stringify(saved) }); + + await service.loadSaved(); + + expect(Filesystem.readFile).not.toHaveBeenCalled(); + expect(service.photos()).toEqual(saved); + }); + + it('starts with an empty gallery when nothing is saved', async () => { + vi.mocked(Preferences.get).mockResolvedValue({ value: null }); + + await service.loadSaved(); + + expect(service.photos()).toEqual([]); + }); + }); + + describe('deletePhoto', () => { + it('removes the photo from the gallery, storage, and filesystem', async () => { + const photos: UserPhoto[] = [ + { filepath: 'file:///data/a.jpeg' }, + { filepath: 'file:///data/b.jpeg' }, + { filepath: 'file:///data/c.jpeg' }, + ]; + service.photos.set(photos); + + await service.deletePhoto(photos[1], 1); + + expect(service.photos()).toEqual([photos[0], photos[2]]); + expect(Preferences.set).toHaveBeenCalledWith({ key: 'photos', value: JSON.stringify([photos[0], photos[2]]) }); + expect(Filesystem.deleteFile).toHaveBeenCalledWith({ path: 'b.jpeg', directory: 'DATA' }); + }); }); }); diff --git a/src/app/tab2/tab2.page.spec.ts b/src/app/tab2/tab2.page.spec.ts index 8fb1ef7..ac3be39 100644 --- a/src/app/tab2/tab2.page.spec.ts +++ b/src/app/tab2/tab2.page.spec.ts @@ -1,26 +1,108 @@ -import { provideZonelessChangeDetection } from '@angular/core'; +import { provideZonelessChangeDetection, signal } from '@angular/core'; import type { ComponentFixture } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing'; -import { provideIonicAngular } from '@ionic/angular'; +import type { ActionSheetButton } from '@ionic/angular'; +import { ActionSheetController, provideIonicAngular } from '@ionic/angular'; + +import type { UserPhoto } from '../services/photo.service'; +import { PhotoService } from '../services/photo.service'; import { Tab2Page } from './tab2.page'; describe('Tab2Page', () => { - let component: Tab2Page; + const photos: UserPhoto[] = [ + { filepath: 'a.jpeg', webviewPath: 'data:image/jpeg;base64,AAA' }, + { filepath: 'b.jpeg', webviewPath: 'data:image/jpeg;base64,BBB' }, + ]; + let fixture: ComponentFixture; + let photoService: { + photos: ReturnType>; + loadSaved: ReturnType; + addNewToGallery: ReturnType; + deletePhoto: ReturnType; + }; + let actionSheet: { present: ReturnType }; + let actionSheetController: { create: ReturnType }; + + // Returns the buttons passed to the action sheet the page opened. + const actionSheetButtons = (): ActionSheetButton[] => actionSheetController.create.mock.calls[0][0].buttons; + + const tapPhoto = async (index: number): Promise => { + const buttons = fixture.nativeElement.querySelectorAll('ion-col button'); + buttons[index].click(); + await vi.waitFor(() => expect(actionSheet.present).toHaveBeenCalled()); + }; beforeEach(async () => { + photoService = { + photos: signal(photos), + loadSaved: vi.fn().mockResolvedValue(undefined), + addNewToGallery: vi.fn().mockResolvedValue(undefined), + deletePhoto: vi.fn().mockResolvedValue(undefined), + }; + actionSheet = { present: vi.fn().mockResolvedValue(undefined) }; + actionSheetController = { create: vi.fn().mockResolvedValue(actionSheet) }; + await TestBed.configureTestingModule({ imports: [Tab2Page], - providers: [provideZonelessChangeDetection(), provideIonicAngular()], + providers: [ + provideZonelessChangeDetection(), + provideIonicAngular(), + { provide: PhotoService, useValue: photoService }, + { provide: ActionSheetController, useValue: actionSheetController }, + ], }).compileComponents(); fixture = TestBed.createComponent(Tab2Page); - component = fixture.componentInstance; fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('loads the saved photos when the page opens', () => { + expect(photoService.loadSaved).toHaveBeenCalledTimes(1); + }); + + it('shows a thumbnail for each photo', () => { + const images: HTMLImageElement[] = Array.from(fixture.nativeElement.querySelectorAll('ion-col img')); + + expect(images.map((img) => img.getAttribute('src'))).toEqual(photos.map((photo) => photo.webviewPath)); + expect(images.map((img) => img.getAttribute('alt'))).toEqual(['Photo 1', 'Photo 2']); + }); + + it('takes a photo when the camera button is tapped', () => { + fixture.nativeElement.querySelector('ion-fab-button').click(); + + expect(photoService.addNewToGallery).toHaveBeenCalledTimes(1); + }); + + it('opens the photo actions when a photo is tapped', async () => { + await tapPhoto(1); + + expect(actionSheetController.create).toHaveBeenCalledWith(expect.objectContaining({ header: 'Photos' })); + expect(actionSheetButtons()).toEqual([ + expect.objectContaining({ text: 'Delete', role: 'destructive', icon: 'trash' }), + expect.objectContaining({ text: 'Cancel', role: 'cancel', icon: 'close' }), + ]); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('deletes the tapped photo when Delete is chosen', async () => { + await tapPhoto(1); + + const deleteButton = actionSheetButtons().find((button) => button.text === 'Delete'); + expect(deleteButton?.handler).toBeTypeOf('function'); + deleteButton?.handler?.(); + + expect(photoService.deletePhoto).toHaveBeenCalledWith(photos[1], 1); + }); + + it('keeps the photo when Cancel is chosen', async () => { + await tapPhoto(0); + + const cancelButton = actionSheetButtons().find((button) => button.text === 'Cancel'); + expect(cancelButton?.handler).toBeTypeOf('function'); + cancelButton?.handler?.(); + + expect(photoService.deletePhoto).not.toHaveBeenCalled(); }); });