add view transitions, update tests for pglite, remove dead safe-area css, update AGENTS.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -101,11 +101,3 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
.safe-area-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { createTestDb } from "@/shared/db/client"
|
||||
import type { PGlite } from "@electric-sql/pglite"
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import type { FeedItem } from "./assemble-feed"
|
||||
import { clearFeedCache, loadFeedCache, mergeFeedItems, saveFeedCache } from "./feed-cache"
|
||||
|
||||
@@ -5,36 +8,30 @@ function makeItem(id: string, date: string | null = "2025-01-15", title = `Poll
|
||||
return { id, kind: "poll", status: "past", title, url: null, date, topics: [], source: "Bundestag" }
|
||||
}
|
||||
|
||||
beforeEach(() => localStorage.clear())
|
||||
let db: PGlite
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await createTestDb()
|
||||
})
|
||||
|
||||
describe("feed cache persistence", () => {
|
||||
it("returns null when no cache exists", () => {
|
||||
expect(loadFeedCache()).toBeNull()
|
||||
it("returns null when no cache exists", async () => {
|
||||
expect(await loadFeedCache(db)).toBeNull()
|
||||
})
|
||||
|
||||
it("round-trips save and load", () => {
|
||||
it("round-trips save and load", async () => {
|
||||
const items = [makeItem("poll-1"), makeItem("poll-2")]
|
||||
saveFeedCache(items)
|
||||
const loaded = loadFeedCache()
|
||||
await saveFeedCache(db, items)
|
||||
const loaded = await loadFeedCache(db)
|
||||
expect(loaded).not.toBeNull()
|
||||
expect(loaded?.items).toEqual(items)
|
||||
expect(typeof loaded?.updatedAt).toBe("number")
|
||||
})
|
||||
|
||||
it("clears cache", () => {
|
||||
saveFeedCache([makeItem("poll-1")])
|
||||
clearFeedCache()
|
||||
expect(loadFeedCache()).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for corrupted data", () => {
|
||||
localStorage.setItem("agw_feed_cache", "not-json")
|
||||
expect(loadFeedCache()).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for missing items array", () => {
|
||||
localStorage.setItem("agw_feed_cache", JSON.stringify({ updatedAt: 123 }))
|
||||
expect(loadFeedCache()).toBeNull()
|
||||
it("clears cache", async () => {
|
||||
await saveFeedCache(db, [makeItem("poll-1")])
|
||||
await clearFeedCache(db)
|
||||
expect(await loadFeedCache(db)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { createTestDb } from "@/shared/db/client"
|
||||
import type { PGlite } from "@electric-sql/pglite"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { BUNDESLAND_TO_PARLIAMENT, clearGeoCache, detectFromCoords, loadCachedResult } from "./geo"
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
let db: PGlite
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal("fetch", mockFetch)
|
||||
localStorage.clear()
|
||||
db = await createTestDb()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -47,7 +50,7 @@ describe("detectFromCoords", () => {
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 }))
|
||||
|
||||
const result = await detectFromCoords(48.1351, 11.582)
|
||||
const result = await detectFromCoords(db, 48.1351, 11.582)
|
||||
expect(result.bundesland).toBe("Bayern")
|
||||
expect(result.landtag_label).toBe("Bayerischer Landtag")
|
||||
expect(result.landtag_parliament_period_id).toBe(149)
|
||||
@@ -60,11 +63,11 @@ describe("detectFromCoords", () => {
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 }))
|
||||
|
||||
await detectFromCoords(48.1351, 11.582)
|
||||
await detectFromCoords(db, 48.1351, 11.582)
|
||||
|
||||
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
|
||||
|
||||
const result = await detectFromCoords(48.2, 11.6)
|
||||
const result = await detectFromCoords(db, 48.2, 11.6)
|
||||
expect(result.mandates).toHaveLength(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
@@ -74,13 +77,13 @@ describe("detectFromCoords", () => {
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 }))
|
||||
|
||||
await detectFromCoords(48.1351, 11.582)
|
||||
await detectFromCoords(db, 48.1351, 11.582)
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 }))
|
||||
|
||||
await detectFromCoords(48.2, 11.6, true)
|
||||
await detectFromCoords(db, 48.2, 11.6, true)
|
||||
// 4 fetches: Nominatim + mandates + Nominatim + mandates (cache skipped)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
@@ -88,7 +91,7 @@ describe("detectFromCoords", () => {
|
||||
it("returns null for unknown state", async () => {
|
||||
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Unknown" } }), { status: 200 }))
|
||||
|
||||
const result = await detectFromCoords(0, 0)
|
||||
const result = await detectFromCoords(db, 0, 0)
|
||||
expect(result.bundesland).toBe("Unknown")
|
||||
expect(result.landtag_label).toBeNull()
|
||||
expect(result.mandates).toEqual([])
|
||||
@@ -97,20 +100,20 @@ describe("detectFromCoords", () => {
|
||||
it("returns null bundesland when address has no state", async () => {
|
||||
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ address: {} }), { status: 200 }))
|
||||
|
||||
const result = await detectFromCoords(0, 0)
|
||||
const result = await detectFromCoords(db, 0, 0)
|
||||
expect(result.bundesland).toBeNull()
|
||||
expect(result.mandates).toEqual([])
|
||||
})
|
||||
|
||||
it("throws on nominatim error", async () => {
|
||||
mockFetch.mockResolvedValueOnce(new Response("error", { status: 500 }))
|
||||
await expect(detectFromCoords(0, 0)).rejects.toThrow("Nominatim error 500")
|
||||
await expect(detectFromCoords(db, 0, 0)).rejects.toThrow("Nominatim error 500")
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadCachedResult", () => {
|
||||
it("returns null when no cache exists", () => {
|
||||
expect(loadCachedResult()).toBeNull()
|
||||
it("returns null when no cache exists", async () => {
|
||||
expect(await loadCachedResult(db)).toBeNull()
|
||||
})
|
||||
|
||||
it("returns cached result after a successful detect", async () => {
|
||||
@@ -118,9 +121,9 @@ describe("loadCachedResult", () => {
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 }))
|
||||
|
||||
await detectFromCoords(48.1351, 11.582)
|
||||
await detectFromCoords(db, 48.1351, 11.582)
|
||||
|
||||
const cached = loadCachedResult()
|
||||
const cached = await loadCachedResult(db)
|
||||
expect(cached).not.toBeNull()
|
||||
expect(cached?.bundesland).toBe("Bayern")
|
||||
expect(cached?.mandates).toHaveLength(1)
|
||||
@@ -133,10 +136,10 @@ describe("clearGeoCache", () => {
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ address: { state: "Bayern" } }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ data: MANDATE_RESPONSE }), { status: 200 }))
|
||||
|
||||
await detectFromCoords(48.1351, 11.582)
|
||||
expect(loadCachedResult()).not.toBeNull()
|
||||
await detectFromCoords(db, 48.1351, 11.582)
|
||||
expect(await loadCachedResult(db)).not.toBeNull()
|
||||
|
||||
clearGeoCache()
|
||||
expect(loadCachedResult()).toBeNull()
|
||||
await clearGeoCache(db)
|
||||
expect(await loadCachedResult(db)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,7 +72,17 @@ function AppLayout() {
|
||||
<TabbarLink
|
||||
key={tab.to}
|
||||
active={currentPath === tab.to}
|
||||
onClick={() => navigate({ to: tab.to })}
|
||||
onClick={() => {
|
||||
const go = () => navigate({ to: tab.to })
|
||||
if (
|
||||
document.startViewTransition &&
|
||||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
document.startViewTransition(go)
|
||||
} else {
|
||||
go()
|
||||
}
|
||||
}}
|
||||
icon={<TabIcon d={tab.icon} />}
|
||||
label={tab.label}
|
||||
/>
|
||||
|
||||
@@ -4,8 +4,7 @@ import { migrations } from "./migrations"
|
||||
let instance: PGlite | null = null
|
||||
let initPromise: Promise<PGlite> | null = null
|
||||
|
||||
async function createDb(dataDir?: string): Promise<PGlite> {
|
||||
const db = new PGlite(dataDir ?? "idb://agw")
|
||||
async function initDb(db: PGlite): Promise<PGlite> {
|
||||
for (const sql of migrations) {
|
||||
await db.exec(sql)
|
||||
}
|
||||
@@ -13,10 +12,10 @@ async function createDb(dataDir?: string): Promise<PGlite> {
|
||||
}
|
||||
|
||||
/** Get the singleton PGlite instance. Safe to call multiple times — only creates once. */
|
||||
export function getDb(dataDir?: string): Promise<PGlite> {
|
||||
export function getDb(): Promise<PGlite> {
|
||||
if (instance) return Promise.resolve(instance)
|
||||
if (!initPromise) {
|
||||
initPromise = createDb(dataDir).then((db) => {
|
||||
initPromise = initDb(new PGlite("idb://agw")).then((db) => {
|
||||
instance = db
|
||||
return db
|
||||
})
|
||||
@@ -26,7 +25,7 @@ export function getDb(dataDir?: string): Promise<PGlite> {
|
||||
|
||||
/** Create a fresh in-memory PGlite for tests. */
|
||||
export async function createTestDb(): Promise<PGlite> {
|
||||
return createDb(undefined)
|
||||
return initDb(new PGlite())
|
||||
}
|
||||
|
||||
/** Reset the singleton (for tests). */
|
||||
|
||||
@@ -1,74 +1,53 @@
|
||||
import { STORAGE_KEYS } from "@/shared/lib/constants"
|
||||
import { act, renderHook } from "@testing-library/react"
|
||||
import { createTestDb } from "@/shared/db/client"
|
||||
import { addFollow, getFollows, removeFollow } from "@/shared/db/follows"
|
||||
import type { PGlite } from "@electric-sql/pglite"
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { _resetFollowsCache, useFollows } from "./use-follows"
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
_resetFollowsCache()
|
||||
let db: PGlite
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await createTestDb()
|
||||
})
|
||||
|
||||
describe("useFollows", () => {
|
||||
it("starts with empty follows", () => {
|
||||
const { result } = renderHook(() => useFollows())
|
||||
expect(result.current.follows).toEqual([])
|
||||
describe("follows data access", () => {
|
||||
it("starts with empty follows", async () => {
|
||||
const follows = await getFollows(db)
|
||||
expect(follows).toEqual([])
|
||||
})
|
||||
|
||||
it("can follow a topic", () => {
|
||||
const { result } = renderHook(() => useFollows())
|
||||
act(() => {
|
||||
result.current.follow("topic", 1, "Umwelt")
|
||||
})
|
||||
expect(result.current.follows).toEqual([{ type: "topic", entity_id: 1, label: "Umwelt" }])
|
||||
expect(result.current.isFollowing("topic", 1)).toBe(true)
|
||||
it("can add a topic follow", async () => {
|
||||
await addFollow(db, "topic", 1, "Umwelt")
|
||||
const follows = await getFollows(db)
|
||||
expect(follows).toEqual([{ type: "topic", entity_id: 1, label: "Umwelt" }])
|
||||
})
|
||||
|
||||
it("can follow a politician", () => {
|
||||
const { result } = renderHook(() => useFollows())
|
||||
act(() => {
|
||||
result.current.follow("politician", 42, "Angela Merkel")
|
||||
})
|
||||
expect(result.current.isFollowing("politician", 42)).toBe(true)
|
||||
expect(result.current.isFollowing("topic", 42)).toBe(false)
|
||||
it("can add a politician follow", async () => {
|
||||
await addFollow(db, "politician", 42, "Angela Merkel")
|
||||
const follows = await getFollows(db)
|
||||
expect(follows).toHaveLength(1)
|
||||
expect(follows[0].type).toBe("politician")
|
||||
expect(follows[0].entity_id).toBe(42)
|
||||
})
|
||||
|
||||
it("can unfollow", () => {
|
||||
const { result } = renderHook(() => useFollows())
|
||||
act(() => {
|
||||
result.current.follow("topic", 1, "Umwelt")
|
||||
result.current.follow("topic", 2, "Bildung")
|
||||
})
|
||||
act(() => {
|
||||
result.current.unfollow("topic", 1)
|
||||
})
|
||||
expect(result.current.follows).toEqual([{ type: "topic", entity_id: 2, label: "Bildung" }])
|
||||
expect(result.current.isFollowing("topic", 1)).toBe(false)
|
||||
it("can remove a follow", async () => {
|
||||
await addFollow(db, "topic", 1, "Umwelt")
|
||||
await addFollow(db, "topic", 2, "Bildung")
|
||||
await removeFollow(db, "topic", 1)
|
||||
const follows = await getFollows(db)
|
||||
expect(follows).toEqual([{ type: "topic", entity_id: 2, label: "Bildung" }])
|
||||
})
|
||||
|
||||
it("does not duplicate follows", () => {
|
||||
const { result } = renderHook(() => useFollows())
|
||||
act(() => {
|
||||
result.current.follow("topic", 1, "Umwelt")
|
||||
result.current.follow("topic", 1, "Umwelt")
|
||||
})
|
||||
expect(result.current.follows).toHaveLength(1)
|
||||
it("does not duplicate follows (ON CONFLICT DO NOTHING)", async () => {
|
||||
await addFollow(db, "topic", 1, "Umwelt")
|
||||
await addFollow(db, "topic", 1, "Umwelt")
|
||||
const follows = await getFollows(db)
|
||||
expect(follows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("persists to localStorage", () => {
|
||||
const { result } = renderHook(() => useFollows())
|
||||
act(() => {
|
||||
result.current.follow("topic", 5, "Wirtschaft")
|
||||
})
|
||||
const raw = localStorage.getItem(STORAGE_KEYS.follows)
|
||||
expect(raw).toBeTruthy()
|
||||
// biome-ignore lint/style/noNonNullAssertion: asserted truthy above
|
||||
const parsed = JSON.parse(raw!)
|
||||
expect(parsed).toEqual([{ type: "topic", entity_id: 5, label: "Wirtschaft" }])
|
||||
})
|
||||
|
||||
it("loads from existing localStorage", () => {
|
||||
localStorage.setItem(STORAGE_KEYS.follows, JSON.stringify([{ type: "politician", entity_id: 99, label: "Test" }]))
|
||||
const { result } = renderHook(() => useFollows())
|
||||
expect(result.current.follows).toEqual([{ type: "politician", entity_id: 99, label: "Test" }])
|
||||
it("supports mixed types", async () => {
|
||||
await addFollow(db, "topic", 5, "Wirtschaft")
|
||||
await addFollow(db, "politician", 99, "Test")
|
||||
const follows = await getFollows(db)
|
||||
expect(follows).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user