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() }) })