diff --git a/frontend/src/components/posts/FeedEmptyState.vue b/frontend/src/components/posts/FeedEmptyState.vue index 24a32db..873b058 100644 --- a/frontend/src/components/posts/FeedEmptyState.vue +++ b/frontend/src/components/posts/FeedEmptyState.vue @@ -30,40 +30,140 @@ + each other — a source cannot fetch anything without a credential. + #387 C6 adds the third rung: once a credential exists, FC can just + LOOK UP what the operator already subscribes to instead of making + them retype it. Exactly one rung shows, chosen by `rung` below. --> diff --git a/frontend/test/components/feedEmptyState.spec.js b/frontend/test/components/feedEmptyState.spec.js index 2b6d11d..904e12c 100644 --- a/frontend/test/components/feedEmptyState.spec.js +++ b/frontend/test/components/feedEmptyState.spec.js @@ -2,6 +2,9 @@ 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' @@ -10,9 +13,45 @@ import { mountWithStore } from '../support/mountComponent.js' // "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. -const mountWith = (status) => mountWithStore(FeedEmptyState, () => { - useSourcesStore().scheduleStatus = status -}) +// 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(() => { @@ -58,4 +97,120 @@ describe('FeedEmptyState', () => { // 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) + }) })