diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/page.spec.ts b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/page.spec.ts new file mode 100644 index 000000000..66295067a --- /dev/null +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/page.spec.ts @@ -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(); +}); diff --git a/web/test/common/data.ts b/web/test/common/data.ts index 2a0e951db..754341748 100644 --- a/web/test/common/data.ts +++ b/web/test/common/data.ts @@ -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) { for (const result of toList(results)) { const existing = this.ocr.get(result.assetId) ?? []; diff --git a/web/test/common/handlers.ts b/web/test/common/handlers.ts index 1a705c3ef..cbc1491da 100644 --- a/web/test/common/handlers.ts +++ b/web/test/common/handlers.ts @@ -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) }), diff --git a/web/test/medium/app.ts b/web/test/medium/app.ts index 88a54d1ea..8765c5d89 100644 --- a/web/test/medium/app.ts +++ b/web/test/medium/app.ts @@ -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(); diff --git a/web/test/medium/specs/asset-viewer/stack.spec.ts b/web/test/medium/specs/asset-viewer/stack.spec.ts index b0d1ab8e3..e795eafb4 100644 --- a/web/test/medium/specs/asset-viewer/stack.spec.ts +++ b/web/test/medium/specs/asset-viewer/stack.spec.ts @@ -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); + }); }); diff --git a/web/test/unit/app.ts b/web/test/unit/app.ts index 4327df826..d184ae627 100644 --- a/web/test/unit/app.ts +++ b/web/test/unit/app.ts @@ -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'),