From 7f0d869841d99fb1ae8112da6d1a0515ae86ad24 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 22 Aug 2026 18:27:35 +0900 Subject: [PATCH 1/2] test: cover public library behavior --- projects/kit/src/lib/types/component.spec.ts | 44 ++++ projects/kit/src/lib/utils/haptics.spec.ts | 58 +++++ .../photo-editor/photo-editor.page.spec.ts | 110 ++++++++ .../photo-viewer/photo-viewer.page.spec.ts | 67 +++++ .../photo-editor/src/lib/pages/util.spec.ts | 26 ++ .../lib/services/photo-file.service.spec.ts | 246 +++++++++++++++++- .../src/lib/services/photo-file.service.ts | 32 ++- ...x-virtual-scroll-element.directive.spec.ts | 8 + .../scroll-header.directive.spec.ts | 92 +++++-- .../virtual-scroll-header.directive.spec.ts | 107 ++++++-- projects/scroll-header/src/lib/util.spec.ts | 21 ++ ...namic-size-virtual-scroll-strategy.spec.ts | 113 ++++++++ ...amic-size-virtual-scroll.directive.spec.ts | 101 +++++++ ...ynamic-size-virtual-scroll.service.spec.ts | 130 ++++++++- .../dynamic-size-virtual-scroll.service.ts | 23 +- .../dynamic-size-virtual-scroll.util.spec.ts | 17 ++ 16 files changed, 1127 insertions(+), 68 deletions(-) create mode 100644 projects/kit/src/lib/types/component.spec.ts create mode 100644 projects/kit/src/lib/utils/haptics.spec.ts create mode 100644 projects/photo-editor/src/lib/pages/util.spec.ts create mode 100644 projects/scroll-header/src/lib/util.spec.ts create mode 100644 projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.directive.spec.ts diff --git a/projects/kit/src/lib/types/component.spec.ts b/projects/kit/src/lib/types/component.spec.ts new file mode 100644 index 0000000..33e1bf0 --- /dev/null +++ b/projects/kit/src/lib/types/component.spec.ts @@ -0,0 +1,44 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { mountViewModel } from './component'; + +@Component({ + template: '', +}) +class MountHostComponent { + readonly value = signal('initial'); + readonly onMount = vi.fn(); + readonly vm = mountViewModel(this, this.onMount); +} + +describe('mountViewModel', () => { + afterEach(() => TestBed.resetTestingModule()); + + it('returns the original host for ViewModel access', () => { + const fixture = TestBed.createComponent(MountHostComponent); + + expect(fixture.componentInstance.vm).toBe(fixture.componentInstance); + }); + + it('runs the mount callback after the first render and only once', () => { + const fixture = TestBed.createComponent(MountHostComponent); + const host = fixture.componentInstance; + + expect(host.onMount).not.toHaveBeenCalled(); + + fixture.detectChanges(); + + expect(host.onMount).toHaveBeenCalledOnce(); + + fixture.detectChanges(); + + expect(host.onMount).toHaveBeenCalledOnce(); + }); + + it('accepts a host without registering a mount callback', () => { + const host = { value: signal(1) }; + + expect(mountViewModel(host)).toBe(host); + }); +}); diff --git a/projects/kit/src/lib/utils/haptics.spec.ts b/projects/kit/src/lib/utils/haptics.spec.ts new file mode 100644 index 0000000..a837850 --- /dev/null +++ b/projects/kit/src/lib/utils/haptics.spec.ts @@ -0,0 +1,58 @@ +import { Capacitor } from '@capacitor/core'; +import { Haptics, ImpactStyle } from '@capacitor/haptics'; + +import { kitImpact } from './haptics'; + +vi.mock('@capacitor/core', () => ({ + Capacitor: { + isNativePlatform: vi.fn(), + }, +})); + +vi.mock('@capacitor/haptics', () => ({ + Haptics: { + impact: vi.fn(), + }, + ImpactStyle: { + Light: 'LIGHT', + Medium: 'MEDIUM', + Heavy: 'HEAVY', + }, +})); + +describe('kitImpact', () => { + beforeEach(() => { + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(false); + vi.mocked(Haptics.impact).mockReset().mockResolvedValue(undefined); + }); + + it('is a no-op on the web', async () => { + await kitImpact(ImpactStyle.Heavy); + + expect(Haptics.impact).not.toHaveBeenCalled(); + }); + + it('requests light impact by default on native platforms', async () => { + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); + + await kitImpact(); + + expect(Haptics.impact).toHaveBeenCalledExactlyOnceWith({ style: ImpactStyle.Light }); + }); + + it('forwards the requested impact style on native platforms', async () => { + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); + + await kitImpact(ImpactStyle.Heavy); + + expect(Haptics.impact).toHaveBeenCalledExactlyOnceWith({ style: ImpactStyle.Heavy }); + }); + + it('propagates a native plugin failure to direct callers', async () => { + const error = new Error('haptics unavailable'); + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); + vi.mocked(Haptics.impact).mockRejectedValueOnce(error); + + await expect(kitImpact()).rejects.toBe(error); + }); +}); diff --git a/projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.spec.ts b/projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.spec.ts index 3773a3e..bc69360 100644 --- a/projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.spec.ts +++ b/projects/photo-editor/src/lib/pages/photo-editor/photo-editor.page.spec.ts @@ -1,12 +1,43 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentRef } from '@angular/core'; +import { vi } from 'vitest'; import { PhotoEditorPage } from './photo-editor.page'; import { testConfig } from '../../../../../util/test.config'; +import { IFilter } from '../../types'; + +interface EditorMock { + applyFilter: ReturnType; + crop: ReturnType; + destroy: ReturnType; + getCropzoneRect: ReturnType; + hasFilter: ReturnType; + removeFilter: ReturnType; + rotate: ReturnType; + setCropzoneRect: ReturnType; + startDrawingMode: ReturnType; + stopDrawingMode: ReturnType; + toDataURL: ReturnType; +} + +const createEditorMock = (): EditorMock => ({ + applyFilter: vi.fn().mockResolvedValue(undefined), + crop: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn(), + getCropzoneRect: vi.fn().mockReturnValue({ left: 1, top: 2, width: 30, height: 40 }), + hasFilter: vi.fn().mockReturnValue(false), + removeFilter: vi.fn().mockResolvedValue(undefined), + rotate: vi.fn().mockResolvedValue(undefined), + setCropzoneRect: vi.fn(), + startDrawingMode: vi.fn(), + stopDrawingMode: vi.fn(), + toDataURL: vi.fn().mockReturnValue('data:image/png;base64,saved'), +}); describe('PhotoEditorPage', () => { let component: PhotoEditorPage; let fixture: ComponentFixture; let componentRef: ComponentRef; + let editor: EditorMock; beforeEach(() => { TestBed.configureTestingModule({ @@ -18,6 +49,8 @@ describe('PhotoEditorPage', () => { componentRef.setInput('value', 'data:image/png;base64,'); componentRef.setInput('headerButtonColorScheme', 'dark'); fixture.detectChanges(); + editor = createEditorMock(); + Reflect.set(component, 'editorInstance', editor); }); it('should create', () => { @@ -49,4 +82,81 @@ describe('PhotoEditorPage', () => { 'var(--ion-photo-editor-header-button-color-on-light, #222428)', ); }); + + it('merges custom labels without replacing the remaining dictionary', async () => { + componentRef.setInput('labels', { save: 'Upload' }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toContain('Upload'); + expect(fixture.nativeElement.textContent).toContain('切り抜き・回転'); + }); + + it.each([ + ['cover', 2], + ['16/9', 16 / 9], + ['1', 1], + ['auto', undefined], + ] as const)('sets the %s crop ratio', (crop, expectedRatio) => { + component.photoCrop.set({ width: 400, height: 200 }); + + component.changeCrop(crop); + + expect(editor.setCropzoneRect).toHaveBeenCalledWith(expectedRatio); + expect(component.currentCrop()).toBe(crop); + }); + + it('applies the crop and resets crop state when closing the crop menu', async () => { + component.footerMenu.set('crop'); + await fixture.whenStable(); + vi.clearAllMocks(); + + await component.closeCrop('apply'); + + expect(editor.crop).toHaveBeenCalledWith({ left: 1, top: 2, width: 30, height: 40 }); + expect(editor.stopDrawingMode).toHaveBeenCalledOnce(); + expect(component.isCropped()).toBe(true); + expect(component.currentCrop()).toBe('cover'); + expect(component.currentRotate()).toBe(0); + expect(component.footerMenu()).toBe('menu'); + }); + + it('replaces an adopted filter and clears it when Default is selected', async () => { + const sepia: IFilter = { name: 'Sepia', type: 'Sepia', option: null, data: '', width: 1, height: 1 }; + const grayscale: IFilter = { name: 'Gray', type: 'Grayscale', option: null, data: '', width: 1, height: 1 }; + const original: IFilter = { name: 'Original', type: 'Default', option: null, data: '', width: 1, height: 1 }; + + await component.filterImage(sepia); + await component.filterImage(grayscale); + await component.filterImage(original); + + expect(editor.applyFilter).toHaveBeenNthCalledWith(1, 'Sepia', null); + expect(editor.applyFilter).toHaveBeenNthCalledWith(2, 'Grayscale', null); + expect(editor.removeFilter).toHaveBeenNthCalledWith(1, 'Sepia'); + expect(editor.removeFilter).toHaveBeenNthCalledWith(2, 'Grayscale'); + }); + + it('replaces the brightness filter using the normalized range value', async () => { + editor.hasFilter.mockReturnValue(true); + + await component.changeRange({ detail: { value: 127.5 } } as never); + + expect(editor.removeFilter).toHaveBeenCalledWith('brightness'); + expect(editor.applyFilter).toHaveBeenCalledWith('brightness', { brightness: 0.5 }); + }); + + it('dismisses with the current editor data URL when saving', () => { + const dismiss = vi.spyOn(component.modalCtrl, 'dismiss').mockResolvedValue(true); + + component.imageSave(); + + expect(editor.toDataURL).toHaveBeenCalledOnce(); + expect(dismiss).toHaveBeenCalledWith({ value: 'data:image/png;base64,saved' }); + }); + + it('destroys the editor when the view leaves', () => { + component.ionViewDidLeave(); + + expect(editor.destroy).toHaveBeenCalledOnce(); + }); }); diff --git a/projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.spec.ts b/projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.spec.ts index 77960c7..8513728 100644 --- a/projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.spec.ts +++ b/projects/photo-editor/src/lib/pages/photo-viewer/photo-viewer.page.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentRef } from '@angular/core'; +import { vi } from 'vitest'; import { PhotoViewerPage } from './photo-viewer.page'; import { testConfig } from '../../../../../util/test.config'; @@ -49,4 +50,70 @@ describe('PhotoViewerPage', () => { 'var(--ion-photo-editor-header-button-color-on-light, #222428)', ); }); + + it('coerces modal primitive inputs and renders the configured delete label', async () => { + componentRef.setInput('index', '2'); + componentRef.setInput('isCircle', 'true'); + componentRef.setInput('enableDelete', 'true'); + componentRef.setInput('enableFooterSafeArea', 'true'); + componentRef.setInput('labels', { delete: 'Remove photo' }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.index()).toBe(2); + expect(component.isCircle()).toBe(true); + expect(component.enableDelete()).toBe(true); + expect(component.enableFooterSafeArea()).toBe(true); + expect(fixture.nativeElement.textContent).toContain('Remove photo'); + }); + + it('dismisses with the active image index and value when removing', () => { + componentRef.setInput('imageUrls', ['first.jpg', 'second.jpg']); + fixture.detectChanges(); + component.swiper().nativeElement.swiper = { activeIndex: 1 } as never; + const dismiss = vi.spyOn(component.modalCtrl, 'dismiss').mockResolvedValue(true); + + component.remove(); + + expect(dismiss).toHaveBeenCalledWith({ + delete: { + index: 1, + value: 'second.jpg', + }, + }); + }); + + it('dismisses on a downward swipe when the active slide is not zoomed', () => { + const dismiss = vi.spyOn(component.modalCtrl, 'dismiss').mockResolvedValue(true); + const host = fixture.nativeElement; + + host.dispatchEvent(new CustomEvent('touchstart', { detail: [undefined, { clientX: 0, clientY: 0 }] })); + host.dispatchEvent(new CustomEvent('touchmove', { detail: [undefined, { clientX: 1, clientY: 10 }] })); + host.dispatchEvent(new CustomEvent('touchend')); + + expect(dismiss).toHaveBeenCalledOnce(); + }); + + it('does not dismiss on a downward swipe while the active slide is zoomed', () => { + componentRef.setInput('imageUrls', ['zoomed.jpg']); + fixture.detectChanges(); + const activeSlide = fixture.nativeElement.querySelector('swiper-slide'); + activeSlide.classList.add('swiper-slide-active', 'swiper-slide-zoomed'); + const dismiss = vi.spyOn(component.modalCtrl, 'dismiss').mockResolvedValue(true); + const host = fixture.nativeElement; + + host.dispatchEvent(new CustomEvent('touchstart', { detail: [undefined, { clientX: 0, clientY: 0 }] })); + host.dispatchEvent(new CustomEvent('touchmove', { detail: [undefined, { clientX: 1, clientY: 10 }] })); + host.dispatchEvent(new CustomEvent('touchend')); + + expect(dismiss).not.toHaveBeenCalled(); + }); + + it('unsubscribes swipe handling on destroy', () => { + const unsubscribe = vi.spyOn(component.watchSwipe$, 'unsubscribe'); + + component.ngOnDestroy(); + + expect(unsubscribe).toHaveBeenCalledOnce(); + }); }); diff --git a/projects/photo-editor/src/lib/pages/util.spec.ts b/projects/photo-editor/src/lib/pages/util.spec.ts new file mode 100644 index 0000000..4361067 --- /dev/null +++ b/projects/photo-editor/src/lib/pages/util.spec.ts @@ -0,0 +1,26 @@ +import { vi } from 'vitest'; + +import { waitToFindDom } from './util'; + +describe('waitToFindDom', () => { + afterEach(() => vi.restoreAllMocks()); + + it('resolves after the requested descendant is added', async () => { + const host = document.createElement('div'); + const clearInterval = vi.spyOn(globalThis, 'clearInterval'); + let resolved = false; + const result = waitToFindDom(host, '.ready').then(() => { + resolved = true; + }); + + expect(resolved).toBe(false); + + const child = document.createElement('span'); + child.className = 'ready'; + host.append(child); + await result; + + expect(resolved).toBe(true); + expect(clearInterval).toHaveBeenCalledOnce(); + }); +}); diff --git a/projects/photo-editor/src/lib/services/photo-file.service.spec.ts b/projects/photo-editor/src/lib/services/photo-file.service.spec.ts index 00d886b..d1f6222 100644 --- a/projects/photo-editor/src/lib/services/photo-file.service.spec.ts +++ b/projects/photo-editor/src/lib/services/photo-file.service.spec.ts @@ -1,19 +1,255 @@ import { TestBed } from '@angular/core/testing'; +import { ActionSheetController, Platform } from '@ionic/angular'; +import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; +import ImageEditor from 'tui-image-editor'; +import { vi } from 'vitest'; +import { PhotoEditorErrors } from '../photoEditorErrors'; import { PhotoFileService } from './photo-file.service'; -import { testConfig } from '../../../../util/test.config'; -describe('PhotoService', () => { +vi.mock('@capacitor/camera', () => ({ + Camera: { + getPhoto: vi.fn(), + pickImages: vi.fn(), + }, + CameraResultType: { DataUrl: 'dataUrl' }, + CameraSource: { Camera: 'CAMERA' }, +})); + +vi.mock('tui-image-editor', () => ({ default: vi.fn() })); + +describe('PhotoFileService', () => { let service: PhotoFileService; + let isPlatform: ReturnType; + let createActionSheet: ReturnType; + let getPhoto: ReturnType; + let pickImages: ReturnType; + let dismissData: 'camera' | 'album' | undefined; + let actionSheetOptions: { buttons: { text: string; role?: string; handler?: () => void }[] } | undefined; beforeEach(() => { + dismissData = undefined; + actionSheetOptions = undefined; + isPlatform = vi.fn().mockReturnValue(true); + getPhoto = vi.mocked(Camera.getPhoto); + pickImages = vi.mocked(Camera.pickImages); + getPhoto.mockReset(); + pickImages.mockReset(); + createActionSheet = vi.fn().mockImplementation(async (options) => { + actionSheetOptions = options; + return { + dismiss: vi.fn().mockResolvedValue(true), + present: vi.fn().mockResolvedValue(undefined), + onDidDismiss: vi.fn().mockImplementation(async () => ({ data: dismissData })), + }; + }); + TestBed.configureTestingModule({ - providers: [...testConfig.providers], + providers: [ + PhotoFileService, + { provide: Platform, useValue: { is: isPlatform } }, + { provide: ActionSheetController, useValue: { create: createActionSheet } }, + ], }); service = TestBed.inject(PhotoFileService); }); - it('should be created', () => { - expect(service).toBeTruthy(); + afterEach(() => { + document.querySelector('input#browserPhotoUploader')?.remove(); + vi.restoreAllMocks(); + }); + + it('rejects browser loading when the required file input is missing', async () => { + isPlatform.mockReturnValue(false); + + await expect(service.loadPhoto(1)).rejects.toBe(PhotoEditorErrors.initialize); + expect(createActionSheet).not.toHaveBeenCalled(); + }); + + it('rejects browser loading when the file chooser is cancelled', async () => { + isPlatform.mockReturnValue(false); + const input = document.createElement('input'); + input.id = 'browserPhotoUploader'; + document.body.append(input); + vi.spyOn(input, 'click').mockImplementation(() => undefined); + + const result = service.loadPhoto(1); + input.dispatchEvent(new Event('cancel')); + + await expect(result).rejects.toBe(PhotoEditorErrors.cancel); + expect(input.click).toHaveBeenCalledOnce(); + }); + + it('rejects an empty browser selection without attempting to resize it', async () => { + isPlatform.mockReturnValue(false); + const input = document.createElement('input'); + input.id = 'browserPhotoUploader'; + Object.defineProperty(input, 'files', { configurable: true, value: [] }); + document.body.append(input); + vi.spyOn(input, 'click').mockImplementation(() => undefined); + const resize = vi.spyOn(service as unknown as { loadPhotoFromFilePath(path: string): Promise }, 'loadPhotoFromFilePath'); + + const result = service.loadPhoto(1); + input.dispatchEvent(new Event('change')); + + await expect(result).rejects.toBe(PhotoEditorErrors.cancel); + expect(resize).not.toHaveBeenCalled(); + }); + + it('returns the resized result selected by the browser file input', async () => { + isPlatform.mockReturnValue(false); + const input = document.createElement('input'); + input.id = 'browserPhotoUploader'; + Object.defineProperty(input, 'files', { + configurable: true, + value: [new File(['image'], 'photo.png', { type: 'image/png' })], + }); + document.body.append(input); + vi.spyOn(input, 'click').mockImplementation(() => undefined); + const resize = vi + .spyOn(service as unknown as { loadPhotoFromFilePath(path: string): Promise }, 'loadPhotoFromFilePath') + .mockResolvedValue('data:image/png;base64,resized'); + + const result = service.loadPhoto(1); + input.dispatchEvent(new Event('change')); + + await expect(result).resolves.toEqual(['data:image/png;base64,resized']); + expect(resize).toHaveBeenCalledWith(expect.stringMatching(/^data:image\/png;base64,/)); + }); + + it('rejects browser loading when resizing the selected image fails', async () => { + isPlatform.mockReturnValue(false); + const input = document.createElement('input'); + input.id = 'browserPhotoUploader'; + Object.defineProperty(input, 'files', { + configurable: true, + value: [new File(['image'], 'photo.png', { type: 'image/png' })], + }); + document.body.append(input); + vi.spyOn(input, 'click').mockImplementation(() => undefined); + const resizeError = new Error('resize failed'); + vi.spyOn(service as unknown as { loadPhotoFromFilePath(path: string): Promise }, 'loadPhotoFromFilePath').mockRejectedValue( + resizeError, + ); + + const result = service.loadPhoto(1); + input.dispatchEvent(new Event('change')); + + await expect(result).rejects.toBe(resizeError); + }); + + it('rejects a non-image browser file without attempting to resize it', async () => { + isPlatform.mockReturnValue(false); + const input = document.createElement('input'); + input.id = 'browserPhotoUploader'; + let inputValue = 'selected'; + Object.defineProperty(input, 'value', { + configurable: true, + get: () => inputValue, + set: (value: string) => { + inputValue = value; + }, + }); + Object.defineProperty(input, 'files', { + configurable: true, + value: [new File(['text'], 'notes.txt', { type: 'text/plain' })], + }); + document.body.append(input); + vi.spyOn(input, 'click').mockImplementation(() => undefined); + const resize = vi.spyOn(service as unknown as { loadPhotoFromFilePath(path: string): Promise }, 'loadPhotoFromFilePath'); + + const result = service.loadPhoto(1); + input.dispatchEvent(new Event('change')); + + await expect(result).rejects.toBe(PhotoEditorErrors.type); + expect(resize).not.toHaveBeenCalled(); + expect(input.value).toBe(''); + }); + + it('uses configured labels and rejects a dismissed native source sheet', async () => { + service.labels = { + camera: 'Take photo', + album: 'Choose photo', + cancel: 'Close', + }; + + await expect(service.loadPhoto(2)).rejects.toBe(PhotoEditorErrors.cancel); + expect(actionSheetOptions?.buttons.map(({ text, role }) => ({ text, role }))).toEqual([ + { text: 'Take photo', role: undefined }, + { text: 'Choose photo', role: undefined }, + { text: 'Close', role: 'cancel' }, + ]); + }); + + it('returns a camera data URL and passes the configured maximum size', async () => { + dismissData = 'camera'; + service.photoMaxSize = 2048; + getPhoto.mockResolvedValue({ + dataUrl: 'data:image/jpeg;base64,camera', + format: 'jpeg', + saved: false, + }); + + await expect(service.loadPhoto(1)).resolves.toEqual(['data:image/jpeg;base64,camera']); + expect(getPhoto).toHaveBeenCalledWith({ + quality: 100, + width: 2048, + allowEditing: false, + resultType: CameraResultType.DataUrl, + source: CameraSource.Camera, + presentationStyle: 'popover', + }); + }); + + it('rejects when native camera capture does not return a data URL', async () => { + dismissData = 'camera'; + getPhoto.mockRejectedValue(new Error('camera unavailable')); + + await expect(service.loadPhoto(1)).rejects.toBe(PhotoEditorErrors.cancel); + }); + + it('loads every selected album path and preserves selection order', async () => { + dismissData = 'album'; + pickImages.mockResolvedValue({ + photos: [ + { webPath: 'file://first', format: 'jpeg' }, + { webPath: 'file://second', format: 'png' }, + ], + }); + const loadPhotoFromFilePath = vi + .spyOn(service as unknown as { loadPhotoFromFilePath(path: string): Promise }, 'loadPhotoFromFilePath') + .mockImplementation(async (path) => `resized:${path}`); + + await expect(service.loadPhoto(2)).resolves.toEqual(['resized:file://first', 'resized:file://second']); + expect(pickImages).toHaveBeenCalledWith({ + quality: 100, + width: 1000, + limit: 2, + presentationStyle: 'popover', + }); + expect(loadPhotoFromFilePath).toHaveBeenCalledTimes(2); + }); + + it('scales the longest image edge to the configured maximum and destroys the editor', async () => { + service.photoMaxSize = 1200; + const editor = { + destroy: vi.fn(), + loadImageFromFile: vi.fn().mockResolvedValue({ newWidth: 4800, newHeight: 3200 }), + toDataURL: vi.fn().mockReturnValue('data:image/jpeg;base64,resized'), + }; + vi.mocked(ImageEditor).mockImplementation(function MockImageEditor() { + return editor as never; + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + blob: vi.fn().mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' })), + } as never); + + const result = await (service as unknown as { loadPhotoFromFilePath(path: string): Promise }).loadPhotoFromFilePath( + 'file://large-image', + ); + + expect(result).toBe('data:image/jpeg;base64,resized'); + expect(editor.toDataURL).toHaveBeenCalledWith({ multiplier: 0.25 }); + expect(editor.destroy).toHaveBeenCalledOnce(); }); }); diff --git a/projects/photo-editor/src/lib/services/photo-file.service.ts b/projects/photo-editor/src/lib/services/photo-file.service.ts index 8252034..33583f8 100644 --- a/projects/photo-editor/src/lib/services/photo-file.service.ts +++ b/projects/photo-editor/src/lib/services/photo-file.service.ts @@ -114,24 +114,28 @@ export class PhotoFileService { inputFile!.removeEventListener('cancel', cancelMethod, false); inputFile!.removeEventListener('change', changeMethod, false); - if (!(e.target as HTMLInputElement).files || !(e.target as HTMLInputElement).files![0]) { + const file = (e.target as HTMLInputElement).files?.[0]; + inputFile.value = ''; + if (!file) { reject(PhotoEditorErrors.cancel); + return; + } + if (file.type.indexOf('image') < 0) { + reject(PhotoEditorErrors.type); + return; } - const file = (e.target as HTMLInputElement).files![0]; const reader = new FileReader(); - reader.onload = (() => { - if (file.type.indexOf('image') < 0) { - reject(PhotoEditorErrors.type); - } - - return async (event) => { - inputFile.value = ''; - const result = event.target!.result as string; - const data = await this.loadPhotoFromFilePath(result); - resolve([data]); - }; - })(); + reader.onload = (event) => { + const result = event.target!.result as string; + void this.loadPhotoFromFilePath(result).then((data) => resolve([data]), reject); + }; + reader.onerror = () => { + reject(reader.error ?? new Error('Failed to read the selected image.')); + }; + reader.onabort = () => { + reject(PhotoEditorErrors.cancel); + }; reader.readAsDataURL(file); }; diff --git a/projects/scroll-header/src/lib/directives/fix-virtual-scroll-element.directive.spec.ts b/projects/scroll-header/src/lib/directives/fix-virtual-scroll-element.directive.spec.ts index 30f24e9..98423d6 100644 --- a/projects/scroll-header/src/lib/directives/fix-virtual-scroll-element.directive.spec.ts +++ b/projects/scroll-header/src/lib/directives/fix-virtual-scroll-element.directive.spec.ts @@ -33,4 +33,12 @@ describe('FixVirtualScrollElementDirective', () => { const directiveInstance = directiveEl.injector.get(FixVirtualScrollElementDirective); expect(directiveInstance).toBeTruthy(); }); + + it('moves the virtual scroll spacer before the content wrapper', () => { + const viewport = fixture.nativeElement.querySelector('cdk-virtual-scroll-viewport'); + const children = Array.from(viewport.children) as HTMLElement[]; + + expect(children[0].classList.contains('cdk-virtual-scroll-spacer')).toBe(true); + expect(children.at(-1)?.classList.contains('cdk-virtual-scroll-content-wrapper')).toBe(true); + }); }); diff --git a/projects/scroll-header/src/lib/directives/scroll-header.directive.spec.ts b/projects/scroll-header/src/lib/directives/scroll-header.directive.spec.ts index 5ad5ff5..c6a8fbb 100644 --- a/projects/scroll-header/src/lib/directives/scroll-header.directive.spec.ts +++ b/projects/scroll-header/src/lib/directives/scroll-header.directive.spec.ts @@ -1,11 +1,12 @@ import { Component } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { IonContent, IonHeader } from '@ionic/angular'; +import { IonContent, IonHeader, ScrollDetail } from '@ionic/angular'; import { ScrollHeaderDirective } from './scroll-header.directive'; import { testConfig } from '../../../../util/test.config'; @Component({ template: ` +
Native Header
Header Content
@@ -20,8 +21,10 @@ class TestComponent {} describe('ScrollHeaderDirective', () => { let fixture: ComponentFixture; let directive: ScrollHeaderDirective; + let contentEl: HTMLIonContentElement; + let nativeHeaderEl: HTMLElement; - beforeEach(() => { + beforeEach(async () => { TestBed.configureTestingModule({ providers: testConfig.providers, }); @@ -30,30 +33,89 @@ describe('ScrollHeaderDirective', () => { const directiveEl = fixture.debugElement.query((el) => el.nativeElement.tagName.toLowerCase() === 'ion-content'); directive = directiveEl.injector.get(ScrollHeaderDirective); + contentEl = directiveEl.nativeElement; + nativeHeaderEl = fixture.nativeElement.querySelector('.native-header'); + + const header = contentEl.querySelector('ion-header'); + Object.defineProperty(header, 'clientHeight', { configurable: true, value: 60 }); + await vi.waitFor(() => expect(contentEl.scrollEvents).toBe(true)); }); + afterEach(() => vi.restoreAllMocks()); + + const scroll = (scrollTop: number) => { + directive.onWindowScroll(new CustomEvent('ionScroll', { detail: { scrollTop } }) as CustomEvent); + }; + it('should create an instance', () => { expect(directive).toBeTruthy(); }); - it('should initialize with correct properties', () => { + it('enables Ionic scroll events and discovers the content header', () => { expect(directive.scrollHeader()).toBeTruthy(); + expect(contentEl.scrollEvents).toBe(true); + }); + + it('hides the content and native headers after scrolling down past the header', () => { + vi.spyOn(performance, 'now').mockReturnValue(20); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0); + return 1; + }); + + scroll(100); + + expect(contentEl.classList.contains('scroll-header-hidden')).toBe(true); + expect(contentEl.classList.contains('scroll-header-sticky')).toBe(true); + expect(contentEl.classList.contains('scroll-header-animated')).toBe(true); + expect(nativeHeaderEl.classList.contains('scroll-header-hidden')).toBe(true); }); - it('should handle scroll events', () => { - const contentEl = fixture.debugElement.query((el) => el.nativeElement.tagName.toLowerCase() === 'ion-content').nativeElement; + it('reveals both headers and keeps the content sticky when scrolling up below the header', () => { + vi.spyOn(performance, 'now').mockReturnValueOnce(20).mockReturnValueOnce(40); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1); + + scroll(120); + scroll(80); + + expect(contentEl.classList.contains('scroll-header-hidden')).toBe(false); + expect(contentEl.classList.contains('scroll-header-sticky')).toBe(true); + expect(contentEl.classList.contains('scroll-header-animated')).toBe(true); + expect(nativeHeaderEl.classList.contains('scroll-header-hidden')).toBe(false); + }); - // スクロールイベントをシミュレート - const scrollEvent = new CustomEvent('ionScroll', { - detail: { - scrollTop: 100, - scrollLeft: 0, - type: 'scroll', - }, + it('clears transient classes when returning to the top', () => { + vi.spyOn(performance, 'now').mockReturnValueOnce(20).mockReturnValueOnce(40); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0); + return 1; }); - contentEl.dispatchEvent(scrollEvent); - // スクロール後のクラスを確認 - expect(contentEl.classList.contains('scroll-header-sticky')).toBeTruthy(); + scroll(100); + scroll(0); + + expect(contentEl.classList.contains('scroll-header-hidden')).toBe(false); + expect(contentEl.classList.contains('scroll-header-sticky')).toBe(false); + expect(contentEl.classList.contains('scroll-header-animated')).toBe(false); + expect(nativeHeaderEl.classList.contains('scroll-header-hidden')).toBe(false); + }); + + it('keeps a fixed header sticky without hiding it', () => { + contentEl.classList.add('fixed'); + vi.spyOn(performance, 'now').mockReturnValue(20); + + scroll(100); + + expect(contentEl.classList.contains('scroll-header-sticky')).toBe(true); + expect(contentEl.classList.contains('scroll-header-hidden')).toBe(false); + }); + + it('ignores scroll updates inside the throttle window', () => { + vi.spyOn(performance, 'now').mockReturnValue(10); + + scroll(100); + + expect(contentEl.classList.contains('scroll-header-sticky')).toBe(false); + expect(contentEl.classList.contains('scroll-header-hidden')).toBe(false); }); }); diff --git a/projects/scroll-header/src/lib/directives/virtual-scroll-header.directive.spec.ts b/projects/scroll-header/src/lib/directives/virtual-scroll-header.directive.spec.ts index be59991..008abb3 100644 --- a/projects/scroll-header/src/lib/directives/virtual-scroll-header.directive.spec.ts +++ b/projects/scroll-header/src/lib/directives/virtual-scroll-header.directive.spec.ts @@ -1,18 +1,18 @@ -import { Component, ElementRef, viewChild } from '@angular/core'; +import { Component, viewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { IonContent, IonHeader } from '@ionic/angular'; +import { IonContent, IonHeader, IonRefresher } from '@ionic/angular'; import { VirtualScrollHeaderDirective } from './virtual-scroll-header.directive'; import { CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; import { testConfig } from '../../../../util/test.config'; -import { Signal } from '@angular/core'; -import { waitFindDom } from '../util'; @Component({ template: ` +
Native Header
Header Content
+
{{ item }} @@ -20,7 +20,15 @@ import { waitFindDom } from '../util'; `, - imports: [IonContent, IonHeader, VirtualScrollHeaderDirective, CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf], + imports: [ + IonContent, + IonHeader, + IonRefresher, + VirtualScrollHeaderDirective, + CdkVirtualScrollViewport, + CdkFixedSizeVirtualScroll, + CdkVirtualForOf, + ], }) class TestComponent { viewport = viewChild(CdkVirtualScrollViewport); @@ -30,35 +38,100 @@ class TestComponent { describe('VirtualScrollHeaderDirective', () => { let fixture: ComponentFixture; let directive: VirtualScrollHeaderDirective; - let viewport: Signal; + let contentEl: HTMLIonContentElement; + let nativeHeaderEl: HTMLElement; + let viewport: CdkVirtualScrollViewport; - beforeEach(() => { + beforeEach(async () => { TestBed.configureTestingModule({ providers: testConfig.providers, }); fixture = TestBed.createComponent(TestComponent); - if (fixture.componentInstance.viewport() === undefined) { - throw 'viewport is undefined'; - } - viewport = fixture.componentInstance.viewport as Signal; fixture.detectChanges(); + viewport = fixture.componentInstance.viewport()!; const directiveEl = fixture.debugElement.query((el) => el.nativeElement.tagName.toLowerCase() === 'ion-content'); directive = directiveEl.injector.get(VirtualScrollHeaderDirective); + contentEl = directiveEl.nativeElement; + nativeHeaderEl = fixture.nativeElement.querySelector('.native-header'); + + const header = contentEl.querySelector('ion-header'); + Object.defineProperty(header, 'clientHeight', { configurable: true, value: 60 }); + await vi.waitFor(() => expect(contentEl.classList.contains('scroll-header-animated')).toBe(true)); }); + afterEach(() => vi.restoreAllMocks()); + it('should create an instance', () => { expect(directive).toBeTruthy(); }); - it('should initialize with correct properties', () => { - expect(directive.virtualScroll()).toBeTruthy(); + it('discovers its children and starts in the animated state', () => { + expect(directive.virtualScroll()).toBe(viewport); expect(directive.scrollHeader()).toBeTruthy(); + expect(contentEl.classList.contains('scroll-header-animated')).toBe(true); }); - it('should add class scroll-header-animated', async () => { - const contentEl = fixture.debugElement.query((el) => el.nativeElement.tagName.toLowerCase() === 'ion-content').nativeElement; - await waitFindDom(contentEl, 'ion-header'); - expect(contentEl.classList.contains('scroll-header-animated')).toBeTruthy(); + it('applies viewport and refresher offsets when measuring the header', () => { + vi.spyOn(performance, 'now').mockReturnValue(20); + + directive.onWindowScroll(100); + + expect(viewport.elementRef.nativeElement.style.marginTop).toBe('-60px'); + expect(viewport.elementRef.nativeElement.style.paddingTop).toBe('60px'); + expect(contentEl.querySelector('ion-refresher')?.style.marginTop).toBe('60px'); + }); + + it('hides the content and native headers after scrolling down past the threshold', () => { + vi.spyOn(performance, 'now').mockReturnValue(20); + + directive.onWindowScroll(100); + + expect(contentEl.classList.contains('scroll-header-hidden')).toBe(true); + expect(contentEl.classList.contains('scroll-header-sticky')).toBe(true); + expect(nativeHeaderEl.classList.contains('scroll-header-hidden')).toBe(true); + }); + + it('reveals both headers while scrolling up and keeps the content sticky', () => { + vi.spyOn(performance, 'now').mockReturnValueOnce(20).mockReturnValueOnce(40); + + directive.onWindowScroll(100); + directive.onWindowScroll(60); + + expect(contentEl.classList.contains('scroll-header-hidden')).toBe(false); + expect(contentEl.classList.contains('scroll-header-sticky')).toBe(true); + expect(nativeHeaderEl.classList.contains('scroll-header-hidden')).toBe(false); + }); + + it('keeps a fixed header sticky without hiding it', () => { + contentEl.classList.add('fixed'); + vi.spyOn(performance, 'now').mockReturnValue(20); + + directive.onWindowScroll(100); + + expect(contentEl.classList.contains('scroll-header-sticky')).toBe(true); + expect(contentEl.classList.contains('scroll-header-hidden')).toBe(false); + }); + + it('ignores updates inside the throttle window', () => { + vi.spyOn(performance, 'now').mockReturnValue(10); + + directive.onWindowScroll(100); + + expect(contentEl.classList.contains('scroll-header-sticky')).toBe(false); + expect(contentEl.classList.contains('scroll-header-hidden')).toBe(false); + }); + + it('forwards viewport scroll events until destroyed', () => { + const onWindowScroll = vi.spyOn(directive, 'onWindowScroll'); + + viewport.elementRef.nativeElement.dispatchEvent(new Event('scroll')); + expect(onWindowScroll).toHaveBeenCalledOnce(); + + onWindowScroll.mockClear(); + directive.ngOnDestroy(); + viewport.elementRef.nativeElement.dispatchEvent(new Event('scroll')); + + expect(onWindowScroll).not.toHaveBeenCalled(); }); }); diff --git a/projects/scroll-header/src/lib/util.spec.ts b/projects/scroll-header/src/lib/util.spec.ts new file mode 100644 index 0000000..bf5c5c7 --- /dev/null +++ b/projects/scroll-header/src/lib/util.spec.ts @@ -0,0 +1,21 @@ +import { waitFindDom } from './util'; + +describe('waitFindDom', () => { + it('resolves once the requested descendant is added', async () => { + const host = document.createElement('div'); + let resolved = false; + const result = waitFindDom(host, '.target').then(() => { + resolved = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(resolved).toBe(false); + + const target = document.createElement('span'); + target.className = 'target'; + host.append(target); + await result; + + expect(resolved).toBe(true); + }); +}); diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.spec.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.spec.ts index 201478d..c0b6680 100644 --- a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.spec.ts +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll-strategy.spec.ts @@ -184,4 +184,117 @@ describe('DynamicSizeVirtualScrollStrategy', () => { expect(() => new DynamicSizeVirtualScrollStrategy(items(50, 0), 20, 50, false)).toThrow(/index 1/); expect(() => new DynamicSizeVirtualScrollStrategy(items(50), -1, 50, false)).toThrow(/buffers/); }); + + it('scrolls to the cumulative offset for a forward index and clamps out-of-range indexes', () => { + const sizes = [30, 70, 20]; + const harness = createViewport(sizes); + const strategy = new DynamicSizeVirtualScrollStrategy(items(...sizes), 10, 20, false); + strategy.attach(harness.viewport); + + strategy.scrollToIndex(2, 'auto'); + expect(harness.viewport.scrollToOffset).toHaveBeenCalledWith(100, 'auto'); + + vi.mocked(harness.viewport.scrollToOffset).mockClear(); + strategy.scrollToIndex(-5, 'auto'); + expect(harness.viewport.scrollToOffset).toHaveBeenCalledWith(0, 'auto'); + + vi.mocked(harness.viewport.scrollToOffset).mockClear(); + strategy.scrollToIndex(99, 'auto'); + expect(harness.viewport.scrollToOffset).toHaveBeenCalledWith(120, 'auto'); + }); + + it('does not scroll when the viewport is detached or the size model is incomplete', () => { + const sizes = [30, 70]; + const harness = createViewport(sizes); + const strategy = new DynamicSizeVirtualScrollStrategy(items(30), 10, 20, false); + + strategy.scrollToIndex(1, 'auto'); + expect(harness.viewport.scrollToOffset).not.toHaveBeenCalled(); + + strategy.attach(harness.viewport); + strategy.scrollToIndex(1, 'auto'); + expect(harness.viewport.scrollToOffset).not.toHaveBeenCalled(); + }); + + it('completes scrolledIndexChange and stops updating after detach', () => { + const harness = createViewport([50, 50], { viewportSize: 50 }); + const strategy = new DynamicSizeVirtualScrollStrategy(items(50, 50), 10, 20, false); + let completed = false; + strategy.scrolledIndexChange.subscribe({ complete: () => (completed = true) }); + + strategy.attach(harness.viewport); + const rangeBeforeDetach = { ...harness.range }; + strategy.detach(); + harness.range = { start: 99, end: 99 }; + strategy.onContentScrolled(); + + expect(completed).toBe(true); + expect(harness.range).toEqual({ start: 99, end: 99 }); + expect(rangeBeforeDetach.end).toBeGreaterThan(0); + }); + + it('clears the rendered range when data length becomes zero', () => { + const harness = createViewport([50, 50], { viewportSize: 50, scrollOffset: 25 }); + const strategy = new DynamicSizeVirtualScrollStrategy(items(50, 50), 10, 20, false); + const indexes: number[] = []; + strategy.scrolledIndexChange.subscribe((index) => indexes.push(index)); + + strategy.attach(harness.viewport); + harness.dataLength = 0; + strategy.onDataLengthChanged(); + + expect(harness.range).toEqual({ start: 0, end: 0 }); + expect(harness.contentOffset).toBe(0); + expect(harness.totalContentSize).toBe(0); + expect(strategy.measureScrollOffset).toBe(0); + expect(indexes.at(-1)).toBe(0); + }); + + it('expands the end buffer when scrolling near the list end', () => { + const sizes = [50, 50, 50, 50, 50]; + const scrollOffset = 130; + const harness = createViewport(sizes, { viewportSize: 50, scrollOffset }); + harness.viewport.measureScrollOffset = () => scrollOffset; + harness.range = { start: 2, end: 4 }; + const strategy = new DynamicSizeVirtualScrollStrategy(items(...sizes), 40, 80, false); + + strategy.attach(harness.viewport); + strategy.onContentScrolled(); + + expect(harness.range.end).toBe(5); + expect(harness.range.start).toBeLessThan(2); + }); + + it('rebuilds an invalid rendered range from buffer geometry', () => { + const sizes = [40, 60, 80]; + const harness = createViewport(sizes, { viewportSize: 60, scrollOffset: 70 }); + harness.range = { start: 3, end: 1 }; + const strategy = new DynamicSizeVirtualScrollStrategy(items(...sizes), 10, 30, false); + + strategy.attach(harness.viewport); + + expect(harness.range.start).toBeLessThan(harness.range.end); + expect(harness.range.end).toBeLessThanOrEqual(sizes.length); + expect(harness.contentOffset).toBe(40); + }); + + it('requests a viewport size check when the viewport height is zero', () => { + const harness = createViewport([50, 50], { viewportSize: 0 }); + const strategy = new DynamicSizeVirtualScrollStrategy(items(50, 50), 10, 20, false); + const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0); + return 0; + }); + + strategy.attach(harness.viewport); + + expect(harness.viewport.checkViewportSize).toHaveBeenCalled(); + rafSpy.mockRestore(); + }); + + it('rejects invalid buffer updates', () => { + const strategy = new DynamicSizeVirtualScrollStrategy(items(50), 10, 20, false); + + expect(() => strategy.updateItemAndBufferSize(items(50), 30, 20, false)).toThrow(/buffers/); + }); }); diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.directive.spec.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.directive.spec.ts new file mode 100644 index 0000000..7c512e6 --- /dev/null +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.directive.spec.ts @@ -0,0 +1,101 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { CdkDynamicSizeVirtualScroll, _dynamicSizeVirtualScrollStrategyFactory } from './dynamic-size-virtual-scroll-strategy'; +import { itemDynamicSize } from './dynamic-size-virtual-scroll.util'; +import { testConfig } from '../../../util/test.config'; + +@Component({ + template: ` + + `, + imports: [CdkVirtualScrollViewport, CdkDynamicSizeVirtualScroll], +}) +class HostComponent { + readonly sizes = signal([{ itemSize: 40 }, { itemSize: 60 }]); + readonly minBufferPx = signal(10); + readonly maxBufferPx = signal(30); + readonly isReverse = signal(false); +} + +describe('CdkDynamicSizeVirtualScroll', () => { + let fixture: ComponentFixture; + let host: HostComponent; + + const getDirective = (): CdkDynamicSizeVirtualScroll => + fixture.debugElement + .query((element) => element.nativeElement.tagName.toLowerCase() === 'cdk-virtual-scroll-viewport') + .injector.get(CdkDynamicSizeVirtualScroll); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: testConfig.providers, + }); + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('exposes the directive scroll offset from the installed strategy', () => { + const directive = getDirective(); + directive._scrollStrategy.measureScrollOffset = 42; + + expect(directive.scrollOffset).toBe(42); + }); + + it('toggles the reverse-scroll class when isReverse changes', () => { + const viewport = fixture.nativeElement.querySelector('cdk-virtual-scroll-viewport') as HTMLElement; + + expect(viewport.classList.contains('reverse-scroll')).toBe(false); + + host.isReverse.set(true); + fixture.detectChanges(); + + expect(viewport.classList.contains('reverse-scroll')).toBe(true); + + host.isReverse.set(false); + fixture.detectChanges(); + + expect(viewport.classList.contains('reverse-scroll')).toBe(false); + }); + + it('updates the strategy when measured item sizes change', () => { + const directive = getDirective(); + const updateSpy = vi.spyOn(directive._scrollStrategy, 'updateItemAndBufferSize'); + const sizes = [{ itemSize: 40 }, { itemSize: 60 }, { itemSize: 80 }]; + + host.sizes.set(sizes); + fixture.detectChanges(); + + expect(updateSpy).toHaveBeenLastCalledWith(sizes, 10, 30, false); + }); + + it('coerces numeric string buffer inputs before updating the strategy', () => { + const directive = getDirective(); + const updateSpy = vi.spyOn(directive._scrollStrategy, 'updateItemAndBufferSize'); + + host.minBufferPx.set('15'); + host.maxBufferPx.set('45'); + fixture.detectChanges(); + + expect(updateSpy).toHaveBeenLastCalledWith(host.sizes(), 15, 45, false); + }); + + it('rejects invalid measured sizes propagated through the directive input', () => { + host.sizes.set([{ itemSize: 0 }]); + + expect(() => fixture.detectChanges()).toThrow(/index 0/); + }); + + it('provides the directive strategy through the factory helper', () => { + const directive = getDirective(); + + expect(_dynamicSizeVirtualScrollStrategyFactory(directive)).toBe(directive._scrollStrategy); + }); +}); diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.spec.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.spec.ts index 85bf5a9..aef821f 100644 --- a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.spec.ts +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.spec.ts @@ -1,8 +1,20 @@ +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; import { DynamicSizeVirtualScrollService } from './dynamic-size-virtual-scroll.service'; import { testConfig } from '../../../util/test.config'; +const createViewportMock = (overrides: Partial = {}): CdkVirtualScrollViewport => + ({ + scrollToOffset: vi.fn(), + measureScrollOffset: vi.fn().mockReturnValue(0), + getRenderedRange: vi.fn().mockReturnValue({ start: 0, end: 0 }), + setRenderedContentOffset: vi.fn(), + setRenderedRange: vi.fn(), + ...overrides, + }) as unknown as CdkVirtualScrollViewport; + describe('DynamicSizeVirtualScrollService', () => { let service: DynamicSizeVirtualScrollService; @@ -13,7 +25,121 @@ describe('DynamicSizeVirtualScrollService', () => { service = TestBed.inject(DynamicSizeVirtualScrollService); }); - it('should be created', () => { - expect(service).toBeTruthy(); + afterEach(() => vi.restoreAllMocks()); + + it('restores a saved scroll offset on init when it is greater than zero', () => { + const viewport = createViewportMock(); + + service.onInit(viewport, 120); + + expect(viewport.scrollToOffset).toHaveBeenCalledWith(120); + }); + + it('does not scroll on init when the saved offset is zero', () => { + const viewport = createViewportMock(); + + service.onInit(viewport, 0); + + expect(viewport.scrollToOffset).not.toHaveBeenCalled(); + }); + + it('returns the top scroll offset on destroy', () => { + const viewport = createViewportMock({ + measureScrollOffset: vi.fn().mockReturnValue(88), + }); + + expect(service.onDestroy(viewport)).toBe(88); + expect(viewport.measureScrollOffset).toHaveBeenCalledWith('top'); + }); + + it('binds temporary items to auto height and fixed items to pixel height', () => { + const heights = TestBed.runInInjectionContext(() => + service.getBindDynamicItemHeight( + signal([ + { itemSize: 50, source: 'fixed' }, + { itemSize: 80, source: 'temporary' }, + ]), + ), + ); + + expect(heights()).toEqual(['50px', 'auto']); + }); + + it('allows merge only at the top of the rendered range within mergeScrollY', () => { + const nearTop = createViewportMock({ + getRenderedRange: vi.fn().mockReturnValue({ start: 0, end: 5 }), + measureScrollOffset: vi.fn().mockReturnValue(service.mergeScrollY - 1), + }); + const scrolledDown = createViewportMock({ + getRenderedRange: vi.fn().mockReturnValue({ start: 0, end: 5 }), + measureScrollOffset: vi.fn().mockReturnValue(service.mergeScrollY), + }); + const notAtTop = createViewportMock({ + getRenderedRange: vi.fn().mockReturnValue({ start: 2, end: 5 }), + measureScrollOffset: vi.fn().mockReturnValue(0), + }); + + expect(service.isEnableMerge(nearTop)).toBe(true); + expect(service.isEnableMerge(scrolledDown)).toBe(false); + expect(service.isEnableMerge(notAtTop)).toBe(false); + }); + + it('resets viewport geometry when refreshing after a destructive array replacement', () => { + const viewport = createViewportMock(); + + service.refreshViewport(viewport); + + expect(viewport.scrollToOffset).toHaveBeenCalledWith(0); + expect(viewport.setRenderedContentOffset).toHaveBeenCalledWith(0); + expect(viewport.setRenderedRange).toHaveBeenCalledWith({ start: 0, end: 0 }); + }); + + it('scrolls immediately to the vertical offset when duration is shorter than two frames', async () => { + const viewport = createViewportMock(); + + await service.scrollToPoint(viewport, 25, 75, 31); + + expect(viewport.scrollToOffset).toHaveBeenCalledExactlyOnceWith(75); + }); + + it('scrolls immediately to the horizontal offset for a horizontal viewport', async () => { + const viewport = createViewportMock({ orientation: 'horizontal' }); + + await service.scrollToPoint(viewport, 25, 75, 31); + + expect(viewport.scrollToOffset).toHaveBeenCalledExactlyOnceWith(25); + }); + + it('eases through intermediate offsets and resolves at the requested point', async () => { + const frames: FrameRequestCallback[] = []; + const viewport = createViewportMock({ + measureScrollOffset: vi.fn().mockReturnValue(100), + }); + vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + + const scrolling = service.scrollToPoint(viewport, 60, 200, 100); + frames.shift()!(0); + frames.shift()!(50); + + expect(viewport.scrollToOffset).toHaveBeenCalledWith(187); + + frames.shift()!(100); + await scrolling; + + expect(viewport.scrollToOffset).toHaveBeenCalledWith(200); + expect(viewport.scrollToOffset).not.toHaveBeenCalledWith(60); + expect(viewport.measureScrollOffset).toHaveBeenCalledExactlyOnceWith('top'); + }); + + it('delegates top scrolling to the smooth point helper', async () => { + const viewport = createViewportMock(); + const scrollToPoint = vi.spyOn(service, 'scrollToPoint').mockResolvedValue(undefined); + + await service.scrollToTopSmooth(viewport); + + expect(scrollToPoint).toHaveBeenCalledExactlyOnceWith(viewport, 0, 0, 400); }); }); diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.ts index 481e800..92b5b30 100644 --- a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.ts +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.service.ts @@ -75,12 +75,11 @@ export class DynamicSizeVirtualScrollService { y: number | undefined | null, duration = 0, ): Promise { + const isHorizontal = el.orientation === 'horizontal'; + const target = isHorizontal ? x : y; if (duration < 32) { - if (y != null) { - el.scrollToOffset(y); - } - if (x != null) { - el.scrollToOffset(x); + if (target != null) { + el.scrollToOffset(target); } return; } @@ -88,22 +87,16 @@ export class DynamicSizeVirtualScrollService { let resolve!: () => void; let startTime = 0; const promise = new Promise((r) => (resolve = r)); - const fromY = el.measureScrollOffset('top'); - const fromX = el.measureScrollOffset('left'); - - const deltaY = y != null ? y - fromY : 0; - const deltaX = x != null ? x - fromX : 0; + const from = el.measureScrollOffset(isHorizontal ? 'left' : 'top'); + const delta = target != null ? target - from : 0; // scroll loop const step = (timeStamp: number) => { const linearTime = Math.min(1, (timeStamp - startTime) / duration) - 1; const easedT = Math.pow(linearTime, 3) + 1; - if (deltaY !== 0) { - el.scrollToOffset(Math.floor(easedT * deltaY + fromY)); - } - if (deltaX !== 0) { - el.scrollToOffset(Math.floor(easedT * deltaX + fromX)); + if (delta !== 0) { + el.scrollToOffset(Math.floor(easedT * delta + from)); } if (easedT < 1) { diff --git a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.spec.ts b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.spec.ts index d5bef02..5c590dc 100644 --- a/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.spec.ts +++ b/projects/scroll-strategies/src/lib/dynamic-size-virtual-scroll.util.spec.ts @@ -50,5 +50,22 @@ describe('dynamic size virtual scroll utilities', () => { it('rejects invalid sizes and buffers', () => { expect(() => validateConfiguration(items(50, 0), 20, 50)).toThrow(/index 1/); expect(() => validateConfiguration(items(50), -1, 50)).toThrow(/buffers/); + expect(() => validateConfiguration(items(50), 50, 20)).toThrow(/buffers/); + expect(() => validateConfiguration(items(Number.NaN), 20, 50)).toThrow(/index 0/); + }); + + it('returns zero when summing before the first item', () => { + expect(sumItemSize(items(30, 70), 0)).toBe(0); + }); + + it('clamps offset lookups to list boundaries', () => { + const prefixSums = createPrefixSums(items(40, 60), 2); + expect(indexAtOffset(prefixSums, 0)).toBe(0); + expect(indexAtOffset(prefixSums, 999)).toBe(1); + expect(endIndexForOffset(prefixSums, 0)).toBe(1); + }); + + it('treats negative pixel distances as zero item count', () => { + expect(calculateItemCountForPixelDistance(items(30, 70), -10)).toBe(0); }); }); From 8caa9c67820d87cf5f9fac1f955683854e0b4350 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 22 Aug 2026 18:35:38 +0900 Subject: [PATCH 2/2] test(kit): make printer image tests deterministic --- projects/kit/printer/src/kit-printer.spec.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/projects/kit/printer/src/kit-printer.spec.ts b/projects/kit/printer/src/kit-printer.spec.ts index b6f564b..02aedda 100644 --- a/projects/kit/printer/src/kit-printer.spec.ts +++ b/projects/kit/printer/src/kit-printer.spec.ts @@ -83,7 +83,18 @@ describe('kitDomToPng', () => { return node; } - afterEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0); + return 1; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); it('pads width/height by 2px on iOS', async () => { getPlatform.mockReturnValue('ios'); @@ -123,7 +134,7 @@ describe('kitDomToPng', () => { height = 10; onload: (() => void) | null = null; set src(_v: string) { - setTimeout(() => this.onload?.()); + this.onload?.(); } }, ); @@ -148,7 +159,7 @@ describe('kitRotationImage', () => { height = 10; onload: (() => void) | null = null; set src(_v: string) { - setTimeout(() => this.onload?.()); + this.onload?.(); } }, );