chore: add regression tests for recent stack bugs (#33020)

This commit is contained in:
Jason Rasmussen authored and Codex committed 2026-11-06 21:36:57 +03:00
1 parent 06c41fdcac
commit 094d65c4bc
6 files changed
+289 -14

No files matched your search

@@ -0,0 +1,95 @@
import { Route } from '#lib/route.ts';
import { factory } from '#test/unit/factories.ts';
import { test } from '#test/unit/fixtures.ts';
import { expect } from 'vitest';
import Page from './+page.svelte';
import { load } from './+page.ts';
test('stacking puts the asset chosen to keep first', async ({ data, router, render, settle, api, app }) => {
const assets = [factory.asset(), factory.asset(), factory.asset()];
data.addDuplicates(assets);
await router.goto(Route.duplicatesUtility());
await render(Page, load);
await app.duplicates.keepToggle(0).click();
await app.duplicates.keepToggle(1).click();
await app.duplicates.stackButton.click();
await settle();
expect(api.createStack).toHaveBeenCalledWith({ body: { assetIds: [assets[1].id, assets[0].id, assets[2].id] } });
expect(api.updateAssets).toHaveBeenCalledWith({
body: { ids: [assets[1].id, assets[0].id, assets[2].id], duplicateId: null },
});
await expect.element(app.duplicates.emptyMessage).toBeVisible();
});
test('resolving a group trashes the rest and shows the next', async ({ data, router, render, settle, api, app }) => {
const first = [factory.asset(), factory.asset()];
data.addDuplicates(first).addDuplicates([factory.asset(), factory.asset()]);
const [{ duplicateId }] = data.duplicateList();
await router.goto(Route.duplicatesUtility());
await render(Page, load);
await app.duplicates.trashButton.click();
await settle();
expect(api.resolveDuplicates).toHaveBeenCalledWith({
body: { groups: [{ duplicateId, keepAssetIds: [first[0].id], trashAssetIds: [first[1].id] }] },
});
await expect.element(app.duplicates.groupPosition(1, 1)).toBeVisible();
});
test('a group that fails to resolve stays', async ({ data, router, render, settle, api, logger, app }) => {
data.addDuplicates([factory.asset(), factory.asset()]);
api.on({ resolveDuplicates: ({ body }) => ({ json: [{ id: body.groups[0].duplicateId, success: false }] }) });
await router.goto(Route.duplicatesUtility());
await render(Page, load);
await app.duplicates.trashButton.click();
await settle();
expect(api.resolveDuplicates).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(/handleError/);
await expect.element(app.duplicates.groupPosition(1, 1)).toBeVisible();
});
test('deduplicate all keeps the suggested assets', async ({ data, router, render, settle, api, app }) => {
const groups = [
[factory.asset(), factory.asset()],
[factory.asset(), factory.asset()],
];
data.addDuplicates(groups[0]).addDuplicates(groups[1]);
const duplicateIds = data.duplicateList().map(({ duplicateId }) => duplicateId);
await router.goto(Route.duplicatesUtility());
await render(Page, load);
await app.duplicates.deduplicateAllButton.click();
await app.confirmButton.click();
await settle();
expect(api.resolveDuplicates).toHaveBeenCalledWith({
body: {
groups: groups.map((assets, index) => ({
duplicateId: duplicateIds[index],
keepAssetIds: [assets[0].id],
trashAssetIds: [assets[1].id],
})),
},
});
await expect.element(app.duplicates.emptyMessage).toBeVisible();
});
test('keep all clears every group without trashing', async ({ data, router, render, settle, api, app }) => {
data.addDuplicates([factory.asset(), factory.asset()]).addDuplicates([factory.asset(), factory.asset()]);
const ids = data.duplicateList().map(({ duplicateId }) => duplicateId);
await router.goto(Route.duplicatesUtility());
await render(Page, load);
await app.duplicates.keepAllButton.click();
await app.confirmButton.click();
await settle();
expect(api.deleteDuplicates).toHaveBeenCalledWith({ body: { ids } });
expect(api.resolveDuplicates).not.toHaveBeenCalled();
await expect.element(app.duplicates.emptyMessage).toBeVisible();
});
+57
View File
@@ -15,6 +15,7 @@ import {
type AssetResponseDto,
type CreateAlbumDto,
type DownloadInfoDto,
type DuplicateResolveDto,
type DuplicateResponseDto,
type MapMarkerResponseDto,
type MemoryResponseDto,
@@ -24,7 +25,9 @@ import {
type PluginMethodResponseDto,
type SearchResponseDto,
type SharedLinkResponseDto,
type StackCreateDto,
type StackResponseDto,
type StackUpdateDto,
type TagResponseDto,
type TimeBucketAssetResponseDto,
type TimeBucketsResponseDto,
@@ -352,6 +355,23 @@ export class MockData {
return [...groups].map(([duplicateId, assets]) => ({ duplicateId, assets, suggestedKeepAssetIds: [assets[0].id] }));
}
resolveDuplicates({ groups }: DuplicateResolveDto) {
for (const { duplicateId, trashAssetIds } of groups) {
this.deleteDuplicates([duplicateId]);
this.deleteAssets(trashAssetIds);
}
return groups.map(({ duplicateId }) => ({ id: duplicateId, success: true }));
}
deleteDuplicates(duplicateIds: string[]) {
const ids = new Set(duplicateIds);
const duplicates = [...this.assets.values()].filter(({ duplicateId }) => duplicateId && ids.has(duplicateId));
for (const asset of duplicates) {
this.assets.set(asset.id, { ...asset, duplicateId: null });
}
return this;
}
removeFromAlbum(albumId: string, assetIds: string[]) {
const members = this.albumAssets.get(albumId);
return assetIds.map((id) => ({ id, success: members?.delete(id) ?? false }));
@@ -475,6 +495,43 @@ export class MockData {
return this;
}
createStack({ assetIds }: StackCreateDto) {
const stack = factory.stack({ assets: assetIds.map((id) => this.assets.get(id)!) });
this.addStacks(stack);
return stack;
}
updateStack(id: string, { primaryAssetId }: StackUpdateDto) {
const stack = this.stacks.get(id);
if (!stack) {
return;
}
const updated = { ...stack, primaryAssetId: primaryAssetId ?? stack.primaryAssetId };
this.stacks.set(id, updated);
return updated;
}
removeFromStack(id: string, assetId: string) {
const stack = this.stacks.get(id);
const asset = this.assets.get(assetId);
if (!stack || !asset) {
return;
}
this.stacks.set(id, { ...stack, assets: stack.assets.filter((asset) => asset.id !== assetId) });
this.assets.set(assetId, { ...asset, stack: null });
return this;
}
deleteStacks(ids: string[]) {
for (const id of ids) {
for (const asset of this.stacks.get(id)?.assets ?? []) {
this.assets.set(asset.id, { ...asset, stack: null });
}
this.stacks.delete(id);
}
return this;
}
addOcr(results: MaybeArray<AssetOcrResponseDto>) {
for (const result of toList(results)) {
const existing = this.ocr.get(result.assetId) ?? [];
+16
View File
@@ -78,6 +78,11 @@ export const createHandlers = (data: MockData): Handlers => {
json: { results: body.assets.map(({ id }) => ({ id, action: AssetUploadAction.Accept })) },
}),
getAssetDuplicates: () => ({ json: data.duplicateList() }),
resolveDuplicates: ({ body }) => ({ json: data.resolveDuplicates(body) }),
deleteDuplicates: ({ body }) => {
data.deleteDuplicates(body.ids);
return { status: 204 };
},
getActivities: ({ params }) => ({ json: data.activityList(params) }),
getActivityStatistics: ({ params }) => ({ json: data.activityStatistics(params) }),
@@ -106,6 +111,17 @@ export const createHandlers = (data: MockData): Handlers => {
const stack = data.getStack(params.id);
return stack ? { json: stack } : notFound;
},
createStack: ({ body }) => ({ status: 201, json: data.createStack(body) }),
updateStack: ({ params, body }) => {
const stack = data.updateStack(params.id, body);
return stack ? { json: stack } : notFound;
},
removeAssetFromStack: ({ params }) =>
data.removeFromStack(params.id, params.assetId) ? { status: 204 } : notFound,
deleteStacks: ({ body }) => {
data.deleteStacks(body.ids);
return { status: 204 };
},
getAllAlbums: ({ params = {} }) => ({ json: data.albumList(params.assetId) }),
createAlbum: ({ body }) => ({ status: 201, json: data.createAlbum(body) }),
+13
View File
@@ -178,6 +178,16 @@ const createTimeline = (page: Page, data: MockData) => {
await page.getByRole('menuitem', { name: 'change_date', exact: true }).click();
},
stack: async () => {
await page.getByLabel('menu', { exact: true }).click();
await page.getByRole('menuitem', { name: 'stack', exact: true }).click();
},
unstack: async () => {
await page.getByLabel('menu', { exact: true }).click();
await page.getByRole('menuitem', { name: 'unstack', exact: true }).click();
},
removeFromAlbum: async () => {
await page.getByLabel('menu', { exact: true }).click();
await page.getByRole('menuitem', { name: 'remove_from_album', exact: true }).click();
@@ -336,6 +346,9 @@ const createViewer = (page: Page) => {
download: () => moreMenuItem('download'),
archive: () => moreMenuItem('to_archive'),
unarchive: () => moreMenuItem('unarchive'),
unstack: () => moreMenuItem('unstack'),
setStackPrimary: () => moreMenuItem('set_stack_primary_asset'),
removeFromStack: () => moreMenuItem('viewer_remove_from_stack'),
permanentlyDelete: async () => {
await page.getByLabel('permanently_delete', { exact: true }).click();
@@ -2,21 +2,105 @@ import { Route } from '#lib/route.ts';
import { factory } from '#test/common/factories.ts';
import { expect, test } from '#test/medium/fixtures.ts';
test('stack strip lists the stack and the detail panel follows the selected asset', async ({ page, data, app }) => {
const primary = factory.asset({ tags: [factory.tag({ name: '1', value: 'test/1' })] });
const second = factory.asset({ tags: [factory.tag({ name: '2', value: 'test/2' })] });
data
.addStacks(factory.stack({ assets: [primary, second] }))
.setPreferences(factory.preferences({ tags: { enabled: true, sidebarWeb: false } }));
await page.goto(Route.viewAsset({ id: primary.id }));
await app.viewer.waitForAsset(primary);
test.describe('stack', () => {
test('stack strip lists the stack and the detail panel follows the selected asset', async ({ page, data, app }) => {
const primary = factory.asset({ tags: [factory.tag({ name: '1', value: 'test/1' })] });
const second = factory.asset({ tags: [factory.tag({ name: '2', value: 'test/2' })] });
data
.addStacks(factory.stack({ assets: [primary, second] }))
.setPreferences(factory.preferences({ tags: { enabled: true, sidebarWeb: false } }));
await page.goto(Route.viewAsset({ id: primary.id }));
await app.viewer.waitForAsset(primary);
await expect(app.viewer.stack).toBeVisible();
await expect(app.viewer.stackAssets).toHaveCount(2);
await expect(app.viewer.stack).toBeVisible();
await expect(app.viewer.stackAssets).toHaveCount(2);
await app.viewer.openDetailPanel();
await expect(app.viewer.tags.first()).toHaveText('test/1');
await app.viewer.openDetailPanel();
await expect(app.viewer.tags.first()).toHaveText('test/1');
await app.viewer.stackThumbnail(second).click();
await expect(app.viewer.tags.first()).toHaveText('test/2');
await app.viewer.stackThumbnail(second).click();
await expect(app.viewer.tags.first()).toHaveText('test/2');
});
test('stack strip is hidden during a slideshow', async ({ page, data, app }) => {
const [primary, second] = [factory.asset(), factory.asset()];
data.addStacks(factory.stack({ assets: [primary, second] }));
await page.goto(Route.viewAsset({ id: primary.id }));
await app.viewer.waitForAsset(primary);
await expect(app.viewer.stack).toBeVisible();
await app.viewer.openSlideshow();
await expect(app.viewer.exitSlideshowButton).toBeVisible();
await expect(app.viewer.stack).not.toBeVisible();
});
test('stacking selected assets from the timeline', async ({ page, data, api, app }) => {
const [first, second] = [factory.asset(), factory.asset()];
data.addAssets([first, second]);
await page.goto(Route.photos());
await app.timeline.select(first);
await app.timeline.select(second);
await app.timeline.selectionBar.stack();
expect(api.createStack).toHaveBeenCalledWith({ body: { assetIds: [first.id, second.id] } });
await expect(app.toast('stacked_assets_count')).toBeVisible();
await expect(app.timeline.selected).toHaveCount(0);
});
test('unstacking selected assets from the timeline', async ({ page, data, api, app }) => {
const stack = factory.stack({ assets: [factory.asset(), factory.asset()] });
data.addStacks(stack);
await page.goto(Route.photos());
await app.timeline.select(stack.assets[0]);
await app.timeline.selectionBar.unstack();
expect(api.deleteStacks).toHaveBeenCalledWith({ body: { ids: [stack.id] } });
await expect(app.toast('unstacked_assets_count')).toBeVisible();
await expect(app.timeline.selected).toHaveCount(0);
});
test('unstacking from the viewer closes it', async ({ page, data, api, app }) => {
const stack = factory.stack({ assets: [factory.asset(), factory.asset()] });
data.addStacks(stack);
await page.goto(Route.viewAsset({ id: stack.assets[0].id }));
await app.viewer.waitForAsset(stack.assets[0]);
await app.viewer.unstack();
expect(api.deleteStacks).toHaveBeenCalledWith({ body: { ids: [stack.id] } });
await expect(app.toast('unstacked_assets_count')).toBeVisible();
await expect(app.viewer.root).not.toBeVisible();
});
test('removing an asset from the stack drops it from the strip', async ({ page, data, api, app }) => {
const stack = factory.stack({ assets: [factory.asset(), factory.asset(), factory.asset()] });
data.addStacks(stack);
const removed = stack.assets[1];
await page.goto(Route.viewAsset({ id: removed.id }));
await app.viewer.waitForAsset(removed);
await app.viewer.removeFromStack();
expect(api.removeAssetFromStack).toHaveBeenCalledWith({ params: { id: stack.id, assetId: removed.id } });
await expect(app.toast('removed_from_stack')).toBeVisible();
await expect(app.viewer.stackAssets).toHaveCount(2);
await expect(app.viewer.stackThumbnail(removed)).toHaveCount(0);
});
test('setting the primary asset of a stack', async ({ page, data, api, app }) => {
const stack = factory.stack({ assets: [factory.asset(), factory.asset()] });
data.addStacks(stack);
const second = stack.assets[1];
await page.goto(Route.viewAsset({ id: second.id }));
await app.viewer.waitForAsset(second);
await app.viewer.setStackPrimary();
expect(api.updateStack).toHaveBeenCalledWith({ params: { id: stack.id }, body: { primaryAssetId: second.id } });
await app.viewer.moreButton.click();
await expect(page.getByRole('menuitem', { name: 'set_stack_primary_asset', exact: true })).toHaveCount(0);
});
});
+10
View File
@@ -46,6 +46,16 @@ export const createApp = () => {
removeMenuItem: page.getByRole('menuitem', { name: 'remove', exact: true }),
},
},
confirmButton: page.getByRole('dialog').getByRole('button', { name: 'confirm', exact: true }),
duplicates: {
keepToggle: (index: number) => button('keep').nth(index),
stackButton: button('stack'),
trashButton: button('trash_count'),
deduplicateAllButton: button('deduplicate_all'),
keepAllButton: button('keep_all').first(),
groupPosition: (current: number, total: number) => page.getByText(`${current} / ${total}`),
emptyMessage: page.getByText('no_duplicates_found'),
},
trash: {
emptyTrashButton: button('empty_trash'),
restoreAllButton: button('restore_all'),