FPI 사용자·국가 랭킹 서버 집계 · YouTube 스밍 · Music Shake 참여
This commit is contained in:
@@ -26,7 +26,7 @@ const FORBIDDEN = [
|
||||
/** 서버 산출 필드 — 문서 자체는 쓸 수 있어도 이 필드는 master 도 못 바꾼다. */
|
||||
const DERIVED_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||
fandoms: ['fpiScore', 'fpiAxes', 'fpiRanks', 'fpiRanksPrev', 'sfpi', 'growthPop',
|
||||
'rankingEligible', 'eclipsePenaltyRevision'],
|
||||
'rankingEligible', 'eclipsePenaltyRevision', 'memberRanked'],
|
||||
users: ['userExp', 'userLevel', 'passportTier', 'totalContributedPop'],
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { rankWithTies } from '../ledger/fpi.js';
|
||||
import { txIdOf } from '../core/ids.js';
|
||||
import { dayKey } from '../core/time.js';
|
||||
import { notifyEclipseWarning } from '../notify/push.js';
|
||||
import { publicNameOf } from '../core/names.js';
|
||||
import { PUBLIC_NAME_FALLBACK, publicNameOf } from '../core/names.js';
|
||||
|
||||
/**
|
||||
* 티커 이벤트 보존 창.
|
||||
@@ -885,39 +885,101 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-ranking-country` — 국가별 지표 랭킹 사전 집계(설계서 §10).
|
||||
* `fn-ranking-country` — **사용자 지표 랭킹 + 국가별 지표 랭킹** 사전 집계 · 5 분 주기(설계서 §2.6 · §4.4).
|
||||
*
|
||||
* 랭킹 화면은 `rankings/{rankingId}` 문서 1건만 읽는다. 축마다 문서를 따로 두어
|
||||
* 축을 바꾸면 다른 문서를 읽게 한다 — 한 문서에 다섯 축을 담으면 화면이 어느 축을
|
||||
* 그리는지 다시 분기해야 하고, 그 분기가 「지표를 바꿔도 같은 Ranking」의 자리다.
|
||||
* 1 분 FPI 집계(`fnFpiAggregate`)는 행성 단위만 적는다. 멤버 단위 값은 멤버 전수를 훑어야
|
||||
* 해서 국가 집계와 같은 5 분 배치에서 한 번에 낸다(설계서 §2.6 「멤버 전수 스캔이라 5 분」).
|
||||
*
|
||||
* 국가는 멤버 문서에 비정규화된 `countryCode`(사용자 프로필 기준)이며, 없으면
|
||||
* `ZZ`(미지정) 버킷으로 모은다.
|
||||
* 1. **멤버별 축 값** — 행성 FPI 와 같은 원천(최근 `fpiWindowDays` 일 · verified `fpiEvents`)을
|
||||
* `uid` 별로 합산해 `members/{uid}.fpiAxes` 에 적는다. Eclipse 차감은 행성 총점만 깎으므로
|
||||
* 개인 값에 넣지 않는다.
|
||||
* 2. **멤버 축별 순위** `rankByAxis` (C-19 1·1·3, 동점 내부 uid 오름차순) · 직전 순위
|
||||
* `rankPrevByAxis` (= 직전 배치의 `rankByAxis`, 행성 `fpiRanksPrev` 와 같은 정의) ·
|
||||
* 「내 상위 %」 `topPercentByAxis` = ⌈순위 ÷ 멤버 수 × 100⌉ — 그 축 기여가 0 이면 적지 않는다
|
||||
* (「상위 100%」는 최하위라는 사실 주장이 된다).
|
||||
* 3. **국가 합** — 위 멤버 값을 사용자 프로필 `users/{uid}.countryCode` 로 묶는다(없으면 `ZZ`).
|
||||
* 멤버 문서에도 `countryCode` · 공개 닉네임(`nickname`, M-7)을 비정규화해 앱이 「내 국가」 행과
|
||||
* 사용자 랭킹 이름을 멤버 문서 한 건으로 그리게 한다.
|
||||
*
|
||||
* 모집단은 행성 랭킹과 같다 — 별첨 B 「정식 행성 중 최근 7 일 유효 활동 존재 행성」.
|
||||
* 여기만 전수로 세면 국가 합이 행성 랭킹의 합보다 커져 두 화면이 어긋난다.
|
||||
* 모집단은 행성 랭킹과 같다 — 별첨 B 「정식 행성 중 최근 7 일 유효 활동 존재 행성」(B-17,
|
||||
* `rankingEligible`). 모집단에서 빠진 행성은 멤버 랭킹 필드를 지워 지난 순위가 남지 않게 한다.
|
||||
* 바뀐 멤버 문서만 쓴다.
|
||||
*
|
||||
* ponytail: 대상 행성의 멤버 전수 스캔이다. 멤버가 늘면 실행 시간을 넘으므로
|
||||
* 9주차 부하 설계에서 국가 카운터 샤드로 옮긴다. 지금은 화면이 실데이터로 도는
|
||||
* 것이 우선이다.
|
||||
* ponytail: 대상 행성의 멤버 · 7 일 이벤트 전수 스캔이다. 멤버가 늘어 실행 시간을 넘으면
|
||||
* 행성별 샤드 배치(Cloud Tasks)로 쪼갠다.
|
||||
*/
|
||||
export const fnRankingCountryAggregate = onSchedule('every 5 minutes', async () => {
|
||||
export const fnRankingCountryAggregate = onSchedule(
|
||||
{ schedule: 'every 5 minutes', timeoutSeconds: 280 },
|
||||
async () => { await aggregateRankings(); },
|
||||
);
|
||||
|
||||
const MEMBER_RANK_FIELDS = ['fpiAxes', 'rankByAxis', 'rankPrevByAxis', 'topPercentByAxis'] as const;
|
||||
|
||||
export async function aggregateRankings(now = Date.now()): Promise<{ fandoms: number; members: number; countries: number }> {
|
||||
const db = getFirestore();
|
||||
const eligible = await db.collection('fandoms')
|
||||
.where('rankingEligible', '==', true).select().get();
|
||||
const members = (await Promise.all(
|
||||
eligible.docs.map((f) => f.ref.collection('members').get()),
|
||||
)).flatMap((snap) => snap.docs);
|
||||
const policy = await loadPolicy();
|
||||
const since = Timestamp.fromMillis(now - policy.fpiWindowDays * 24 * 60 * 60 * 1000);
|
||||
const [eligible, ranked] = await Promise.all([
|
||||
db.collection('fandoms').where('rankingEligible', '==', true).select().get(),
|
||||
db.collection('fandoms').where('memberRanked', '==', true).select('rankingEligible').get(),
|
||||
]);
|
||||
const writer = db.bulkWriter();
|
||||
|
||||
// 모집단에서 빠진 행성 — 지난 순위를 지운다. 남겨 두면 활동 없는 행성에 옛 순위 · 상위 % 가 뜬다.
|
||||
for (const fandom of ranked.docs) {
|
||||
if (fandom.get('rankingEligible') === true) continue;
|
||||
const members = await fandom.ref.collection('members').select(...MEMBER_RANK_FIELDS).get();
|
||||
for (const m of members.docs) {
|
||||
if (MEMBER_RANK_FIELDS.some((f) => m.get(f) !== undefined)) {
|
||||
void writer.update(m.ref, Object.fromEntries(MEMBER_RANK_FIELDS.map((f) => [f, FieldValue.delete()])));
|
||||
}
|
||||
}
|
||||
void writer.update(fandom.ref, { memberRanked: false });
|
||||
}
|
||||
|
||||
const totals = new Map<string, Record<FpiAxis, number>>();
|
||||
for (const member of members) {
|
||||
const code = (member.get('countryCode') as string | undefined) || 'ZZ';
|
||||
const axes = (member.get('fpiAxes') as Record<FpiAxis, number> | undefined) ?? {} as Record<FpiAxis, number>;
|
||||
const bucket = totals.get(code)
|
||||
?? Object.fromEntries(FPI_AXES.map((a) => [a, 0])) as Record<FpiAxis, number>;
|
||||
for (const axis of FPI_AXES) bucket[axis] += axes[axis] ?? 0;
|
||||
totals.set(code, bucket);
|
||||
let memberTotal = 0;
|
||||
for (const fandom of eligible.docs) {
|
||||
const [members, events] = await Promise.all([
|
||||
fandom.ref.collection('members').get(),
|
||||
fandom.ref.collection('fpiEvents').where('verified', '==', true).where('occurredAt', '>=', since).get(),
|
||||
]);
|
||||
const values = new Map<string, Record<FpiAxis, number>>(
|
||||
members.docs.map((m) => [m.id, zeroAxes()]));
|
||||
for (const ev of events.docs) {
|
||||
const bucket = values.get(ev.get('uid') as string);
|
||||
const axis = ev.get('axis') as FpiAxis;
|
||||
const value = ev.get('value');
|
||||
// 차감 이벤트(uid · axis 없음)와 탈퇴한 사용자의 이벤트는 개인 값에 넣지 않는다.
|
||||
if (bucket && axis in bucket && typeof value === 'number' && Number.isFinite(value)) bucket[axis] += value;
|
||||
}
|
||||
const patches = memberRankPatches(values);
|
||||
const profiles = await profilesOf(members.docs.map((m) => m.id));
|
||||
for (const member of members.docs) {
|
||||
const patch = patches.get(member.id)!;
|
||||
const profile = profiles.get(member.id);
|
||||
const code = profile?.countryCode ?? 'ZZ';
|
||||
const next = {
|
||||
fpiAxes: patch.fpiAxes,
|
||||
rankByAxis: patch.rankByAxis,
|
||||
// 직전 순위 = 직전 배치의 순위. 첫 배치는 현재 순위(변동 0)로 둔다.
|
||||
rankPrevByAxis: (member.get('rankByAxis') as Record<string, number> | undefined) ?? patch.rankByAxis,
|
||||
topPercentByAxis: patch.topPercentByAxis,
|
||||
countryCode: code,
|
||||
// 사용자 랭킹의 이름 칸 — 공개 닉네임만(M-7). 프로필이 없으면 「팬」.
|
||||
nickname: profile?.nickname ?? PUBLIC_NAME_FALLBACK,
|
||||
};
|
||||
if (Object.entries(next).some(([k, v]) => stable(member.get(k)) !== stable(v))) {
|
||||
void writer.update(member.ref, next);
|
||||
}
|
||||
const total = totals.get(code) ?? zeroAxes();
|
||||
for (const axis of FPI_AXES) total[axis] += patch.fpiAxes[axis];
|
||||
totals.set(code, total);
|
||||
}
|
||||
void writer.update(fandom.ref, { memberRanked: true });
|
||||
memberTotal += members.size;
|
||||
}
|
||||
await writer.close();
|
||||
|
||||
for (const axis of FPI_AXES) {
|
||||
const doc = db.doc(`rankings/country-${axis}`);
|
||||
@@ -938,10 +1000,63 @@ export const fnRankingCountryAggregate = onSchedule('every 5 minutes', async ()
|
||||
}));
|
||||
await doc.set({ axis, rows, updatedAt: FieldValue.serverTimestamp() });
|
||||
}
|
||||
logger.info('[ranking] 국가 집계 완료', {
|
||||
countries: totals.size, fandoms: eligible.size, members: members.length,
|
||||
});
|
||||
});
|
||||
const result = { fandoms: eligible.size, members: memberTotal, countries: totals.size };
|
||||
logger.info('[ranking] 사용자 · 국가 집계 완료', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 키 순서와 무관한 비교용 직렬화 — Firestore 가 돌려주는 맵의 키 순서는 쓴 순서와 다를 수 있다. */
|
||||
function stable(value: unknown): string {
|
||||
return JSON.stringify(value, (_, v: unknown) => v && typeof v === 'object' && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : 1))) : v) ?? 'undefined';
|
||||
}
|
||||
|
||||
function zeroAxes(): Record<FpiAxis, number> {
|
||||
return Object.fromEntries(FPI_AXES.map((a) => [a, 0])) as Record<FpiAxis, number>;
|
||||
}
|
||||
|
||||
/** 사용자 프로필의 국가 코드 · 공개 닉네임. 한 번에 읽는 수는 300건씩 자른다. */
|
||||
async function profilesOf(uids: string[]): Promise<Map<string, { countryCode: string | undefined; nickname: string }>> {
|
||||
const db = getFirestore();
|
||||
const out = new Map<string, { countryCode: string | undefined; nickname: string }>();
|
||||
for (let i = 0; i < uids.length; i += 300) {
|
||||
const refs = uids.slice(i, i + 300).map((uid) => db.doc(`users/${uid}`));
|
||||
if (refs.length === 0) continue;
|
||||
for (const snap of await db.getAll(...refs, { fieldMask: ['countryCode', 'nickname'] })) {
|
||||
if (!snap.exists) continue;
|
||||
const code = snap.get('countryCode');
|
||||
out.set(snap.id, {
|
||||
countryCode: typeof code === 'string' && /^[A-Z]{2}$/.test(code) ? code : undefined,
|
||||
nickname: publicNameOf(snap),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 행성 멤버의 축별 순위 · 「내 상위 %」(순수 계산). 순위는 C-19 공동 순위 — 실값 내림차순,
|
||||
* 동점은 같은 순위, 동점 내부는 uid 오름차순. 상위 % 는 ⌈순위 ÷ 멤버 수 × 100⌉(최소 1),
|
||||
* 그 축 값이 0 이면 비운다.
|
||||
*/
|
||||
export function memberRankPatches(values: ReadonlyMap<string, Record<FpiAxis, number>>): Map<string, {
|
||||
fpiAxes: Record<FpiAxis, number>;
|
||||
rankByAxis: Partial<Record<FpiAxis, number>>;
|
||||
topPercentByAxis: Partial<Record<FpiAxis, number>>;
|
||||
}> {
|
||||
const out = new Map([...values].map(([uid, fpiAxes]) => [uid, {
|
||||
fpiAxes, rankByAxis: {} as Partial<Record<FpiAxis, number>>, topPercentByAxis: {} as Partial<Record<FpiAxis, number>>,
|
||||
}]));
|
||||
const n = values.size;
|
||||
for (const axis of FPI_AXES) {
|
||||
for (const { item: [uid, axes], rank } of rankWithTies([...values], ([, a]) => a[axis], ([uid]) => uid)) {
|
||||
const row = out.get(uid)!;
|
||||
row.rankByAxis[axis] = rank;
|
||||
if (axes[axis] > 0) row.topPercentByAxis[axis] = Math.max(1, Math.ceil(rank / n * 100));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-campaign-fund` — 캠페인 후원.
|
||||
|
||||
@@ -7,7 +7,7 @@ export {
|
||||
fnPopGrant, fnPopSpend, fnPopRefund, fnPopReconcile,
|
||||
} from './ledger/pop.js';
|
||||
export {
|
||||
fnQuestAttend, fnQuestVerifySteps, fnAdReward,
|
||||
fnQuestAttend, fnQuestVerifySteps, fnAdReward, fnQuestStreamParticipate,
|
||||
} from './quest/quest.js';
|
||||
export {
|
||||
fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate,
|
||||
|
||||
@@ -169,6 +169,44 @@ export const fnQuestAttend = onCall(async (req) => {
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-quest-stream-participate` — Streaming · YouTube 시청 Quest **무보상 기본 참여**(원장 C-04 · B-25).
|
||||
*
|
||||
* POP 원장을 쓰지 않는다(검증 기준 「POP 지급 원장 0건」). 참여 기록(`questProgress`)과 FPI 원천
|
||||
* 이벤트만 같은 트랜잭션에 남긴다 — 보상이 없는 유효 활동도 FPI 에 들어간다(C-34).
|
||||
*
|
||||
* - 축은 **COMMUNITY**. C-35 매핑에 Streaming 이 없어, 같은 외부 행동(스트리밍 · 투표)의 TIME ATTACK
|
||||
* 참여 1건 = COMMUNITY 1회(기능명세 §TIME ATTACK)를 따른다. 확인 필요 항목으로 보고한다.
|
||||
* - 외부 플랫폼의 실제 재생 완료는 검증할 수 없으므로 **「외부 행동 완료」로 판정하지 않는다**(C-92).
|
||||
* 그래서 「검증 활동」 표식(`verifiedCompletions`, 생성 자격 집계)은 남기지 않는다.
|
||||
* - 한도는 다른 운영 Quest 와 같이 `quests/{id}.dailyLimit`(없으면 하루 1회).
|
||||
*/
|
||||
export const fnQuestStreamParticipate = onCall(async (req) => {
|
||||
const uid = callerUid(req);
|
||||
const questId = str(req.data, 'questId');
|
||||
return participateStreaming(uid, questId);
|
||||
});
|
||||
|
||||
export async function participateStreaming(uid: string, questId: string, now = new Date()) {
|
||||
await questOf(uid, questId, ['streaming', 'stream', 'youtube']);
|
||||
const db = getFirestore();
|
||||
const key = dayKey(now);
|
||||
const limit = await dailyLimitOf(questId);
|
||||
const ref = db.doc(`users/${uid}/questProgress/${questId}`);
|
||||
const count = await db.runTransaction(async (tx) => {
|
||||
const [snap, user] = await Promise.all([tx.get(ref), tx.get(db.doc(`users/${uid}`))]);
|
||||
const done = snap.get('dayKey') === key ? ((snap.get('count') as number | undefined) ?? 0) : 0;
|
||||
if (done >= limit) fail(ERR.popDuplicate, '오늘은 이미 참여했습니다.');
|
||||
progressWriter(ref, questId, key, done + 1, 0)(tx);
|
||||
writeFpiEvent(tx, {
|
||||
uid, fandomId: fandomOfUser(user), axis: 'community', units: 1, activity: 'streaming',
|
||||
sourceId: `stream:${questId}:${key}:${done + 1}`, occurredAt: now,
|
||||
}, user);
|
||||
return done + 1;
|
||||
});
|
||||
return { rewarded: 0, questId, remaining: limit - count };
|
||||
}
|
||||
|
||||
/**
|
||||
* `fn-quest-verify-steps` — 만보기.
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user