// The point of the two-stage load: the count is on screen before the follow // set arrives, because on a cold cache that set is a walk of the viewer's whole // follow list and no row may be held open for it (see src/background.ts). // // `load` never touches the DOM — it redraws by calling back into the host — so // this exercises the real card without a document. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PageState, SessionInfo } from '../../lib/types' import type { CardHost } from './card' const scanSubscribers = vi.fn() const followedSubscribers = vi.fn() const viewerFollows = vi.fn() const peek = vi.fn() const put = vi.fn() vi.mock('../../lib/subscribers', () => ({ SCAN_CAP: 500, FOLLOWS_CAP: 10_000, scanSubscribers: (...a: unknown[]) => scanSubscribers(...a), followedSubscribers: (...a: unknown[]) => followedSubscribers(...a), })) vi.mock('./follows', () => ({ viewerFollows: (...a: unknown[]) => viewerFollows(...a), resetFollows: () => {}, })) vi.mock('../../lib/cache', () => ({ peek: (...a: unknown[]) => peek(...a), put: (...a: unknown[]) => put(...a), })) vi.mock('../dom', () => ({ $: () => ({}) })) vi.mock('../../lib/atproto', () => ({ bskyProfileUrl: (h: string) => `https://bsky.app/${h}` })) const { facesCaveat, subscribersCard } = await import('./subscribers') const PUB = 'at://did:plc:pub00000000000000000000/site.standard.publication/self' const ME = 'did:plc:viewer000000000000000000' const FRIEND = 'did:plc:friend000000000000000000' /** What the host was told to draw, in the order it was told. */ let draws: number[] = [] function hostFor(did?: string): CardHost { return { state: () => ({ url: 'https://pub.example', fetchedAt: 0, pub: { uri: PUB } }) as PageState, session: () => (did ? ({ did } as SessionInfo) : undefined), rerender: () => draws.push(draws.length), loading: () => false, } } beforeEach(() => { draws = [] subscribersCard.reset?.() for (const m of [scanSubscribers, followedSubscribers, viewerFollows, peek, put]) m.mockReset() peek.mockResolvedValue(undefined) put.mockResolvedValue(undefined) scanSubscribers.mockResolvedValue({ total: 12, dids: [FRIEND], truncated: false }) followedSubscribers.mockResolvedValue({ followed: [{ did: FRIEND }], followedTotal: 1 }) }) afterEach(() => { vi.unstubAllGlobals() }) describe('the subscribers card', () => { it('draws the count before the follow set lands, and the faces after', async () => { let release!: (v: unknown) => void viewerFollows.mockReturnValue(new Promise((r) => (release = r))) const load = subscribersCard.load?.(hostFor(ME), false) // Let the scan settle without settling the follow set. await vi.waitFor(() => expect(draws).toHaveLength(1)) expect(followedSubscribers).not.toHaveBeenCalled() release({ did: ME, following: new Set([FRIEND]) }) await load expect(draws).toHaveLength(2) expect(followedSubscribers).toHaveBeenCalledOnce() }) it('does not ask the index for DIDs it has nobody to match them against', async () => { await subscribersCard.load?.(hostFor(undefined), false) expect(scanSubscribers).toHaveBeenCalledWith(PUB, undefined, { faces: false }) expect(viewerFollows).not.toHaveBeenCalled() // The count is the whole answer for a signed-out popup, so it is kept. expect(put).toHaveBeenCalledOnce() }) it('keeps the count, uncached, when the follow set never arrives', async () => { viewerFollows.mockResolvedValue(undefined) await subscribersCard.load?.(hostFor(ME), false) expect(draws).toHaveLength(1) // A half-answer is not one to remember for a day. expect(put).not.toHaveBeenCalled() }) it('does not file one account’s faces under another’s key', async () => { // Switched account while the walk was out: the answer is not this key's. viewerFollows.mockResolvedValue({ did: 'did:plc:someoneelse0000000000000', following: new Set() }) await subscribersCard.load?.(hostFor(ME), false) expect(put).not.toHaveBeenCalled() expect(followedSubscribers).not.toHaveBeenCalled() }) it('carries the follow set’s own cap into the summary', async () => { viewerFollows.mockResolvedValue({ did: ME, following: new Set([FRIEND]), truncated: true }) await subscribersCard.load?.(hostFor(ME), false) expect(put.mock.calls[0]?.[1]).toMatchObject({ followsTruncated: true }) }) it('serves a stored summary without touching the index', async () => { peek.mockResolvedValue({ total: 9, followed: [], followedTotal: 0, truncated: false }) await subscribersCard.load?.(hostFor(ME), false) expect(scanSubscribers).not.toHaveBeenCalled() expect(draws).toHaveLength(1) }) }) describe('facesCaveat', () => { const summary = { total: 9, followed: [], followedTotal: 1, truncated: false } it('says nothing when neither cap bit', () => { expect(facesCaveat(summary)).toBe('') }) it('names both windows a face can be missing from', () => { const both = facesCaveat({ ...summary, truncated: true, followsTruncated: true }) expect(both).toContain('500 subscribers') expect(both).toContain(`${(10_000).toLocaleString()} accounts you follow`) }) it('reads an entry cached before the follow cap existed as untruncated', () => { expect(facesCaveat({ ...summary, truncated: true })).not.toContain('you follow') }) })