feat: initial sketch with AI

This commit is contained in:
2026-08-21 17:09:32 +02:00
parent d3fe34cb22
commit 10aefdd655
40 changed files with 13491 additions and 459 deletions
+267
View File
@@ -0,0 +1,267 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
type Record = { id?: unknown; [key: string]: unknown }
function createFakeTable(autoIncrement = false) {
const store = new Map<unknown, Record>()
let nextId = 1
const table = {
async toArray() {
return Array.from(store.values()).map((v) => ({ ...v }))
},
async add(record: Record) {
const id = autoIncrement ? nextId++ : record.id
const toStore = autoIncrement ? { ...record, id } : record
store.set(id, { ...toStore })
return id
},
async get(id: unknown) {
const found = store.get(id)
return found ? { ...found } : undefined
},
async put(record: Record) {
store.set(record.id, { ...record })
return record.id
},
async delete(id: unknown) {
store.delete(id)
},
async update(id: unknown, changes: Record) {
const existing = store.get(id)
if (!existing) return 0
store.set(id, { ...existing, ...changes })
return 1
},
async count() {
return store.size
},
where(field: string) {
return {
equals(value: unknown) {
return {
async toArray() {
return Array.from(store.values())
.filter((v) => v[field] === value)
.map((v) => ({ ...v }))
},
}
},
}
},
orderBy(field: string) {
return {
async toArray() {
return Array.from(store.values())
.sort((a, b) => Number(a[field]) - Number(b[field]))
.map((v) => ({ ...v }))
},
}
},
filter(predicate: (record: Record) => boolean) {
return {
async toArray() {
return Array.from(store.values())
.filter(predicate)
.map((v) => ({ ...v }))
},
}
},
}
return table
}
const fakeDb = {
lists: createFakeTable(),
listItems: createFakeTable(),
syncQueue: createFakeTable(true),
}
vi.mock('@/database/db', () => ({
db: fakeDb,
}))
const listsApiMocks = vi.hoisted(() => ({
getListsApi: vi.fn(),
createListApi: vi.fn(),
getListItemsApi: vi.fn(),
createListItemApi: vi.fn(),
updateListItemApi: vi.fn(),
setListItemCompletedApi: vi.fn(),
addUserToListApi: vi.fn(),
removeUserFromListApi: vi.fn(),
}))
vi.mock('@/api/lists', () => listsApiMocks)
const { useListsStore } = await import('../lists')
describe('useListsStore', () => {
beforeEach(async () => {
setActivePinia(createPinia())
vi.restoreAllMocks()
Object.values(listsApiMocks).forEach((mock) => mock.mockReset())
for (const table of Object.values(fakeDb)) {
const all = await table.toArray()
for (const record of all) {
await table.delete(record.id)
}
}
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })
// Default the read endpoints to an empty result so that the pullFromServer()
// step chained onto every sync() call doesn't interfere with unrelated tests.
listsApiMocks.getListsApi.mockResolvedValue([])
listsApiMocks.getListItemsApi.mockResolvedValue([])
})
it('creates a list locally, queues a sync entry, and remaps the id after a successful sync', async () => {
listsApiMocks.createListApi.mockResolvedValueOnce({ id: 'server-id-1', name: 'Groceries' })
const store = useListsStore()
const localList = await store.createList('Groceries', [])
// wait for the fire-and-forget sync triggered by createList to settle
await store.sync()
expect(listsApiMocks.createListApi).toHaveBeenCalledWith({
name: 'Groceries',
user_ids: [],
})
expect(store.lists.find((list) => list.id === localList.id)).toBeUndefined()
const synced = store.lists.find((list) => list.id === 'server-id-1')
expect(synced).toBeDefined()
expect(synced?.pendingSync).toBe(false)
expect(store.pendingCount).toBe(0)
})
it('creates a list item locally and remaps it to the server-assigned id once synced', async () => {
listsApiMocks.createListItemApi.mockResolvedValueOnce({
id: 'server-item-1',
list_id: '',
title: 'Milk',
is_completed: false,
})
const store = useListsStore()
const item = await store.createListItem('list-1', 'Milk')
await store.sync()
expect(listsApiMocks.createListItemApi).toHaveBeenCalledWith({
list_id: 'list-1',
title: 'Milk',
})
expect(store.listItems.find((entry) => entry.id === item.id)).toBeUndefined()
const synced = store.listItems.find((entry) => entry.id === 'server-item-1')
expect(synced).toBeDefined()
expect(synced?.pendingSync).toBe(false)
expect(store.pendingCount).toBe(0)
})
it('keeps the entry in the sync queue and records the error when the API call fails', async () => {
listsApiMocks.createListItemApi.mockRejectedValueOnce(new Error('Network error'))
const store = useListsStore()
await store.createListItem('list-1', 'Bread')
await store.sync()
expect(store.pendingCount).toBe(1)
expect(store.error).toBe('Network error')
})
it('updates a list item locally and pushes the change to the server', async () => {
listsApiMocks.createListItemApi.mockResolvedValueOnce({
id: 'server-item-2',
list_id: '',
title: 'Eggs',
is_completed: false,
})
listsApiMocks.updateListItemApi.mockResolvedValueOnce(undefined)
const store = useListsStore()
await store.createListItem('list-1', 'Eggs')
await store.sync()
const created = store.listItems.find((entry) => entry.title === 'Eggs')!
await store.updateListItem(created.id, { is_completed: true })
await store.sync()
expect(listsApiMocks.updateListItemApi).toHaveBeenCalledWith({
list_item_id: created.id,
is_completed: true,
})
const updated = store.listItems.find((entry) => entry.id === created.id)
expect(updated?.is_completed).toBe(true)
expect(updated?.pendingSync).toBe(false)
})
it('does not attempt to sync while offline', async () => {
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
const store = useListsStore()
await store.createList('Offline list')
await store.sync()
expect(listsApiMocks.createListApi).not.toHaveBeenCalled()
expect(store.pendingCount).toBe(1)
})
it('sets a list item completed locally and pushes it via the dedicated endpoint', async () => {
listsApiMocks.createListItemApi.mockResolvedValueOnce({
id: 'server-item-3',
list_id: '',
title: 'Eggs',
is_completed: false,
})
listsApiMocks.setListItemCompletedApi.mockResolvedValueOnce(undefined)
const store = useListsStore()
await store.createListItem('list-1', 'Eggs')
await store.sync()
const created = store.listItems.find((entry) => entry.title === 'Eggs')!
await store.setListItemCompleted(created.id, true)
await store.sync()
expect(listsApiMocks.setListItemCompletedApi).toHaveBeenCalledWith(created.id, {
is_completed: true,
})
const updated = store.listItems.find((entry) => entry.id === created.id)
expect(updated?.is_completed).toBe(true)
expect(updated?.pendingSync).toBe(false)
})
it('pulls lists and items from the server and merges them locally', async () => {
listsApiMocks.getListsApi.mockResolvedValueOnce([{ id: 'server-list-1', name: 'Groceries' }])
listsApiMocks.getListItemsApi.mockResolvedValueOnce([
{ id: 'server-item-1', list_id: 'server-list-1', title: 'Milk', is_completed: false },
])
const store = useListsStore()
await store.pullFromServer()
expect(store.lists.find((list) => list.id === 'server-list-1')).toBeDefined()
expect(store.listItems.find((item) => item.id === 'server-item-1')).toBeDefined()
})
it('does not overwrite a locally pending list with stale server data', async () => {
listsApiMocks.createListApi.mockImplementation(() => new Promise(() => {}))
const store = useListsStore()
const localList = await store.createList('Local only')
listsApiMocks.getListsApi.mockResolvedValueOnce([
{ id: localList.id, name: 'Server version' },
])
await store.pullFromServer()
const stillLocal = store.lists.find((list) => list.id === localList.id)
expect(stillLocal?.name).toBe('Local only')
expect(stillLocal?.pendingSync).toBe(true)
})
})