This commit is contained in:
Kevin Nowald 2026-01-21 18:21:02 +00:00 committed by GitHub
commit 93e129a377
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 373 additions and 32 deletions

View file

@ -8,52 +8,104 @@
<v-list nav slim tile density="compact" class="metadata__list mt-2">
<v-list-item v-if="model.Title" class="metadata__item">
<div v-tooltip="$pgettext(`Photo`, `Title`)" class="text-subtitle-2 meta-title">{{ model.Title }}</div>
<!-- v-text-field
:model-value="modelValue.Title"
:placeholder="$gettext('Add a title')"
density="comfortable"
variant="solo-filled"
hide-details
class="pa-0 font-weight-bold"
></v-text-field -->
</v-list-item>
<v-list-item v-if="model.Caption" class="metadata__item">
<div v-tooltip="$gettext('Caption')" class="text-body-2 meta-caption">{{ model.Caption }}</div>
<!-- v-textarea
:model-value="modelValue.Caption"
:placeholder="$gettext('Add a caption')"
density="comfortable"
variant="solo-filled"
hide-details
autocomplete="off"
auto-grow
:rows="1"
class="pa-0"
></v-textarea -->
</v-list-item>
<v-divider v-if="model.Title || model.Caption" class="my-4"></v-divider>
<v-list-item v-tooltip="$gettext(`Taken`)" :title="formatTime(model)" prepend-icon="mdi-calendar" class="metadata__item">
<!-- template #append>
<v-icon icon="mdi-pencil" size="20"></v-icon>
</template -->
</v-list-item>
<v-list-item v-tooltip="$gettext(`Size`)" :title="model.getTypeInfo()" :prepend-icon="model.getTypeIcon()" class="metadata__item"> </v-list-item>
<!-- Date and Time -->
<v-list-item v-tooltip="$gettext('Taken')" :title="formatTime(model)" prepend-icon="mdi-calendar" class="metadata__item"> </v-list-item>
<!-- File Info -->
<v-list-item v-if="typeInfo" v-tooltip="$gettext('Size')" :title="typeInfo" :prepend-icon="typeIcon" class="metadata__item"> </v-list-item>
<!-- Camera Section (only shown when full photo data is loaded) -->
<template v-if="hasCamera">
<v-divider class="my-4"></v-divider>
<v-list-item v-if="cameraName" v-tooltip="$gettext('Camera')" :title="cameraName" prepend-icon="mdi-camera" class="metadata__item"> </v-list-item>
<v-list-item v-if="lensInfo" v-tooltip="$gettext('Lens')" :title="lensInfo" prepend-icon="mdi-camera-iris" class="metadata__item"> </v-list-item>
<v-list-item v-if="exposureInfo" v-tooltip="$gettext('Exposure')" :title="exposureInfo" prepend-icon="mdi-tune" class="metadata__item"> </v-list-item>
</template>
<!-- Location Section -->
<template v-if="model.Lat && model.Lng">
<v-divider class="my-4"></v-divider>
<v-list-item v-if="locationLabel" v-tooltip="$gettext('Location')" :title="locationLabel" prepend-icon="mdi-map-marker" class="metadata__item">
</v-list-item>
<v-list-item
v-tooltip="$gettext(`Location`)"
prepend-icon="mdi-map-marker"
:title="model.getLatLng()"
v-tooltip="$gettext('Coordinates')"
prepend-icon="mdi-crosshairs-gps"
:title="latLng"
class="clickable metadata__item"
@click.stop="model.copyLatLng()"
@click.stop="copyLatLng"
>
</v-list-item>
<v-list-item v-if="featPlaces" class="mx-0 px-0">
<v-list-item
v-if="model.Altitude"
v-tooltip="$gettext('Altitude')"
:title="model.Altitude + ' m'"
prepend-icon="mdi-elevation-rise"
class="metadata__item"
>
</v-list-item>
<v-list-item v-if="featPlaces" class="mx-0 px-0" style="margin-top: 0.5rem">
<p-map :latlng="[model.Lat, model.Lng]"></p-map>
</v-list-item>
</template>
<!-- Details Section (only shown when full photo data is loaded) -->
<template v-if="hasDetails">
<v-divider class="my-4"></v-divider>
<v-list-item
v-if="photo.Details && photo.Details.Artist"
v-tooltip="$gettext('Artist')"
:title="photo.Details.Artist"
prepend-icon="mdi-account"
class="metadata__item"
>
</v-list-item>
<v-list-item
v-if="photo.Details && photo.Details.Copyright"
v-tooltip="$gettext('Copyright')"
:title="photo.Details.Copyright"
prepend-icon="mdi-copyright"
class="metadata__item"
>
</v-list-item>
<v-list-item
v-if="photo.Details && photo.Details.License"
v-tooltip="$gettext('License')"
:title="photo.Details.License"
prepend-icon="mdi-certificate"
class="metadata__item"
>
</v-list-item>
<v-list-item
v-if="photo.Details && photo.Details.Subject"
v-tooltip="$gettext('Subject')"
:title="photo.Details.Subject"
prepend-icon="mdi-tag"
class="metadata__item"
>
</v-list-item>
<v-list-item
v-if="photo.Details && photo.Details.Keywords"
v-tooltip="$gettext('Keywords')"
:title="photo.Details.Keywords"
prepend-icon="mdi-tag-multiple"
class="metadata__item"
>
</v-list-item>
<v-list-item
v-if="photo.Details && photo.Details.Notes"
v-tooltip="$gettext('Notes')"
:title="photo.Details.Notes"
prepend-icon="mdi-note-text"
class="metadata__item"
>
</v-list-item>
</template>
</v-list>
</div>
</div>
@ -62,8 +114,10 @@
<script>
import { DateTime } from "luxon";
import * as formats from "options/formats";
import $util from "common/util";
import PMap from "component/map.vue";
import Photo from "model/photo";
export default {
name: "PSidebarInfo",
@ -89,17 +143,135 @@ export default {
return {
actions: [],
featPlaces: this.$config.feature("places"),
photo: null,
loading: false,
};
},
computed: {
model() {
return this.modelValue;
},
typeInfo() {
if (typeof this.model.getTypeInfo === "function") {
return this.model.getTypeInfo();
}
return "";
},
typeIcon() {
if (typeof this.model.getTypeIcon === "function") {
return this.model.getTypeIcon();
}
return "mdi-image";
},
latLng() {
if (typeof this.model.getLatLng === "function") {
return this.model.getLatLng();
}
if (this.model.Lat && this.model.Lng) {
return `${this.model.Lat.toFixed(5)}°N\u2003${this.model.Lng.toFixed(5)}°E`;
}
return "";
},
hasCamera() {
if (!this.photo) return false;
return this.photo.CameraID > 1 || this.photo.CameraMake || this.photo.CameraModel || this.photo.Iso || this.photo.Exposure;
},
cameraName() {
if (!this.photo) return "";
if (this.photo.CameraMake && this.photo.CameraModel) {
return `${this.photo.CameraMake} ${this.photo.CameraModel}`;
} else if (this.photo.Camera && this.photo.Camera.Make && this.photo.Camera.Model) {
return `${this.photo.Camera.Make} ${this.photo.Camera.Model}`;
}
return "";
},
lensInfo() {
if (!this.photo) return "";
const parts = [];
if (this.photo.LensModel) {
parts.push(this.photo.LensModel.replace("f/", "ƒ/"));
} else if (this.photo.Lens && this.photo.Lens.Model) {
parts.push(this.photo.Lens.Model.replace("f/", "ƒ/"));
}
if (this.photo.FocalLength && !parts.some((p) => p.includes("mm"))) {
parts.push(`${this.photo.FocalLength}mm`);
}
if (this.photo.FNumber && !parts.some((p) => p.includes("ƒ/"))) {
parts.push(`ƒ/${this.photo.FNumber}`);
}
return parts.join(", ");
},
exposureInfo() {
if (!this.photo) return "";
const parts = [];
if (this.photo.Iso) {
parts.push(`ISO ${this.photo.Iso}`);
}
if (this.photo.FNumber) {
parts.push(`ƒ/${this.photo.FNumber}`);
}
if (this.photo.Exposure) {
parts.push(this.photo.Exposure);
}
if (this.photo.FocalLength) {
parts.push(`${this.photo.FocalLength}mm`);
}
return parts.join(", ");
},
locationLabel() {
if (this.photo) {
if (this.photo.PlaceLabel) {
return this.photo.PlaceLabel;
}
if (this.photo.Place && this.photo.Place.Label) {
return this.photo.Place.Label;
}
}
return "";
},
hasDetails() {
if (!this.photo) return false;
const d = this.photo.Details;
return d && (d.Artist || d.Copyright || d.License || d.Subject || d.Keywords || d.Notes);
},
},
watch: {
"model.UID": {
immediate: true,
handler(uid) {
if (uid) {
this.loadPhoto(uid);
} else {
this.photo = null;
}
},
},
},
methods: {
close() {
this.$emit("close");
},
copyLatLng() {
if (typeof this.model.copyLatLng === "function") {
this.model.copyLatLng();
} else if (this.model.Lat && this.model.Lng) {
$util.copyText(`${this.model.Lat.toString()},${this.model.Lng.toString()}`);
}
},
loadPhoto(uid) {
if (!uid || this.loading) return;
this.loading = true;
new Photo()
.find(uid)
.then((p) => {
this.photo = p;
this.loading = false;
})
.catch(() => {
this.photo = null;
this.loading = false;
});
},
formatTime(model) {
if (!model || !model.TakenAtLocal) {
return this.$gettext("Unknown");

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import PSidebarInfo from "component/sidebar/info.vue";
import * as contexts from "options/contexts";
import { DateTime } from "luxon";
@ -19,6 +19,23 @@ vi.mock("options/formats", () => ({
DATETIME_MED_TZ: "DATETIME_MED_TZ",
}));
// Mock Photo model
const mockPhotoFind = vi.fn();
vi.mock("model/photo", () => ({
default: class Photo {
find(uid) {
return mockPhotoFind(uid);
}
},
}));
// Mock $util
vi.mock("common/util", () => ({
default: {
copyText: vi.fn(),
},
}));
describe("PSidebarInfo component", () => {
let wrapper;
let originalFromISO;
@ -31,12 +48,34 @@ describe("PSidebarInfo component", () => {
TimeZone: "UTC",
Lat: 52.52,
Lng: 13.405,
Altitude: 100,
getTypeInfo: vi.fn().mockReturnValue("JPEG, 1920x1080"),
getTypeIcon: vi.fn().mockReturnValue("mdi-file-image"),
getLatLng: vi.fn().mockReturnValue("52.5200, 13.4050"),
copyLatLng: vi.fn(),
};
const mockPhotoData = {
UID: "abc123",
CameraID: 2,
CameraMake: "Fujifilm",
CameraModel: "X-T4",
LensModel: "XF 35mm f/1.4 R",
Iso: 400,
FNumber: 1.4,
Exposure: "1/250",
FocalLength: 35,
PlaceLabel: "Berlin, Germany",
Details: {
Artist: "John Doe",
Copyright: "2023 John Doe",
License: "CC BY-NC 4.0",
Subject: "Street Photography",
Keywords: "street, urban, city",
Notes: "Test notes",
},
};
beforeEach(() => {
vi.clearAllMocks();
@ -50,6 +89,9 @@ describe("PSidebarInfo component", () => {
};
});
// Mock Photo.find() to resolve with photo data
mockPhotoFind.mockResolvedValue(mockPhotoData);
wrapper = mount(PSidebarInfo, {
props: {
modelValue: mockModel,
@ -126,4 +168,131 @@ describe("PSidebarInfo component", () => {
const formattedTime = wrapper.vm.formatTime(modelWithoutTime);
expect(formattedTime).toBe("Unknown");
});
it("should fetch full photo data when model UID is present", async () => {
await flushPromises();
expect(mockPhotoFind).toHaveBeenCalledWith("abc123");
expect(wrapper.vm.photo).toEqual(mockPhotoData);
});
it("should display camera name when photo data is loaded", async () => {
await flushPromises();
expect(wrapper.vm.hasCamera).toBe(true);
expect(wrapper.vm.cameraName).toBe("Fujifilm X-T4");
});
it("should display lens info when photo data is loaded", async () => {
await flushPromises();
expect(wrapper.vm.lensInfo).toContain("XF 35mm");
});
it("should display exposure info when photo data is loaded", async () => {
await flushPromises();
const exposureInfo = wrapper.vm.exposureInfo;
expect(exposureInfo).toContain("ISO 400");
expect(exposureInfo).toContain("ƒ/1.4");
expect(exposureInfo).toContain("1/250");
expect(exposureInfo).toContain("35mm");
});
it("should display location label when photo data is loaded", async () => {
await flushPromises();
expect(wrapper.vm.locationLabel).toBe("Berlin, Germany");
});
it("should detect when photo has details", async () => {
await flushPromises();
expect(wrapper.vm.hasDetails).toBeTruthy();
});
it("should not show camera section when photo has no camera data", async () => {
mockPhotoFind.mockResolvedValue({
UID: "abc123",
CameraID: 1,
Details: {},
});
const modelNoCam = { ...mockModel, UID: "nocam123" };
const wrapperNoCamera = mount(PSidebarInfo, {
props: {
modelValue: modelNoCam,
context: contexts.Photos,
},
global: {
stubs: {
PMap: true,
},
},
});
await flushPromises();
expect(wrapperNoCamera.vm.hasCamera).toBeFalsy();
});
it("should not show details section when photo has no details", async () => {
mockPhotoFind.mockResolvedValue({
UID: "abc123",
CameraID: 1,
Details: {},
});
const modelNoDetails = { ...mockModel, UID: "nodetails123" };
const wrapperNoDetails = mount(PSidebarInfo, {
props: {
modelValue: modelNoDetails,
context: contexts.Photos,
},
global: {
stubs: {
PMap: true,
},
},
});
await flushPromises();
expect(wrapperNoDetails.vm.hasDetails).toBeFalsy();
});
it("should handle Photo.find() rejection gracefully", async () => {
mockPhotoFind.mockRejectedValue(new Error("API error"));
const wrapperError = mount(PSidebarInfo, {
props: {
modelValue: mockModel,
context: contexts.Photos,
},
global: {
stubs: {
PMap: true,
},
},
});
await flushPromises();
expect(wrapperError.vm.photo).toBeNull();
expect(wrapperError.vm.loading).toBe(false);
});
it("should use fallback latLng when model has no getLatLng method", () => {
const modelWithoutMethods = {
UID: "xyz789",
Lat: 48.8566,
Lng: 2.3522,
};
const wrapperFallback = mount(PSidebarInfo, {
props: {
modelValue: modelWithoutMethods,
context: contexts.Photos,
},
global: {
stubs: {
PMap: true,
},
},
});
expect(wrapperFallback.vm.latLng).toContain("48.85660");
expect(wrapperFallback.vm.latLng).toContain("2.35220");
});
});