Files

150 lines
7.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});