@@ -15,14 +15,20 @@ vi.mock('$lib/tauri-commands', async () => {
1515 }
1616} )
1717
18+ vi . mock ( '$lib/settings-store' , ( ) => ( {
19+ loadSettings : vi . fn ( ) ,
20+ } ) )
21+
1822import { getAiStatus , getAiModelInfo , startAiDownload , cancelAiDownload , dismissAiOffer } from '$lib/tauri-commands'
23+ import { loadSettings } from '$lib/settings-store'
1924import {
2025 getAiState ,
2126 initAiState ,
2227 handleDownload ,
2328 handleCancel ,
2429 handleDismiss ,
2530 handleGotIt ,
31+ notifyAiOnboardingComplete ,
2632 resetForTesting ,
2733} from './ai-state.svelte'
2834
@@ -35,10 +41,24 @@ const mockModelInfo = {
3541 baseOverheadBytes : 3500000000 ,
3642}
3743
44+ const ONBOARDED = {
45+ showHiddenFiles : true ,
46+ fullDiskAccessChoice : 'allow' as const ,
47+ isOnboarded : true ,
48+ }
49+
50+ const NOT_ONBOARDED = {
51+ showHiddenFiles : true ,
52+ fullDiskAccessChoice : 'notAskedYet' as const ,
53+ isOnboarded : false ,
54+ }
55+
3856describe ( 'ai-state' , ( ) => {
3957 beforeEach ( ( ) => {
4058 vi . clearAllMocks ( )
4159 resetForTesting ( )
60+ // Default: existing tests assume the user is past onboarding so the offer surfaces.
61+ vi . mocked ( loadSettings ) . mockResolvedValue ( ONBOARDED )
4262 } )
4363
4464 describe ( 'getAiState' , ( ) => {
@@ -266,4 +286,140 @@ describe('ai-state', () => {
266286 expect ( state . downloadProgress ) . toBeNull ( )
267287 } )
268288 } )
289+
290+ describe ( 'onboarding gate' , ( ) => {
291+ it ( 'suppresses Offer when isOnboarded is false' , async ( ) => {
292+ vi . mocked ( loadSettings ) . mockResolvedValue ( NOT_ONBOARDED )
293+ vi . mocked ( getAiStatus ) . mockResolvedValue ( 'offer' )
294+ vi . mocked ( getAiModelInfo ) . mockResolvedValue ( mockModelInfo )
295+
296+ await initAiState ( )
297+
298+ const state = getAiState ( )
299+ expect ( state . notificationState ) . toBe ( 'hidden' )
300+ expect ( state . pendingOffer ) . toBe ( true )
301+ expect ( state . onboarded ) . toBe ( false )
302+ } )
303+
304+ it ( 'surfaces Offer immediately when isOnboarded is true at init' , async ( ) => {
305+ vi . mocked ( loadSettings ) . mockResolvedValue ( ONBOARDED )
306+ vi . mocked ( getAiStatus ) . mockResolvedValue ( 'offer' )
307+ vi . mocked ( getAiModelInfo ) . mockResolvedValue ( mockModelInfo )
308+
309+ await initAiState ( )
310+
311+ const state = getAiState ( )
312+ expect ( state . notificationState ) . toBe ( 'offer' )
313+ expect ( state . pendingOffer ) . toBe ( false )
314+ expect ( state . onboarded ) . toBe ( true )
315+ } )
316+
317+ it ( 'surfaces a deferred Offer when notifyAiOnboardingComplete is called' , async ( ) => {
318+ vi . mocked ( loadSettings ) . mockResolvedValue ( NOT_ONBOARDED )
319+ vi . mocked ( getAiStatus ) . mockResolvedValue ( 'offer' )
320+ vi . mocked ( getAiModelInfo ) . mockResolvedValue ( mockModelInfo )
321+
322+ await initAiState ( )
323+ expect ( getAiState ( ) . notificationState ) . toBe ( 'hidden' )
324+
325+ notifyAiOnboardingComplete ( )
326+
327+ const state = getAiState ( )
328+ expect ( state . notificationState ) . toBe ( 'offer' )
329+ expect ( state . pendingOffer ) . toBe ( false )
330+ expect ( state . onboarded ) . toBe ( true )
331+ } )
332+
333+ it ( 'does not flip state to Offer when there is no pending offer' , async ( ) => {
334+ // Backend says Available — model already installed, no offer to surface.
335+ vi . mocked ( loadSettings ) . mockResolvedValue ( NOT_ONBOARDED )
336+ vi . mocked ( getAiStatus ) . mockResolvedValue ( 'available' )
337+ vi . mocked ( getAiModelInfo ) . mockResolvedValue ( mockModelInfo )
338+
339+ await initAiState ( )
340+ expect ( getAiState ( ) . notificationState ) . toBe ( 'hidden' )
341+
342+ notifyAiOnboardingComplete ( )
343+
344+ const state = getAiState ( )
345+ expect ( state . notificationState ) . toBe ( 'hidden' )
346+ expect ( state . onboarded ) . toBe ( true )
347+ } )
348+
349+ it ( 'is idempotent — second call does not re-trigger the offer' , async ( ) => {
350+ vi . mocked ( loadSettings ) . mockResolvedValue ( NOT_ONBOARDED )
351+ vi . mocked ( getAiStatus ) . mockResolvedValue ( 'offer' )
352+ vi . mocked ( getAiModelInfo ) . mockResolvedValue ( mockModelInfo )
353+
354+ await initAiState ( )
355+ notifyAiOnboardingComplete ( )
356+ // User dismisses the offer.
357+ vi . mocked ( dismissAiOffer ) . mockResolvedValue ( undefined )
358+ await handleDismiss ( )
359+ expect ( getAiState ( ) . notificationState ) . toBe ( 'hidden' )
360+
361+ // A second onboarding-complete signal must NOT resurrect the dismissed offer.
362+ notifyAiOnboardingComplete ( )
363+
364+ expect ( getAiState ( ) . notificationState ) . toBe ( 'hidden' )
365+ } )
366+
367+ it ( 'does not regress onboarded=true if notifyAiOnboardingComplete fires while loadSettings is in flight' , async ( ) => {
368+ // Race: legacy fallback paths in `+page.svelte` (hasFda + !isOnboarded, deny + !isOnboarded)
369+ // call `notifyAiOnboardingComplete()` without any user gate. If `initAiState` is still
370+ // awaiting `loadSettings()` when that hook fires — and disk hasn't synced the
371+ // `notifyOnboardingComplete()` save yet — a plain assignment in initAiState would clobber
372+ // the hook's `onboarded = true` back to a stale `false`, leaving the offer permanently gated.
373+ let resolveSettings : ( ( value : typeof NOT_ONBOARDED ) => void ) | undefined
374+ vi . mocked ( loadSettings ) . mockImplementation (
375+ ( ) =>
376+ new Promise < typeof NOT_ONBOARDED > ( ( resolve ) => {
377+ resolveSettings = resolve
378+ } ) ,
379+ )
380+ vi . mocked ( getAiStatus ) . mockResolvedValue ( 'offer' )
381+ vi . mocked ( getAiModelInfo ) . mockResolvedValue ( mockModelInfo )
382+
383+ const initPromise = initAiState ( )
384+
385+ // Simulate the legacy fallback firing the hook before loadSettings resolves.
386+ notifyAiOnboardingComplete ( )
387+ expect ( getAiState ( ) . onboarded ) . toBe ( true )
388+
389+ // Now resolve loadSettings with stale `isOnboarded: false` (disk not yet synced).
390+ resolveSettings ?.( NOT_ONBOARDED )
391+ await initPromise
392+
393+ const state = getAiState ( )
394+ expect ( state . onboarded ) . toBe ( true )
395+ // Status is `offer` and onboarded is sticky-true, so the offer surfaces directly.
396+ expect ( state . notificationState ) . toBe ( 'offer' )
397+ expect ( state . pendingOffer ) . toBe ( false )
398+ } )
399+
400+ it ( 'does not gate downloading state — install events flow through even when not onboarded' , async ( ) => {
401+ // Edge case: user starts onboarding, app keeps running, backend somehow emits installing/ready.
402+ // The gate only suppresses the initial Offer, not in-flight install signals (which can only
403+ // arise after the user accepted the offer somewhere).
404+ vi . mocked ( loadSettings ) . mockResolvedValue ( NOT_ONBOARDED )
405+ vi . mocked ( getAiStatus ) . mockResolvedValue ( 'offer' )
406+ vi . mocked ( getAiModelInfo ) . mockResolvedValue ( mockModelInfo )
407+ let installingCallback : ( ( ) => void ) | undefined
408+ let completeCallback : ( ( ) => void ) | undefined
409+ vi . mocked ( listen ) . mockImplementation ( ( event , callback ) => {
410+ if ( event === 'ai-installing' ) installingCallback = callback as ( ) => void
411+ if ( event === 'ai-install-complete' ) completeCallback = callback as ( ) => void
412+ return Promise . resolve ( ( ) => { } )
413+ } )
414+
415+ await initAiState ( )
416+ expect ( getAiState ( ) . notificationState ) . toBe ( 'hidden' )
417+
418+ installingCallback ?.( )
419+ expect ( getAiState ( ) . notificationState ) . toBe ( 'installing' )
420+
421+ completeCallback ?.( )
422+ expect ( getAiState ( ) . notificationState ) . toBe ( 'ready' )
423+ } )
424+ } )
269425} )
0 commit comments