import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { getFirestore, Timestamp } from 'firebase-admin/firestore'; import { bootAdmin, shutdownAdmin } from './support.js'; import { aggregateRankings, memberRankPatches } from '../src/fandom/fandom.js'; import { participateStreaming } from '../src/quest/quest.js'; import { FPI_AXES, type FpiAxis } from '../src/core/config.js'; /** * 사용자 · 국가 지표 랭킹 5분 집계(`fnRankingCountryAggregate`) · 스트리밍 무보상 참여(C-04). * * 화면이 읽는 필드 — `members/{uid}.fpiAxes · rankByAxis · rankPrevByAxis · topPercentByAxis · * countryCode · nickname` 과 `rankings/country-{축}` — 가 같은 원천(7일 fpiEvents)에서 나오는지 본다. */ const db = () => getFirestore(); const A = 'rank-test-a'; const B = 'rank-test-b'; const IDLE = 'rank-test-idle'; const USERS = ['rank-u1', 'rank-u2', 'rank-u3'] as const; const axes = (v: Partial>) => Object.fromEntries(FPI_AXES.map((a) => [a, v[a] ?? 0])) as Record; async function wipe() { for (const id of [A, B, IDLE]) await db().recursiveDelete(db().doc(`fandoms/${id}`)); for (const uid of USERS) await db().recursiveDelete(db().doc(`users/${uid}`)); for (const axis of FPI_AXES) await db().doc(`rankings/country-${axis}`).delete(); await db().doc('quests/rank-stream').delete(); } async function event(fandom: string, uid: string | null, axis: FpiAxis | null, value: number, daysAgo = 0) { await db().collection(`fandoms/${fandom}/fpiEvents`).add({ ...(uid ? { uid } : {}), ...(axis ? { axis } : { kind: 'score_adjustment' }), value, verified: true, occurredAt: Timestamp.fromMillis(Date.now() - daysAgo * 86_400_000), }); } beforeAll(() => { bootAdmin(); }); beforeEach(wipe); afterAll(async () => { await wipe(); await shutdownAdmin(); }); describe('memberRankPatches (순수)', () => { it('1·1·3 공동 순위 · 동점 내부 uid 오름차순 · 상위 % 는 기여 0 이면 비운다', () => { const out = memberRankPatches(new Map([ ['c', axes({ bpm: 5 })], ['a', axes({ bpm: 5 })], ['b', axes({ bpm: 1 })], ['d', axes({})], ])); expect(out.get('a')!.rankByAxis.bpm).toBe(1); expect(out.get('c')!.rankByAxis.bpm).toBe(1); expect(out.get('b')!.rankByAxis.bpm).toBe(3); expect(out.get('d')!.rankByAxis.bpm).toBe(4); // ⌈순위 ÷ 4 × 100⌉ expect(out.get('a')!.topPercentByAxis.bpm).toBe(25); expect(out.get('b')!.topPercentByAxis.bpm).toBe(75); expect(out.get('d')!.topPercentByAxis.bpm).toBeUndefined(); expect(out.get('a')!.topPercentByAxis.health).toBeUndefined(); }); }); describe('aggregateRankings — 사용자 · 국가 랭킹 (B-17 · C-19)', () => { it('멤버 축 값 · 순위 · 직전 순위 · 상위 % · 국가 합을 7일 이벤트에서 낸다', async () => { await db().doc(`users/${USERS[0]}`).set({ countryCode: 'KR', nickname: '하나' }); await db().doc(`users/${USERS[1]}`).set({ countryCode: 'JP', nickname: 'a@b.c' }); await db().doc(`fandoms/${A}`).set({ stage: 1, rankingEligible: true }); for (const uid of USERS) await db().doc(`fandoms/${A}/members/${uid}`).set({ uid }); await event(A, USERS[0], 'bpm', 3); await event(A, USERS[1], 'bpm', 5); await event(A, USERS[1], 'community', 2); await event(A, USERS[0], 'bpm', 100, 8); // 창 밖 await event(A, null, null, -50); // Eclipse 차감 — 개인 값 아님 await event(A, 'left-user', 'bpm', 9); // 탈퇴자 — 멤버 아님 await aggregateRankings(); const m = async (uid: string) => (await db().doc(`fandoms/${A}/members/${uid}`).get()).data()!; const u1 = await m(USERS[0]); expect(u1.fpiAxes.bpm).toBe(3); expect(u1.rankByAxis.bpm).toBe(2); expect(u1.rankPrevByAxis.bpm).toBe(2); // 첫 배치는 변동 0 expect(u1.topPercentByAxis).toEqual({ bpm: 67 }); expect(u1.countryCode).toBe('KR'); expect(u1.nickname).toBe('하나'); const u2 = await m(USERS[1]); expect(u2.rankByAxis.bpm).toBe(1); expect(u2.topPercentByAxis).toEqual({ bpm: 34, community: 34 }); expect(u2.nickname).toBe('팬'); // 이메일 형태 닉네임은 쓰지 않는다(M-7) expect((await m(USERS[2])).countryCode).toBe('ZZ'); // 프로필 없음 const bpm = (await db().doc('rankings/country-bpm').get()).get('rows'); expect(bpm).toEqual([ { code: 'JP', value: 5, rank: 1, change: 0 }, { code: 'KR', value: 3, rank: 2, change: 0 }, { code: 'ZZ', value: 0, rank: 3, change: 0 }, ]); // 순위가 뒤집히면 직전 순위가 남아 ▲▼ 근거가 된다. await event(A, USERS[0], 'bpm', 10); await aggregateRankings(); const after = await m(USERS[0]); expect(after.rankByAxis.bpm).toBe(1); expect(after.rankPrevByAxis.bpm).toBe(2); const rows = (await db().doc('rankings/country-bpm').get()).get('rows'); expect(rows[0]).toEqual({ code: 'KR', value: 13, rank: 1, change: 1 }); }); it('모집단 밖(최근 7일 활동 없음 · 성운)은 집계하지 않고 지난 순위를 지운다', async () => { await db().doc(`fandoms/${IDLE}`).set({ stage: 2, rankingEligible: true }); await db().doc(`fandoms/${IDLE}/members/${USERS[0]}`).set({ uid: USERS[0] }); await event(IDLE, USERS[0], 'health', 4); await db().doc(`fandoms/${B}`).set({ stage: 0, rankingEligible: false }); await db().doc(`fandoms/${B}/members/${USERS[1]}`).set({ uid: USERS[1] }); await event(B, USERS[1], 'health', 7); await aggregateRankings(); expect((await db().doc(`fandoms/${IDLE}/members/${USERS[0]}`).get()).get('rankByAxis.health')).toBe(1); expect((await db().doc(`fandoms/${B}/members/${USERS[1]}`).get()).get('fpiAxes')).toBeUndefined(); // 1분 집계가 모집단에서 내렸다(B-17). await db().doc(`fandoms/${IDLE}`).update({ rankingEligible: false }); await aggregateRankings(); const idle = (await db().doc(`fandoms/${IDLE}/members/${USERS[0]}`).get()).data()!; expect(idle.fpiAxes).toBeUndefined(); expect(idle.rankByAxis).toBeUndefined(); expect(idle.topPercentByAxis).toBeUndefined(); expect((await db().doc(`fandoms/${IDLE}`).get()).get('memberRanked')).toBe(false); }); }); describe('participateStreaming — 무보상 기본 참여 (C-04 · B-25)', () => { it('POP 원장 없이 참여 기록과 FPI 원천만 남기고 하루 한도를 지킨다', async () => { const uid = USERS[2]; await db().doc(`users/${uid}`).set({ primaryFandomId: A }); await db().doc(`fandoms/${A}`).set({ stage: 1 }); await db().doc('quests/rank-stream').set({ type: 'streaming', dailyLimit: 1, rewardPop: 10 }); const result = await participateStreaming(uid, 'rank-stream'); expect(result).toMatchObject({ rewarded: 0, remaining: 0 }); expect((await db().collection(`users/${uid}/popTransactions`).get()).size).toBe(0); expect((await db().doc(`users/${uid}/questProgress/rank-stream`).get()).get('rewardedPop')).toBe(0); const events = await db().collection(`fandoms/${A}/fpiEvents`).where('uid', '==', uid).get(); expect(events.docs.map((e) => [e.get('axis'), e.get('activity'), e.get('value')])) .toEqual([['community', 'streaming', 1]]); // 외부 재생 완료는 검증할 수 없어 「검증 활동」으로 세지 않는다(C-92). expect((await db().collection(`users/${uid}/verifiedCompletions`).get()).size).toBe(0); await expect(participateStreaming(uid, 'rank-stream')).rejects.toThrow(); await db().doc('quests/rank-stream').update({ type: 'attend' }); await expect(participateStreaming(uid, 'rank-stream', new Date(Date.now() + 86_400_000))).rejects.toThrow(); }); });