File size: 4,597 Bytes
a1b6014
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'

const { request } = vi.hoisted(() => ({ request: vi.fn(async () => undefined) }))

vi.mock('@/store/gateway', async () => {
  const { atom } = await import('nanostores')

  return { $gateway: atom<unknown>(null), activeGateway: () => ({ request }) }
})
vi.mock('@/store/session', async () => {
  const { atom } = await import('nanostores')

  return { $awaitingResponse: atom(false), $busy: atom(false) }
})
vi.mock('react-router', () => ({ useNavigate: () => vi.fn() }))
vi.mock('./local-setup-offer', () => ({ offerLocalSetupTip: () => false }))
vi.mock('./local-runtime-update-offer', () => ({ offerLocalRuntimeUpdateTip: () => false }))

import { en } from '@/i18n/en'

const DAY_MS = 24 * 60 * 60_000
const START = new Date('2026-01-01T12:00:00Z').getTime()

beforeEach(() => {
  vi.resetModules()
  localStorage.clear()
  request.mockClear()
  vi.useFakeTimers()
  vi.setSystemTime(START)
})

afterEach(() => {
  cleanup()
  vi.useRealTimers()
})

it('retires both tutorial features after 30 days across launches, then keeps a manual re-enable', async () => {
  const { useTipRotation } = await import('./use-tip-rotation')
  let tips = await import('@/store/tips')
  let tours = await import('@/store/tours')
  const firstLaunch = renderHook(() => useTipRotation(en.tips))

  expect(tips.$tipsEnabled.get()).toBe(true)
  expect(tours.$toursEnabled.get()).toBe(true)
  expect(request).not.toHaveBeenCalled()
  firstLaunch.unmount()

  // Reload updated modules against the same persistent storage, just as an
  // app update does. The deadline must remain anchored to the first launch.
  vi.setSystemTime(START + 30 * DAY_MS - 30_000)
  vi.resetModules()
  const returning = await import('./use-tip-rotation')
  tips = await import('@/store/tips')
  tours = await import('@/store/tours')
  const secondLaunch = renderHook(() => returning.useTipRotation(en.tips))

  expect(tips.$tipsEnabled.get()).toBe(true)
  expect(tours.$toursEnabled.get()).toBe(true)
  tips.resetTips() // Replaying the catalog must not restart the month either.
  tips.showTip({ side: 'bottom', targets: ['body'], text: 'A pending tip' })

  act(() => vi.advanceTimersByTime(30_000))

  expect(tips.$tipsEnabled.get()).toBe(false)
  expect(tours.$toursEnabled.get()).toBe(false)
  expect(tips.$activeTip.get()).toBeNull()
  expect(request).toHaveBeenCalledWith('config.set', { key: 'display.in_app_tips', value: 'false' })
  expect(request).toHaveBeenCalledWith('config.set', { key: 'display.in_app_tours', value: 'false' })

  act(() => {
    tips.setTipsEnabled(true)
    tours.setToursEnabled(true)
    tips.resetTips()
  })
  secondLaunch.unmount()

  vi.setSystemTime(START + 60 * DAY_MS)
  vi.resetModules()
  const later = await import('./use-tip-rotation')
  tips = await import('@/store/tips')
  tours = await import('@/store/tours')
  renderHook(() => later.useTipRotation(en.tips))
  act(() => vi.advanceTimersByTime(30_000))

  expect(tips.$tipsEnabled.get()).toBe(true)
  expect(tours.$toursEnabled.get()).toBe(true)
})

it('uses existing tip history on upgrade without treating invalid dates as experience', async () => {
  localStorage.setItem(
    'hermes.desktop.tips.shownAt.v1',
    JSON.stringify({
      old: START - 31 * DAY_MS,
      recent: START - DAY_MS,
      invalid: 'not a timestamp',
      future: START + DAY_MS,
      zero: 0,
      negative: -1
    })
  )
  const { useTipRotation } = await import('./use-tip-rotation')
  let tips = await import('@/store/tips')
  let tours = await import('@/store/tours')
  const upgraded = renderHook(() => useTipRotation(en.tips))

  expect(tips.$tipsEnabled.get()).toBe(false)
  expect(tours.$toursEnabled.get()).toBe(false)
  upgraded.unmount()

  localStorage.clear()
  localStorage.setItem(
    'hermes.desktop.tips.shownAt.v1',
    JSON.stringify({ invalid: 'not a timestamp', future: START + DAY_MS, zero: 0, negative: -1 })
  )
  localStorage.setItem('hermes.desktop.tips.rotation.v1', 'false')
  vi.resetModules()
  const fresh = await import('./use-tip-rotation')
  tips = await import('@/store/tips')
  tours = await import('@/store/tours')
  renderHook(() => fresh.useTipRotation(en.tips))

  // No usable history: begin the month now, never turn an existing Off on.
  expect(tips.$tipsEnabled.get()).toBe(false)
  expect(tours.$toursEnabled.get()).toBe(true)
  act(() => {
    vi.setSystemTime(START + 30 * DAY_MS)
    vi.advanceTimersByTime(30_000)
  })
  expect(tours.$toursEnabled.get()).toBe(false)
})