FPI 사용자·국가 랭킹 서버 집계 · YouTube 스밍 · Music Shake 참여

This commit is contained in:
Sn-Kinos
2026-10-05 17:49:47 +09:00
parent 5238fc4ef3
commit 96a95a12d1
31 changed files with 738 additions and 116 deletions
+149
View File
@@ -0,0 +1,149 @@
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<Record<FpiAxis, number>>) =>
Object.fromEntries(FPI_AXES.map((a) => [a, v[a] ?? 0])) as Record<FpiAxis, number>;
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();
});
});