`/` always dumped you on the gardens list; a phone user who lives in one garden had to open the list and tap in every time. Now the device remembers the garden it was last in and `/` resumes there. - lib/lastGarden.ts: per-device localStorage (pansy:last-garden), same swallow-failures rationale as the seed tray / recents. getLastGardenId guards against a non-positive/garbage stored value. - The `/` route redirects to the stored garden when present, else /gardens. - The editor records the garden on successful load, and — if it 404s (deleted or access revoked) — forgets it (only if it's the stored one, so a bad direct link can't wipe a good resume target) and bounces to the list, so a stale id can't trap the user on an error screen. Transient errors still show the retryable message. - ApiError.isNotFound getter (mirrors isConflict/isUnauthorized). Verified live at 390px: resume into the last garden; a stale id bounces to /gardens and clears itself. tsc + vitest (incl. new lastGarden test) green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
71 lines
2.3 KiB
TypeScript
71 lines
2.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { getLastGardenId, rememberLastGarden, forgetLastGarden } from './lastGarden'
|
|
|
|
// The tests run in the node environment (no DOM), so stand up a minimal
|
|
// in-memory localStorage rather than pull in jsdom for one thin module.
|
|
function installStorage(impl?: Partial<Storage>) {
|
|
const store = new Map<string, string>()
|
|
const base: Storage = {
|
|
getItem: (k) => store.get(k) ?? null,
|
|
setItem: (k, v) => void store.set(k, String(v)),
|
|
removeItem: (k) => void store.delete(k),
|
|
clear: () => store.clear(),
|
|
key: (i) => [...store.keys()][i] ?? null,
|
|
get length() {
|
|
return store.size
|
|
},
|
|
}
|
|
;(globalThis as { localStorage?: Storage }).localStorage = { ...base, ...impl }
|
|
}
|
|
|
|
beforeEach(() => installStorage())
|
|
afterEach(() => {
|
|
delete (globalThis as { localStorage?: Storage }).localStorage
|
|
})
|
|
|
|
describe('lastGarden', () => {
|
|
it('round-trips a remembered garden id', () => {
|
|
expect(getLastGardenId()).toBeNull()
|
|
rememberLastGarden(42)
|
|
expect(getLastGardenId()).toBe(42)
|
|
})
|
|
|
|
it('rejects a non-positive or unparseable stored value', () => {
|
|
localStorage.setItem('pansy:last-garden', 'not-a-number')
|
|
expect(getLastGardenId()).toBeNull()
|
|
localStorage.setItem('pansy:last-garden', '0')
|
|
expect(getLastGardenId()).toBeNull()
|
|
localStorage.setItem('pansy:last-garden', '-3')
|
|
expect(getLastGardenId()).toBeNull()
|
|
})
|
|
|
|
it('forgets unconditionally with no argument', () => {
|
|
rememberLastGarden(7)
|
|
forgetLastGarden()
|
|
expect(getLastGardenId()).toBeNull()
|
|
})
|
|
|
|
it('forgets only when the stored id matches onlyIfEquals', () => {
|
|
rememberLastGarden(5)
|
|
// A 404 on a different (directly-linked) garden must not wipe the good resume.
|
|
forgetLastGarden(9)
|
|
expect(getLastGardenId()).toBe(5)
|
|
// A 404 on the stored garden itself does clear it.
|
|
forgetLastGarden(5)
|
|
expect(getLastGardenId()).toBeNull()
|
|
})
|
|
|
|
it('swallows storage failures instead of throwing', () => {
|
|
installStorage({
|
|
setItem: () => {
|
|
throw new Error('quota')
|
|
},
|
|
getItem: () => {
|
|
throw new Error('blocked')
|
|
},
|
|
})
|
|
expect(() => rememberLastGarden(1)).not.toThrow()
|
|
expect(getLastGardenId()).toBeNull() // getItem throwing → null, not a crash
|
|
})
|
|
})
|