477 lines
21 KiB
TypeScript
477 lines
21 KiB
TypeScript
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { getFirestore, FieldValue, Timestamp, Transaction } from 'firebase-admin/firestore';
|
|
|
|
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
|
import { grantPop } from '../src/ledger/ledger.js';
|
|
import {
|
|
aggregateActiveEclipses,
|
|
defendEclipse,
|
|
runEclipseSchedule,
|
|
fnFpiAggregate,
|
|
} from '../src/fandom/fandom.js';
|
|
|
|
/** v1.5 §5: 운영 일정, DAU 오류 취소, 시작 스냅샷, 실패 확정 차감과 경합을 검증한다. */
|
|
|
|
const FANDOM = 'test-trig-fandom';
|
|
const A = 'test-trig-user-a';
|
|
|
|
/** 침식률 — 수치표 v1.4 「침식 0.25% FPI」. */
|
|
const EROSION_PERCENT = 0.25;
|
|
|
|
/**
|
|
* 방어 목표(팬덤 공동 Shake 누적). **POP 이 아니다** — 방어는 POP 을 소모하지 않는다
|
|
* (설계서 v1.1 §9-#5). 한 번의 참여로 채울 수 있게 작게 잡았다.
|
|
*/
|
|
const TARGET = 60;
|
|
|
|
/** 축별 기준 FPI. 0.25% 가 정수로 떨어지도록 400의 배수로 잡았다. */
|
|
const BASE_AXES: Record<string, number> = {
|
|
bpm: 400_000,
|
|
health: 200_000,
|
|
referral: 80_000,
|
|
community: 40_000,
|
|
contribution: 120_000,
|
|
};
|
|
|
|
const AXES = Object.keys(BASE_AXES);
|
|
|
|
/** 기준 FPI 총합. 침식 전후를 이 값으로 견준다. */
|
|
const BASE_TOTAL = Object.values(BASE_AXES).reduce((a, b) => a + b, 0);
|
|
|
|
/** KST 시각을 절대 시각으로. 발동 시각 판정이 KST 기준이다. */
|
|
const kst = (day: string, hhmm: string) => new Date(`${day}T${hhmm}:00+09:00`);
|
|
|
|
const DAY = '2026-09-20';
|
|
const FIRST = '13:00';
|
|
const SECOND = '21:00';
|
|
const WARN_SECONDS = 300;
|
|
|
|
beforeAll(() => {
|
|
// 발동과 방어가 같은 시각을 보도록 Date만 고정한다. Firestore 타이머는 실제로 돈다.
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
vi.setSystemTime(kst(DAY, '13:10'));
|
|
bootAdmin();
|
|
});
|
|
// **끝나고도 치운다.** 남겨 두면 다음 파일의 전역 질의(`fn-fpi-aggregate` ·
|
|
// `fn-eclipse-trigger` 는 팬덤 전수를 돈다)가 이 팬덤까지 집어 값이 흔들린다.
|
|
afterAll(async () => {
|
|
await wipeUser(A);
|
|
await wipeFandom();
|
|
await shutdownAdmin();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
async function wipeFandom(): Promise<void> {
|
|
const db = getFirestore();
|
|
const root = db.doc(`fandoms/${FANDOM}`);
|
|
const eclipses = await root.collection('eclipseRounds').get();
|
|
for (const doc of eclipses.docs) {
|
|
for (const sub of ['shards', 'defenseLogs', 'participants']) {
|
|
const snap = await doc.ref.collection(sub).get();
|
|
await Promise.all(snap.docs.map((d) => d.ref.delete()));
|
|
}
|
|
await doc.ref.delete();
|
|
}
|
|
for (const sub of ['fpiEvents', 'liveEvents', 'members', 'fandomDailyStats']) {
|
|
const snap = await root.collection(sub).get();
|
|
await Promise.all(snap.docs.map((d) => d.ref.delete()));
|
|
}
|
|
await root.delete();
|
|
await db.doc('config/eclipse').delete();
|
|
}
|
|
|
|
/**
|
|
* 팬덤 하나에 FPI 원장을 세운다.
|
|
*
|
|
* **`fpiAxes` 를 직접 쓰지 않고 이벤트로 만든다.** 집계가 이벤트에서 요약을 다시
|
|
* 만들므로, 요약만 심어 두면 첫 집계에서 전부 0 이 되어 침식할 것이 없어진다.
|
|
*/
|
|
async function seedFandom(overrides: Record<string, unknown> = {}): Promise<void> {
|
|
const db = getFirestore();
|
|
await db.doc('config/eclipse').set({
|
|
enabled: true,
|
|
schedule: [{ hhmm: FIRST }, { hhmm: SECOND }],
|
|
erosionPercent: EROSION_PERCENT,
|
|
warnSeconds: WARN_SECONDS,
|
|
perCallMaxShakes: 60,
|
|
windowSec: 1800,
|
|
// 같은 사용자로 연속 참여해 보므로 속도 제한을 끈다. 제한 자체는
|
|
// eclipse.test.ts 의 전용 테스트가 본다.
|
|
minIntervalMs: 0,
|
|
...overrides,
|
|
});
|
|
await db.doc(`fandoms/${FANDOM}`).set({
|
|
name: 'BTS', stage: 1, fandomName: 'BTS',
|
|
createdAt: Timestamp.fromDate(kst(DAY, '00:00')),
|
|
});
|
|
// 활동 이벤트는 발동 시각보다 앞서고 7 일 창 안이다.
|
|
const occurredAt = Timestamp.fromDate(kst(DAY, '09:00'));
|
|
for (const axis of AXES) {
|
|
await db.doc(`fandoms/${FANDOM}/fpiEvents/seed-${axis}`).set({
|
|
axis, value: BASE_AXES[axis], verified: true, occurredAt,
|
|
reasonCode: 'SEED',
|
|
});
|
|
}
|
|
await db.doc(`users/${A}`).set({ uid: A, nickname: '아미보이', fandomId: FANDOM });
|
|
await grantPop({
|
|
uid: A, popType: 'activity', amount: 10_000,
|
|
reasonCode: 'ADMIN_GRANT', idempotencyKey: `g:trig:${A}`,
|
|
});
|
|
}
|
|
|
|
/** 집계를 실제로 한 번 돌린다. 스케줄 래퍼를 직접 호출한다. */
|
|
const aggregateFpi = () =>
|
|
(fnFpiAggregate.run as (e: unknown) => Promise<void>)({});
|
|
|
|
/** 가중치와 실패 차감까지 반영한 현재 FPI 총점. */
|
|
async function currentFpiTotal(): Promise<number> {
|
|
const snap = await getFirestore().doc(`fandoms/${FANDOM}`).get();
|
|
return snap.get('fpiScore') as number;
|
|
}
|
|
|
|
/** 사유 코드별 FPI 이벤트 수. 「감소 경로가 Eclipse 뿐인가」의 확인 수단이다. */
|
|
async function fpiEventsByReason(): Promise<Record<string, number>> {
|
|
const snap = await getFirestore()
|
|
.collection(`fandoms/${FANDOM}/fpiEvents`).get();
|
|
const out: Record<string, number> = {};
|
|
for (const doc of snap.docs) {
|
|
const reason = (doc.get('reasonCode') as string | undefined) ?? '(없음)';
|
|
out[reason] = (out[reason] ?? 0) + 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await wipeUser(A);
|
|
await wipeFandom();
|
|
});
|
|
|
|
describe('발동 — 하루 정확히 2 회 (원장 B-55)', () => {
|
|
it('DAU 누락 회차는 취소 사유를 남기고 활성화·FPI 차감 없이 종료한다', async () => {
|
|
await seedFandom();
|
|
await aggregateFpi();
|
|
const db = getFirestore();
|
|
await db.doc(`fandoms/${FANDOM}`).update({ createdAt: Timestamp.fromDate(kst('2026-09-19', '00:00')) });
|
|
await runEclipseSchedule(kst(DAY, FIRST));
|
|
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`);
|
|
expect((await ref.get()).data()).toMatchObject({
|
|
status: 'cancelled', result: 'cancelled_error', cancelReason: 'MISSING_OR_INVALID_DAU',
|
|
});
|
|
expect((await ref.get()).get('activatedAt')).toBeUndefined();
|
|
expect((await fpiEventsByReason())['ECLIPSE_EROSION'] ?? 0).toBe(0);
|
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
|
|
await runEclipseSchedule(kst(DAY, FIRST));
|
|
expect((await ref.get()).get('status')).toBe('cancelled');
|
|
await db.doc(`fandoms/${FANDOM}/fandomDailyStats/2026-09-19`).set({ dau: 30 });
|
|
await runEclipseSchedule(kst(DAY, FIRST));
|
|
expect((await ref.get()).get('status')).toBe('cancelled');
|
|
await runEclipseSchedule(kst(DAY, SECOND));
|
|
expect((await db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-2`).get()).data()).toMatchObject({
|
|
status: 'active', target: 2, averageDau: 30,
|
|
});
|
|
});
|
|
|
|
it('v1.5 기본값으로 07:47/17:47 예고하고 07:50/17:50부터 60초 방어한다', async () => {
|
|
const db = getFirestore();
|
|
await db.doc('config/eclipse').set({ enabled: true });
|
|
await db.doc(`fandoms/${FANDOM}`).set({ name: '기본 운영값 검증', fpiScore: 0, createdAt: Timestamp.fromDate(kst(DAY, '00:00')) });
|
|
for (const [round, warning, start] of [[1, '07:47', '07:50'], [2, '17:47', '17:50']] as const) {
|
|
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-${round}`);
|
|
await runEclipseSchedule(new Date(kst(DAY, warning).getTime() - 1));
|
|
expect((await ref.get()).exists).toBe(false);
|
|
await runEclipseSchedule(kst(DAY, warning));
|
|
const scheduled = await ref.get();
|
|
expect(scheduled.get('status')).toBe('scheduled');
|
|
expect(scheduled.get('target')).toBe(1);
|
|
expect(scheduled.get('dauDays')).toEqual([]);
|
|
expect(scheduled.get('warnAt').toMillis()).toBe(kst(DAY, warning).getTime());
|
|
expect(scheduled.get('startAt').toMillis()).toBe(kst(DAY, start).getTime());
|
|
expect(scheduled.get('endsAt').toMillis() - scheduled.get('startAt').toMillis()).toBe(60_000);
|
|
expect(scheduled.get('windowSec')).toBe(60);
|
|
expect(scheduled.get('qualifyShakes')).toBe(60);
|
|
// C-22 「푸시 발송 시각 = 시작 − 3분」 — 예고 판정 시각이 회차 문서에 남는다.
|
|
expect(scheduled.get('alertPushedAt').toMillis()).toBe(kst(DAY, warning).getTime());
|
|
await runEclipseSchedule(kst(DAY, start));
|
|
expect((await ref.get()).get('status')).toBe('active');
|
|
await runEclipseSchedule(new Date(kst(DAY, start).getTime() + 60_000));
|
|
expect((await ref.get()).get('status')).toBe('ended');
|
|
}
|
|
});
|
|
|
|
it('사전 알림 시각에 예약되고 발동 시각에 활성이 된다', async () => {
|
|
await seedFandom();
|
|
await aggregateFpi();
|
|
const db = getFirestore();
|
|
const ref = db.doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`);
|
|
|
|
// **반환 카운터를 단정하지 않는다.** `runEclipseSchedule` 은 모든 팬덤을 돌아
|
|
// 전역 건수를 돌려주므로, 다른 테스트가 남긴 팬덤이 있으면 값이 흔들린다.
|
|
// 이 테스트가 볼 것은 「이 팬덤의 이 회차 문서가 어떤 상태인가」다.
|
|
// 사전 알림 전에는 문서가 없다.
|
|
await runEclipseSchedule(kst(DAY, '12:50'));
|
|
expect((await ref.get()).exists).toBe(false);
|
|
|
|
// 사전 알림 시각(발동 300초 전 = 12:55)에 예약된다.
|
|
await runEclipseSchedule(kst(DAY, '12:56'));
|
|
let snap = await ref.get();
|
|
expect(snap.get('status')).toBe('scheduled');
|
|
// 푸시 워커가 읽을 사전 알림 시각. 예약 시점보다 앞이면 예고가 불가능하다.
|
|
expect((snap.get('warnAt') as Timestamp).toMillis())
|
|
.toBe(kst(DAY, '12:55').getTime());
|
|
expect((snap.get('startAt') as Timestamp).toMillis())
|
|
.toBe(kst(DAY, FIRST).getTime());
|
|
|
|
// 발동 시각에 활성이 된다.
|
|
await runEclipseSchedule(kst(DAY, '13:00'));
|
|
snap = await ref.get();
|
|
expect(snap.get('status')).toBe('active');
|
|
});
|
|
|
|
it('하루에 1차·2차 딱 둘만 만든다', async () => {
|
|
await seedFandom();
|
|
await aggregateFpi();
|
|
|
|
// 하루를 1분 간격이 아니라 구간별로 훑는다.
|
|
for (const at of ['00:10', '12:56', '13:00', '13:20', '20:56', '21:00', '23:50']) {
|
|
await runEclipseSchedule(kst(DAY, at));
|
|
}
|
|
|
|
const eclipses = await getFirestore()
|
|
.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
|
|
expect(eclipses.size).toBe(2);
|
|
expect(eclipses.docs.map((d) => d.id).sort()).toEqual([`${DAY}-1`, `${DAY}-2`]);
|
|
expect(eclipses.docs.map((d) => d.get('round')).sort()).toEqual([1, 2]);
|
|
});
|
|
|
|
it('같은 회차를 여러 번 돌려도 중복 발동 0 건이다', async () => {
|
|
await seedFandom();
|
|
await aggregateFpi();
|
|
|
|
// 같은 분을 열 번 돌린다. 조회 후 쓰기로 막았다면 여기서 새 문서가 생긴다.
|
|
for (let i = 0; i < 10; i++) await runEclipseSchedule(kst(DAY, '13:05'));
|
|
// 동시에도 돌려 본다 — 스케줄 인스턴스가 겹치는 상황이다.
|
|
await Promise.all(
|
|
Array.from({ length: 5 }, () => runEclipseSchedule(kst(DAY, '13:06'))),
|
|
);
|
|
|
|
const eclipses = await getFirestore()
|
|
.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
|
|
expect(eclipses.size).toBe(1);
|
|
expect(eclipses.docs[0]!.get('status')).toBe('active');
|
|
|
|
// 시작 시점에는 차감 이벤트가 없어야 한다.
|
|
const events = await getFirestore()
|
|
.collection(`fandoms/${FANDOM}/fpiEvents`).get();
|
|
const erosions = events.docs
|
|
.filter((d) => d.get('reasonCode') === 'ECLIPSE_EROSION');
|
|
expect(erosions).toHaveLength(0);
|
|
});
|
|
|
|
it('활성화가 꺼진 팬덤은 발동하지 않는다', async () => {
|
|
await seedFandom({ enabled: false });
|
|
await aggregateFpi();
|
|
|
|
await runEclipseSchedule(kst(DAY, '13:05'));
|
|
const eclipses = await getFirestore()
|
|
.collection(`fandoms/${FANDOM}/eclipseRounds`).get();
|
|
expect(eclipses.size).toBe(0);
|
|
});
|
|
|
|
it('발동 시각·침식률을 운영값으로 바꾸면 재배포 없이 따라간다', async () => {
|
|
// 원장 B-55 검증 기준 「방어 수치가 Admin 에서 앱 재배포 없이 변경됨」.
|
|
await seedFandom({ schedule: [{ hhmm: '06:30' }], erosionPercent: 1, warnSeconds: 60 });
|
|
await aggregateFpi();
|
|
|
|
await runEclipseSchedule(kst(DAY, '06:20'));
|
|
expect((await getFirestore()
|
|
.collection(`fandoms/${FANDOM}/eclipseRounds`).get()).size).toBe(0);
|
|
|
|
await runEclipseSchedule(kst(DAY, '06:31'));
|
|
const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get();
|
|
expect(snap.get('status')).toBe('active');
|
|
expect(snap.get('erosionPercent')).toBe(1);
|
|
expect(snap.get('fpiAtStart')).toBe(BASE_TOTAL);
|
|
expect(snap.get('erodedTotal')).toBeUndefined();
|
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
|
|
});
|
|
|
|
it('v0.3.0 팬덤별 firstAt 설정도 하위호환으로 읽는다', async () => {
|
|
await seedFandom();
|
|
await aggregateFpi();
|
|
await getFirestore().doc(`fandoms/${FANDOM}`)
|
|
.set({ eclipseSettings: { firstAt: '08:00' } }, { merge: true });
|
|
|
|
await runEclipseSchedule(kst(DAY, '08:01'));
|
|
const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get();
|
|
expect((snap.get('startAt') as Timestamp).toMillis())
|
|
.toBe(kst(DAY, '08:00').getTime());
|
|
});
|
|
|
|
it('방어 창이 지나면 닫는다', async () => {
|
|
await seedFandom();
|
|
await aggregateFpi();
|
|
await runEclipseSchedule(kst(DAY, '13:01'));
|
|
|
|
// 창은 30분이다.
|
|
await runEclipseSchedule(kst(DAY, '13:20'));
|
|
expect((await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get())
|
|
.get('status')).toBe('active');
|
|
|
|
await runEclipseSchedule(kst(DAY, '13:31'));
|
|
// 검수 D3 — 마감이 지나면 `ended` 로 닫아 배너 질의에서 빠진다.
|
|
expect((await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get())
|
|
.get('status')).toBe('ended');
|
|
});
|
|
});
|
|
|
|
|
|
describe('일정 — 배열 길이 = 하루 회차 수 · 팬덤별 우선 (원장 C-24)', () => {
|
|
const ids = async () => (await getFirestore().collection(`fandoms/${FANDOM}/eclipseRounds`).get())
|
|
.docs.map((d) => [d.id, (d.get('startAt') as Timestamp).toMillis()]).sort();
|
|
|
|
it('공통 일정 3회를 설정하면 하루 3회차를 만든다', async () => {
|
|
await seedFandom({ schedule: [{ hhmm: '07:50' }, { hhmm: '12:00' }, { hhmm: '17:50' }] });
|
|
await aggregateFpi();
|
|
for (const at of ['07:50', '12:00', '17:50', '23:59']) await runEclipseSchedule(kst(DAY, at));
|
|
expect(await ids()).toEqual([
|
|
[`${DAY}-1`, kst(DAY, '07:50').getTime()],
|
|
[`${DAY}-2`, kst(DAY, '12:00').getTime()],
|
|
[`${DAY}-3`, kst(DAY, '17:50').getTime()],
|
|
]);
|
|
});
|
|
|
|
it('팬덤별 eclipseSchedule 이 있으면 공통 일정 대신 그것을 쓰고 푸시·종료를 다시 계산한다', async () => {
|
|
await seedFandom({ warnSeconds: 180, windowSec: 60 });
|
|
await aggregateFpi();
|
|
await getFirestore().doc(`fandoms/${FANDOM}`)
|
|
.set({ eclipseSchedule: [{ hhmm: '09:10', tz: 'Asia/Seoul' }] }, { merge: true });
|
|
for (const at of ['09:07', '09:10', '13:00', '21:00', '23:59']) await runEclipseSchedule(kst(DAY, at));
|
|
expect(await ids()).toEqual([[`${DAY}-1`, kst(DAY, '09:10').getTime()]]);
|
|
const snap = await getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`).get();
|
|
expect(snap.get('warnAt').toMillis()).toBe(kst(DAY, '09:07').getTime());
|
|
expect(snap.get('alertPushedAt').toMillis()).toBe(kst(DAY, '09:07').getTime());
|
|
expect(snap.get('endsAt').toMillis()).toBe(kst(DAY, '09:10').getTime() + 60_000);
|
|
});
|
|
});
|
|
|
|
describe('v1.5 실패 확정 시에만 시작 FPI 기준으로 1회 차감', () => {
|
|
const round = () => getFirestore().doc(`fandoms/${FANDOM}/eclipseRounds/${DAY}-1`);
|
|
async function start() {
|
|
await seedFandom();
|
|
await aggregateFpi();
|
|
await getFirestore().doc(`fandoms/${FANDOM}`).update({ sfpi: 12345 });
|
|
await runEclipseSchedule(kst(DAY, '13:00'));
|
|
}
|
|
const penalty = BASE_TOTAL * EROSION_PERCENT / 100;
|
|
|
|
it('시작과 방어 중에는 차감·복구 이벤트 없이 시작값만 보관한다', async () => {
|
|
await start();
|
|
expect((await round().get()).get('fpiAtStart')).toBe(BASE_TOTAL);
|
|
await aggregateFpi();
|
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
|
|
expect(await fpiEventsByReason()).toEqual({ SEED: 5 });
|
|
});
|
|
|
|
it('성공은 마감 후에도 무차감이며 재집계가 보너스 FPI를 만들지 않는다', async () => {
|
|
await start();
|
|
await defendEclipse({ uid: A, fandomId: FANDOM, eclipseId: `${DAY}-1`, clientSeq: 'win', shakes: TARGET });
|
|
await runEclipseSchedule(kst(DAY, '13:30'));
|
|
await aggregateActiveEclipses();
|
|
await aggregateFpi();
|
|
expect((await round().get()).data()).toMatchObject({ result: 'success', appliedAt: null });
|
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
|
|
expect(await fpiEventsByReason()).toEqual({ SEED: 5 });
|
|
});
|
|
|
|
it('새 활동을 보존하고 마감 경계부터 실패 1건만 차감하며 과거 sFPI는 그대로다', async () => {
|
|
await start();
|
|
const db = getFirestore();
|
|
await db.doc(`fandoms/${FANDOM}/fpiEvents/new-activity`).set({
|
|
axis: 'bpm', value: 1000000, verified: true, reasonCode: 'SEED',
|
|
occurredAt: Timestamp.fromDate(kst(DAY, '13:05')),
|
|
});
|
|
await aggregateFpi();
|
|
await runEclipseSchedule(new Date(kst(DAY, '13:30').getTime() - 1));
|
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000);
|
|
await Promise.all([runEclipseSchedule(kst(DAY, '13:30')), runEclipseSchedule(kst(DAY, '13:30'))]);
|
|
expect((await round().get()).data()).toMatchObject({ result: 'failed', penalty });
|
|
expect((await round().get()).get('appliedAt').toMillis()).toBe(kst(DAY, '13:30').getTime());
|
|
// 이미 확정된 회차를 다시 돌려도 차감은 1회다(G-02).
|
|
await runEclipseSchedule(kst(DAY, '13:31'));
|
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000 - penalty);
|
|
await aggregateFpi();
|
|
await aggregateFpi();
|
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL + 1000000 - penalty);
|
|
expect((await db.doc(`fandoms/${FANDOM}`).get()).get('sfpi')).toBe(12345);
|
|
expect((await fpiEventsByReason())['ECLIPSE_EROSION']).toBe(1);
|
|
const event = await db.doc(`fandoms/${FANDOM}/fpiEvents/eclipse-${DAY}-1-penalty`).get();
|
|
expect(event.get('occurredAt').toMillis()).toBe(kst(DAY, '13:30').getTime());
|
|
expect(event.get('value')).toBe(-penalty);
|
|
});
|
|
|
|
it('차감 기록 저장 실패는 회차 결과와 FPI를 함께 취소하며 재시도로 복구한다', async () => {
|
|
await start();
|
|
const spy = vi.spyOn(Transaction.prototype, 'create').mockImplementationOnce(() => { throw new Error('penalty write failed'); });
|
|
try {
|
|
await expect(runEclipseSchedule(kst(DAY, '13:30'))).rejects.toThrow('penalty write failed');
|
|
} finally { spy.mockRestore(); }
|
|
expect((await round().get()).get('status')).toBe('active');
|
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
|
|
expect((await fpiEventsByReason())['ECLIPSE_EROSION']).toBeUndefined();
|
|
await runEclipseSchedule(kst(DAY, '13:30'));
|
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty);
|
|
expect((await round().get()).get('result')).toBe('failed');
|
|
});
|
|
|
|
it('결과 집계가 오염되면 실패로 처리하지 않고 무차감 취소한다', async () => {
|
|
await start();
|
|
await round().collection('shards').doc('invalid').set({ qualifiedParticipants: '1' });
|
|
await runEclipseSchedule(kst(DAY, '13:30'));
|
|
expect((await round().get()).data()).toMatchObject({ result: 'cancelled_error', status: 'cancelled' });
|
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL);
|
|
expect(await fpiEventsByReason()).toEqual({ SEED: 5 });
|
|
});
|
|
|
|
it('축별 가중치와 소수 차감에도 시작 FPI의 정확한 비율을 유지한다', async () => {
|
|
const db = getFirestore();
|
|
const config = db.doc('config/policy');
|
|
const original = (await config.get()).data();
|
|
try {
|
|
await config.set({ fpiWeights: { bpm: 2, health: 0.5, referral: 1, community: 1, contribution: 1 } }, { merge: true });
|
|
await seedFandom();
|
|
await db.doc(`fandoms/${FANDOM}/fpiEvents/seed-bpm`).update({ value: BASE_AXES.bpm! + 1 });
|
|
await aggregateFpi();
|
|
const startFpi = await currentFpiTotal();
|
|
expect(startFpi).toBe(1140002);
|
|
await runEclipseSchedule(kst(DAY, '13:00'));
|
|
await runEclipseSchedule(kst(DAY, '13:30'));
|
|
const loss = startFpi * 0.0025;
|
|
expect((await round().get()).get('penalty')).toBe(loss);
|
|
expect(await currentFpiTotal()).toBeCloseTo(startFpi - loss, 8);
|
|
await aggregateFpi();
|
|
expect(await currentFpiTotal()).toBeCloseTo(startFpi - loss, 8);
|
|
} finally {
|
|
if (original) await config.set(original);
|
|
else await config.delete();
|
|
}
|
|
});
|
|
|
|
it('마감 전 읽은 FPI 집계가 뒤늦게 저장돼도 확정 차감을 덮어쓰지 않는다', async () => {
|
|
await start();
|
|
const original = Transaction.prototype.get;
|
|
let closed = false;
|
|
const spy = vi.spyOn(Transaction.prototype, 'get').mockImplementation(async function (this: Transaction, ...args) {
|
|
if (!closed && 'path' in args[0] && args[0].path === `fandoms/${FANDOM}`) {
|
|
closed = true;
|
|
await runEclipseSchedule(kst(DAY, '13:30'));
|
|
}
|
|
return original.apply(this, args);
|
|
});
|
|
try { await aggregateFpi(); } finally { spy.mockRestore(); }
|
|
expect(closed).toBe(true);
|
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty);
|
|
await aggregateFpi();
|
|
expect(await currentFpiTotal()).toBe(BASE_TOTAL - penalty);
|
|
});
|
|
});
|