Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions projects/kit/printer/src/kit-printer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -123,7 +134,7 @@ describe('kitDomToPng', () => {
height = 10;
onload: (() => void) | null = null;
set src(_v: string) {
setTimeout(() => this.onload?.());
this.onload?.();
}
},
);
Expand All @@ -148,7 +159,7 @@ describe('kitRotationImage', () => {
height = 10;
onload: (() => void) | null = null;
set src(_v: string) {
setTimeout(() => this.onload?.());
this.onload?.();
}
},
);
Expand Down
44 changes: 44 additions & 0 deletions projects/kit/src/lib/types/component.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
58 changes: 58 additions & 0 deletions projects/kit/src/lib/utils/haptics.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
crop: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
getCropzoneRect: ReturnType<typeof vi.fn>;
hasFilter: ReturnType<typeof vi.fn>;
removeFilter: ReturnType<typeof vi.fn>;
rotate: ReturnType<typeof vi.fn>;
setCropzoneRect: ReturnType<typeof vi.fn>;
startDrawingMode: ReturnType<typeof vi.fn>;
stopDrawingMode: ReturnType<typeof vi.fn>;
toDataURL: ReturnType<typeof vi.fn>;
}

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<PhotoEditorPage>;
let componentRef: ComponentRef<PhotoEditorPage>;
let editor: EditorMock;

beforeEach(() => {
TestBed.configureTestingModule({
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
});
});
26 changes: 26 additions & 0 deletions projects/photo-editor/src/lib/pages/util.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading