150 lines
7.6 KiB
TypeScript
150 lines
7.6 KiB
TypeScript
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();
|
||
});
|
||
});
|