mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
fix(web): enable asset viewer navigation across memory boundaries (#25741)
This commit is contained in:
parent
9f52d864cf
commit
95e8e474b8
6 changed files with 568 additions and 1 deletions
2
e2e/src/generators/memory.ts
Normal file
2
e2e/src/generators/memory.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { generateMemoriesFromTimeline, generateMemory } from './memory/model-objects';
|
||||||
|
export type { MemoryConfig, MemoryYearConfig } from './memory/model-objects';
|
||||||
84
e2e/src/generators/memory/model-objects.ts
Normal file
84
e2e/src/generators/memory/model-objects.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { MemoryType, type MemoryResponseDto, type OnThisDayDto } from '@immich/sdk';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { toAssetResponseDto } from 'src/generators/timeline/rest-response';
|
||||||
|
import type { MockTimelineAsset } from 'src/generators/timeline/timeline-config';
|
||||||
|
import { SeededRandom, selectRandomMultiple } from 'src/generators/timeline/utils';
|
||||||
|
|
||||||
|
export type MemoryConfig = {
|
||||||
|
id?: string;
|
||||||
|
ownerId: string;
|
||||||
|
year: number;
|
||||||
|
memoryAt: string;
|
||||||
|
isSaved?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MemoryYearConfig = {
|
||||||
|
year: number;
|
||||||
|
assetCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[]): MemoryResponseDto {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const memoryId = config.id ?? faker.string.uuid();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: memoryId,
|
||||||
|
assets: assets.map((asset) => toAssetResponseDto(asset)),
|
||||||
|
data: { year: config.year } as OnThisDayDto,
|
||||||
|
memoryAt: config.memoryAt,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
isSaved: config.isSaved ?? false,
|
||||||
|
ownerId: config.ownerId,
|
||||||
|
type: MemoryType.OnThisDay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMemoriesFromTimeline(
|
||||||
|
timelineAssets: MockTimelineAsset[],
|
||||||
|
ownerId: string,
|
||||||
|
memoryConfigs: MemoryYearConfig[],
|
||||||
|
seed: number = 42,
|
||||||
|
): MemoryResponseDto[] {
|
||||||
|
const rng = new SeededRandom(seed);
|
||||||
|
const memories: MemoryResponseDto[] = [];
|
||||||
|
const usedAssetIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const config of memoryConfigs) {
|
||||||
|
const yearAssets = timelineAssets.filter((asset) => {
|
||||||
|
const assetYear = DateTime.fromISO(asset.fileCreatedAt).year;
|
||||||
|
return assetYear === config.year && !usedAssetIds.has(asset.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (yearAssets.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countToSelect = Math.min(config.assetCount, yearAssets.length);
|
||||||
|
const selectedAssets = selectRandomMultiple(yearAssets, countToSelect, rng);
|
||||||
|
|
||||||
|
for (const asset of selectedAssets) {
|
||||||
|
usedAssetIds.add(asset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedAssets.sort(
|
||||||
|
(a, b) => DateTime.fromISO(b.fileCreatedAt).diff(DateTime.fromISO(a.fileCreatedAt)).milliseconds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const memoryAt = DateTime.now().set({ year: config.year }).toISO()!;
|
||||||
|
|
||||||
|
memories.push(
|
||||||
|
generateMemory(
|
||||||
|
{
|
||||||
|
ownerId,
|
||||||
|
year: config.year,
|
||||||
|
memoryAt,
|
||||||
|
},
|
||||||
|
selectedAssets,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return memories;
|
||||||
|
}
|
||||||
65
e2e/src/mock-network/memory-network.ts
Normal file
65
e2e/src/mock-network/memory-network.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import type { MemoryResponseDto } from '@immich/sdk';
|
||||||
|
import { BrowserContext } from '@playwright/test';
|
||||||
|
|
||||||
|
export type MemoryChanges = {
|
||||||
|
memoryDeletions: string[];
|
||||||
|
assetRemovals: Map<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setupMemoryMockApiRoutes = async (
|
||||||
|
context: BrowserContext,
|
||||||
|
memories: MemoryResponseDto[],
|
||||||
|
changes: MemoryChanges,
|
||||||
|
) => {
|
||||||
|
await context.route('**/api/memories*', async (route, request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
if (pathname === '/api/memories' && request.method() === 'GET') {
|
||||||
|
const activeMemories = memories
|
||||||
|
.filter((memory) => !changes.memoryDeletions.includes(memory.id))
|
||||||
|
.map((memory) => {
|
||||||
|
const removedAssets = changes.assetRemovals.get(memory.id) ?? [];
|
||||||
|
return {
|
||||||
|
...memory,
|
||||||
|
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((memory) => memory.assets.length > 0);
|
||||||
|
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: activeMemories,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryMatch = pathname.match(/\/api\/memories\/([^/]+)$/);
|
||||||
|
if (memoryMatch && request.method() === 'GET') {
|
||||||
|
const memoryId = memoryMatch[1];
|
||||||
|
const memory = memories.find((m) => m.id === memoryId);
|
||||||
|
|
||||||
|
if (!memory || changes.memoryDeletions.includes(memoryId)) {
|
||||||
|
return route.fulfill({ status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedAssets = changes.assetRemovals.get(memoryId) ?? [];
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: {
|
||||||
|
...memory,
|
||||||
|
assets: memory.assets.filter((asset) => !removedAssets.includes(asset.id)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\/api\/memories\/([^/]+)$/.test(pathname) && request.method() === 'DELETE') {
|
||||||
|
const memoryId = pathname.split('/').pop()!;
|
||||||
|
changes.memoryDeletions.push(memoryId);
|
||||||
|
return route.fulfill({ status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fallback();
|
||||||
|
});
|
||||||
|
};
|
||||||
289
e2e/src/web/specs/memory/memory-viewer.ui-spec.ts
Normal file
289
e2e/src/web/specs/memory/memory-viewer.ui-spec.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { MemoryResponseDto } from '@immich/sdk';
|
||||||
|
import { test } from '@playwright/test';
|
||||||
|
import { generateMemoriesFromTimeline } from 'src/generators/memory';
|
||||||
|
import {
|
||||||
|
Changes,
|
||||||
|
createDefaultTimelineConfig,
|
||||||
|
generateTimelineData,
|
||||||
|
TimelineAssetConfig,
|
||||||
|
TimelineData,
|
||||||
|
} from 'src/generators/timeline';
|
||||||
|
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
|
||||||
|
import { MemoryChanges, setupMemoryMockApiRoutes } from 'src/mock-network/memory-network';
|
||||||
|
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
|
||||||
|
import { memoryAssetViewerUtils, memoryGalleryUtils, memoryViewerUtils } from 'src/web/specs/memory/utils';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test.describe('Memory Viewer - Gallery Asset Viewer Navigation', () => {
|
||||||
|
let adminUserId: string;
|
||||||
|
let timelineRestData: TimelineData;
|
||||||
|
let memories: MemoryResponseDto[];
|
||||||
|
const assets: TimelineAssetConfig[] = [];
|
||||||
|
const testContext = new TimelineTestContext();
|
||||||
|
const changes: Changes = {
|
||||||
|
albumAdditions: [],
|
||||||
|
assetDeletions: [],
|
||||||
|
assetArchivals: [],
|
||||||
|
assetFavorites: [],
|
||||||
|
};
|
||||||
|
const memoryChanges: MemoryChanges = {
|
||||||
|
memoryDeletions: [],
|
||||||
|
assetRemovals: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
adminUserId = faker.string.uuid();
|
||||||
|
testContext.adminId = adminUserId;
|
||||||
|
|
||||||
|
timelineRestData = generateTimelineData({
|
||||||
|
...createDefaultTimelineConfig(),
|
||||||
|
ownerId: adminUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||||
|
assets.push(...timeBucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
memories = generateMemoriesFromTimeline(
|
||||||
|
assets,
|
||||||
|
adminUserId,
|
||||||
|
[
|
||||||
|
{ year: 2024, assetCount: 3 },
|
||||||
|
{ year: 2023, assetCount: 2 },
|
||||||
|
{ year: 2022, assetCount: 4 },
|
||||||
|
],
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
await setupBaseMockApiRoutes(context, adminUserId);
|
||||||
|
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||||
|
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
testContext.slowBucket = false;
|
||||||
|
changes.albumAdditions = [];
|
||||||
|
changes.assetDeletions = [];
|
||||||
|
changes.assetArchivals = [];
|
||||||
|
changes.assetFavorites = [];
|
||||||
|
memoryChanges.memoryDeletions = [];
|
||||||
|
memoryChanges.assetRemovals.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Asset viewer navigation from gallery', () => {
|
||||||
|
test('shows both prev/next buttons for middle asset within a memory', async ({ page }) => {
|
||||||
|
const firstMemory = memories[0];
|
||||||
|
const middleAsset = firstMemory.assets[1];
|
||||||
|
|
||||||
|
await memoryViewerUtils.openMemoryPageWithAsset(page, middleAsset.id);
|
||||||
|
await memoryGalleryUtils.clickThumbnail(page, middleAsset.id);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, middleAsset);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||||
|
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows next button when at last asset of first memory (next memory exists)', async ({ page }) => {
|
||||||
|
const firstMemory = memories[0];
|
||||||
|
const lastAssetOfFirstMemory = firstMemory.assets.at(-1)!;
|
||||||
|
|
||||||
|
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirstMemory.id);
|
||||||
|
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirstMemory.id);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirstMemory);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||||
|
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows prev button when at first asset of last memory (prev memory exists)', async ({ page }) => {
|
||||||
|
const lastMemory = memories.at(-1)!;
|
||||||
|
const firstAssetOfLastMemory = lastMemory.assets[0];
|
||||||
|
|
||||||
|
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfLastMemory.id);
|
||||||
|
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfLastMemory.id);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfLastMemory);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||||
|
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can navigate from last asset of memory to first asset of next memory', async ({ page }) => {
|
||||||
|
const firstMemory = memories[0];
|
||||||
|
const secondMemory = memories[1];
|
||||||
|
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||||
|
const firstAssetOfSecond = secondMemory.assets[0];
|
||||||
|
|
||||||
|
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
|
||||||
|
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.clickNextButton(page);
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.expectCurrentAssetId(page, firstAssetOfSecond.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can navigate from first asset of memory to last asset of previous memory', async ({ page }) => {
|
||||||
|
const firstMemory = memories[0];
|
||||||
|
const secondMemory = memories[1];
|
||||||
|
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||||
|
const firstAssetOfSecond = secondMemory.assets[0];
|
||||||
|
|
||||||
|
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
|
||||||
|
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.clickPreviousButton(page);
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides prev button at very first asset (first memory, first asset, no prev memory)', async ({ page }) => {
|
||||||
|
const firstMemory = memories[0];
|
||||||
|
const veryFirstAsset = firstMemory.assets[0];
|
||||||
|
|
||||||
|
await memoryViewerUtils.openMemoryPageWithAsset(page, veryFirstAsset.id);
|
||||||
|
await memoryGalleryUtils.clickThumbnail(page, veryFirstAsset.id);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, veryFirstAsset);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.expectPreviousButtonNotVisible(page);
|
||||||
|
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides next button at very last asset (last memory, last asset, no next memory)', async ({ page }) => {
|
||||||
|
const lastMemory = memories.at(-1)!;
|
||||||
|
const veryLastAsset = lastMemory.assets.at(-1)!;
|
||||||
|
|
||||||
|
await memoryViewerUtils.openMemoryPageWithAsset(page, veryLastAsset.id);
|
||||||
|
await memoryGalleryUtils.clickThumbnail(page, veryLastAsset.id);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, veryLastAsset);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.expectNextButtonNotVisible(page);
|
||||||
|
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Keyboard navigation', () => {
|
||||||
|
test('ArrowLeft navigates to previous asset across memory boundary', async ({ page }) => {
|
||||||
|
const firstMemory = memories[0];
|
||||||
|
const secondMemory = memories[1];
|
||||||
|
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||||
|
const firstAssetOfSecond = secondMemory.assets[0];
|
||||||
|
|
||||||
|
await memoryViewerUtils.openMemoryPageWithAsset(page, firstAssetOfSecond.id);
|
||||||
|
await memoryGalleryUtils.clickThumbnail(page, firstAssetOfSecond.id);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||||
|
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ArrowRight navigates to next asset across memory boundary', async ({ page }) => {
|
||||||
|
const firstMemory = memories[0];
|
||||||
|
const secondMemory = memories[1];
|
||||||
|
const lastAssetOfFirst = firstMemory.assets.at(-1)!;
|
||||||
|
const firstAssetOfSecond = secondMemory.assets[0];
|
||||||
|
|
||||||
|
await memoryViewerUtils.openMemoryPageWithAsset(page, lastAssetOfFirst.id);
|
||||||
|
await memoryGalleryUtils.clickThumbnail(page, lastAssetOfFirst.id);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, lastAssetOfFirst);
|
||||||
|
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, firstAssetOfSecond);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Memory Viewer - Single Asset Memory Edge Cases', () => {
|
||||||
|
let adminUserId: string;
|
||||||
|
let timelineRestData: TimelineData;
|
||||||
|
let memories: MemoryResponseDto[];
|
||||||
|
const assets: TimelineAssetConfig[] = [];
|
||||||
|
const testContext = new TimelineTestContext();
|
||||||
|
const changes: Changes = {
|
||||||
|
albumAdditions: [],
|
||||||
|
assetDeletions: [],
|
||||||
|
assetArchivals: [],
|
||||||
|
assetFavorites: [],
|
||||||
|
};
|
||||||
|
const memoryChanges: MemoryChanges = {
|
||||||
|
memoryDeletions: [],
|
||||||
|
assetRemovals: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
adminUserId = faker.string.uuid();
|
||||||
|
testContext.adminId = adminUserId;
|
||||||
|
|
||||||
|
timelineRestData = generateTimelineData({
|
||||||
|
...createDefaultTimelineConfig(),
|
||||||
|
ownerId: adminUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||||
|
assets.push(...timeBucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
memories = generateMemoriesFromTimeline(
|
||||||
|
assets,
|
||||||
|
adminUserId,
|
||||||
|
[
|
||||||
|
{ year: 2024, assetCount: 2 },
|
||||||
|
{ year: 2023, assetCount: 1 },
|
||||||
|
{ year: 2022, assetCount: 2 },
|
||||||
|
],
|
||||||
|
123,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
await setupBaseMockApiRoutes(context, adminUserId);
|
||||||
|
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||||
|
await setupMemoryMockApiRoutes(context, memories, memoryChanges);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
testContext.slowBucket = false;
|
||||||
|
changes.albumAdditions = [];
|
||||||
|
changes.assetDeletions = [];
|
||||||
|
changes.assetArchivals = [];
|
||||||
|
changes.assetFavorites = [];
|
||||||
|
memoryChanges.memoryDeletions = [];
|
||||||
|
memoryChanges.assetRemovals.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single asset memory shows both prev/next when surrounded by other memories', async ({ page }) => {
|
||||||
|
const singleAssetMemory = memories[1];
|
||||||
|
const singleAsset = singleAssetMemory.assets[0];
|
||||||
|
|
||||||
|
await memoryViewerUtils.openMemoryPageWithAsset(page, singleAsset.id);
|
||||||
|
await memoryGalleryUtils.clickThumbnail(page, singleAsset.id);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.waitForViewerOpen(page);
|
||||||
|
await memoryAssetViewerUtils.waitForAssetLoad(page, singleAsset);
|
||||||
|
|
||||||
|
await memoryAssetViewerUtils.expectPreviousButtonVisible(page);
|
||||||
|
await memoryAssetViewerUtils.expectNextButtonVisible(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
e2e/src/web/specs/memory/utils.ts
Normal file
123
e2e/src/web/specs/memory/utils.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
|
import { expect, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
function getAssetIdFromUrl(url: URL): string | null {
|
||||||
|
const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/);
|
||||||
|
if (pathMatch) {
|
||||||
|
return pathMatch[1];
|
||||||
|
}
|
||||||
|
return url.searchParams.get('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const memoryViewerUtils = {
|
||||||
|
locator(page: Page) {
|
||||||
|
return page.locator('#memory-viewer');
|
||||||
|
},
|
||||||
|
|
||||||
|
async waitForMemoryLoad(page: Page) {
|
||||||
|
await expect(this.locator(page)).toBeVisible();
|
||||||
|
await expect(page.locator('#memory-viewer img').first()).toBeVisible();
|
||||||
|
},
|
||||||
|
|
||||||
|
async openMemoryPage(page: Page) {
|
||||||
|
await page.goto('/memory');
|
||||||
|
await this.waitForMemoryLoad(page);
|
||||||
|
},
|
||||||
|
|
||||||
|
async openMemoryPageWithAsset(page: Page, assetId: string) {
|
||||||
|
await page.goto(`/memory?id=${assetId}`);
|
||||||
|
await this.waitForMemoryLoad(page);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const memoryGalleryUtils = {
|
||||||
|
locator(page: Page) {
|
||||||
|
return page.locator('#gallery-memory');
|
||||||
|
},
|
||||||
|
|
||||||
|
thumbnailWithAssetId(page: Page, assetId: string) {
|
||||||
|
return page.locator(`#gallery-memory [data-thumbnail-focus-container][data-asset="${assetId}"]`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async scrollToGallery(page: Page) {
|
||||||
|
const showGalleryButton = page.getByLabel('Show gallery');
|
||||||
|
if (await showGalleryButton.isVisible()) {
|
||||||
|
await showGalleryButton.click();
|
||||||
|
}
|
||||||
|
await expect(this.locator(page)).toBeInViewport();
|
||||||
|
},
|
||||||
|
|
||||||
|
async clickThumbnail(page: Page, assetId: string) {
|
||||||
|
await this.scrollToGallery(page);
|
||||||
|
await this.thumbnailWithAssetId(page, assetId).click();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllThumbnails(page: Page) {
|
||||||
|
await this.scrollToGallery(page);
|
||||||
|
return page.locator('#gallery-memory [data-thumbnail-focus-container]');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const memoryAssetViewerUtils = {
|
||||||
|
locator(page: Page) {
|
||||||
|
return page.locator('#immich-asset-viewer');
|
||||||
|
},
|
||||||
|
|
||||||
|
async waitForViewerOpen(page: Page) {
|
||||||
|
await expect(this.locator(page)).toBeVisible();
|
||||||
|
},
|
||||||
|
|
||||||
|
async waitForAssetLoad(page: Page, asset: AssetResponseDto) {
|
||||||
|
const viewer = this.locator(page);
|
||||||
|
const imgLocator = viewer.locator(`img[draggable="false"][src*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
|
||||||
|
const videoLocator = viewer.locator(`video[poster*="/api/assets/${asset.id}/thumbnail?size=preview"]`);
|
||||||
|
|
||||||
|
await imgLocator.or(videoLocator).waitFor({ timeout: 10_000 });
|
||||||
|
},
|
||||||
|
|
||||||
|
nextButton(page: Page) {
|
||||||
|
return page.getByLabel('View next asset');
|
||||||
|
},
|
||||||
|
|
||||||
|
previousButton(page: Page) {
|
||||||
|
return page.getByLabel('View previous asset');
|
||||||
|
},
|
||||||
|
|
||||||
|
async expectNextButtonVisible(page: Page) {
|
||||||
|
await expect(this.nextButton(page)).toBeVisible();
|
||||||
|
},
|
||||||
|
|
||||||
|
async expectNextButtonNotVisible(page: Page) {
|
||||||
|
await expect(this.nextButton(page)).toHaveCount(0);
|
||||||
|
},
|
||||||
|
|
||||||
|
async expectPreviousButtonVisible(page: Page) {
|
||||||
|
await expect(this.previousButton(page)).toBeVisible();
|
||||||
|
},
|
||||||
|
|
||||||
|
async expectPreviousButtonNotVisible(page: Page) {
|
||||||
|
await expect(this.previousButton(page)).toHaveCount(0);
|
||||||
|
},
|
||||||
|
|
||||||
|
async clickNextButton(page: Page) {
|
||||||
|
await this.nextButton(page).click();
|
||||||
|
},
|
||||||
|
|
||||||
|
async clickPreviousButton(page: Page) {
|
||||||
|
await this.previousButton(page).click();
|
||||||
|
},
|
||||||
|
|
||||||
|
async closeViewer(page: Page) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(this.locator(page)).not.toBeVisible();
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentAssetId(page: Page): string | null {
|
||||||
|
const url = new URL(page.url());
|
||||||
|
return getAssetIdFromUrl(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
async expectCurrentAssetId(page: Page, expectedAssetId: string) {
|
||||||
|
await expect.poll(() => this.getCurrentAssetId(page)).toBe(expectedAssetId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -68,7 +68,11 @@
|
||||||
let currentMemoryAssetFull = $derived.by(async () =>
|
let currentMemoryAssetFull = $derived.by(async () =>
|
||||||
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
|
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
|
||||||
);
|
);
|
||||||
let currentTimelineAssets = $derived(current?.memory.assets || []);
|
let currentTimelineAssets = $derived([
|
||||||
|
...(current?.previousMemory?.assets ?? []),
|
||||||
|
...(current?.memory.assets ?? []),
|
||||||
|
...(current?.nextMemory?.assets ?? []),
|
||||||
|
]);
|
||||||
|
|
||||||
let isSaved = $derived(current?.memory.isSaved);
|
let isSaved = $derived(current?.memory.isSaved);
|
||||||
let viewerHeight = $state(0);
|
let viewerHeight = $state(0);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue