Tests: Stabilize sidebar-edit-003 and add HEIF JPEG fallback coverage #5554 #4966 #4965 #5509

* Tests: Stabilize sidebar-edit-003 and add HEIF JPEG fallback coverage

* Tests: Stabilize lightbox sidebar-edit suite on fast systems
This commit is contained in:
Ömer Duran 2026-05-06 13:41:51 +03:00 committed by GitHub
parent aa4d51bed1
commit 4005aea4fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 246 additions and 79 deletions

View file

@ -47,9 +47,8 @@ test.meta("testID", "sidebar-edit-001").meta({ mode: "public" })(
await t.expect(photoviewer.sidebarRow(field.icon).withText(field.value).exists).ok();
}
// The backend normalizes keywords (split on whitespace/commas,
// lowercased, deduped), so we assert on a unique single-word token
// that survives that pass.
// The backend lower-cases and dedupes keywords; assert on a single
// lowercase token that survives that pass.
const keywordsInput = await photoviewer.startInlineEditBySection("Keywords");
await t.typeText(keywordsInput, "sidebareditkw", { replace: true });
await photoviewer.confirmInlineEditBySection("Keywords");
@ -90,77 +89,140 @@ test.meta("testID", "sidebar-edit-003").meta({ mode: "public" })(
async (t) => {
await photoviewer.openSidebarOnFirstPhoto();
// Date / time dialog: change all five user-editable fields. Re-opening
// the dialog after save is the most direct check that each field round-
// tripped through the API; the sidebar only formats a subset of these
// (year is always rendered, timezone only when set).
const dateTimeDialog = photoviewer.dateTimeDialog;
const cameraDialog = photoviewer.cameraDialog;
const locationDialog = photoviewer.locationDialog;
const optionWith = (text) => Selector('div[role="option"]').withText(text);
// Clicking a rendered option is racy on fast systems where the list
// re-mounts between visibility and click; keyboard nav is stable.
const pickAutocomplete = async (input, value) => {
await t.typeText(input, value, { replace: true }).pressKey("down enter");
};
const pickFromSelect = async (field, value) => {
await t.click(field).click(optionWith(value));
};
// Other tests in this suite edit the same photo; snapshot now and
// restore at the end so leftover timezone/values don't leak.
await photoviewer.openSidebarDialog("takenAt");
const yearInput = photoviewer.dateTimeDialog.find(".input-year input");
const monthInput = photoviewer.dateTimeDialog.find(".input-month input");
const dayInput = photoviewer.dateTimeDialog.find(".input-day input");
const timeInput = photoviewer.dateTimeDialog.find(".input-local-time input");
const timeZoneInput = photoviewer.dateTimeDialog.find(".input-timezone input");
await t.typeText(yearInput, "2022", { replace: true }).pressKey("tab");
await t.typeText(monthInput, "7", { replace: true }).pressKey("tab");
await t.typeText(dayInput, "15", { replace: true }).pressKey("tab");
await t.typeText(timeInput, "13:45:30", { replace: true }).pressKey("tab");
await t.typeText(timeZoneInput, "UTC", { replace: true }).pressKey("enter");
await t.click(photoviewer.dateTimeDialog.find(".action-confirm"));
await t.expect(photoviewer.dateTimeDialog.visible).notOk();
await t.expect(photoviewer.sidebarRow("mdi-calendar").withText("2022").exists).ok();
const initialYear = await dateTimeDialog.yearValue.innerText;
const initialMonth = await dateTimeDialog.monthValue.innerText;
const initialDay = await dateTimeDialog.dayValue.innerText;
const initialLocalTime = await dateTimeDialog.localTime.value;
const initialTimezone = await dateTimeDialog.timezoneValue.innerText;
await t.click(dateTimeDialog.cancel);
await t.expect(dateTimeDialog.root.visible).notOk();
// Re-open and verify each input was persisted. The autocompletes render
// their selected value as the input's `value` attribute, so we read it
// back rather than relying on locale-formatted display strings.
await photoviewer.openSidebarDialog("takenAt");
await t.expect(yearInput.value).eql("2022");
await t.expect(monthInput.value).eql("7");
await t.expect(dayInput.value).eql("15");
await t.expect(timeInput.value).eql("13:45:30");
await t.expect(timeZoneInput.value).eql("UTC");
await t.click(photoviewer.dateTimeDialog.find(".action-cancel"));
await t.expect(photoviewer.dateTimeDialog.visible).notOk();
// Camera dialog: change every numeric field. Camera/Lens autocompletes
// are intentionally skipped — their item lists depend on backend fixture
// data, which varies across acceptance environments. The four numeric
// fields cover the dialog's persistence path end-to-end.
await photoviewer.openSidebarDialog("camera");
const isoInput = photoviewer.cameraDialog.find(".input-iso input");
const exposureInput = photoviewer.cameraDialog.find(".input-exposure input");
const fNumberInput = photoviewer.cameraDialog.find(".input-fnumber input");
const focalLengthInput = photoviewer.cameraDialog.find(".input-focal-length input");
await t.typeText(isoInput, "6400", { replace: true });
await t.typeText(exposureInput, "1/250", { replace: true });
await t.typeText(fNumberInput, "1.8", { replace: true });
await t.typeText(focalLengthInput, "35", { replace: true });
await t.click(photoviewer.cameraDialog.find(".action-confirm"));
await t.expect(photoviewer.cameraDialog.visible).notOk();
const initialCamera = await cameraDialog.cameraValue.innerText;
const initialLens = await cameraDialog.lensValue.innerText;
const initialIso = await cameraDialog.iso.value;
const initialExposure = await cameraDialog.exposure.value;
const initialFnumber = await cameraDialog.fnumber.value;
const initialFocalLength = await cameraDialog.focalLength.value;
await t.click(cameraDialog.cancel);
await t.expect(cameraDialog.root.visible).notOk();
// Re-open and verify every numeric field came back from the backend.
await photoviewer.openSidebarDialog("camera");
await t.expect(isoInput.value).eql("6400");
await t.expect(exposureInput.value).eql("1/250");
await t.expect(fNumberInput.value).eql("1.8");
await t.expect(focalLengthInput.value).eql("35");
await t.click(photoviewer.cameraDialog.find(".action-cancel"));
await t.expect(photoviewer.cameraDialog.visible).notOk();
// Location dialog: a raw coordinate string avoids hitting an external
// reverse-geocoder, which keeps the test deterministic in offline
// acceptance environments.
await photoviewer.openSidebarDialog("location");
const coordsInput = photoviewer.locationDialog.find(".input-coordinates input");
await t.expect(coordsInput.visible).ok();
await t.typeText(coordsInput, "52.5200, 13.4050", { replace: true }).pressKey("enter");
await t.click(photoviewer.locationDialog.find(".action-confirm"));
await t.expect(photoviewer.locationDialog.visible).notOk();
const initialCoordinates = await locationDialog.coordinates.value;
await t.click(locationDialog.cancel);
await t.expect(locationDialog.root.visible).notOk();
await photoviewer.openSidebarDialog("takenAt");
await pickAutocomplete(dateTimeDialog.year, "2022");
await pickAutocomplete(dateTimeDialog.month, "07");
await pickAutocomplete(dateTimeDialog.day, "15");
await t.typeText(dateTimeDialog.localTime, "13:45:30", { replace: true }).pressKey("tab");
await pickAutocomplete(dateTimeDialog.timezone, "UTC");
await t.click(dateTimeDialog.confirm);
await t.expect(dateTimeDialog.root.visible).notOk();
// For UTC photos formatTime() drops the zone abbreviation, so "UTC"
// never appears in the sidebar text — verified via the dialog below.
const calendarRow = photoviewer.sidebarRow("mdi-calendar");
await t.expect(calendarRow.withText("2022").exists).ok();
await t.expect(calendarRow.withText("Jul").exists).ok();
await t.expect(calendarRow.withText("15").exists).ok();
await photoviewer.openSidebarDialog("takenAt");
await t.expect(dateTimeDialog.yearValue.innerText).eql("2022");
await t.expect(dateTimeDialog.monthValue.innerText).eql("07");
await t.expect(dateTimeDialog.dayValue.innerText).eql("15");
await t.expect(dateTimeDialog.localTime.value).eql("13:45:30");
await t.expect(dateTimeDialog.timezoneValue.innerText).eql("UTC");
await t.click(dateTimeDialog.cancel);
await t.expect(dateTimeDialog.root.visible).notOk();
const cameraName = "Canon EOS M10";
const lensName = "EF-M15-45mm f/3.5-6.3 IS STM";
await photoviewer.openSidebarDialog("camera");
await pickFromSelect(cameraDialog.camera, cameraName);
await pickFromSelect(cameraDialog.lens, lensName);
await t.typeText(cameraDialog.iso, "6400", { replace: true });
await t.typeText(cameraDialog.exposure, "1/250", { replace: true });
await t.typeText(cameraDialog.fnumber, "1.8", { replace: true });
await t.typeText(cameraDialog.focalLength, "35", { replace: true });
await t.click(cameraDialog.confirm);
await t.expect(cameraDialog.root.visible).notOk();
const cameraRow = photoviewer.sidebarRow("mdi-camera");
await t.expect(cameraRow.withText(cameraName).exists).ok();
await t.expect(cameraRow.withText("ISO 6400").exists).ok();
await t.expect(cameraRow.withText("1/250").exists).ok();
await photoviewer.openSidebarDialog("camera");
await t.expect(cameraDialog.cameraValue.innerText).eql(cameraName);
await t.expect(cameraDialog.lensValue.innerText).eql(lensName);
await t.expect(cameraDialog.iso.value).eql("6400");
await t.expect(cameraDialog.exposure.value).eql("1/250");
await t.expect(cameraDialog.fnumber.value).eql("1.8");
await t.expect(cameraDialog.focalLength.value).eql("35");
await t.click(cameraDialog.cancel);
await t.expect(cameraDialog.root.visible).notOk();
// Raw coordinates avoid hitting the external reverse-geocoder.
await photoviewer.openSidebarDialog("location");
await t.expect(locationDialog.coordinates.visible).ok();
await t.typeText(locationDialog.coordinates, "52.5200, 13.4050", { replace: true }).pressKey("enter");
await t.click(locationDialog.confirm);
await t.expect(locationDialog.root.visible).notOk();
await t.expect(Selector(".p-sidebar-info .p-map").exists).ok();
// The coordinates row title is composed by Photo.getLatLng(); we assert
// both halves so the test fails noisily if either axis fails to persist.
// Substring match — the optional " · <altitude> m" suffix can follow.
const coordinatesRow = photoviewer.sidebarRow("mdi-map-marker").nextSibling(".v-list-item");
await t.expect(coordinatesRow.getAttribute("title")).contains("52.52");
await t.expect(coordinatesRow.getAttribute("title")).contains("13.405");
await t.expect(coordinatesRow.withText("52.52000").exists).ok();
await t.expect(coordinatesRow.withText("13.40500").exists).ok();
// Skip empty fields: typeText("") is a no-op and v-select has no clear.
await photoviewer.openSidebarDialog("takenAt");
if (initialYear) await pickAutocomplete(dateTimeDialog.year, initialYear);
if (initialMonth) await pickAutocomplete(dateTimeDialog.month, initialMonth);
if (initialDay) await pickAutocomplete(dateTimeDialog.day, initialDay);
if (initialLocalTime) {
await t.typeText(dateTimeDialog.localTime, initialLocalTime, { replace: true }).pressKey("tab");
}
if (initialTimezone) await pickAutocomplete(dateTimeDialog.timezone, initialTimezone);
await t.click(dateTimeDialog.confirm);
await t.expect(dateTimeDialog.root.visible).notOk();
await photoviewer.openSidebarDialog("camera");
if (initialCamera) await pickFromSelect(cameraDialog.camera, initialCamera);
if (initialLens) await pickFromSelect(cameraDialog.lens, initialLens);
if (initialIso) await t.typeText(cameraDialog.iso, initialIso, { replace: true });
if (initialExposure) await t.typeText(cameraDialog.exposure, initialExposure, { replace: true });
if (initialFnumber) await t.typeText(cameraDialog.fnumber, initialFnumber, { replace: true });
if (initialFocalLength) {
await t.typeText(cameraDialog.focalLength, initialFocalLength, { replace: true });
}
await t.click(cameraDialog.confirm);
await t.expect(cameraDialog.root.visible).notOk();
if (initialCoordinates) {
await photoviewer.openSidebarDialog("location");
await t.typeText(locationDialog.coordinates, initialCoordinates, { replace: true }).pressKey("enter");
await t.click(locationDialog.confirm);
await t.expect(locationDialog.root.visible).notOk();
}
}
);

View file

@ -0,0 +1,18 @@
import { Selector } from "testcafe";
export default class CameraDialog {
constructor() {
this.root = Selector(".p-camera-dialog", { timeout: 15000 });
this.camera = this.root.find(".input-camera");
this.lens = this.root.find(".input-lens");
// v-select renders the chosen option in `.v-select__selection-text`.
this.cameraValue = this.root.find(".input-camera .v-select__selection-text");
this.lensValue = this.root.find(".input-lens .v-select__selection-text");
this.iso = this.root.find(".input-iso input");
this.exposure = this.root.find(".input-exposure input");
this.fnumber = this.root.find(".input-fnumber input");
this.focalLength = this.root.find(".input-focal-length input");
this.confirm = this.root.find(".action-confirm");
this.cancel = this.root.find(".action-cancel");
}
}

View file

@ -0,0 +1,20 @@
import { Selector } from "testcafe";
export default class DateTimeDialog {
constructor() {
this.root = Selector(".p-datetime-dialog", { timeout: 15000 });
this.year = this.root.find(".input-year input");
this.month = this.root.find(".input-month input");
this.day = this.root.find(".input-day input");
this.localTime = this.root.find(".input-local-time input");
this.timezone = this.root.find(".input-timezone input");
// v-autocomplete renders the chosen option text in a sibling element;
// the inner <input>.value tracks the search/filter string instead.
this.yearValue = this.root.find(".input-year .v-autocomplete__selection");
this.monthValue = this.root.find(".input-month .v-autocomplete__selection");
this.dayValue = this.root.find(".input-day .v-autocomplete__selection");
this.timezoneValue = this.root.find(".input-timezone .v-autocomplete__selection");
this.confirm = this.root.find(".action-confirm");
this.cancel = this.root.find(".action-cancel");
}
}

