diff --git a/ogre/src/__tests__/get.test.ts b/ogre/src/__tests__/get.test.ts index 6fe88e16a..6be92cd93 100644 --- a/ogre/src/__tests__/get.test.ts +++ b/ogre/src/__tests__/get.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "bun:test"; import worker, { decodePayload } from "../index"; -import { formatAvatarUrl } from "../components/shared/avatar"; +import { avatarDid, formatAvatarUrl } from "../components/shared/avatar"; describe("worker fetch and GET payload decoding", () => { it("decodes JSON payload directly", () => { @@ -58,6 +58,25 @@ describe("worker fetch and GET payload decoding", () => { expect(formatAvatarUrl(undefined)).toBeUndefined(); }); + it("reads the did out of tangled avatar URLs", () => { + expect( + avatarDid("https://tangled.org/avatar/did%3Aplc%3A123?size=256&format=jpeg"), + ).toBe("did:plc:123"); + expect(avatarDid("https://next.tangled.org/avatar/did%3Aweb%3Aa.b?size=256")).toBe( + "did:web:a.b", + ); + expect(avatarDid("https://avatar.tangled.sh/sig/did:plc:123?size=256")).toBe( + "did:plc:123", + ); + expect(avatarDid("https://avatar-dev.tangled.org/sig/did:plc:123")).toBe( + "did:plc:123", + ); + expect(avatarDid("https://example.com/did:plc:123")).toBeUndefined(); + expect(avatarDid("https://tangled.org/avatar/")).toBeUndefined(); + expect(avatarDid("data:image/jpeg;base64,123")).toBeUndefined(); + expect(avatarDid(undefined)).toBeUndefined(); + }); + it("accepts payloads without optional avatarUrl or createdAt", async () => { const payload = { type: "repository", diff --git a/ogre/src/__tests__/render.test.ts b/ogre/src/__tests__/render.test.ts index df7b0af2e..022bfb941 100644 --- a/ogre/src/__tests__/render.test.ts +++ b/ogre/src/__tests__/render.test.ts @@ -1,4 +1,4 @@ -import { test, describe, beforeAll } from "bun:test"; +import { test, describe, beforeAll, expect, spyOn } from "bun:test"; import { writeFileSync, mkdirSync, readFileSync } from "fs"; import { join } from "path"; import { h, type VNode } from "preact"; @@ -12,6 +12,8 @@ import { CommitCard } from "../components/cards/commit"; import { FileCard } from "../components/cards/file"; import { TagCard } from "../components/cards/tag"; import { PipelineCard } from "../components/cards/pipeline"; +import { Avatar } from "../components/shared/avatar"; +import { COLORS } from "../components/shared/constants"; import { repositoryCardSchema, issueCardSchema, @@ -69,6 +71,7 @@ const renderAndSave = async
(component: VNode
, filename: string) => {
const { svg, png } = await renderCard(component as VNode);
saveSvg(filename.replace(".png", ".svg"), svg);
savePng(filename, png);
+ return svg;
};
describe("repository card", () => {
@@ -153,6 +156,29 @@ describe("pull request cards", () => {
);
});
+ test("renders pull request without diff stats", async () => {
+ const { filesChanged, additions, deletions, ...data } =
+ createPullRequestData(avatarDataUri, { rounds: 1 });
+ const validated = pullRequestCardSchema.parse(data);
+ expect(validated.filesChanged).toBeUndefined();
+ const svg = await renderAndSave(
+ h(PullRequestCard, validated),
+ "pull-request-card-no-diff.png",
+ );
+ expect(svg).not.toContain(COLORS.diff.additions.text);
+ });
+
+ test("renders pull request with diff stats", async () => {
+ const validated = pullRequestCardSchema.parse(
+ createPullRequestData(avatarDataUri),
+ );
+ const svg = await renderAndSave(
+ h(PullRequestCard, validated),
+ "pull-request-card-diff.png",
+ );
+ expect(svg).toContain(COLORS.diff.additions.text);
+ });
+
test("renders pull request with long title", async () => {
const data = createLongTitlePullRequestData(avatarDataUri, {
pullRequestNumber: 42,
@@ -176,6 +202,29 @@ describe("pull request cards", () => {
});
});
+describe("avatar", () => {
+ test("shows the did's gradient when the image fails to load", async () => {
+ const fetch = spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response("blob not found", { status: 400 }),
+ );
+ const svg = await renderAndSave(
+ h(Avatar, {
+ src: "https://avatar.tangled.sh/sig/did:plc:3rwz3xfw2crswgifqgc3g7zh?size=256",
+ size: 120,
+ }),
+ "avatar-failed.png",
+ ).finally(() => fetch.mockRestore());
+ expect(svg).toContain(" {
describe("gradientCss", () => {
it.each(dids)("is a radial glow over a linear gradient for %s", (did) => {
expect(gradientCss(did)).toMatch(
- /^radial-gradient\(\d+(\.\d+)?% \d+(\.\d+)?% at [\d.]+% [\d.]+%, rgb\(\d+ \d+ \d+ \/ [\d.]+\), rgb\(\d+ \d+ \d+ \/ 0\)\), linear-gradient\(\d+deg, (#[0-9a-f]{6} -?[\d.]+%(, )?)+\)$/
+ /^radial-gradient\(\d+(\.\d+)?% \d+(\.\d+)?% at [\d.]+% [\d.]+%, #[0-9a-f]{8}, #[0-9a-f]{6}00\), linear-gradient\(\d+deg, (#[0-9a-f]{6} -?[\d.]+%(, )?)+\)$/
);
});
diff --git a/web/src/lib/avatarGradient.ts b/web/src/lib/avatarGradient.ts
index dc08d236c..89671c82e 100644
--- a/web/src/lib/avatarGradient.ts
+++ b/web/src/lib/avatarGradient.ts
@@ -59,8 +59,11 @@ export const gradientFor = (seed: string): Gradient => {
return { angle, stops, glow };
};
-const rgb = (hex: string) =>
- [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)).join(" ");
+// ogre's satori reads rgb(r g b / 0) as opaque black but handles hex alpha
+const alpha = (opacity: number) =>
+ Math.round(opacity * 255)
+ .toString(16)
+ .padStart(2, "0");
// the same picture as the worker's svg, as a css background-image
export const gradientCss = (seed: string): string => {
@@ -72,10 +75,9 @@ export const gradientCss = (seed: string): string => {
.map((s) => `${s.color} ${round(50 + (s.offset - 50) * scale)}%`)
.join(", ");
const radius = round(glow.r * 100);
- const color = rgb(glow.color);
return (
`radial-gradient(${radius}% ${radius}% at ${round(glow.x * 100)}% ${round(glow.y * 100)}%, ` +
- `rgb(${color} / ${glow.opacity}), rgb(${color} / 0)), ` +
+ `${glow.color}${alpha(glow.opacity)}, ${glow.color}00), ` +
`linear-gradient(${(angle + 135) % 360}deg, ${linear})`
);
};