删除 android/、ios/ 两个 Capacitor 原生工程与 capacitor.config.ts、 ionic.config.json,以及 scripts/android、scripts/ios.sh 构建脚本; 根与 apps/web 的 @capacitor/* 依赖和相关 npm scripts 一并移除。 Web 侧去掉原生分支:登录只走云乐坊容器授权面板或顶层重定向, 暗色模式不再改原生状态栏,移除 Android 物理返回键处理。 保留 @nuxtjs/ionic(Web UI 框架)、云乐坊 window.ylf 桥接与 PWA。 决策记录见 docs/decisions/0004-web-only.md。
139 lines
5.3 KiB
TypeScript
139 lines
5.3 KiB
TypeScript
import type { RecipeItem } from '../apps/web/app/types'
|
|
import type { DbRecipeItem } from '../apps/web/app/utils/db'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createCloudbaseUserDataRemote, USER_DATA_FUNCTION } from '../apps/web/app/adapters/userData'
|
|
import { favoriteEntries, setFavoriteEntries, toggleFavorite } from '../apps/web/app/composables/store/favorite'
|
|
import { addRecipeHistory, clearRecipeHistories, recipeHistories, removeRecipeHistory } from '../apps/web/app/composables/store/history'
|
|
import { exportLocalUserData, importLocalUserData, localUserData } from '../apps/web/app/composables/store/userData'
|
|
import { userDataMeta } from '../apps/web/app/composables/store/userDataMeta'
|
|
|
|
const NOW = 1_800_000_000_000
|
|
|
|
function recipe(name: string, id?: number): DbRecipeItem {
|
|
return { id, name, stuff: [], tools: [] }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ now: NOW })
|
|
setFavoriteEntries([])
|
|
recipeHistories.value = []
|
|
userDataMeta.value = { favoriteRemovals: [], historyRemovals: [], historyClearedAt: 0, lastUid: '' }
|
|
return () => vi.useRealTimers()
|
|
})
|
|
|
|
describe('favorite tombstones', () => {
|
|
it('records a removal and clears it when favorited again', () => {
|
|
toggleFavorite(recipe('A', 1))
|
|
expect(favoriteEntries.value).toEqual([{ id: 1, time: NOW }])
|
|
expect(userDataMeta.value.favoriteRemovals).toEqual([])
|
|
|
|
vi.advanceTimersByTime(10)
|
|
toggleFavorite(recipe('A', 1))
|
|
expect(favoriteEntries.value).toEqual([])
|
|
expect(userDataMeta.value.favoriteRemovals).toEqual([{ id: 1, time: NOW + 10 }])
|
|
|
|
vi.advanceTimersByTime(10)
|
|
toggleFavorite(recipe('A', 1))
|
|
expect(userDataMeta.value.favoriteRemovals).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('history tombstones', () => {
|
|
it('records single removals', () => {
|
|
addRecipeHistory(recipe('A'))
|
|
removeRecipeHistory(recipeHistories.value[0]!)
|
|
expect(recipeHistories.value).toEqual([])
|
|
expect(userDataMeta.value.historyRemovals).toEqual([{ name: 'A', time: NOW }])
|
|
})
|
|
|
|
it('records a clear watermark and drops single removals', () => {
|
|
addRecipeHistory(recipe('A'))
|
|
removeRecipeHistory(recipeHistories.value[0]!)
|
|
addRecipeHistory(recipe('B'))
|
|
clearRecipeHistories()
|
|
expect(recipeHistories.value).toEqual([])
|
|
expect(userDataMeta.value.historyClearedAt).toBe(NOW)
|
|
expect(userDataMeta.value.historyRemovals).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('export / import', () => {
|
|
it('merges an export from another device into local data', () => {
|
|
toggleFavorite(recipe('A', 1))
|
|
addRecipeHistory(recipe('A'))
|
|
const file = exportLocalUserData()
|
|
|
|
setFavoriteEntries([{ id: 2, time: NOW - 5 }])
|
|
recipeHistories.value = []
|
|
const result = importLocalUserData(JSON.stringify(file))
|
|
|
|
expect(result).toEqual({ favorites: 2, histories: 1 })
|
|
expect(favoriteEntries.value.map(f => f.id)).toEqual([2, 1])
|
|
expect(recipeHistories.value.map(h => h.recipe.name)).toEqual(['A'])
|
|
})
|
|
|
|
it('leaves local data alone for an unrecognised file', () => {
|
|
toggleFavorite(recipe('A', 1))
|
|
expect(importLocalUserData('{"favorites":[]}')).toBeNull()
|
|
expect(favoriteEntries.value).toEqual([{ id: 1, time: NOW }])
|
|
})
|
|
|
|
it('applies imported deletions', () => {
|
|
toggleFavorite(recipe('A', 1))
|
|
const file = exportLocalUserData()
|
|
file.favorites = []
|
|
file.favoriteRemovals = [{ id: 1, time: NOW + 1 }]
|
|
|
|
importLocalUserData(JSON.stringify(file))
|
|
expect(favoriteEntries.value).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('localUserData', () => {
|
|
it('only rewrites the last uid when it changes', () => {
|
|
const before = userDataMeta.value
|
|
localUserData.writeLastUid('')
|
|
expect(userDataMeta.value).toBe(before)
|
|
localUserData.writeLastUid('u1')
|
|
expect(localUserData.readLastUid()).toBe('u1')
|
|
})
|
|
})
|
|
|
|
describe('createCloudbaseUserDataRemote', () => {
|
|
const document = {
|
|
version: 1 as const,
|
|
favorites: [{ id: 1, time: NOW }],
|
|
favoriteRemovals: [],
|
|
histories: [{ recipe: { name: 'A', stuff: [], tools: [] } as RecipeItem, time: NOW }],
|
|
historyRemovals: [],
|
|
historyClearedAt: 0,
|
|
updatedAt: NOW,
|
|
}
|
|
|
|
it('reads through the cook-user-data function without naming a uid', async () => {
|
|
const callFunction = vi.fn(async () => ({ ok: true, document: { favorites: [] } }))
|
|
const remote = createCloudbaseUserDataRemote(callFunction)
|
|
|
|
await expect(remote.read('u1')).resolves.toEqual({ favorites: [] })
|
|
expect(callFunction).toHaveBeenCalledWith(USER_DATA_FUNCTION, { action: 'get' })
|
|
})
|
|
|
|
it('returns null when the account has no document', async () => {
|
|
const remote = createCloudbaseUserDataRemote(async () => ({ ok: true, document: null }))
|
|
await expect(remote.read('u1')).resolves.toBeNull()
|
|
})
|
|
|
|
it('writes the whole document', async () => {
|
|
const callFunction = vi.fn(async () => ({ ok: true, updatedAt: NOW }))
|
|
await createCloudbaseUserDataRemote(callFunction).write('u1', document)
|
|
expect(callFunction).toHaveBeenCalledWith(USER_DATA_FUNCTION, { action: 'set', document })
|
|
})
|
|
|
|
it('surfaces function failures', async () => {
|
|
const remote = createCloudbaseUserDataRemote(async () => ({ ok: false, code: 'LOGIN_REQUIRED' }))
|
|
await expect(remote.read('u1')).rejects.toThrow('LOGIN_REQUIRED')
|
|
await expect(remote.write('u1', document)).rejects.toThrow('LOGIN_REQUIRED')
|
|
await expect(createCloudbaseUserDataRemote(async () => null).read('u1')).rejects.toThrow('unknown')
|
|
})
|
|
})
|