@snowcone-app/ui 0.4.9 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Cropped-frame delivery state machine (RealtimeProvider internals).
3
+ *
4
+ * The sdk hook and the composite engine are mocked; these tests pin the
5
+ * provider's money-path invariants:
6
+ * - the session flips to overlayCrop:'auto' only after EVERY configured
7
+ * mockup has a frozen full-frame background
8
+ * - cropped frames are composited and consumers only ever see full-frame
9
+ * URLs (blob: URLs from the composite)
10
+ * - any composite failure falls back to full-frame delivery for the session
11
+ * and re-renders the affected mockup so the edit isn't lost
12
+ * - priority requests for never-rendered mockups drop the session back to
13
+ * full-frame before rendering
14
+ */
15
+ import { describe, expect, it, beforeEach, vi } from "vitest";
16
+ import React from "react";
17
+ import { render, waitFor, act } from "@testing-library/react";
18
+ import {
19
+ RealtimeProvider,
20
+ useRealtime,
21
+ type RealtimeContextValue,
22
+ } from "../RealtimeProvider";
23
+
24
+ const h = vi.hoisted(() => {
25
+ return {
26
+ state: {
27
+ results: [] as Array<Record<string, unknown>>,
28
+ },
29
+ sendConfig: vi.fn(),
30
+ updateMockupIds: vi.fn(),
31
+ compositeCroppedFrame: vi.fn(),
32
+ loadImageElement: vi.fn(),
33
+ // Stable identities across renders — the real hook returns stable
34
+ // callbacks; fresh fns per render would churn the provider's effects.
35
+ sendCanvasBlob: vi.fn(),
36
+ sendCanvasState: vi.fn(),
37
+ sendColorBlob: vi.fn(),
38
+ updatePlacementSettings: vi.fn(),
39
+ connect: vi.fn(),
40
+ disconnect: vi.fn(),
41
+ flushPendingBlobs: vi.fn(),
42
+ hasPendingBlobs: vi.fn(() => false),
43
+ };
44
+ });
45
+
46
+ vi.mock("@snowcone-app/sdk/react", () => ({
47
+ useRealtimeMockup: () => ({
48
+ isConnected: true,
49
+ isConfigured: true,
50
+ renderError: null,
51
+ mockupResults: h.state.results,
52
+ sendCanvasBlob: h.sendCanvasBlob,
53
+ sendCanvasState: h.sendCanvasState,
54
+ sendColorBlob: h.sendColorBlob,
55
+ sendConfig: h.sendConfig,
56
+ updateMockupIds: h.updateMockupIds,
57
+ updatePlacementSettings: h.updatePlacementSettings,
58
+ connect: h.connect,
59
+ disconnect: h.disconnect,
60
+ flushPendingBlobs: h.flushPendingBlobs,
61
+ hasPendingBlobs: h.hasPendingBlobs,
62
+ }),
63
+ }));
64
+
65
+ vi.mock("@snowcone-app/sdk", () => ({
66
+ resolveVariantId: () => "gv1",
67
+ }));
68
+
69
+ vi.mock("../Product", () => {
70
+ const product = {
71
+ id: "prod1",
72
+ placements: [{ label: "Front", type: "image" }],
73
+ mockups: [{ id: "m1" }, { id: "m2" }],
74
+ };
75
+ return {
76
+ useProductOptional: () => ({ product, selection: {} }),
77
+ };
78
+ });
79
+
80
+ vi.mock("../MockupBackground", () => ({
81
+ useMockupBackgroundOptional: () => undefined,
82
+ }));
83
+
84
+ vi.mock("../croppedFrameComposite", async (importOriginal) => {
85
+ const actual = await importOriginal<typeof import("../croppedFrameComposite")>();
86
+ return {
87
+ ...actual,
88
+ compositeCroppedFrame: h.compositeCroppedFrame,
89
+ loadImageElement: h.loadImageElement,
90
+ };
91
+ });
92
+
93
+ let capturedCtx: RealtimeContextValue;
94
+ function Capture() {
95
+ capturedCtx = useRealtime();
96
+ return null;
97
+ }
98
+
99
+ function Harness({ version }: { version: number }) {
100
+ void version; // re-render trigger — the mocked hook returns h.state.results
101
+ return (
102
+ <RealtimeProvider autoEnable>
103
+ <Capture />
104
+ </RealtimeProvider>
105
+ );
106
+ }
107
+
108
+ function fullResult(mockupId: string, url: string) {
109
+ return { mockupId, imageUrl: url, renderUrl: url, imageSize: 1000, placement: "Front" };
110
+ }
111
+
112
+ function croppedResult(mockupId: string, url: string) {
113
+ return {
114
+ ...fullResult(mockupId, url),
115
+ cropRect: { x: 0.2, y: 0.1, w: 0.5, h: 0.6 },
116
+ };
117
+ }
118
+
119
+ /** sendConfig payloads observed since the last mock reset. */
120
+ function sentConfigs(): Array<Record<string, unknown>> {
121
+ return h.sendConfig.mock.calls.map((c) => c[0] as Record<string, unknown>);
122
+ }
123
+
124
+ let blobCounter = 0;
125
+
126
+ beforeEach(() => {
127
+ vi.clearAllMocks();
128
+ h.state.results = [];
129
+ blobCounter = 0;
130
+ h.loadImageElement.mockResolvedValue({ naturalWidth: 1400, naturalHeight: 782 });
131
+ h.compositeCroppedFrame.mockResolvedValue(new Blob(["x"], { type: "image/webp" }));
132
+ // jsdom has no createObjectURL
133
+ globalThis.URL.createObjectURL = vi.fn(() => `blob:composite-${++blobCounter}`);
134
+ globalThis.URL.revokeObjectURL = vi.fn();
135
+ });
136
+
137
+ async function seedAndFlip(rerender: (ui: React.ReactElement) => void) {
138
+ h.state.results = [fullResult("m1", "https://r/m1-full")];
139
+ await act(async () => rerender(<Harness version={2} />));
140
+ expect(sentConfigs().some((c) => c.overlayCrop)).toBe(false);
141
+
142
+ h.state.results = [
143
+ fullResult("m1", "https://r/m1-full"),
144
+ fullResult("m2", "https://r/m2-full"),
145
+ ];
146
+ await act(async () => rerender(<Harness version={3} />));
147
+ await waitFor(() => {
148
+ expect(sentConfigs().some((c) => c.overlayCrop === "auto")).toBe(true);
149
+ });
150
+ }
151
+
152
+ describe("RealtimeProvider cropped delivery", () => {
153
+ it("flips to overlayCrop:auto only after every configured mockup seeded", async () => {
154
+ const { rerender } = render(<Harness version={1} />);
155
+ // Initial config: no crop
156
+ await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
157
+ expect(sentConfigs()[0].overlayCrop).toBeUndefined();
158
+ await seedAndFlip(rerender);
159
+ });
160
+
161
+ it("decodes the frozen background at seed time, before the SDK can revoke its blob: URL", async () => {
162
+ // With ws binary delivery the full frame's imageUrl is the SDK's live
163
+ // blob: URL, revoked when the next frame for that mockup arrives. The
164
+ // background must be decoded while the URL is still alive.
165
+ const { rerender } = render(<Harness version={1} />);
166
+ await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
167
+ h.state.results = [fullResult("m1", "blob:live-m1"), fullResult("m2", "blob:live-m2")];
168
+ await act(async () => rerender(<Harness version={3} />));
169
+ await waitFor(() => {
170
+ expect(h.loadImageElement).toHaveBeenCalledWith("blob:live-m1");
171
+ expect(h.loadImageElement).toHaveBeenCalledWith("blob:live-m2");
172
+ });
173
+ // ...and not again at composite time — the decoded element is reused.
174
+ const loadsAfterSeed = h.loadImageElement.mock.calls.length;
175
+ await waitFor(() => expect(sentConfigs().some((c) => c.overlayCrop === "auto")).toBe(true));
176
+ h.state.results = [croppedResult("m1", "blob:live-m1-crop"), fullResult("m2", "blob:live-m2")];
177
+ await act(async () => rerender(<Harness version={4} />));
178
+ await waitFor(() => expect(h.compositeCroppedFrame).toHaveBeenCalled());
179
+ expect(h.loadImageElement.mock.calls.length).toBe(loadsAfterSeed);
180
+ });
181
+
182
+ it("composites cropped frames — consumers only ever see full-frame URLs", async () => {
183
+ const { rerender } = render(<Harness version={1} />);
184
+ await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
185
+ await seedAndFlip(rerender);
186
+
187
+ const seen: string[] = [];
188
+ capturedCtx.subscribeMockupResultById("m1", (r) => seen.push(r.imageUrl));
189
+
190
+ h.state.results = [
191
+ croppedResult("m1", "https://r/m1-crop-frame"),
192
+ fullResult("m2", "https://r/m2-full"),
193
+ ];
194
+ await act(async () => rerender(<Harness version={4} />));
195
+
196
+ await waitFor(() => {
197
+ expect(seen.some((u) => u.startsWith("blob:composite-"))).toBe(true);
198
+ });
199
+ // The raw cropped URL never reaches subscribers or the cache
200
+ expect(seen).not.toContain("https://r/m1-crop-frame");
201
+ expect(capturedCtx.getMockupUrl("m1")).toMatch(/^blob:composite-/);
202
+ expect(h.compositeCroppedFrame).toHaveBeenCalledWith(
203
+ expect.objectContaining({
204
+ frameUrl: "https://r/m1-crop-frame",
205
+ rect: { x: 0.2, y: 0.1, w: 0.5, h: 0.6 },
206
+ })
207
+ );
208
+ });
209
+
210
+ it("falls back to full-frame for the session when compositing fails, and re-renders the edit", async () => {
211
+ const { rerender } = render(<Harness version={1} />);
212
+ await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
213
+ await seedAndFlip(rerender);
214
+ h.sendConfig.mockClear();
215
+
216
+ h.compositeCroppedFrame.mockRejectedValue(new Error("E_OVERLAY_ENCODE_FAILED"));
217
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
218
+ h.state.results = [croppedResult("m1", "https://r/m1-crop-frame")];
219
+ await act(async () => rerender(<Harness version={4} />));
220
+
221
+ await waitFor(() => {
222
+ // Unflipped: config re-sent WITHOUT overlayCrop
223
+ expect(sentConfigs().length).toBeGreaterThan(0);
224
+ expect(sentConfigs().every((c) => !c.overlayCrop)).toBe(true);
225
+ // The lost edit is re-rendered full-frame
226
+ expect(h.updateMockupIds).toHaveBeenCalledWith(["m1"]);
227
+ });
228
+
229
+ // Kill-switch: even after everything re-seeds, no flip this session
230
+ h.sendConfig.mockClear();
231
+ h.state.results = [
232
+ fullResult("m1", "https://r/m1-full-2"),
233
+ fullResult("m2", "https://r/m2-full-2"),
234
+ ];
235
+ await act(async () => rerender(<Harness version={5} />));
236
+ expect(sentConfigs().some((c) => c.overlayCrop === "auto")).toBe(false);
237
+ errSpy.mockRestore();
238
+ });
239
+
240
+ it("drops to full-frame before rendering a never-seeded mockup via updateMockupIds", async () => {
241
+ const { rerender } = render(<Harness version={1} />);
242
+ await waitFor(() => expect(h.sendConfig).toHaveBeenCalled());
243
+ await seedAndFlip(rerender);
244
+ h.sendConfig.mockClear();
245
+
246
+ act(() => {
247
+ capturedCtx.updateMockupIds(["m3"]);
248
+ });
249
+ // Config re-sent without crop BEFORE the render request goes out
250
+ expect(sentConfigs().length).toBe(1);
251
+ expect(sentConfigs()[0].overlayCrop).toBeUndefined();
252
+ expect(h.updateMockupIds).toHaveBeenCalledWith(["m3"]);
253
+
254
+ // Seeded mockups don't unflip
255
+ h.sendConfig.mockClear();
256
+ act(() => {
257
+ capturedCtx.updateMockupIds(["m1", "m2"]);
258
+ });
259
+ expect(h.sendConfig).not.toHaveBeenCalled();
260
+ });
261
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ allBackgroundsSeeded,
4
+ compositeOffset,
5
+ isFullRect,
6
+ } from "../croppedFrameComposite";
7
+
8
+ describe("isFullRect", () => {
9
+ it("treats a missing rect as full frame", () => {
10
+ expect(isFullRect(undefined)).toBe(true);
11
+ expect(isFullRect(null)).toBe(true);
12
+ });
13
+
14
+ it("treats the identity rect as full frame", () => {
15
+ expect(isFullRect({ x: 0, y: 0, w: 1, h: 1 })).toBe(true);
16
+ });
17
+
18
+ it("treats any real crop as cropped", () => {
19
+ expect(isFullRect({ x: 0.37, y: 0.1, w: 0.45, h: 0.87 })).toBe(false);
20
+ expect(isFullRect({ x: 0, y: 0, w: 0.999, h: 1 })).toBe(false);
21
+ });
22
+ });
23
+
24
+ describe("compositeOffset", () => {
25
+ it("recovers the server's integer pixel offsets from fractions", () => {
26
+ // Server cropped at x=518px, y=102px of a 1400×782 frame.
27
+ const rect = { x: 518 / 1400, y: 102 / 782, w: 0.4, h: 0.6 };
28
+ expect(compositeOffset(rect, 1400, 782)).toEqual({ dx: 518, dy: 102 });
29
+ });
30
+
31
+ it("survives fraction truncation to 4 decimals within ±1px", () => {
32
+ const rect = { x: 0.37, y: 0.1304, w: 0.4, h: 0.6 };
33
+ const { dx, dy } = compositeOffset(rect, 1400, 782);
34
+ expect(Math.abs(dx - 518)).toBeLessThanOrEqual(1);
35
+ expect(Math.abs(dy - 102)).toBeLessThanOrEqual(1);
36
+ });
37
+
38
+ it("is zero at the origin", () => {
39
+ expect(compositeOffset({ x: 0, y: 0, w: 1, h: 1 }, 1400, 782)).toEqual({
40
+ dx: 0,
41
+ dy: 0,
42
+ });
43
+ });
44
+ });
45
+
46
+ describe("allBackgroundsSeeded", () => {
47
+ it("requires every configured mockup", () => {
48
+ const seeded = new Set(["a", "b"]);
49
+ expect(allBackgroundsSeeded(["a", "b"], seeded)).toBe(true);
50
+ expect(allBackgroundsSeeded(["a", "b", "c"], seeded)).toBe(false);
51
+ });
52
+
53
+ it("never flips on an empty config", () => {
54
+ expect(allBackgroundsSeeded([], new Set(["a"]))).toBe(false);
55
+ });
56
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Cropped-frame compositing for the realtime provider (PDP fast delivery).
3
+ *
4
+ * With `overlayCrop: 'auto'` on the session config, the server ships only the
5
+ * pixels inside the product region (a fraction of the full frame's bytes) and
6
+ * reports the fractional rect it cropped with. The provider composites that
7
+ * frame over the mockup's frozen full-frame background on a canvas, so every
8
+ * consumer keeps receiving plain full-frame URLs — the delivery strategy is
9
+ * invisible outside the provider.
10
+ *
11
+ * The cropped frame is rendered at the same pixel scale as the full frame
12
+ * (crop width in px = rect.w × render width), so compositing is an integer
13
+ * translate + draw at natural size — no scaling, no resampling.
14
+ */
15
+
16
+ export interface CropRect {
17
+ x: number;
18
+ y: number;
19
+ w: number;
20
+ h: number;
21
+ }
22
+
23
+ /** WebP quality for composited frames (PNG fallback on Safari is lossless). */
24
+ const COMPOSITE_QUALITY = 0.92;
25
+
26
+ /** A missing rect or the identity rect both mean "this is a full frame". */
27
+ export function isFullRect(rect: CropRect | null | undefined): boolean {
28
+ if (!rect) return true;
29
+ return rect.x === 0 && rect.y === 0 && rect.w === 1 && rect.h === 1;
30
+ }
31
+
32
+ /**
33
+ * Pixel offset to draw the cropped frame at. The server derived the fractions
34
+ * from integer pixel offsets at this same render width, so rounding recovers
35
+ * the original integers (worst case ±1px from fraction truncation, inside the
36
+ * crop's padded region).
37
+ */
38
+ export function compositeOffset(
39
+ rect: CropRect,
40
+ bgWidth: number,
41
+ bgHeight: number
42
+ ): { dx: number; dy: number } {
43
+ return {
44
+ dx: Math.round(rect.x * bgWidth),
45
+ dy: Math.round(rect.y * bgHeight),
46
+ };
47
+ }
48
+
49
+ /** Every mockup the session renders must have a frozen background before the
50
+ * session may flip to cropped delivery (a cropped frame can't seed one). */
51
+ export function allBackgroundsSeeded(
52
+ mockupIds: string[],
53
+ seeded: ReadonlySet<string>
54
+ ): boolean {
55
+ return mockupIds.length > 0 && mockupIds.every((id) => seeded.has(id));
56
+ }
57
+
58
+ /** Load + decode an image for compositing (CORS-clean so the canvas stays
59
+ * untainted — render hosts serve ACAO for the crossorigin display path). */
60
+ export function loadImageElement(url: string): Promise<HTMLImageElement> {
61
+ return new Promise((resolve, reject) => {
62
+ const img = new Image();
63
+ img.crossOrigin = "anonymous";
64
+ img.onload = () => {
65
+ // decode() forces full decode off the critical path; a decode rejection
66
+ // (e.g. Safari under memory pressure) still leaves a drawable image.
67
+ if (img.decode) {
68
+ img.decode().then(
69
+ () => resolve(img),
70
+ () => resolve(img)
71
+ );
72
+ } else {
73
+ resolve(img);
74
+ }
75
+ };
76
+ img.onerror = () =>
77
+ reject(
78
+ new Error(`E_OVERLAY_FRAME_LOAD: image failed to load: ${url.slice(0, 160)}`)
79
+ );
80
+ img.src = url;
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Composite a cropped frame over its full-frame background. Returns an
86
+ * encoded Blob (WebP where the browser supports canvas WebP encode, PNG
87
+ * otherwise per the toBlob spec fallback). Throws typed `E_OVERLAY_*` errors —
88
+ * the provider treats any throw as "disable cropped delivery this session".
89
+ */
90
+ export async function compositeCroppedFrame(args: {
91
+ background: HTMLImageElement;
92
+ frameUrl: string;
93
+ rect: CropRect;
94
+ /** Reusable canvas to avoid per-frame allocations. */
95
+ canvas?: HTMLCanvasElement;
96
+ }): Promise<Blob> {
97
+ const frame = await loadImageElement(args.frameUrl);
98
+ const width = args.background.naturalWidth;
99
+ const height = args.background.naturalHeight;
100
+ if (!width || !height) {
101
+ throw new Error("E_OVERLAY_BG_EMPTY: background image has no pixels");
102
+ }
103
+ const canvas = args.canvas ?? document.createElement("canvas");
104
+ // Setting dimensions also clears any previous composite.
105
+ canvas.width = width;
106
+ canvas.height = height;
107
+ const ctx = canvas.getContext("2d");
108
+ if (!ctx) {
109
+ throw new Error("E_OVERLAY_NO_2D_CONTEXT: canvas 2d context unavailable");
110
+ }
111
+ ctx.drawImage(args.background, 0, 0);
112
+ const { dx, dy } = compositeOffset(args.rect, width, height);
113
+ // Natural size — pixel-exact overlay, no scaling.
114
+ ctx.drawImage(frame, dx, dy);
115
+ const blob = await new Promise<Blob | null>((resolve) =>
116
+ canvas.toBlob(resolve, "image/webp", COMPOSITE_QUALITY)
117
+ );
118
+ if (!blob) {
119
+ throw new Error("E_OVERLAY_ENCODE_FAILED: canvas.toBlob returned null");
120
+ }
121
+ return blob;
122
+ }