Compare commits

...

18 Commits

Author SHA1 Message Date
Ömer Duran
e42f7c0b0f Frontend: Enhance album creation logic to handle partial failures and improve user feedback 2025-09-24 23:55:46 +03:00
graciousgrey
9368820102 Merge branch 'develop' into albums-multi-select-add 2025-09-23 16:50:36 +02:00
Ömer Duran
31a3e22067 Frontend: Add utility functions for album selection and implement watcher in dialog 2025-09-18 15:35:53 +03:00
Ömer Duran
b4eb58f5e6 Frontend: Remove error logging 2025-09-18 14:16:35 +03:00
graciousgrey
9130712049 Merge branch 'develop' into albums-multi-select-add 2025-09-18 10:05:03 +02:00
graciousgrey
1a068e65a9 Tests: Improve acceptance tests 2025-09-17 16:07:53 +02:00
graciousgrey
ab565c963d Tests: Cover additional test cases 2025-09-12 15:04:12 +02:00
Ömer Duran
90ba179a6d Tests: Refactor album duplication test for clarity and consistency 2025-09-10 12:25:25 +03:00
Ömer Duran
897deb9fe8 Frontend: Update album dialog placeholder text 2025-09-10 12:11:21 +03:00
Ömer Duran
f57d9b95a1 Frontend: Remove deep watcher from album dialog and implement deduplication logic for selected albums in upload dialog 2025-09-10 12:10:13 +03:00
Ömer Duran
43be423a9a Tests: Remove unused triggerAlbumDialogAndType method 2025-09-10 11:45:52 +03:00
Ömer Duran
33c2baf2bf Frontend: Increase max-width of photo album dialog from 390 to 500 2025-09-10 11:43:43 +03:00
graciousgrey
df2dd80dac Tests: Improve page mdoel and remove .only 2025-09-09 13:08:38 +02:00
Ömer Duran
5ca8253f17 Tests: Enhance album functionality tests to support adding/removing photos from multiple albums and address album duplication bug 2025-09-09 11:38:05 +03:00
Ömer Duran
23fd7b426d Frontend: Enhance clipboard functionality by adding input validation and deduplication for album UIDs across multiple components 2025-09-09 10:15:57 +03:00
graciousgrey
d20ca7a28e Tests: Adapt acceptance tests to changes 2025-09-08 14:19:47 +02:00
graciousgrey
1879558421 Merge branch 'develop' into albums-multi-select-add 2025-09-08 10:23:09 +02:00
Ömer Duran
3e4d9944a7 Frontend: add multi-select to Add to Album and support multi-target actions 2025-08-27 08:47:51 +03:00
16 changed files with 572 additions and 76 deletions

View File

@@ -0,0 +1,60 @@
// Utility functions for handling album selection logic
export function processAlbumSelection(selectedAlbums, availableAlbums) {
if (!Array.isArray(selectedAlbums)) {
return { processed: [], changed: false };
}
let changed = false;
const processed = [];
const seenUids = new Set();
selectedAlbums.forEach((item) => {
// If it's a string, try to match it with existing albums
if (typeof item === "string" && item.trim().length > 0) {
const matchedAlbum = availableAlbums.find(
(album) => album.Title && album.Title.toLowerCase() === item.trim().toLowerCase()
);
if (matchedAlbum && !seenUids.has(matchedAlbum.UID)) {
// Replace string with actual album object
processed.push(matchedAlbum);
seenUids.add(matchedAlbum.UID);
changed = true;
} else if (!matchedAlbum) {
// Keep as string for new album creation
processed.push(item.trim());
}
} else if (typeof item === "object" && item?.UID && !seenUids.has(item.UID)) {
// Keep existing album objects, but prevent duplicates
processed.push(item);
seenUids.add(item.UID);
} else if (typeof item === "object" && item?.UID && seenUids.has(item.UID)) {
// Skip duplicate album objects
changed = true;
}
});
return {
processed,
changed: changed || processed.length !== selectedAlbums.length
};
}
// Creates a selectedAlbums watcher for Vue components
export function createAlbumSelectionWatcher(albumsProperty) {
return {
handler(newVal) {
const availableAlbums = this[albumsProperty] || [];
const { processed, changed } = processAlbumSelection(newVal, availableAlbums);
if (changed) {
this.$nextTick(() => {
this.selectedAlbums = processed;
}).catch((error) => {
console.error('Error updating selectedAlbums:', error);
});
}
}
};
}

View File

