chore: add regression tests for recent stack bugs (#33020)
This commit is contained in:
1 parent
06c41fdcac
commit
094d65c4bc
6 files changed
+289
-14
No files matched your search
+95
@@ -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();
|
||||
});
|
||||
@@ -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) ?? [];
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user