Major update for v1 and tests

This commit is contained in:
khalid@traclabs.com
2026-05-23 03:28:58 -05:00
parent 628a6765f4
commit b1fb6fa3aa
1748 changed files with 27723 additions and 1854 deletions

View File

@@ -0,0 +1,209 @@
// Tests for persistence.js — the editor-state save/load layer.
//
// What this guards
// ────────────────
// persistence.js is one layer above storage.js. It adds the
// editor-specific knowledge of WHAT shape gets saved and which
// fields shouldn't survive a reload. The crucial logic is:
//
// 1. Round-trip integrity: elements + preferences come back exactly
// as they went in, modulo the next point.
// 2. Blob URL stripping: elements whose `src` is a `blob:` URL
// are dropped at save time. Blob URLs go stale immediately on
// reload, so persisting them produces broken-image elements
// after refresh.
//
// The stripping is the rule with the most blast radius — if it ever
// regresses, every user who'd uploaded a photo would see broken-image
// placeholders on reload. The tests below pin that behavior precisely
// (including the negative case: data: and https:// URLs must survive).
//
// Test isolation
// ──────────────
// Same pattern as storage.test.js: clear localStorage in beforeEach
// so the missing-state assertions actually see a clean slate.
import {
loadPersistedState,
savePersistedState,
clearPersistedState,
} from './persistence';
// The storage key is private to persistence.js. We don't import it;
// we test through the public API instead. The only time we touch
// localStorage directly is in beforeEach to clear state.
describe('persistence', () => {
beforeEach(() => {
window.localStorage.clear();
});
describe('loadPersistedState on empty storage', () => {
it('returns null when nothing has been saved', () => {
// Fresh user, first visit. Caller treats null as "start with
// defaults". If this regressed to returning an empty object,
// the caller would think there's a saved session and skip
// the default-template-load path.
expect(loadPersistedState()).toBeNull();
});
});
describe('savePersistedState / loadPersistedState round-trip', () => {
it('round-trips a typical editor state', () => {
const state = {
elements: [
{
id: 'a',
type: 'image',
src: 'data:image/png;base64,abc',
x: 10,
y: 20,
width: 100,
height: 100,
},
{
id: 'b',
type: 'text',
text: 'Hello',
fontSize: 48,
fontFamily: 'Pacifico',
x: 50,
y: 200,
},
],
activeTemplateId: 'tee-001',
preferences: { lastColor: '#ec4899', lastSize: 'M' },
};
savePersistedState(state);
expect(loadPersistedState()).toEqual(state);
});
it('preserves remote URL src values', () => {
// Sticker library elements have remote URLs like `/stickers/foo.svg`
// — these survive reload and must be kept.
const state = {
elements: [
{ id: 'a', type: 'sticker', src: '/stickers/star.svg' },
],
};
savePersistedState(state);
const loaded = loadPersistedState();
expect(loaded.elements).toHaveLength(1);
expect(loaded.elements[0].src).toBe('/stickers/star.svg');
});
it('preserves data: URL src values', () => {
// Cropped photos get saved as data: URLs after the crop apply
// (they're rasterized from canvas → base64). These survive
// reload and must be kept.
const state = {
elements: [
{ id: 'a', type: 'image', src: 'data:image/png;base64,iVBOR' },
],
};
savePersistedState(state);
const loaded = loadPersistedState();
expect(loaded.elements).toHaveLength(1);
expect(loaded.elements[0].src).toMatch(/^data:/);
});
});
describe('blob URL stripping at save time', () => {
it('strips elements whose src is a blob: URL', () => {
// The headline rule: blob: URLs don't survive reload. The
// user has uploaded a photo, the editor created an
// `Object URL` for the file, the URL became `blob:https://...`,
// and persisting it would result in a 404'd image on reload.
// The fix is to drop the element entirely so the saved state
// doesn't contain a broken reference.
const state = {
elements: [
{ id: 'a', type: 'image', src: 'blob:https://example.com/abc-123' },
{ id: 'b', type: 'text', text: 'Hello', x: 0, y: 0 },
],
};
savePersistedState(state);
const loaded = loadPersistedState();
expect(loaded.elements).toHaveLength(1);
expect(loaded.elements[0].id).toBe('b');
});
it('keeps multiple non-blob elements alongside the stripped blob element', () => {
// The filter is per-element. Adjacent good elements must be
// unaffected even when one is stripped.
const state = {
elements: [
{ id: 'a', type: 'image', src: 'data:image/png;base64,abc' },
{ id: 'b', type: 'image', src: 'blob:https://example.com/x' },
{ id: 'c', type: 'sticker', src: '/stickers/star.svg' },
{ id: 'd', type: 'text', text: 'Hi' },
],
};
savePersistedState(state);
const loaded = loadPersistedState();
expect(loaded.elements.map((el) => el.id)).toEqual(['a', 'c', 'd']);
});
it('preserves non-element fields when filtering elements', () => {
// Stripping happens inside the elements array only; the rest
// of the state object (preferences, activeTemplateId, etc.)
// is unchanged.
const state = {
elements: [
{ id: 'a', type: 'image', src: 'blob:https://example.com/x' },
],
preferences: { lastColor: '#ec4899' },
activeTemplateId: 'tee-001',
};
savePersistedState(state);
const loaded = loadPersistedState();
expect(loaded.elements).toEqual([]);
expect(loaded.preferences).toEqual({ lastColor: '#ec4899' });
expect(loaded.activeTemplateId).toBe('tee-001');
});
it('handles a state with no elements field gracefully', () => {
// Pre-elements state, or state from a future schema where
// elements moved elsewhere. The stripping function should
// not crash — it should just pass the state through.
const state = { preferences: { lastColor: '#000000' } };
savePersistedState(state);
expect(loadPersistedState()).toEqual(state);
});
});
describe('clearPersistedState', () => {
it('removes saved state so subsequent loads return null', () => {
savePersistedState({ elements: [{ id: 'a', type: 'text', text: 'Hi' }] });
expect(loadPersistedState()).not.toBeNull();
clearPersistedState();
expect(loadPersistedState()).toBeNull();
});
it('is a no-op when nothing was saved', () => {
// Defensive — calling clear on an empty store shouldn't throw.
expect(() => clearPersistedState()).not.toThrow();
expect(loadPersistedState()).toBeNull();
});
});
describe('malformed saved data', () => {
it('treats a non-object saved value as no saved state', () => {
// If something other than our save format ends up at the
// storage key (e.g. an older app version wrote a string), the
// load path must return null rather than hand back a value
// the caller can't safely destructure.
window.localStorage.setItem('pawfectly-yours-state-v1', JSON.stringify('not an object'));
expect(loadPersistedState()).toBeNull();
});
it('treats malformed JSON at the storage key as no saved state', () => {
// Same protection one layer up — storage.readJson already
// returns null on JSON.parse failure, persistence inherits
// that. Pinning the behavior here ensures the contract holds
// end-to-end through both layers.
window.localStorage.setItem('pawfectly-yours-state-v1', '{not valid');
expect(loadPersistedState()).toBeNull();
});
});
});