check / check (push) Successful in 30s
Adds a synchronous, key-free snapshot() returning LibrarySnapshot (one PhotoRecord per fileID, deduped, newest first) with edited-name/time precedence (pubMagicMetadata over basic metadata) in milliseconds, plus AlbumRecord (favorites identified by type). subscribe({onChange}) delivers LibraryChange (changed/removed albums and files, refreshedAt) only when a refresh changes something; unsubscribe stops delivery. Built on the existing refresh loop; served from RAM, safe to send over IPC.
Model: opus-4-8
280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
/**
|
|
* Tests for the plain-record mapping in `src/library/records.ts` (issue #43).
|
|
*
|
|
* The library keeps decrypted `Collection`/`EnteFile` objects in RAM, but those
|
|
* carry binary keys and cannot cross the Electron IPC boundary. `deriveRecords`
|
|
* projects them into plain `AlbumRecord`/`PhotoRecord` values — no keys, no
|
|
* `Uint8Array`, JSON-safe — that the GUI process consumes. This file pins:
|
|
*
|
|
* 1. Field mapping from `metadata` and the two magic-metadata layers, using the
|
|
* real Ente field names confirmed against the fixtures in
|
|
* `test/cli/metadata-backup.test.ts` (`w`/`h`) and `test/library/store.test.ts`
|
|
* (`visibility`): title/takenAt precedence, caption, width/height, geo,
|
|
* visibility → isArchived/isHidden, fileType.
|
|
* 2. `takenAt` is milliseconds; Ente stores creationTime/editedTime in
|
|
* microseconds, so the record divides by 1000.
|
|
* 3. Deduplication: one `PhotoRecord` per fileID even when the file belongs to
|
|
* several collections, with every membership's collection id in `albumIDs`.
|
|
* 4. Ordering: photos and album `fileIDs` are newest first.
|
|
* 5. No key material survives the projection.
|
|
* 6. `diffRecords` reports exactly what changed between two derivations, and
|
|
* returns undefined when nothing changed.
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import {
|
|
deriveRecords,
|
|
snapshotFrom,
|
|
diffRecords,
|
|
} from "../../src/library/records.js";
|
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
|
|
|
const OWNER = 42;
|
|
|
|
// Microsecond epoch values, as Ente stores times. 1e15 ≈ 2001 in microseconds.
|
|
const T = (micros: number): number => micros;
|
|
|
|
const collection = (
|
|
id: number,
|
|
opts: Partial<Collection> = {},
|
|
): Collection => ({
|
|
id,
|
|
ownerID: OWNER,
|
|
key: new Uint8Array([id & 0xff, 1, 2, 3]),
|
|
name: `album-${id}`,
|
|
type: "album",
|
|
updationTime: T(1_700_000_000_000_000),
|
|
isShared: false,
|
|
...opts,
|
|
});
|
|
|
|
const file = (
|
|
id: number,
|
|
collectionID: number,
|
|
opts: Partial<EnteFile> & {
|
|
creationTime?: number;
|
|
title?: string;
|
|
} = {},
|
|
): EnteFile => {
|
|
const { creationTime, title, ...rest } = opts;
|
|
return {
|
|
id,
|
|
collectionID,
|
|
ownerID: OWNER,
|
|
key: new Uint8Array([id & 0xff, 9, 8, 7]),
|
|
metadata: {
|
|
title: title ?? `file-${id}.jpg`,
|
|
fileType: "image",
|
|
creationTime: creationTime ?? T(1_700_000_000_000_000),
|
|
modificationTime: T(1_700_000_000_000_000),
|
|
},
|
|
file: { decryptionHeader: "aGVhZGVy" },
|
|
thumbnail: { decryptionHeader: "dGh1bWI=" },
|
|
updationTime: T(1_700_000_000_000_000),
|
|
...rest,
|
|
};
|
|
};
|
|
|
|
describe("deriveRecords: photo mapping", () => {
|
|
it("projects a file into a plain PhotoRecord with no key material", () => {
|
|
const f = file(1001, 1, {
|
|
metadata: {
|
|
title: "IMG_1.jpg",
|
|
fileType: "image",
|
|
creationTime: T(1_699_000_000_000_000),
|
|
modificationTime: T(1_699_000_000_000_000),
|
|
latitude: 52.52,
|
|
longitude: 13.405,
|
|
},
|
|
});
|
|
const { photos } = deriveRecords([collection(1)], [f]);
|
|
const rec = photos.get(1001)!;
|
|
|
|
expect(rec.fileID).toBe(1001);
|
|
expect(rec.albumIDs).toEqual([1]);
|
|
expect(rec.title).toBe("IMG_1.jpg");
|
|
expect(rec.fileType).toBe("image");
|
|
expect(rec.latitude).toBeCloseTo(52.52);
|
|
expect(rec.longitude).toBeCloseTo(13.405);
|
|
expect(rec.isArchived).toBe(false);
|
|
expect(rec.isHidden).toBe(false);
|
|
|
|
// Safe to send over IPC: no key, no Uint8Array, JSON round-trips whole.
|
|
expect("key" in rec).toBe(false);
|
|
expect(JSON.parse(JSON.stringify(rec))).toEqual(rec);
|
|
});
|
|
|
|
it("takenAt is creationTime converted from microseconds to milliseconds", () => {
|
|
const f = file(1001, 1, { creationTime: T(1_699_000_000_000_000) });
|
|
const { photos } = deriveRecords([collection(1)], [f]);
|
|
expect(photos.get(1001)!.takenAt).toBe(1_699_000_000_000);
|
|
});
|
|
|
|
it("prefers pubMagicMetadata.editedName and editedTime over metadata", () => {
|
|
const f = file(1001, 1, {
|
|
title: "original.jpg",
|
|
creationTime: T(1_699_000_000_000_000),
|
|
pubMagicMetadata: {
|
|
editedName: "Sunset over the bay",
|
|
editedTime: T(1_650_000_000_000_000),
|
|
},
|
|
});
|
|
const { photos } = deriveRecords([collection(1)], [f]);
|
|
const rec = photos.get(1001)!;
|
|
expect(rec.title).toBe("Sunset over the bay");
|
|
expect(rec.takenAt).toBe(1_650_000_000_000);
|
|
});
|
|
|
|
it("falls back to metadata when the edited fields are empty or absent", () => {
|
|
const f = file(1001, 1, {
|
|
title: "original.jpg",
|
|
creationTime: T(1_699_000_000_000_000),
|
|
pubMagicMetadata: { editedName: "" },
|
|
});
|
|
const { photos } = deriveRecords([collection(1)], [f]);
|
|
const rec = photos.get(1001)!;
|
|
expect(rec.title).toBe("original.jpg");
|
|
expect(rec.takenAt).toBe(1_699_000_000_000);
|
|
});
|
|
|
|
it("maps caption and width/height from the public magic metadata", () => {
|
|
const f = file(1001, 1, {
|
|
pubMagicMetadata: {
|
|
caption: "at the beach",
|
|
w: 3000,
|
|
h: 2000,
|
|
},
|
|
});
|
|
const rec = deriveRecords([collection(1)], [f]).photos.get(1001)!;
|
|
expect(rec.caption).toBe("at the beach");
|
|
expect(rec.width).toBe(3000);
|
|
expect(rec.height).toBe(2000);
|
|
});
|
|
|
|
it("omits optional fields that are absent from the metadata", () => {
|
|
const rec = deriveRecords([collection(1)], [file(1001, 1)]).photos.get(
|
|
1001,
|
|
)!;
|
|
expect("caption" in rec).toBe(false);
|
|
expect("width" in rec).toBe(false);
|
|
expect("height" in rec).toBe(false);
|
|
expect("latitude" in rec).toBe(false);
|
|
});
|
|
|
|
it("reads archived and hidden from private magicMetadata.visibility", () => {
|
|
const archived = file(1, 1, { magicMetadata: { visibility: 1 } });
|
|
const hidden = file(2, 1, { magicMetadata: { visibility: 2 } });
|
|
const visible = file(3, 1, { magicMetadata: { visibility: 0 } });
|
|
const { photos } = deriveRecords(
|
|
[collection(1)],
|
|
[archived, hidden, visible],
|
|
);
|
|
expect(photos.get(1)).toMatchObject({
|
|
isArchived: true,
|
|
isHidden: false,
|
|
});
|
|
expect(photos.get(2)).toMatchObject({
|
|
isArchived: false,
|
|
isHidden: true,
|
|
});
|
|
expect(photos.get(3)).toMatchObject({
|
|
isArchived: false,
|
|
isHidden: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("deriveRecords: dedup and ordering", () => {
|
|
it("emits one PhotoRecord per fileID across memberships, all albums listed", () => {
|
|
// File 1001 belongs to collections 1 and 2; 2002 only to 2.
|
|
const files = [
|
|
file(1001, 1, { creationTime: T(1_700_000_000_000_000) }),
|
|
file(1001, 2, { creationTime: T(1_700_000_000_000_000) }),
|
|
file(2002, 2, { creationTime: T(1_710_000_000_000_000) }),
|
|
];
|
|
const { photos } = deriveRecords([collection(1), collection(2)], files);
|
|
expect([...photos.keys()].sort((a, b) => a - b)).toEqual([1001, 2002]);
|
|
expect(photos.get(1001)!.albumIDs).toEqual([1, 2]);
|
|
expect(photos.get(2002)!.albumIDs).toEqual([2]);
|
|
});
|
|
|
|
it("orders snapshot photos newest first by takenAt", () => {
|
|
const files = [
|
|
file(1, 1, { creationTime: T(1_600_000_000_000_000) }),
|
|
file(2, 1, { creationTime: T(1_800_000_000_000_000) }),
|
|
file(3, 1, { creationTime: T(1_700_000_000_000_000) }),
|
|
];
|
|
const snap = snapshotFrom(deriveRecords([collection(1)], files), 123);
|
|
expect(snap.photos.map((p) => p.fileID)).toEqual([2, 3, 1]);
|
|
expect(snap.takenAt).toBe(123);
|
|
});
|
|
|
|
it("orders album fileIDs newest first", () => {
|
|
const files = [
|
|
file(1, 7, { creationTime: T(1_600_000_000_000_000) }),
|
|
file(2, 7, { creationTime: T(1_800_000_000_000_000) }),
|
|
file(3, 7, { creationTime: T(1_700_000_000_000_000) }),
|
|
];
|
|
const { albums } = deriveRecords([collection(7)], files);
|
|
expect(albums.get(7)!.fileIDs).toEqual([2, 3, 1]);
|
|
});
|
|
});
|
|
|
|
describe("deriveRecords: album mapping", () => {
|
|
it("carries collection identity, sharing, and the favorites type", () => {
|
|
const fav = collection(9, {
|
|
name: "Favorites",
|
|
type: "favorites",
|
|
isShared: true,
|
|
updationTime: T(1_705_000_000_000_000),
|
|
});
|
|
const rec = deriveRecords([fav], [file(1, 9)]).albums.get(9)!;
|
|
expect(rec).toMatchObject({
|
|
collectionID: 9,
|
|
name: "Favorites",
|
|
type: "favorites",
|
|
isShared: true,
|
|
updationTime: T(1_705_000_000_000_000),
|
|
});
|
|
expect("key" in rec).toBe(false);
|
|
expect(JSON.parse(JSON.stringify(rec))).toEqual(rec);
|
|
});
|
|
});
|
|
|
|
describe("diffRecords", () => {
|
|
const at = 999;
|
|
|
|
it("returns undefined when nothing changed", () => {
|
|
const a = deriveRecords([collection(1)], [file(1, 1)]);
|
|
const b = deriveRecords([collection(1)], [file(1, 1)]);
|
|
expect(diffRecords(a, b, at)).toBeUndefined();
|
|
});
|
|
|
|
it("reports added and changed albums and photos and removals", () => {
|
|
const before = deriveRecords(
|
|
[collection(1), collection(2)],
|
|
[file(1, 1), file(2, 2)],
|
|
);
|
|
// Collection 2 is gone (album + its only file removed). Collection 1 is
|
|
// renamed (changed album), gains file 3, and file 1 is retitled.
|
|
const after = deriveRecords(
|
|
[collection(1, { name: "renamed" })],
|
|
[
|
|
file(1, 1, {
|
|
pubMagicMetadata: { editedName: "new title" },
|
|
}),
|
|
file(3, 1),
|
|
],
|
|
);
|
|
const change = diffRecords(before, after, at)!;
|
|
expect(change.refreshedAt).toBe(at);
|
|
expect(change.albumIDsRemoved).toEqual([2]);
|
|
expect(change.fileIDsRemoved).toEqual([2]);
|
|
expect(change.albumsChanged.map((a) => a.collectionID)).toEqual([1]);
|
|
expect(change.albumsChanged[0]!.name).toBe("renamed");
|
|
expect(
|
|
change.photosChanged.map((p) => p.fileID).sort((x, y) => x - y),
|
|
).toEqual([1, 3]);
|
|
});
|
|
});
|