test.social/core/persistence/image.persistence.test.ts
test.social/core/persistence/image.persistence.test.ts is a file in Codebase Testing. 221 lines of code and 0 definitions.
import {
MALFORMED_LEDGER,
SHARE_TOO_LARGE,
SUBJECT_SEPARATOR,
} from "@banes-lab/social-share/configuration/strings/card.strings.ts";
import { describe, expect, it } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import {
expectedImages,
hashOf,
ledgerKeyOf,
missingImages,
orphanImages,
outdatedJobs,
pruneOrphans,
readLedger,
renderShareMap,
shareEntriesOf,
staleEntries,
writeImages,
writeLedger,
writeShareMap,
} from "@banes-lab/social-share/core/persistence/image.persistence.ts";
import type { CardInput } from "@banes-lab/social-share/types/card.types.ts";
import type { RenderedImage } from "@banes-lab/social-share/types/image.types.ts";
import { SHARE_BYTE_LIMIT } from "@banes-lab/social-share/configuration/configs/card.config.ts";
import { absolutePath } from "@ssot/paths";
import { createCard } from "@banes-lab/social-share/core/factories/card.factory.ts";
import { imageFilesOf } from "@banes-lab/social-share/core/converters/filename.converter.ts";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { writeVerbatim } from "@govlab/canonical-write";
const OG = { height: 630, id: "og", width: 1200 };
const input: CardInput = {
alt: "A demo card.",
id: "demo",
layers: [{ id: "title", kind: "text", placement: { x: 0.5, y: (frame) => frame.progress }, text: "Demo" }],
page: "home",
profiles: ["og"],
stylesheet: "",
timeline: { fps: 20, frames: 20 },
tone: "page-gold",
};
const spec = createCard(input);
const stored = createCard({
alt: "A demo card.",
id: "demo",
layers: [{ id: "title", kind: "text", placement: { x: 0.5, y: 0.5 }, text: "Demo" }],
page: "home",
profiles: ["og"],
stylesheet: "",
timeline: { frames: 4 },
tone: "page-gold",
});
const imagesOf = function imagesOf(hash: string): readonly RenderedImage[] {
return [1, 0.75, 0.5, 0.25].map((scale) => ({
animation: Buffer.from("gif"),
card: "demo",
hash,
motion: Buffer.from("webp"),
profile: "og",
scale,
still: Buffer.from("png"),
video: Buffer.from("mp4"),
}));
};
const shareFolder = function shareFolder(sizes: Readonly<Record<string, number>>): string {
const folder = mkdtempSync(join(tmpdir(), "social-share-"));
for (const [file, bytes] of Object.entries(sizes)) {
writeVerbatim(join(folder, file), Buffer.alloc(bytes));
}
return folder;
};
describe("expectedImages", () => {
it("expects four files per size for a moving card and one per size for a still", () => {
expect(expectedImages([spec]).size).toBe(16);
expect(expectedImages([createCard({ ...input, timeline: { frames: 1 } })]).size).toBe(4);
});
});
describe("shareEntriesOf and renderShareMap", () => {
it("points the card's page at the three-quarter animation that fits the limit, and names no video", () => {
const folder = shareFolder({ "demo-w900-h473.og.generated.gif": 1024 });
const entries = shareEntriesOf([spec], folder);
expect(entries.map((entry) => entry.page)).toStrictEqual(["home"]);
expect(entries[0]?.source.endsWith("/demo-w900-h473.og.generated.gif")).toBe(true);
expect(renderShareMap(entries)).toContain("demo-w900-h473.og.generated.gif");
expect(renderShareMap(entries)).not.toContain(".mp4");
});
it("steps down to a smaller animation when the three-quarter one is over the limit", () => {
const folder = shareFolder({
"demo-w600-h315.og.generated.gif": 1024,
"demo-w900-h473.og.generated.gif": SHARE_BYTE_LIMIT + 1,
});
expect(shareEntriesOf([spec], folder)[0]?.source.endsWith("/demo-w600-h315.og.generated.gif")).toBe(true);
});
it("throws when no animation fits, and for a card without the share profile", () => {
const heavy = SHARE_BYTE_LIMIT + 1;
const folder = shareFolder({
"demo-w300-h158.og.generated.gif": heavy,
"demo-w600-h315.og.generated.gif": heavy,
"demo-w900-h473.og.generated.gif": heavy,
});
expect(() => shareEntriesOf([spec], folder)).toThrow(SHARE_TOO_LARGE);
expect(() => shareEntriesOf([createCard({ ...input, profiles: ["x"] })], folder)).toThrow("demo");
});
it("names the full-size still of every article profile the card renders, and none for a card that renders none", () => {
const folder = shareFolder({ "demo-w900-h473.og.generated.gif": 1024 });
const [entry] = shareEntriesOf([createCard({ ...input, profiles: ["og", "x", "standard", "square"] })], folder);
expect(entry?.stills.map((still) => still.slice(still.lastIndexOf("/") + 1))).toStrictEqual([
"demo-w1200-h675.x.generated.png",
"demo-w1200-h900.standard.generated.png",
"demo-w1080-h1080.square.generated.png",
]);
expect(shareEntriesOf([spec], folder)[0]?.stills).toStrictEqual([]);
});
it("points a still card at its still", () => {
const folder = shareFolder({ "demo-w900-h473.og.generated.png": 1024 });
const [still] = shareEntriesOf([createCard({ ...input, timeline: { frames: 1 } })], folder);
expect(still?.source.endsWith("/demo-w900-h473.og.generated.png")).toBe(true);
});
});
describe("hashOf and staleEntries", () => {
it("hashes deterministically and moves when a resolved frame changes", () => {
const moved = createCard({
...input,
layers: [{ id: "title", kind: "text", placement: { x: 0.4, y: 0 }, text: "Demo" }],
});
expect(hashOf(spec, OG)).toBe(hashOf(spec, OG));
expect(hashOf(moved, OG)).not.toBe(hashOf(spec, OG));
});
it("reports every card and profile whose ledger hash is not the current one", () => {
const key = ledgerKeyOf("demo", "og");
const current = new Map([[key, hashOf(spec, OG)]]);
expect(key).toBe("demo.og");
expect(staleEntries([spec], current)).toStrictEqual([]);
expect(staleEntries([spec], new Map())).toStrictEqual([key]);
});
it("moves when the bytes of an image a card shows change, even with an identical spec", () => {
const source = `/social-hash-${String(process.pid)}.gif`;
const location = join(absolutePath("app.public"), source);
const shown = createCard({
alt: "A card with a mark.",
id: "marked",
layers: [{ alt: "Mark", id: "mark", kind: "image", placement: { x: 0, y: 0 }, source }],
page: "home",
profiles: ["og"],
stylesheet: "",
tone: "page-gold",
});
try {
writeVerbatim(location, "first");
const before = hashOf(shown, OG);
writeVerbatim(location, "second");
expect(hashOf(shown, OG)).not.toBe(before);
} finally {
rmSync(location, { force: true });
}
});
});
describe("writeImages, missingImages, orphanImages and pruneOrphans", () => {
it("writes every scale, then finds nothing missing and prunes only files no card renders", () => {
const folder = mkdtempSync(join(tmpdir(), "social-images-"));
expect(missingImages([stored], folder)).toStrictEqual(imageFilesOf(stored, OG));
expect(writeImages(imagesOf("h"), folder)).toStrictEqual(imageFilesOf(stored, OG));
writeVerbatim(join(folder, "demo.og.0123.gif"), "stale");
expect(missingImages([stored], folder)).toStrictEqual([]);
expect(orphanImages([stored], folder)).toStrictEqual(["demo.og.0123.gif"]);
expect(pruneOrphans([stored], folder)).toStrictEqual(["demo.og.0123.gif"]);
expect(existsSync(join(folder, "demo.og.0123.gif"))).toBe(false);
expect(orphanImages([stored], join(folder, "absent"))).toStrictEqual([]);
});
it("removes the moving files of a card that became a still", () => {
const folder = mkdtempSync(join(tmpdir(), "social-images-"));
writeImages(imagesOf("h"), folder);
const stills = imagesOf("h").map((image) => ({ ...image, animation: null, motion: null, video: null }));
expect(writeImages(stills, folder).every((file) => file.endsWith(".png"))).toBe(true);
expect(existsSync(join(folder, "demo-w1200-h630.og.generated.gif"))).toBe(false);
expect(existsSync(join(folder, "demo-w1200-h630.og.generated.webp"))).toBe(false);
expect(existsSync(join(folder, "demo-w1200-h630.og.generated.mp4"))).toBe(false);
});
});
describe("readLedger and writeLedger", () => {
it("round-trips the hashes of live cards and drops the ones no card declares", async () => {
const folder = mkdtempSync(join(tmpdir(), "social-ledger-"));
const location = join(folder, "share.generated.json");
expect(readLedger(location).size).toBe(0);
await writeLedger(location, new Map([["gone.og", "old"]]), imagesOf(hashOf(stored, OG)), [stored]);
expect([...readLedger(location)]).toStrictEqual([["demo.og", hashOf(stored, OG)]]);
});
it("throws on a ledger that is not a list of rows", () => {
const folder = mkdtempSync(join(tmpdir(), "social-ledger-"));
const location = join(folder, "share.generated.json");
writeVerbatim(location, JSON.stringify({ demo: "hash" }));
expect(() => readLedger(location)).toThrow(MALFORMED_LEDGER + SUBJECT_SEPARATOR + location);
});
});
describe("outdatedJobs", () => {
it("skips a profile whose hash matches the ledger and whose files exist, and selects one that changed or lost a file", () => {
const folder = mkdtempSync(join(tmpdir(), "social-jobs-"));
writeImages(imagesOf("h"), folder);
const current = new Map([["demo.og", hashOf(stored, OG)]]);
expect(outdatedJobs([stored], current, folder)).toStrictEqual([]);
expect(
outdatedJobs([stored], new Map([["demo.og", "old"]]), folder).map((job) => job.profile.id),
).toStrictEqual(["og"]);
rmSync(join(folder, "demo-w1200-h630.og.generated.gif"));
expect(outdatedJobs([stored], current, folder)).toHaveLength(1);
});
});
describe("writeShareMap", () => {
it("writes the page to image map as a typed module", async () => {
const folder = mkdtempSync(join(tmpdir(), "social-map-"));
const location = join(folder, "share.generated.ts");
await writeShareMap(location, [
{ alt: "A demo card.", page: "home", source: "/static/card/generated/demo.gif", stills: [] },
]);
const written = readFileSync(location, "utf8");
expect(written).toContain("SHARE_IMAGES");
expect(written).toContain("demo.gif");
});
});