chore: add regression tests for tags (#33051)

(cherry picked from commit 7b7b5b8c5a1c13f65bb5696e7e06908b41a00e59)
This commit is contained in:
Jason Rasmussen authored and Codex committed 2026-11-07 21:31:44 +03:00
1 parent 12b2c6ac0a
commit cca2dd7ece
6 files changed
+226

No files matched your search

@@ -0,0 +1,55 @@
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('creating a tag under the current tag shows it as a child', async ({ data, router, render, settle, app, api }) => {
data.addTags(factory.tag({ name: 'Travel' }));
await router.goto(Route.tags({ path: 'Travel' }));
await render(Page, load);
await settle();
await app.tags.createButton.click();
await app.tags.form.tagInput.fill('Japan');
await app.tags.form.createButton.click();
await settle();
expect(api.upsertTags).toHaveBeenCalledWith({ body: { tags: ['Travel/Japan'] } });
await expect.element(app.tags.child('Japan')).toBeVisible();
});
test('renaming the current tag opens its new path', async ({ data, router, render, settle, app, api }) => {
const travel = factory.tag({ name: 'Travel' });
data.addTags(travel);
await router.goto(Route.tags({ path: 'Travel' }));
await render(Page, load);
await settle();
await app.tags.editButton.click();
await app.tags.form.nameInput.fill('Trips');
await app.tags.form.saveButton.click();
await settle();
expect(api.updateTag).toHaveBeenCalledWith({ params: { id: travel.id }, body: { name: 'Trips', color: null } });
expect(router.url.href).toBe(new URL(Route.tags({ path: 'Trips' }), router.url).href);
await expect.element(app.tags.explorerLink('Trips')).toBeVisible();
});
test('deleting the current tag opens its parent', async ({ data, router, render, settle, app, api }) => {
const travel = factory.tag({ name: 'Travel' });
const japan = factory.tag({ name: 'Japan', value: 'Travel/Japan', parentId: travel.id });
data.addTags([travel, japan]);
await router.goto(Route.tags({ path: 'Travel/Japan' }));
await render(Page, load);
await settle();
await app.tags.deleteButton.click();
await app.tags.form.deleteButton.click();
await settle();
expect(api.deleteTag).toHaveBeenCalledWith({ params: { id: japan.id } });
expect(router.url.href).toBe(new URL(Route.tags({ path: 'Travel' }), router.url).href);
await expect.element(app.tags.explorerLink('Japan')).not.toBeInTheDocument();
});
+80
View File
@@ -42,6 +42,7 @@ import {
type StackResponseDto,
type StackUpdateDto,
type TagResponseDto,
type TagUpdateDto,
type TimeBucketAssetResponseDto,
type TimeBucketsResponseDto,
type UpdateAlbumDto,
@@ -506,6 +507,85 @@ export class MockData {
return [...this.tags.values()];
}
upsertTags(values: string[]) {
return values.map((value) => this.upsertTag(value));
}
updateTag(id: string, dto: TagUpdateDto) {
const tag = this.tags.get(id);
if (!tag) {
return;
}
const name = dto.name ?? tag.name;
const value = tag.value.slice(0, tag.value.length - tag.name.length) + name;
for (const descendant of this.tagList()) {
if (descendant.value.startsWith(`${tag.value}/`)) {
this.tags.set(descendant.id, { ...descendant, value: value + descendant.value.slice(tag.value.length) });
}
}
const updated = { ...tag, name, value, color: dto.color === undefined ? tag.color : (dto.color ?? undefined) };
this.tags.set(id, updated);
return updated;
}
deleteTag(id: string) {
const tag = this.tags.get(id);
if (!tag) {
return false;
}
for (const descendant of this.tagList()) {
if (descendant.id === id || descendant.value.startsWith(`${tag.value}/`)) {
this.tags.delete(descendant.id);
}
}
return true;
}
tagAssets(assetIds: string[], tagIds: string[]) {
const tags = tagIds.map((id) => this.tags.get(id)).filter((tag) => tag !== undefined);
let count = 0;
for (const assetId of assetIds) {
const asset = this.assets.get(assetId);
if (!asset) {
continue;
}
const existing = asset.tags ?? [];
const added = tags.filter((tag) => existing.every(({ id }) => id !== tag.id));
count += added.length;
this.assets.set(assetId, { ...asset, tags: [...existing, ...added] });
}
return count;
}
untagAssets(tagId: string, assetIds: string[]) {
return assetIds.map((id) => {
const asset = this.assets.get(id);
if (!asset?.tags?.some((tag) => tag.id === tagId)) {
return { id, success: false };
}
this.assets.set(id, { ...asset, tags: asset.tags.filter((tag) => tag.id !== tagId) });
return { id, success: true };
});
}
private upsertTag(value: string): TagResponseDto {
const existing = this.tagList().find((tag) => tag.value === value);
if (existing) {
return existing;
}
const separator = value.lastIndexOf('/');
const parent = separator === -1 ? undefined : this.upsertTag(value.slice(0, separator));
const tag = factory.tag({ name: value.slice(separator + 1), value, parentId: parent?.id });
this.tags.set(tag.id, tag);
return tag;
}
addActivities(activities: MaybeArray<ActivityResponseDto>) {
for (const activity of toList(activities)) {
this.activities.set(activity.id, activity);
+8
View File
@@ -116,6 +116,14 @@ export const createHandlers = (data: MockData): Handlers => {
const tag = data.getTag(params.id);
return tag ? { json: tag } : notFound;
},
upsertTags: ({ body }) => ({ json: data.upsertTags(body.tags) }),
updateTag: ({ params, body }) => {
const tag = data.updateTag(params.id, body);
return tag ? { json: tag } : notFound;
},
deleteTag: ({ params }) => (data.deleteTag(params.id) ? { status: 204 } : notFound),
bulkTagAssets: ({ body }) => ({ json: { count: data.tagAssets(body.assetIds, body.tagIds) } }),
untagAssets: ({ params, body }) => ({ json: data.untagAssets(params.id, body.ids) }),
getSearchSuggestions: ({ params }) => ({ json: data.searchSuggestions(params) as string[] }),
updatePeople: ({ body }) => ({ json: data.updatePeople(body.people) }),
+11
View File
@@ -337,6 +337,17 @@ const createViewer = (page: Page) => {
},
navbarActions: page.getByTestId('asset-viewer-navbar-actions'),
tags: page.getByTestId('detail-panel-tags').getByRole('link'),
tag: (value: string) => page.getByTestId('detail-panel-tags').getByRole('link', { name: value, exact: true }),
addTagButton: page.getByTestId('detail-panel-tags').getByRole('button', { name: 'add_tag', exact: true }),
removeTagButton: page.getByTestId('detail-panel-tags').getByRole('button', { name: 'remove_tag', exact: true }),
tagAssets: {
input: page.getByRole('dialog').getByLabel('tag', { exact: true }),
submitButton: page.getByRole('dialog').getByRole('button', { name: 'tag_assets', exact: true }),
pick: async (value: string) => {
await page.getByRole('dialog').getByLabel('tag', { exact: true }).fill(value);
await page.getByRole('dialog').getByLabel('tag', { exact: true }).press('Enter');
},
},
media,
image: (quality: 'thumbnail' | 'preview' | 'original') => content.getByTestId(quality).filter({ visible: true }),
stackThumbnail: (asset: Asset) => stack.locator(`[data-asset="${asset.id}"]`),
@@ -0,0 +1,58 @@
import { Route } from '#lib/route.ts';
import { factory } from '#test/common/factories.ts';
import { expect, test } from '#test/medium/fixtures.ts';
test.describe('detail panel tags', () => {
test.beforeEach(({ data }) => {
data.setPreferences(factory.preferences({ tags: { enabled: true, sidebarWeb: false } }));
});
test('tagging with an existing tag shows it in the panel', async ({ page, data, api, app }) => {
const travel = factory.tag({ name: 'Travel' });
const asset = factory.asset();
data.addTags(travel).addAssets(asset);
await page.goto(Route.viewAsset({ id: asset.id }));
await app.viewer.waitForAsset(asset);
await app.viewer.openDetailPanel();
await app.viewer.addTagButton.click();
await app.viewer.tagAssets.pick('Travel');
await app.viewer.tagAssets.submitButton.click();
await expect(app.viewer.tag('Travel')).toBeVisible();
expect(api.upsertTags).not.toHaveBeenCalled();
expect(api.bulkTagAssets).toHaveBeenCalledWith({ body: { assetIds: [asset.id], tagIds: [travel.id] } });
});
test('tagging with a new tag creates it and shows it in the panel', async ({ page, data, api, app }) => {
const asset = factory.asset();
data.addAssets(asset);
await page.goto(Route.viewAsset({ id: asset.id }));
await app.viewer.waitForAsset(asset);
await app.viewer.openDetailPanel();
await app.viewer.addTagButton.click();
await app.viewer.tagAssets.pick('Japan');
await app.viewer.tagAssets.submitButton.click();
await expect(app.viewer.tag('Japan')).toBeVisible();
expect(api.upsertTags).toHaveBeenCalledWith({ body: { tags: ['Japan'] } });
const [japan] = data.tagList();
expect(api.bulkTagAssets).toHaveBeenCalledWith({ body: { assetIds: [asset.id], tagIds: [japan.id] } });
});
test('removing a tag drops it from the panel', async ({ page, data, api, app }) => {
const travel = factory.tag({ name: 'Travel' });
const asset = factory.asset({ tags: [travel] });
data.addTags(travel).addAssets(asset);
await page.goto(Route.viewAsset({ id: asset.id }));
await app.viewer.waitForAsset(asset);
await app.viewer.openDetailPanel();
await expect(app.viewer.tag('Travel')).toBeVisible();
await app.viewer.removeTagButton.click();
await expect(app.viewer.tag('Travel')).not.toBeVisible();
expect(api.untagAssets).toHaveBeenCalledWith({ params: { id: travel.id }, body: { ids: [asset.id] } });
});
});
+14
View File
@@ -112,6 +112,20 @@ export const createApp = () => {
groupPosition: (current: number, total: number) => page.getByText(`${current} / ${total}`),
emptyMessage: page.getByText('no_duplicates_found'),
},
tags: {
createButton: button('create_tag'),
editButton: button('edit_tag'),
deleteButton: button('delete_tag'),
explorerLink: (name: string) => sidebar.getByRole('link', { name, exact: true }),
child: (name: string) => main.getByRole('button', { name, exact: true }),
form: {
tagInput: page.getByLabelText('tag', { exact: true }),
nameInput: page.getByLabelText('name', { exact: true }),
createButton: page.getByRole('dialog').getByRole('button', { name: 'create', exact: true }),
saveButton: page.getByRole('dialog').getByRole('button', { name: 'save', exact: true }),
deleteButton: page.getByRole('dialog').getByRole('button', { name: 'delete', exact: true }),
},
},
trash: {
emptyTrashButton: button('empty_trash'),
restoreAllButton: button('restore_all'),