Files
sky_phone/frontend/src/stores/app-store.test.ts
T
Leon.Schmidt 165d26ec9b ADD - register CityWarn phone app
Register CityWarn as a built-in, non-removable phone app with notification defaults and catalog ordering. Add its runtime WebP icon and update app inventory contracts to cover installation and launch metadata.
2026-08-18 15:36:26 +02:00

464 lines
14 KiB
TypeScript

import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DEFAULT_INSTALLED_PHONE_APP_IDS } from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import {
getHomeFolder,
HOME_GRID_PAGE_SIZE,
HOME_LAYOUT_VERSION,
removeHomeApp,
} from '@/utils/homeLayout'
const mocks = vi.hoisted(() => ({
phone: {
device: { imei: 'phone-a' },
isOpen: true,
saveDeviceNamespace: vi.fn(),
},
}))
vi.mock('@/stores/phone', () => ({
usePhoneStore: () => mocks.phone,
}))
describe('app store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mocks.phone.device.imei = 'phone-a'
mocks.phone.isOpen = true
mocks.phone.saveDeviceNamespace.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('installs the sixteen system apps by default', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
expect([...DEFAULT_INSTALLED_PHONE_APP_IDS]).toEqual([
'phone',
'messages',
'calculator',
'camera',
'clock',
'weather',
'mail',
'notes',
'memos',
'photos',
'app-store',
'settings',
'map',
'calendar',
'health',
'citywarn',
])
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
expect(apps.isInstalled(appId)).toBe(true)
}
expect(apps.isInstalled('banking')).toBe(false)
expect(apps.isInstalled('feather')).toBe(false)
expect(apps.isInstalled('snake')).toBe(false)
expect(apps.isInstalled('health')).toBe(true)
expect(apps.isInstalled('citywarn')).toBe(true)
})
it('removes old automatic apps unless the player installed them', () => {
const apps = useAppStoreStore()
apps.hydrate({
claimedApps: ['feather'],
homeLayout: {
dock: [],
grid: ['banking', 'feather', 'phone'],
hidden: [],
version: 5,
},
})
expect(apps.homeLayout.grid).not.toContain('banking')
expect(apps.homeLayout.grid).toContain('feather')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('hydrates valid launch counts and persists launches with claimed apps', () => {
const apps = useAppStoreStore()
apps.hydrate({
claimedApps: ['snake'],
launchCounts: { mail: 3, invalid: 20, phone: -1 },
})
apps.recordLaunch('mail')
expect(apps.launchCounts).toEqual({ mail: 4 })
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
claimedApps: ['snake'],
homeLayout: apps.homeLayout,
launchCounts: { mail: 4 },
})
})
it('keeps external app state while migrating version 4 layouts', () => {
const apps = useAppStoreStore()
const grid = Array.from({ length: 24 }, () => null as string | null)
grid[20] = 'external-radio'
apps.hydrate({
claimedApps: ['external-radio'],
homeLayout: { dock: [], grid, hidden: [], version: 4 },
launchCounts: { 'external-radio': 3 },
})
expect(apps.claimedApps).toEqual(['external-radio'])
expect(apps.homeLayout.version).toBe(HOME_LAYOUT_VERSION)
expect(apps.homeLayout.grid).toContain('external-radio')
const pageAppCount = apps.homeLayout.grid
.slice(0, HOME_GRID_PAGE_SIZE)
.filter((appId) => appId !== null).length
expect(apps.homeLayout.grid.slice(0, pageAppCount)).not.toContain(null)
expect(apps.launchCounts).toEqual({ 'external-radio': 3 })
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('keeps external app state from current layouts', () => {
const apps = useAppStoreStore()
apps.hydrate({
claimedApps: ['external-radio'],
homeLayout: {
dock: [],
grid: ['external-radio'],
hidden: [],
pageCount: 1,
version: HOME_LAYOUT_VERSION,
},
launchCounts: { 'external-radio': 3 },
})
expect(apps.claimedApps).toEqual(['external-radio'])
expect(apps.homeLayout.grid).toContain('external-radio')
expect(apps.launchCounts).toEqual({ 'external-radio': 3 })
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
})
it('persists a page-aware version 3 migration once', () => {
const apps = useAppStoreStore()
const grid = Array.from({ length: 40 }, () => null as string | null)
grid[20] = 'external-page-two'
apps.hydrate({
claimedApps: ['external-page-two'],
homeLayout: { dock: [], grid, hidden: [], version: 3 },
})
expect(apps.homeLayout.version).toBe(HOME_LAYOUT_VERSION)
expect(apps.homeLayout.grid[20]).not.toBe('external-page-two')
expect(apps.homeLayout.grid[24]).toBe('external-page-two')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('installs a downloadable app after a three second loading state', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.installApp('snake')
expect(apps.installingApps.snake).toBe(true)
expect(apps.claimedApps).toEqual([])
vi.advanceTimersByTime(2999)
expect(apps.claimedApps).toEqual([])
vi.advanceTimersByTime(1)
expect(apps.installingApps.snake).toBeUndefined()
expect(apps.claimedApps).toEqual(['snake'])
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
claimedApps: ['snake'],
homeLayout: apps.homeLayout,
launchCounts: {},
})
})
it('fully uninstalls removable apps and allows downloading them again', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['snake'] })
mocks.phone.saveDeviceNamespace.mockClear()
expect(apps.uninstallApp('snake')).toBe(true)
expect(apps.isInstalled('snake')).toBe(false)
expect(apps.uninstalledApps).toEqual(['snake'])
expect(apps.homeLayout.hidden).toContain('snake')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
claimedApps: [],
homeLayout: apps.homeLayout,
launchCounts: {},
uninstalledApps: ['snake'],
})
apps.installApp('snake')
vi.advanceTimersByTime(3000)
expect(apps.isInstalled('snake')).toBe(true)
expect(apps.uninstalledApps).toEqual([])
expect(apps.homeLayout.hidden).not.toContain('snake')
})
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
const apps = useAppStoreStore()
apps.hydrate({
uninstalledApps: ['calculator', 'phone', 'not-an-app'],
})
expect(apps.uninstalledApps).toEqual([])
expect(apps.isInstalled('calculator')).toBe(true)
expect(apps.isInstalled('phone')).toBe(true)
})
it('protects every default app from full uninstallation', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
expect(apps.uninstallApp(appId)).toBe(false)
expect(apps.isInstalled(appId)).toBe(true)
}
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
})
it('ignores duplicate installation requests and invalid persisted ids', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['memory', 'not-an-app'] })
apps.installApp('snake')
apps.installApp('snake')
vi.advanceTimersByTime(3000)
expect(apps.claimedApps).toEqual(['memory', 'snake'])
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('reinstalls claimed apps removed from the Home Screen', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['memory'] })
apps.removeHomeApp('notes')
apps.removeHomeApp('memory')
mocks.phone.saveDeviceNamespace.mockClear()
apps.installApp('notes')
apps.installApp('memory')
expect(apps.installingApps).toEqual({ memory: true })
expect(apps.homeLayout.hidden).toEqual(['memory'])
vi.advanceTimersByTime(3000)
expect(apps.installingApps).toEqual({})
expect(apps.homeLayout.hidden).toEqual([])
expect(apps.homeLayout.grid).toContain('notes')
expect(apps.homeLayout.grid).toContain('memory')
expect(apps.claimedApps).toEqual(['memory'])
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('prevents every default app from being removed from the Home Screen', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
apps.removeHomeApp(appId)
expect(apps.homeLayout.hidden).not.toContain(appId)
}
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
})
it('restores protected apps hidden by older persisted layouts', () => {
const apps = useAppStoreStore()
const legacyLayout = removeHomeApp(apps.homeLayout, 'mail')
apps.hydrate({ homeLayout: legacyLayout })
expect(apps.homeLayout.hidden).not.toContain('mail')
expect(apps.homeLayout.grid).toContain('mail')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledWith('apps', {
claimedApps: [],
homeLayout: apps.homeLayout,
launchCounts: {},
})
})
it('persists home reordering and removal independently from installation', () => {
const apps = useAppStoreStore()
apps.hydrate({ claimedApps: ['memory'] })
mocks.phone.saveDeviceNamespace.mockClear()
const memoryIndex = apps.homeLayout.grid.indexOf('memory')
apps.moveHomeApp('grid', memoryIndex, 'grid', 0)
expect(apps.homeLayout.grid[0]).toBe('memory')
apps.removeHomeApp('memory')
expect(apps.homeLayout.grid).not.toContain('memory')
expect(apps.homeLayout.hidden).toContain('memory')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
claimedApps: ['memory'],
homeLayout: apps.homeLayout,
launchCounts: {},
})
})
it('persists a cross-page drop exactly once', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
const sourceIndex = apps.homeLayout.grid.indexOf('phone')
expect(sourceIndex).toBeGreaterThanOrEqual(0)
expect(
apps.moveHomeAppToGridPage('grid', sourceIndex, 2, 0, [
HOME_GRID_PAGE_SIZE,
HOME_GRID_PAGE_SIZE,
]),
).toBe(true)
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('persists widget reflow without pulling apps back a page', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
const originalApps = apps.homeLayout.grid.filter(Boolean)
expect(
apps.applyWidgetGridCapacities(
[10, HOME_GRID_PAGE_SIZE],
[HOME_GRID_PAGE_SIZE, 10],
),
).toBe(true)
expect(
apps.homeLayout.grid.slice(0, HOME_GRID_PAGE_SIZE).filter(Boolean),
).toEqual(originalApps.slice(0, 10))
expect(
apps.homeLayout.grid
.slice(HOME_GRID_PAGE_SIZE, HOME_GRID_PAGE_SIZE * 2)
.filter(Boolean),
).toEqual(originalApps.slice(10))
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('persists and restores an otherwise empty extra page', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
expect(apps.addHomePage()).toBe(true)
expect(apps.homeLayout.pageCount).toBe(2)
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
const persisted = mocks.phone.saveDeviceNamespace.mock.calls[0]?.[1] as {
homeLayout: typeof apps.homeLayout
}
apps.hydrate({
...persisted,
homeLayout: {
...persisted.homeLayout,
grid: persisted.homeLayout.grid.slice(0, HOME_GRID_PAGE_SIZE),
},
})
expect(apps.homeLayout.pageCount).toBe(2)
expect(apps.homeLayout.grid).toHaveLength(HOME_GRID_PAGE_SIZE * 2)
})
it('persists the complete folder lifecycle through store actions', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
const notesIndex = apps.homeLayout.grid.indexOf('notes')
const clockIndex = apps.homeLayout.grid.indexOf('clock')
const folderId = apps.createHomeFolder(
'grid',
notesIndex,
'grid',
clockIndex,
'Utilities',
)
expect(folderId).toBeTruthy()
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
'clock',
'notes',
])
const mailIndex = apps.homeLayout.grid.indexOf('mail')
expect(apps.addHomeAppToFolder('grid', mailIndex, folderId!)).toBe(true)
apps.moveHomeFolderApp(folderId!, 2, 0)
apps.renameHomeFolder(folderId!, 'Work')
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
apps: ['mail', 'notes', 'clock'],
name: 'Work',
})
const emptyIndex = apps.homeLayout.grid.indexOf(null)
expect(apps.extractHomeFolderApp(folderId!, 0, 'grid', emptyIndex)).toBe(
true,
)
expect(apps.homeLayout.grid).toContain('mail')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(5)
})
it('does not commit an installation to a different phone', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.installApp('snake')
mocks.phone.device.imei = 'phone-b'
vi.advanceTimersByTime(3000)
expect(apps.installingApps).toEqual({})
expect(apps.claimedApps).toEqual([])
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
})
it('cancels installation timers when hydration changes device scope', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.installApp('snake')
expect(vi.getTimerCount()).toBe(1)
mocks.phone.device.imei = 'phone-b'
apps.hydrate(null)
expect(vi.getTimerCount()).toBe(0)
vi.advanceTimersByTime(3000)
expect(apps.claimedApps).toEqual([])
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
})
it('cancels installation timers when the phone closes', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.installApp('snake')
mocks.phone.isOpen = false
apps.cancelPendingInstalls()
expect(vi.getTimerCount()).toBe(0)
expect(apps.installingApps).toEqual({})
vi.advanceTimersByTime(3000)
expect(apps.claimedApps).toEqual([])
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
})
})