// @vitest-environment happy-dom import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import FeedEmptyState from '../../src/components/posts/FeedEmptyState.vue' import { useCredentialsStore } from '../../src/stores/credentials.js' import { useMembershipReconcileStore } from '../../src/stores/membershipReconcile.js' import { useMembershipSyncStore } from '../../src/stores/membershipSync.js' import { useSourcesStore } from '../../src/stores/sources.js' import { mountWithStore } from '../support/mountComponent.js' // #387 B4. This is the first screen a fresh install shows anyone, so the thing // worth pinning is that it tells the two empties apart. Telling an operator to // "add a source" when they already have three and are mid-backfill is worse // than saying nothing — it reads as the app not knowing its own state. // The second argument is C6's world. Omitting it means "no credential", which // is both the fresh-install default and the rung every B4 case above asserts — // so those keep passing unchanged rather than needing the new vocabulary. const mountWith = (status, { credentials = [], sync = [], reconcile = [] } = {}) => mountWithStore(FeedEmptyState, () => { useSourcesStore().scheduleStatus = status useCredentialsStore().byPlatform = new Map( credentials.map((name) => [name, { platform: name }]), ) useMembershipSyncStore().platforms = sync useMembershipReconcileStore().platforms = reconcile }) const EMPTY = { total_sources: 0 } const SYNCED = [{ platform: 'patreon', last_success_at: '2026-09-12T00:00:00Z' }] const NEVER = [{ platform: 'patreon', last_success_at: null }] // Every rung's distinguishing phrase, so a test can assert that exactly ONE is // on screen. Each is chosen to sit on a SINGLE template line: a phrase spanning // a line break would never match once the renderer keeps the newline, and a // mutual-exclusion check whose phrases never match passes vacuously — coverage // in appearance only (rule #167). Both sides are whitespace-normalised anyway, // so indentation changes cannot quietly break it either. const RUNG_PHRASES = { credential: 'Add a credential', discover: 'Find what you already subscribe to', adopt: 'not being followed here yet', unavailable: "couldn't reach", manual: 'Everything you subscribe to on', } const flat = (s) => s.replace(/\s+/g, ' ').trim() function rungsShown (w) { const text = flat(w.text()) return Object.entries(RUNG_PHRASES) .filter(([, phrase]) => text.includes(flat(phrase))) .map(([name]) => name) } describe('FeedEmptyState', () => { beforeEach(() => { globalThis.fetch = vi.fn(async () => { throw new Error('offline') }) }) afterEach(() => vi.restoreAllMocks()) it('a fresh install gets the on-ramp, credential first', () => { const w = mountWith({ total_sources: 0 }) expect(w.text()).toContain('Add a credential') expect(w.text()).toContain('Add a source') expect(w.text()).not.toContain("See what's running") }) it('an install that is already fetching is told so, not told to set up', () => { const w = mountWith({ total_sources: 3 }) expect(w.text()).toContain('3 sources') expect(w.text()).toContain("See what's running") expect(w.text()).not.toContain('Add a credential') }) it('singularises a lone source', () => { const w = mountWith({ total_sources: 1 }) expect(w.text()).toContain('1 source') expect(w.text()).not.toContain('1 sources') }) it('falls back to the on-ramp when the status call never answered', () => { // Safe direction: the on-ramp is merely redundant to an established // operator, whereas "see what's running" shown to someone with nothing // configured is a dead end. const w = mountWith(null) expect(w.text()).toContain('Add a credential') }) it('shows the brand mark, sized to be readable', () => { // logo.svg stops reading below ~48px — that is why the 22px nav slot has a // different mark. If this ever shrinks to a glyph, it is the wrong asset. const img = mountWith({ total_sources: 0 }).find('img.fc-empty__mark') expect(img.exists()).toBe(true) expect(img.attributes('src')).toBe('/logo.svg') expect(Number(img.attributes('width'))).toBeGreaterThanOrEqual(48) // Decorative: the surrounding prose already carries the meaning. expect(img.attributes('alt')).toBe('') }) // --- #387 C6: the discovery rung ----------------------------------------- // // The loop this closes: a new installer has already told Patreon which // creators they follow, and making them retype that list is the friction the // whole milestone is about. These pin that the rung shown matches what is // actually POSSIBLE — offering discovery before a credential exists, or // after it has proven unreachable, is a button that cannot work. it('offers discovery once a credential exists and nothing has synced', () => { const w = mountWith(EMPTY, { credentials: ['patreon'], sync: NEVER }) expect(w.text()).toContain('Find what you already subscribe to') expect(w.text()).toContain('patreon') // Nothing is added for them — the offer/never-auto-add line from C4. expect(w.text()).toContain('Nothing is added automatically') }) it('never offers discovery before a credential exists', () => { // The button would 404 against an unconnected platform. Absence beats a // dead control on the first screen anyone sees. const w = mountWith(EMPTY, { sync: NEVER }) expect(w.text()).not.toContain('Find what you already subscribe to') expect(w.text()).toContain('Add a credential') }) it('a sweep that has never worked reads as unavailable, not as a retry', () => { // Rule #164: an install with no outbound network must reach this screen and // be told plainly. Not a spinner, not a crash, not a button that will fail // the same way — and the manual path stays open. const w = mountWith(EMPTY, { credentials: ['patreon'], sync: [{ platform: 'patreon', last_success_at: null, last_error_type: 'ConnectionError' }], }) expect(w.text()).toContain("couldn't reach") expect(w.text()).toContain('ConnectionError') expect(w.text()).toContain('Add a source') expect(w.text()).not.toContain('Find what you already subscribe to') }) it('a roster that synced once and failed since is NOT unavailable', () => { // It has a roster, just an ageing one — C4's freshness gate handles that. // Collapsing the two would hide a usable roster behind an error banner. const w = mountWith(EMPTY, { credentials: ['patreon'], sync: [{ platform: 'patreon', last_success_at: '2026-09-12T00:00:00Z', last_error_type: 'PatreonAuthError', }], reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }], }) expect(w.text()).not.toContain("couldn't reach") expect(w.text()).toContain('not being followed here yet') }) it('counts what there is to adopt, and sends them to the picker', () => { const w = mountWith(EMPTY, { credentials: ['patreon'], sync: SYNCED, reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }, { id: 2 }] }], }) expect(w.text()).toContain('2 creators you subscribe to are') expect(w.text()).toContain('Choose who to follow') }) it('singularises a lone unmatched creator', () => { const w = mountWith(EMPTY, { credentials: ['patreon'], sync: SYNCED, reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }], }) expect(w.text()).toContain('1 creator you subscribe to is') expect(w.text()).not.toContain('1 creators') }) it('a fully-tracked roster stops offering discovery', () => { // Nothing left to find, so the manual path is the honest last rung rather // than a discovery button that would return an empty list. const w = mountWith(EMPTY, { credentials: ['patreon'], sync: SYNCED, reconcile: [{ platform: 'patreon', subscribed_not_tracked: [] }], }) expect(w.text()).toContain('Everything you subscribe to on') expect(w.text()).toContain('Add a source') expect(w.text()).not.toContain('Find what you already subscribe to') expect(w.text()).not.toContain('not being followed here yet') }) it('shows exactly one rung in every state', () => { // The component claims the rungs are mutually exclusive by construction. // This is that claim, asserted — two on screen at once would be worst // exactly here, on the first screen a new installer ever sees. const states = [ [EMPTY, {}], [EMPTY, { credentials: ['patreon'], sync: NEVER }], [EMPTY, { credentials: ['patreon'], sync: [{ platform: 'patreon', last_success_at: null, last_error_type: 'ConnectionError' }] }], [EMPTY, { credentials: ['patreon'], sync: SYNCED, reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }] }], [EMPTY, { credentials: ['patreon'], sync: SYNCED, reconcile: [{ platform: 'patreon', subscribed_not_tracked: [] }] }], ] for (const [status, world] of states) { expect(rungsShown(mountWith(status, world))).toHaveLength(1) } }) it('an install that is already fetching never sees an on-ramp rung', () => { // hasSources wins over everything C6 added — someone mid-backfill is not // onboarding, whatever their roster says. const w = mountWith({ total_sources: 3 }, { credentials: ['patreon'], sync: SYNCED, reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }], }) expect(w.text()).toContain("See what's running") expect(rungsShown(w)).toHaveLength(0) }) })