View file

@ -0,0 +1,10 @@
import { Selector } from "testcafe";
export default class LocationDialog {
constructor() {
this.root = Selector(".p-location-dialog", { timeout: 15000 });
this.coordinates = this.root.find(".input-coordinates input");
this.confirm = this.root.find(".action-confirm");
this.cancel = this.root.find(".action-cancel");
}
}

View file

@ -1,6 +1,9 @@
import { Selector, t } from "testcafe";
import Toolbar from "./toolbar";
import Photo from "./photo";
import DateTimeDialog from "./dialog/date-time";
import CameraDialog from "./dialog/camera";
import LocationDialog from "./dialog/location";
export default class Page {
constructor() {
@ -45,11 +48,10 @@ export default class Page {
this.sidebarChips = Selector(".p-sidebar-info .meta-chip", { timeout: 15000 });
this.faceMarkerEjectButton = Selector(".metadata__person-row .meta-marker-eject", { timeout: 15000 });
this.faceMarkerNameInput = Selector(".metadata__person-row .meta-inline-marker input", { timeout: 15000 });
// Edit dialogs launched from the sidebar pencils. Timeouts are generous
// enough for Vuetify teleport mounting + reverse-geocoder lookups.
this.dateTimeDialog = Selector(".p-datetime-dialog", { timeout: 15000 });
this.cameraDialog = Selector(".p-camera-dialog", { timeout: 15000 });
this.locationDialog = Selector(".p-location-dialog", { timeout: 15000 });
// Edit dialogs launched from the sidebar pencils.
this.dateTimeDialog = new DateTimeDialog();
this.cameraDialog = new CameraDialog();
this.locationDialog = new LocationDialog();
}
// Locate the v-list-item that contains a given MDI prepend-icon.
@ -103,6 +105,10 @@ export default class Page {
await t.wait(200);
await t.pressKey("enter");
await this.confirmInlineEditBySection(sectionLabel);
// The combobox menu teleports to <body>; on fast systems a lingering
// overlay intercepts the next click (notably the lightbox close).
await t.expect(Selector(".p-sidebar-info .meta-inline-edit:not(.meta-inline-marker)").exists).notOk();
await t.expect(Selector(".meta-inline-menu").exists).notOk();
}
// Title/Caption enter editing from either a pencil (value present) or
@ -129,16 +135,15 @@ export default class Page {
async openSidebarDialog(which) {
if (which === "takenAt") {
await t.click(this.sidebarRow("mdi-calendar").find(".meta-inline-pencil"));
await t.expect(this.dateTimeDialog.visible).ok();
await t.expect(this.dateTimeDialog.root.visible).ok();
} else if (which === "camera") {
await t.click(this.sidebarRow("mdi-camera").find(".meta-inline-pencil"));
await t.expect(this.cameraDialog.visible).ok();
await t.expect(this.cameraDialog.root.visible).ok();
} else if (which === "location") {
// Two rows (Location and Coordinates) host a location pencil depending
// on whether the photo has lat/lng; both carry the modifier class that
// disambiguates them from the other inline metadata pencils.
// Both the Location and Coordinates rows expose a pencil with this
// modifier class; either one opens the same dialog.
await t.click(Selector(".p-sidebar-info .meta-inline-pencil--location"));
await t.expect(this.locationDialog.visible).ok();
await t.expect(this.locationDialog.root.visible).ok();
} else {
throw new Error(`Unknown sidebar dialog: ${which}`);
}

View file

@ -74,7 +74,7 @@ type SearchPhotosGeo struct {
People string `form:"people" example:"people:\"Jane & John\"" notes:"Subject names, combinable with & or |"`
Subjects string `form:"subjects" example:"subjects:\"Jane & John\"" notes:"Alias for people"` // Alias for People
Keywords string `form:"keywords" example:"keywords:\"sand&water\"" notes:"Keywords, combinable with & or |"`
Label string `form:"label" example:"label:cat|dog" notes:"Label names, separated by |"`
Label string `form:"label" example:"label:\"cat|dog&!blurry\"" notes:"Label names: | is OR within a group, & is AND between groups (every positive group must match), leading ! negates a group (e.g. !rejected). Category expansion applies to both positive and negative terms. Escape a literal &, |, or leading ! with \\"`
Category string `form:"category" example:"category:airport" notes:"Location category type"`
Album string `form:"album" example:"album:berlin" notes:"Album UID or name, supports * wildcards"`
Albums string `form:"albums" example:"albums:\"South Africa & Birds\"" notes:"Album names, combinable with & or |"`

View file

@ -283,6 +283,58 @@ func TestConvert_JpegConvertCmds(t *testing.T) {
assert.True(t, found)
}
// TestConvert_JpegConvertCmds_HeifFallback verifies that the documented external
// fallback command is emitted for HEIC and AVIF inputs when libheif tooling
// (heif-dec / heif-convert) is available — see issue #5509.
func TestConvert_JpegConvertCmds_HeifFallback(t *testing.T) {
cnf := config.TestConfig()
if !cnf.HeifConvertEnabled() {
t.Skip("heif-dec/heif-convert must be available for the HEIF fallback path")
}
convert := NewConvert(cnf)
heifBin := filepath.Base(cnf.HeifConvertBin())
cases := []struct {
name, src, dst string
}{
{"Heic", "iphone_15_pro.heic", "iphone_15_pro.heic.jpg"},
{"Avif", "fox.profile0.8bpc.yuv420.avif", "fox.profile0.8bpc.yuv420.avif.jpg"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srcFile := filepath.Join(cnf.SamplesPath(), tc.src)
dstFile := filepath.Join(cnf.SidecarPath(), cnf.SamplesPath(), tc.dst)
mediaFile, err := NewMediaFile(srcFile)
if err != nil {
t.Fatal(err)
}
cmds, useMutex, err := convert.JpegConvertCmds(mediaFile, dstFile, "")
if err != nil {
t.Fatal(err)
}
assert.False(t, useMutex)
assert.NotEmpty(t, cmds)
found := false
for _, cmd := range cmds {
s := cmd.String()
if strings.Contains(s, heifBin) && strings.Contains(s, srcFile) && strings.Contains(s, dstFile) {
found = true
break
}
}
assert.True(t, found, "expected a %s fallback command for %s", heifBin, tc.src)
})
}
}
func TestConvert_PngConvertCmds(t *testing.T) {
cnf := config.TestConfig()
convert := NewConvert(cnf)