@@ -184,10 +184,27 @@ export default {
this.clearSelection();
this.expanded = false;
},
cloneAlbums(ppid) {
cloneAlbums(ppidOrList) {
if (!ppidOrList) {
return;
}
// Validate array input
if (Array.isArray(ppidOrList) && ppidOrList.length === 0) {
return;
}
this.dialog.album = false;
$api.post(`albums/${ppid}/clone`, { albums: this.selection }).then(() => this.onCloned());
const targets = Array.isArray(ppidOrList) ? ppidOrList : [ppidOrList];
// Deduplicate target album UIDs
const uniqueTargets = [...new Set(targets.filter((uid) => uid))];
Promise.all(uniqueTargets.map((uid) => $api.post(`albums/${uid}/clone`, { albums: this.selection })))
.then(() => this.onCloned())
.catch((error) => {
$notify.error(this.$gettext("Some albums could not be cloned"));
});
},
onCloned() {
this.clearClipboard();

View File

@@ -113,10 +113,28 @@ export default {
this.clearSelection();
this.expanded = false;
},
addToAlbum(ppid) {
addToAlbum(ppidOrList) {
if (!ppidOrList) {
return;
}
// Validate array input
if (Array.isArray(ppidOrList) && ppidOrList.length === 0) {
return;
}
this.dialog.album = false;
$api.post(`albums/${ppid}/photos`, { files: this.selection }).then(() => this.onAdded());
const albumUids = Array.isArray(ppidOrList) ? ppidOrList : [ppidOrList];
// Deduplicate album UIDs
const uniqueAlbumUids = [...new Set(albumUids.filter((uid) => uid))];
const body = { files: this.selection };
Promise.all(uniqueAlbumUids.map((uid) => $api.post(`albums/${uid}/photos`, body)))
.then(() => this.onAdded())
.catch((error) => {
$notify.error(this.$gettext("Some albums could not be updated"));
});
},
onAdded() {
this.clearClipboard();

View File

@@ -115,14 +115,28 @@ export default {
this.clearSelection();
this.expanded = false;
},
addToAlbum(ppid) {
if (!this.canAddAlbums) {
addToAlbum(ppidOrList) {
if (!this.canAddAlbums || !ppidOrList) {
return;
}
// Validate array input
if (Array.isArray(ppidOrList) && ppidOrList.length === 0) {
return;
}
this.dialog.album = false;
$api.post(`albums/${ppid}/photos`, { labels: this.selection }).then(() => this.onAdded());
const albumUids = Array.isArray(ppidOrList) ? ppidOrList : [ppidOrList];
// Deduplicate album UIDs
const uniqueAlbumUids = [...new Set(albumUids.filter((uid) => uid))];
const body = { labels: this.selection };
Promise.all(uniqueAlbumUids.map((uid) => $api.post(`albums/${uid}/photos`, body)))
.then(() => this.onAdded())
.catch((error) => {
$notify.error(this.$gettext("Some albums could not be updated"));
});
},
onAdded() {
this.clearClipboard();

View File

@@ -109,10 +109,28 @@ export default {
this.clearSelection();
this.expanded = false;
},
addToAlbum(ppid) {
addToAlbum(ppidOrList) {
if (!ppidOrList) {
return;
}
// Validate array input
if (Array.isArray(ppidOrList) && ppidOrList.length === 0) {
return;
}
this.dialog.album = false;
$api.post(`albums/${ppid}/photos`, { subjects: this.selection }).then(() => this.onAdded());
const albumUids = Array.isArray(ppidOrList) ? ppidOrList : [ppidOrList];
// Deduplicate album UIDs
const uniqueAlbumUids = [...new Set(albumUids.filter((uid) => uid))];
const body = { subjects: this.selection };
Promise.all(uniqueAlbumUids.map((uid) => $api.post(`albums/${uid}/photos`, body)))
.then(() => this.onAdded())
.catch((error) => {
$notify.error(this.$gettext("Some albums could not be updated"));
});
},
onAdded() {
this.clearClipboard();

View File

@@ -2,7 +2,7 @@
<v-dialog
:model-value="visible"
persistent
max-width="390"
max-width="500"
class="p-dialog p-photo-album-dialog"
@keydown.esc.exact="close"
@after-enter="afterEnter"
@@ -17,20 +17,38 @@
<v-card-text>
<v-combobox
ref="input"
v-model="album"
autocomplete="off"
:placeholder="$gettext('Select or create an album')"
:items="items"
v-model="selectedAlbums"
:disabled="loading"
:loading="loading"
hide-no-data
hide-details
return-object
chips
closable-chips
multiple
class="input-albums"
:items="items"
item-title="Title"
item-value="UID"
class="input-album"
@keyup.enter.native="confirm"
:placeholder="$gettext('Select or create albums')"
return-object
>
<template #no-data>
<v-list-item>
<v-list-item-title>
{{ $gettext(`Press enter to create a new album.`) }}
</v-list-item-title>
</v-list-item>
</template>
<template #chip="chip">
<v-chip
:model-value="chip.selected"
:disabled="chip.disabled"
prepend-icon="mdi-bookmark"
class="text-truncate"
@click:close="removeSelection(chip.index)"
>
{{ chip.item.title ? chip.item.title : chip.item }}
</v-chip>
</template>
</v-combobox>
</v-card-text>
<v-card-actions class="action-buttons">
@@ -38,7 +56,7 @@
{{ $gettext(`Cancel`) }}
</v-btn>
<v-btn
:disabled="!album"
:disabled="selectedAlbums.length === 0"
variant="flat"
color="highlight"
class="action-confirm text-white"
@@ -53,6 +71,7 @@
</template>
<script>
import Album from "model/album";
import { createAlbumSelectionWatcher } from "common/albums";
// TODO: Handle cases where users have more than 10000 albums.
const MaxResults = 10000;
@@ -65,13 +84,13 @@ export default {
default: false,
},
},
emits: ["close", "confirm"],
data() {
return {
loading: false,
newAlbum: null,
album: null,
albums: [],
items: [],
selectedAlbums: [],
labels: {
addToAlbum: this.$gettext("Add to album"),
createAlbum: this.$gettext("Create album"),
@@ -85,6 +104,7 @@ export default {
this.load("");
}
},
selectedAlbums: createAlbumSelectionWatcher('items'),
},
methods: {
afterEnter() {
@@ -101,23 +121,85 @@ export default {
return;
}
if (typeof this.album === "object" && this.album?.UID) {
this.loading = true;
this.$emit("confirm", this.album?.UID);
} else if (typeof this.album === "string" && this.album.length > 0) {
this.loading = true;
let newAlbum = new Album({ Title: this.album, UID: "", Favorite: false });
const existingUids = [];
const namesToCreate = [];
newAlbum
.save()
.then((a) => {
this.album = a;
this.$emit("confirm", a.UID);
})
.catch(() => {
this.loading = false;
});
(this.selectedAlbums || []).forEach((a) => {
if (typeof a === "object" && a?.UID) {
existingUids.push(a.UID);
} else if (typeof a === "string" && a.length > 0) {
namesToCreate.push(a);
}
});
// Deduplicate existing UIDs
const uniqueExistingUids = [...new Set(existingUids)];
this.loading = true;
if (namesToCreate.length === 0) {
this.$emit("confirm", uniqueExistingUids);
this.loading = false;
return;
}
// Create albums in parallel and handle partial failures without closing the dialog
const creations = namesToCreate.map((title) => ({
title,
promise: new Album({ Title: title, UID: "", Favorite: false }).save(),
}));
Promise.allSettled(creations.map((c) => c.promise))
.then((results) => {
const createdAlbums = [];
const failedTitles = [];
results.forEach((res, idx) => {
const originalTitle = creations[idx].title;
if (res.status === "fulfilled" && res.value && res.value.UID) {
createdAlbums.push(res.value);
} else {
failedTitles.push(originalTitle);
}
});
if (failedTitles.length > 0) {
// Replace successfully created string tokens with album objects so they are not retried
const byTitle = new Map(createdAlbums.map((a) => [a.Title || a.title || "", a]));
this.selectedAlbums = (this.selectedAlbums || []).map((it) => {
if (typeof it === "string") {
const t = it.trim();
const created = byTitle.get(t);
return created ? created : it;
}
return it;
});
// Add created albums to the combobox items so they can be selected by object
const known = new Set((this.items || []).map((a) => a.UID));
createdAlbums.forEach((a) => {
if (a && a.UID && !known.has(a.UID)) {
this.items.push(a);
known.add(a.UID);
}
});
// Notify user and keep dialog open for corrections
this.$notify.error(
this.$gettext("Some albums could not be created. Please correct the names and try again.")
);
return; // Do not emit confirm; keep dialog open
}
// All created successfully → emit and let parent close the dialog
const createdUids = createdAlbums
.map((a) => a && a.UID)
.filter((u) => typeof u === "string" && u.length > 0);
this.$emit("confirm", [...uniqueExistingUids, ...createdUids]);
})
.finally(() => {
this.loading = false;
});
},
onLoad() {
this.loading = true;
@@ -137,10 +219,13 @@ export default {
},
reset() {
this.loading = false;
this.newAlbum = null;
this.selectedAlbums = [];
this.albums = [];
this.items = [];
},
removeSelection(index) {
this.selectedAlbums.splice(index, 1);
},
load(q) {
if (this.loading) {
return;

View File

@@ -326,8 +326,13 @@ export default {
$notify.success(this.$gettext("Selection restored"));
this.clearClipboard();
},
addToAlbum(ppid) {
if (!ppid || !this.canManage) {
addToAlbum(ppidOrList) {
if (!ppidOrList || !this.canManage) {
return;
}
// Validate array input
if (Array.isArray(ppidOrList) && ppidOrList.length === 0) {
return;
}
@@ -338,9 +343,16 @@ export default {
this.busy = true;
this.dialog.album = false;
$api
.post(`albums/${ppid}/photos`, { photos: this.selection })
const albumUids = Array.isArray(ppidOrList) ? ppidOrList : [ppidOrList];
// Deduplicate album UIDs
const uniqueAlbumUids = [...new Set(albumUids.filter((uid) => uid))];
const body = { photos: this.selection };
Promise.all(uniqueAlbumUids.map((uid) => $api.post(`albums/${uid}/photos`, body)))
.then(() => this.onAdded())
.catch((error) => {
$notify.error(this.$gettext("Some albums could not be updated"));
})
.finally(() => {
this.busy = false;
});

View File

@@ -60,7 +60,7 @@
:items="albums"
item-title="Title"
item-value="UID"
:placeholder="$gettext('Select or create an album')"
:placeholder="$gettext('Select or create albums')"
return-object
>
<template #no-data>
@@ -134,6 +134,7 @@
import $api from "common/api";
import $notify from "common/notify";
import Album from "model/album";
import { createAlbumSelectionWatcher } from "common/albums";
import { Duration } from "luxon";
export default {
@@ -206,6 +207,7 @@ export default {
this.reset();
}
},
selectedAlbums: createAlbumSelectionWatcher('albums'),
},
methods: {
afterEnter() {
@@ -393,10 +395,15 @@ export default {
addToAlbums.push(a);
} else if (a instanceof Album && a.UID) {
addToAlbums.push(a.UID);
} else if (typeof a === "object" && a?.UID) {
addToAlbums.push(a.UID);
}
});
}
// Deduplicate album UIDs
addToAlbums = [...new Set(addToAlbums)];
async function performUpload(ctx) {
for (let i = 0; i < ctx.selected.length; i++) {
let file = ctx.selected[i];

View File

@@ -145,18 +145,27 @@ test.meta("testID", "albums-003").meta({ type: "short", mode: "public" })("Commo
});
test.meta("testID", "albums-004").meta({ type: "short", mode: "public" })(
"Common: Add/Remove Photos to/from album",
"Common: Add/Remove Photos to/from multiple albums",
async (t) => {
// Get initial counts for both Holiday and Christmas albums
await menu.openPage("albums");
await toolbar.search("Holiday");
const AlbumUid = await album.getNthAlbumUid("all", 0);
await album.openAlbumWithUid(AlbumUid);
const PhotoCount = await photo.getPhotoCount("all");
const HolidayAlbumUid = await album.getNthAlbumUid("all", 0);
await album.openAlbumWithUid(HolidayAlbumUid);
const HolidayPhotoCount = await photo.getPhotoCount("all");
await menu.openPage("albums");
await toolbar.search("Christmas");
const ChristmasAlbumUid = await album.getNthAlbumUid("all", 0);
await album.openAlbumWithUid(ChristmasAlbumUid);
const ChristmasPhotoCount = await photo.getPhotoCount("all");
// Select photos to add to albums
await menu.openPage("browse");
await toolbar.search("photo:true");
const FirstPhotoUid = await photo.getNthPhotoUid("image", 0);
const SecondPhotoUid = await photo.getNthPhotoUid("image", 1);
// Verify photos are not in any albums initially
await page.clickCardTitleOfUID(FirstPhotoUid);
await t
.click(photoedit.infoTab)
@@ -164,19 +173,32 @@ test.meta("testID", "albums-004").meta({ type: "short", mode: "public" })(
.notOk()
.expect(Selector("td").withText("Holiday").visible)
.notOk()
.expect(Selector("td").withText("Christmas").visible)
.notOk()
.click(photoedit.dialogClose);
// Select both photos and add to multiple albums simultaneously
await photo.selectPhotoFromUID(SecondPhotoUid);
await photoviewer.openPhotoViewer("uid", FirstPhotoUid);
await photoviewer.triggerPhotoViewerAction("select-toggle");
await photoviewer.triggerPhotoViewerAction("close-button");
await contextmenu.triggerContextMenuAction("album", "Holiday");
await contextmenu.triggerContextMenuAction("album", ["Holiday", "Christmas", "Food"]);
// Verify photos were added to Holiday album
await menu.openPage("albums");
await album.openAlbumWithUid(AlbumUid);
const PhotoCountAfterAdd = await photo.getPhotoCount("all");
await album.openAlbumWithUid(HolidayAlbumUid);
const HolidayPhotoCountAfterAdd = await photo.getPhotoCount("all");
await t.expect(HolidayPhotoCountAfterAdd).eql(HolidayPhotoCount + 2);
await t.expect(PhotoCountAfterAdd).eql(PhotoCount + 2);
// Verify photos were added to Christmas album
await menu.openPage("albums");
await album.openAlbumWithUid(ChristmasAlbumUid);
const ChristmasPhotoCountAfterAdd = await photo.getPhotoCount("all");
await t.expect(ChristmasPhotoCountAfterAdd).eql(ChristmasPhotoCount + 2);
// Verify photo info shows all albums
await menu.openPage("browse");
await toolbar.search("photo:true");
await page.clickCardTitleOfUID(FirstPhotoUid);
await t
.click(photoedit.infoTab)
@@ -184,15 +206,58 @@ test.meta("testID", "albums-004").meta({ type: "short", mode: "public" })(
.ok()
.expect(Selector("td").withText("Holiday").visible)
.ok()
.expect(Selector("td").withText("Food").visible)
.ok()
.expect(Selector("td").withText("Christmas").visible)
.ok()
.click(photoedit.dialogClose);
// Remove photos from Holiday album and verify count
await menu.openPage("albums");
await album.openAlbumWithUid(HolidayAlbumUid);
await photo.selectPhotoFromUID(FirstPhotoUid);
await photo.selectPhotoFromUID(SecondPhotoUid);
await contextmenu.triggerContextMenuAction("remove", "");
const PhotoCountAfterRemove = await photo.getPhotoCount("all");
const HolidayPhotoCountAfterRemove = await photo.getPhotoCount("all");
await t.expect(HolidayPhotoCountAfterRemove).eql(HolidayPhotoCountAfterAdd - 2);
await t.expect(PhotoCountAfterRemove).eql(PhotoCountAfterAdd - 2);
// Verify photos are still in Christmas album
await menu.openPage("albums");
await album.openAlbumWithUid(ChristmasAlbumUid);
const ChristmasPhotoCountAfterHolidayRemove = await photo.getPhotoCount("all");
await t.expect(ChristmasPhotoCountAfterHolidayRemove).eql(ChristmasPhotoCountAfterAdd);
// Verify photo info shows only Christmas album now
await menu.openPage("browse");
await toolbar.search("photo:true");
await page.clickCardTitleOfUID(FirstPhotoUid);
await t
.click(photoedit.infoTab)
.expect(Selector("td").withText("Albums").visible)
.ok()
.expect(Selector("td").withText("Holiday").visible)
.notOk()
.expect(Selector("td").withText("Christmas").visible)
.ok()
.click(photoedit.dialogClose);
// Remove photos from Christmas album to clean up
await menu.openPage("albums");
await album.openAlbumWithUid(ChristmasAlbumUid);
await photo.selectPhotoFromUID(FirstPhotoUid);
await photo.selectPhotoFromUID(SecondPhotoUid);
await contextmenu.triggerContextMenuAction("remove", "");
const ChristmasPhotoCountAfterRemove = await photo.getPhotoCount("all");
await t.expect(ChristmasPhotoCountAfterRemove).eql(ChristmasPhotoCount);
// Delete Food album
await menu.openPage("albums");
await toolbar.search("Food");
const FoodUid = await album.getNthAlbumUid("all", 0);
await album.selectAlbumFromUID(FoodUid);
await contextmenu.triggerContextMenuAction("delete", "");
// Final verification that photos are not in any albums
await menu.openPage("browse");
await toolbar.search("photo:true");
await page.clickCardTitleOfUID(FirstPhotoUid);
@@ -200,12 +265,46 @@ test.meta("testID", "albums-004").meta({ type: "short", mode: "public" })(
.click(photoedit.infoTab)
.expect(Selector("td").withText("Albums").visible)
.notOk()
.expect(Selector("td").withText("Food").visible)
.notOk()
.expect(Selector("td").withText("Holiday").visible)
.notOk()
.expect(Selector("td").withText("Christmas").visible)
.notOk()
.click(photoedit.dialogClose);
}
);
test.meta("testID", "albums-004-duplicate").meta({ type: "short", mode: "public" })(
"Album duplication when selecting from dropdown then typing same name",
async (t) => {
await menu.openPage("browse");
await toolbar.search("photo:true");
const FirstPhotoUid = await photo.getNthPhotoUid("image", 0);
await photo.selectPhotoFromUID(FirstPhotoUid);
await contextmenu.openContextMenu();
await t.click(Selector("button.action-album"));
await t.click(Selector(".input-albums input"));
const holidayOption = Selector("div").withText("Holiday").parent('div[role="option"]');
if (await holidayOption.visible) {
await t.click(holidayOption);
const afterDropdown = await Selector("span.v-chip").withText("Holiday").count;
await t.expect(afterDropdown).eql(1, "Should have 1 chip after dropdown selection");
await t.click(Selector(".input-albums input"));
await t.typeText(Selector(".input-albums input"), "Holiday", { replace: true }).pressKey("enter");
const afterTyping = await Selector("span.v-chip").withText("Holiday").count;
await t.expect(afterTyping).eql(1, "Should still have only 1 chip after typing duplicate");
}
await t.click(Selector(".action-cancel"));
}
);
test.meta("testID", "albums-005").meta({ mode: "public" })("Common: Use album search and filters", async (t) => {
await menu.openPage("albums");
if (t.browser.platform === "mobile") {
@@ -231,11 +330,12 @@ test.meta("testID", "albums-005").meta({ mode: "public" })("Common: Use album se
});
test.meta("testID", "albums-006").meta({ mode: "public" })("Common: Test album autocomplete", async (t) => {
await menu.openPage("browse");
await toolbar.search("photo:true");
const FirstPhotoUid = await photo.getNthPhotoUid("image", 0);
await photo.selectPhotoFromUID(FirstPhotoUid);
await contextmenu.openContextMenu();
await t.click(Selector("button.action-album")).click(Selector(".input-album input"));
await t.click(Selector("button.action-album")).click(Selector(".input-albums input"));
await t
.expect(page.selectOption.withText("Holiday").visible)
@@ -243,7 +343,7 @@ test.meta("testID", "albums-006").meta({ mode: "public" })("Common: Test album a
.expect(page.selectOption.withText("Christmas").visible)
.ok();
await t.typeText(Selector(".input-album input"), "C", { replace: true });
await t.typeText(Selector(".input-albums input"), "C", { replace: true });
await t
.expect(page.selectOption.withText("Holiday").visible)

View File

@@ -112,15 +112,23 @@ test.meta("testID", "calendar-004").meta({ type: "short", mode: "public" })(
async (t) => {
await menu.openPage("albums");
const AlbumCount = await album.getAlbumCount("all");
await toolbar.search("Holiday");
const HolidayAlbumUid = await album.getNthAlbumUid("all", 0);
await album.openAlbumWithUid(HolidayAlbumUid);
const InitialPhotoCountHoliday = await photo.getPhotoCount("all");
await menu.openPage("calendar");
const SecondCalendarUid = await album.getNthAlbumUid("all", 1);
await album.openAlbumWithUid(SecondCalendarUid);
const PhotoCountInCalendar = await photo.getPhotoCount("all");
const FirstPhotoUid = await photo.getNthPhotoUid("image", 0);
const SecondPhotoUid = await photo.getNthPhotoUid("image", 1);
const ThirdPhotoUid = await photo.getNthPhotoUid("image", 2);
const FourthPhotoUid = await photo.getNthPhotoUid("image", 3);
const FifthPhotoUid = await photo.getNthPhotoUid("image", 4);
await menu.openPage("calendar");
await album.selectAlbumFromUID(SecondCalendarUid);
await contextmenu.triggerContextMenuAction("clone", "NotYetExistingAlbumForCalendar");
await contextmenu.triggerContextMenuAction("clone", ["NotYetExistingAlbumForCalendar", "Holiday"]);
await menu.openPage("albums");
const AlbumCountAfterCreation = await album.getAlbumCount("all");
@@ -145,6 +153,18 @@ test.meta("testID", "calendar-004").meta({ type: "short", mode: "public" })(
}
const AlbumCountAfterDelete = await album.getAlbumCount("all");
await t.expect(AlbumCountAfterDelete).eql(AlbumCount);
await album.openAlbumWithUid(HolidayAlbumUid);
await photo.selectPhotoFromUID(FirstPhotoUid);
await photo.selectPhotoFromUID(SecondPhotoUid);
await photo.selectPhotoFromUID(ThirdPhotoUid);
await photo.selectPhotoFromUID(FourthPhotoUid);
await photo.selectPhotoFromUID(FifthPhotoUid);
await contextmenu.triggerContextMenuAction("remove", "");
const PhotoCountHolidayAfterDelete = await photo.getPhotoCount("all");
await t.expect(PhotoCountHolidayAfterDelete).eql(InitialPhotoCountHoliday);
await menu.openPage("calendar");
await album.openAlbumWithUid(SecondCalendarUid);
await photo.checkPhotoVisibility(FirstPhotoUid, true);

View File

@@ -127,6 +127,10 @@ test.meta("testID", "folders-004").meta({ mode: "public" })(
async (t) => {
await menu.openPage("albums");
const AlbumCount = await album.getAlbumCount("all");
await toolbar.search("Holiday");
const HolidayAlbumUid = await album.getNthAlbumUid("all", 0);
await album.openAlbumWithUid(HolidayAlbumUid);
const InitialPhotoCountHoliday = await photo.getPhotoCount("all");
await menu.openPage("folders");
const ThirdFolderUid = await album.getNthAlbumUid("all", 2);
await album.openAlbumWithUid(ThirdFolderUid);
@@ -134,7 +138,7 @@ test.meta("testID", "folders-004").meta({ mode: "public" })(
const FirstPhotoUid = await photo.getNthPhotoUid("image", 0);
await menu.openPage("folders");
await album.selectAlbumFromUID(ThirdFolderUid);
await contextmenu.triggerContextMenuAction("clone", "NotYetExistingAlbumForFolder");
await contextmenu.triggerContextMenuAction("clone", ["Holiday", "NotYetExistingAlbumForFolder"]);
await menu.openPage("albums");
const AlbumCountAfterCreation = await album.getAlbumCount("all");
@@ -155,6 +159,13 @@ test.meta("testID", "folders-004").meta({ mode: "public" })(
await t.expect(AlbumCountAfterDelete).eql(AlbumCount);
await album.openAlbumWithUid(HolidayAlbumUid);
await photo.selectPhotoFromUID(FirstPhotoUid);
await contextmenu.triggerContextMenuAction("remove", "");
const PhotoCountHolidayAfterDelete = await photo.getPhotoCount("all");
await t.expect(PhotoCountHolidayAfterDelete).eql(InitialPhotoCountHoliday);
await menu.openPage("folders");
await album.openAlbumWithUid(ThirdFolderUid);
await photo.checkPhotoVisibility(FirstPhotoUid, true);

View File

@@ -124,12 +124,17 @@ test.meta("testID", "labels-003").meta({ mode: "public" })("Common: Rename Label
await t.expect(Selector("div.no-results").visible).ok();
});
test.meta("testID", "labels-003").meta({ mode: "public" })("Common: Add label to album", async (t) => {
test.meta("testID", "labels-003").meta({ mode: "public" })("Common: Add label to albums", async (t) => {
await menu.openPage("albums");
await toolbar.search("Christmas");
const AlbumUid = await album.getNthAlbumUid("all", 0);
await album.openAlbumWithUid(AlbumUid);
const PhotoCount = await photo.getPhotoCount("all");
const ChristmasAlbumUid = await album.getNthAlbumUid("all", 0);
await album.openAlbumWithUid(ChristmasAlbumUid);
const InitialPhotoCountChristmas = await photo.getPhotoCount("all");
await menu.openPage("albums");
await toolbar.search("Holiday");
const HolidayAlbumUid = await album.getNthAlbumUid("all", 0);
await album.openAlbumWithUid(HolidayAlbumUid);
const InitialPhotoCountHoliday = await photo.getPhotoCount("all");
await menu.openPage("labels");
await toolbar.search("sunglasses");
const LabelSunglasses = await label.getNthLabeltUid(0);
@@ -143,12 +148,12 @@ test.meta("testID", "labels-003").meta({ mode: "public" })("Common: Add label to
await menu.openPage("labels");
await label.triggerHoverAction("uid", LabelSunglasses, "select");
await contextmenu.checkContextMenuCount("1");
await contextmenu.triggerContextMenuAction("album", "Christmas");
await contextmenu.triggerContextMenuAction("album", ["Christmas", "Holiday"]);
await menu.openPage("albums");
await album.openAlbumWithUid(AlbumUid);
const PhotoCountAfterAdd = await photo.getPhotoCount("all");
await album.openAlbumWithUid(ChristmasAlbumUid);
const PhotoCountAfterAddChristmas = await photo.getPhotoCount("all");
await t.expect(PhotoCountAfterAdd).eql(PhotoCount + 5);
await t.expect(PhotoCountAfterAddChristmas).eql(InitialPhotoCountChristmas + 5);
await photo.triggerHoverAction("uid", FirstPhotoSunglasses, "select");
await photo.triggerHoverAction("uid", SecondPhotoSunglasses, "select");
@@ -157,9 +162,21 @@ test.meta("testID", "labels-003").meta({ mode: "public" })("Common: Add label to
await photo.triggerHoverAction("uid", FifthPhotoSunglasses, "select");
await contextmenu.triggerContextMenuAction("remove", "");
const PhotoCountAfterDelete = await photo.getPhotoCount("all");
const PhotoCountAfterDeleteChristmas = await photo.getPhotoCount("all");
await t.expect(PhotoCountAfterDelete).eql(PhotoCountAfterAdd - 5);
await t.expect(PhotoCountAfterDeleteChristmas).eql(PhotoCountAfterAddChristmas - 5);
await menu.openPage("albums");
await album.openAlbumWithUid(HolidayAlbumUid);
await photo.triggerHoverAction("uid", FirstPhotoSunglasses, "select");
await photo.triggerHoverAction("uid", SecondPhotoSunglasses, "select");
await photo.triggerHoverAction("uid", ThirdPhotoSunglasses, "select");
await photo.triggerHoverAction("uid", FourthPhotoSunglasses, "select");
await photo.triggerHoverAction("uid", FifthPhotoSunglasses, "select");
await contextmenu.triggerContextMenuAction("remove", "");
const PhotoCountHolidayAfterDelete = await photo.getPhotoCount("all");
await t.expect(PhotoCountHolidayAfterDelete).eql(InitialPhotoCountHoliday);
});
test.meta("testID", "labels-004").meta({ mode: "public" })("Common: Delete label", async (t) => {

View File

@@ -116,15 +116,24 @@ test.meta("testID", "moments-003").meta({ mode: "public" })(
async (t) => {
await menu.openPage("albums");
const AlbumCount = await album.getAlbumCount("all");
await toolbar.search("Holiday");
const HolidayAlbumUid = await album.getNthAlbumUid("all", 0);
await album.openAlbumWithUid(HolidayAlbumUid);
const InitialPhotoCountHoliday = await photo.getPhotoCount("all");
await menu.openPage("moments");
const FirstMomentUid = await album.getNthAlbumUid("all", 0);
await album.openAlbumWithUid(FirstMomentUid);
const SecondMomentUid = await album.getNthAlbumUid("all", 1);
await album.openAlbumWithUid(SecondMomentUid);
const PhotoCountInMoment = await photo.getPhotoCount("all");
const FirstPhotoUid = await photo.getNthPhotoUid("image", 0);
const SecondPhotoUid = await photo.getNthPhotoUid("image", 1);
const ThirdPhotoUid = await photo.getNthPhotoUid("image", 2);
const FourthPhotoUid = await photo.getNthPhotoUid("image", 3);
const FifthPhotoUid = await photo.getNthPhotoUid("image", 4);
const SixthPhotoUid = await photo.getNthPhotoUid("image", 5);
const SeventhPhotoUid = await photo.getNthPhotoUid("image", 6);
await menu.openPage("moments");
await album.selectAlbumFromUID(FirstMomentUid);
await contextmenu.triggerContextMenuAction("clone", "NotYetExistingAlbumForMoment");
await album.selectAlbumFromUID(SecondMomentUid);
await contextmenu.triggerContextMenuAction("clone", ["NotYetExistingAlbumForMoment", "Holiday"]);
await menu.openPage("albums");
const AlbumCountAfterCreation = await album.getAlbumCount("all");
@@ -144,10 +153,24 @@ test.meta("testID", "moments-003").meta({ mode: "public" })(
await contextmenu.triggerContextMenuAction("delete", "");
const AlbumCountAfterDelete = await album.getAlbumCount("all");
await album.openAlbumWithUid(HolidayAlbumUid);
await photo.selectPhotoFromUID(FirstPhotoUid);
await photo.selectPhotoFromUID(SecondPhotoUid);
await photo.selectPhotoFromUID(ThirdPhotoUid);
await photo.selectPhotoFromUID(FourthPhotoUid);
await photo.selectPhotoFromUID(FifthPhotoUid);
await photo.selectPhotoFromUID(SixthPhotoUid);
await photo.selectPhotoFromUID(SeventhPhotoUid);
await contextmenu.triggerContextMenuAction("remove", "");
const PhotoCountHolidayAfterDelete = await photo.getPhotoCount("all");
await t.expect(PhotoCountHolidayAfterDelete).eql(InitialPhotoCountHoliday);
await t.expect(AlbumCountAfterDelete).eql(AlbumCount);
await menu.openPage("moments");
await album.openAlbumWithUid(FirstMomentUid);
await album.openAlbumWithUid(SecondMomentUid);
await photo.checkPhotoVisibility(FirstPhotoUid, true);
await photo.checkPhotoVisibility(SecondPhotoUid, true);
}

View File

@@ -114,6 +114,10 @@ test.meta("testID", "states-003").meta({ mode: "public" })(
async (t) => {
await menu.openPage("albums");
const AlbumCount = await album.getAlbumCount("all");
await toolbar.search("Holiday");
const HolidayAlbumUid = await album.getNthAlbumUid("all", 0);
await album.openAlbumWithUid(HolidayAlbumUid);
const InitialPhotoCountHoliday = await photo.getPhotoCount("all");
await menu.openPage("states");
await toolbar.search("Canada");
const FirstStateUid = await album.getNthAlbumUid("all", 0);
@@ -123,7 +127,7 @@ test.meta("testID", "states-003").meta({ mode: "public" })(
const SecondPhotoUid = await photo.getNthPhotoUid("image", 1);
await menu.openPage("states");
await album.selectAlbumFromUID(FirstStateUid);
await contextmenu.triggerContextMenuAction("clone", "NotYetExistingAlbumForState");
await contextmenu.triggerContextMenuAction("clone", ["NotYetExistingAlbumForState", "Holiday"]);
await menu.openPage("albums");
const AlbumCountAfterCreation = await album.getAlbumCount("all");
@@ -144,6 +148,13 @@ test.meta("testID", "states-003").meta({ mode: "public" })(
const AlbumCountAfterDelete = await album.getAlbumCount("all");
await t.expect(AlbumCountAfterDelete).eql(AlbumCount);
await album.openAlbumWithUid(HolidayAlbumUid);
await photo.selectPhotoFromUID(FirstPhotoUid);
await photo.selectPhotoFromUID(SecondPhotoUid);
await contextmenu.triggerContextMenuAction("remove", "");
const PhotoCountHolidayAfterDelete = await photo.getPhotoCount("all");
await t.expect(PhotoCountHolidayAfterDelete).eql(InitialPhotoCountHoliday);
await menu.openPage("states");
await album.openAlbumWithUid(FirstStateUid);

View File

@@ -39,10 +39,22 @@ export default class Page {
if (action === "delete") {
await t.click(Selector("button.action-confirm"));
}
if ((action === "album") | (action === "clone")) {
await t.typeText(Selector(".input-album input"), albumName, { replace: true });
if (await Selector("div").withText(albumName).parent('div[role="option"]').visible) {
await t.click(Selector("div").withText(albumName).parent('div[role="option"]'));
if ((action === "album") || (action === "clone")) {
await t.click(Selector(".input-albums"));
// Handle single album name or array of album names
const albumNames = Array.isArray(albumName) ? albumName : [albumName];
for (const name of albumNames) {
if (await Selector("div").withText(name).parent('div[role="option"]').visible) {
// Click on the album option to select it
await t
.click(Selector("div").withText(name).parent('div[role="option"]'))
.click(Selector("div i.mdi-bookmark"));
} else {
await t.typeText(Selector(".input-albums input"), name).click(Selector("div i.mdi-bookmark"));
}
await t.expect(Selector("span.v-chip").withText(name).visible).ok();
}
await t.click(Selector("button.action-confirm"));
}

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { processAlbumSelection } from '../../../src/common/albums.js';
function album(title, uid) {
return { Title: title, UID: uid };
}
describe('processAlbumSelection', () => {
it('trims whitespace and matches existing albums (case-insensitive)', () => {
const available = [album('Summer', '1')];
const selected = [' summer '];
const { processed, changed } = processAlbumSelection(selected, available);
expect(processed).toHaveLength(1);
expect(processed[0]).toEqual(available[0]);
expect(changed).toBe(true);
});
it('deduplicates identical UIDs and strings resolving to same album', () => {
const a1 = album('Trips', 't1');
const selected = [a1, a1, 'trips', 'TRIPS'];
const { processed, changed } = processAlbumSelection(selected, [a1]);
expect(processed).toHaveLength(1);
expect(processed[0]).toEqual(a1);
expect(changed).toBe(true);
});
it('keeps unmatched names as trimmed strings (for creation later)', () => {
const selected = [' New Album '];
const { processed, changed } = processAlbumSelection(selected, []);
expect(processed).toEqual(['New Album']);
// No structural change: only trimming does not count as change if lengths are equal and no replacements/drops
expect(changed).toBe(false);
});
it('drops empty / whitespace-only entries and reports change', () => {
const selected = [' ', '\n', '\t', ' Name '];
const { processed, changed } = processAlbumSelection(selected, []);
expect(processed).toEqual(['Name']);
expect(changed).toBe(true);
});
it('reconciles selection with new available items (race condition)', () => {
const selected = [' Road Trip '];
const availableThen = [album('Road Trip', 'rt01')];
const { processed, changed } = processAlbumSelection(selected, availableThen);
expect(processed).toHaveLength(1);
expect(processed[0]).toEqual(availableThen[0]);
expect(changed).toBe(true);
});
it('preserves existing album objects and prevents duplicates', () => {
const a = album('Family', 'fam1');
const selected = [a, { ...a }]; // two distinct objects with same UID
const { processed, changed } = processAlbumSelection(selected, [a]);
expect(processed).toHaveLength(1);
expect(processed[0]).toEqual(a);
expect(changed).toBe(true);
});
});