first commit

This commit is contained in:
2026-10-12 11:22:54 +08:00
parent 0a4e3efc37
commit 1c3a297ce5
38 changed files with 13343 additions and 0 deletions

364
tests/mock-engine.test.js Normal file
View File

@@ -0,0 +1,364 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { claimWeeklyTask, createInitialState, createHostOrder, createJoinOrder, payOrder, cancelOrder, addDemoGuest, checkIn, checkInDemoParticipant, confirmResult, confirmVenue, getCompetitionProgress, getWeeklyTasks, markVenueUnavailable, reconcileState, requestCancellation, reviewCancellationRequest, seedParticipantCount, setDemoTime, submitResult, upgradeState, mockNow, CUTOFF_MS, CHECKIN_OPEN_MS, CHECKIN_CLOSE_MS, VENUE_CONFIRM_MS } from '../src/mock/engine.js';
import { getDateAfter, getVenueSlots } from '../src/mock/data.js';
// 发起人未支付时只暂占场地,支付后才公开招募。
test('host payment and cancellation control venue inventory', () => {
const state = createInitialState();
const slot = getVenueSlots('lining', state.matches).find((item) => item.remaining > 0);
const before = slot.remaining;
const order = createHostOrder(state, { venueId: 'lining', slotId: slot.id, level: '中级', capacity: 2 });
const match = state.matches.find((item) => item.id === order.matchId);
assert.equal(match.status, 'draft');
assert.equal(match.players, 0);
assert.equal(getVenueSlots('lining', state.matches).find((item) => item.id === slot.id).remaining, before - 1);
payOrder(state, order.id);
assert.equal(match.status, 'recruiting');
assert.equal(match.players, 1);
addDemoGuest(state, match.id);
assert.equal(match.status, 'formed');
cancelOrder(state, order.id);
assert.equal(match.status, 'canceled');
assert.equal(order.status, 'refunded');
assert.equal(getVenueSlots('lining', state.matches).find((item) => item.id === slot.id).remaining, before);
});
// 参与者暂占名额不可重复下单,付款后取消会全额退款并释放座位。
test('join hold, payment, and refund', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
assert.throws(() => createJoinOrder(state, match.id), /已有/);
assert.equal(match.players, 3);
payOrder(state, order.id);
assert.equal(match.players, 4);
assert.equal(match.status, 'formed');
cancelOrder(state, order.id);
assert.equal(order.status, 'refunded');
assert.equal(match.players, 3);
assert.equal(match.status, 'recruiting');
});
// 支付超时和报名截止都由统一的演示时钟结算。
test('expiry and cutoff settle orders', () => {
const state = createInitialState();
const expired = createJoinOrder(state, 'm1');
state.nowOffsetMs += 11 * 60 * 1000;
reconcileState(state);
assert.equal(expired.status, 'expired');
assert.equal(state.matches.find((item) => item.id === 'm1').players, 3);
const paid = createJoinOrder(state, 'm4');
payOrder(state, paid.id);
const match = state.matches.find((item) => item.id === 'm4');
state.nowOffsetMs += match.startAt - CUTOFF_MS - mockNow(state) + 1000;
reconcileState(state);
assert.equal(match.status, 'canceled');
assert.equal(paid.status, 'refunded');
assert.throws(() => createJoinOrder(state, match.id), /停止报名/);
});
// 已满员拼场跨过报名截止仍保持成局,待支付的发起人超时则释放场地。
test('formed match survives cutoff while expired host releases venue', () => {
const state = createInitialState();
const formed = state.matches.find((item) => item.id === 'm3');
const slot = getVenueSlots('lining', state.matches).find((item) => item.remaining > 0);
const before = slot.remaining;
const hostOrder = createHostOrder(state, { venueId: 'lining', slotId: slot.id, level: '中级', capacity: 2 });
state.nowOffsetMs += 11 * 60 * 1000;
reconcileState(state);
assert.equal(hostOrder.status, 'expired');
assert.equal(getVenueSlots('lining', state.matches).find((item) => item.id === slot.id).remaining, before);
state.nowOffsetMs += formed.startAt - CUTOFF_MS - mockNow(state) + 1000;
reconcileState(state);
assert.equal(formed.status, 'formed');
});
// 场地未按时确认或场地方无法履约时,整场取消并全额退款。
test('venue confirmation deadline and unavailability refund paid orders', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
state.nowOffsetMs += match.startAt - VENUE_CONFIRM_MS - mockNow(state) + 1000;
reconcileState(state);
assert.equal(match.status, 'canceled');
assert.equal(order.status, 'refunded');
const second = createInitialState();
const formed = second.matches.find((item) => item.id === 'm3');
confirmVenue(second, formed.id);
assert.equal(formed.venueStatus, 'confirmed');
markVenueUnavailable(second, formed.id);
assert.equal(formed.status, 'canceled');
assert.equal(formed.venueStatus, 'unavailable');
});
// 签到仅在窗口内有效;赛果确认后只为本人结算一次段位积分。
test('check-in, completion, and result confirmation', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
confirmVenue(state, match.id);
assert.throws(() => checkIn(state, order.id), /签到时段/);
state.nowOffsetMs += match.startAt - CHECKIN_OPEN_MS - mockNow(state) + 1000;
checkIn(state, order.id);
match.demoAttendance = { 0: 'checked_in', 1: 'checked_in', 2: 'checked_in' };
assert.equal(order.attendance, 'checked_in');
state.nowOffsetMs += match.startAt + 2 * 60 * 60 * 1000 - mockNow(state) + 1000;
reconcileState(state);
assert.equal(match.status, 'completed');
submitResult(state, order.id, 11, 9, 5);
assert.equal(match.result.status, 'submitted');
confirmResult(state, match.id);
assert.equal(match.result.status, 'confirmed');
assert.equal(state.rankPoints, 160);
assert.equal(state.rankHistory[0].eventId, `match-${match.id}`);
assert.throws(() => confirmResult(state, match.id), /没有待确认/);
assert.equal(state.rankPoints, 160);
assert.equal(state.assessment, null);
});
// 本人签到后的落败赛果只结算 80 分完赛积分。
test('loss earns participation points only for a checked-in player', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
confirmVenue(state, match.id);
state.nowOffsetMs += match.startAt - CHECKIN_OPEN_MS - mockNow(state) + 1000;
checkIn(state, order.id);
match.demoAttendance = { 0: 'checked_in', 1: 'checked_in', 2: 'checked_in' };
state.nowOffsetMs += match.startAt + 2 * 60 * 60 * 1000 - mockNow(state) + 1000;
submitResult(state, order.id, 9, 11, 4);
confirmResult(state, match.id);
assert.equal(state.rankPoints, 80);
assert.equal(state.rankHistory.length, 1);
});
// 签到截止后未操作的已付款订单标记未签到,不能补签。
test('missed check-in becomes no-show', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
confirmVenue(state, match.id);
state.nowOffsetMs += match.startAt + CHECKIN_CLOSE_MS - mockNow(state) + 1000;
reconcileState(state);
assert.equal(order.attendance, 'no_show');
assert.throws(() => checkIn(state, order.id), /签到时段/);
});
// 签到人数不足时整场取消;已签到用户退款,未签到用户不退款且不能提交赛果。
test('insufficient attendance cancels match with selective refunds', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
confirmVenue(state, match.id);
state.nowOffsetMs += match.startAt - CHECKIN_OPEN_MS - mockNow(state) + 1000;
checkIn(state, order.id);
state.nowOffsetMs += match.startAt + CHECKIN_CLOSE_MS - mockNow(state) + 1000;
reconcileState(state);
assert.equal(match.status, 'canceled');
assert.equal(match.cancellationReason, '签到人数不足,拼场取消');
assert.equal(order.status, 'refunded');
const noShowState = createInitialState();
const noShowMatch = noShowState.matches.find((item) => item.id === 'm1');
const noShowOrder = createJoinOrder(noShowState, noShowMatch.id);
payOrder(noShowState, noShowOrder.id);
confirmVenue(noShowState, noShowMatch.id);
noShowState.nowOffsetMs += noShowMatch.startAt + CHECKIN_CLOSE_MS - mockNow(noShowState) + 1000;
reconcileState(noShowState);
assert.equal(noShowOrder.attendance, 'no_show');
assert.equal(noShowOrder.status, 'paid');
assert.equal(noShowOrder.forfeitedAt > 0, true);
});
// 截止后的取消申请由运营审核,驳回保留名额,通过取消整场并退款。
test('late cancellation request needs review', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm1');
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
confirmVenue(state, match.id);
state.nowOffsetMs += match.startAt - CUTOFF_MS - mockNow(state) + 1000;
assert.throws(() => cancelOrder(state, order.id), /无法主动取消/);
requestCancellation(state, order.id, '个人原因');
reviewCancellationRequest(state, order.id, false);
assert.equal(order.status, 'paid');
assert.equal(order.cancellationRequest.status, 'rejected');
requestCancellation(state, order.id, '场地方原因');
reviewCancellationRequest(state, order.id, true);
assert.equal(match.status, 'canceled');
assert.equal(order.status, 'refunded');
});
// 上一阶段的本地订单、人数和用户资料在升级后保持不变。
test('v2 data upgrades without losing orders or profile', () => {
const state = createInitialState();
const order = createJoinOrder(state, 'm1');
payOrder(state, order.id);
state.profileName = '测试球友';
state.version = 2;
delete state.matches[0].venueStatus;
delete state.matches[0].durationHours;
delete order.attendance;
assert.equal(upgradeState(state), true);
assert.equal(state.version, 5);
assert.equal(state.profileName, '测试球友');
assert.equal(order.status, 'paid');
assert.equal(state.matches[0].players, 4);
assert.equal(state.matches[0].venueStatus, 'pending');
assert.equal(state.rankPoints, 0);
assert.equal(upgradeState(state), false);
});
// 积分门槛与自评匹配段位分别计算,最高档显示满进度。
test('competition rank follows points independently from matching rank', () => {
const state = createInitialState();
state.assessment = { level: '入门', total: 5, completedAt: Date.now() };
assert.deepEqual([getCompetitionProgress(0).current.name, getCompetitionProgress(400).current.name, getCompetitionProgress(800).current.name], ['入门', '初级', '中级']);
assert.equal(getCompetitionProgress(1280).percent, 60);
assert.equal(state.assessment.level, '入门');
state.rankPoints = 3200;
assert.equal(getCompetitionProgress(state.rankPoints).current.name, '精锐');
assert.equal(getCompetitionProgress(state.rankPoints).percent, 100);
assert.equal(getCompetitionProgress(state.rankPoints).next, null);
});
// 旧版本地状态升级时保留档案和订单,清除旧的虚拟初始积分。
test('v3 data upgrades without fabricated rank points', () => {
const state = createInitialState();
state.version = 3;
state.profileName = '旧用户';
state.assessment = { level: '高级', total: 25, completedAt: 123 };
delete state.rankPoints;
delete state.rankHistory;
assert.equal(upgradeState(state), true);
assert.equal(state.version, 5);
assert.equal(state.profileName, '旧用户');
assert.equal(state.assessment.level, '高级');
assert.equal(state.rankHistory.length, 0);
assert.equal(upgradeState(state), false);
assert.equal(state.rankHistory.length, 0);
});
// 已确认的胜场解锁本周任务,奖励领取后不能重复加分。
test('weekly win task can be claimed once', () => {
const state = createInitialState();
const match = state.matches[0];
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
order.attendance = 'checked_in';
match.status = 'completed';
match.startAt = Date.now();
match.result = { homeScore: 11, awayScore: 9, rating: 5, status: 'confirmed', confirmedAt: Date.now() };
assert.equal(getWeeklyTasks(state).find((task) => task.id === 'wins').progress, 1);
assert.equal(claimWeeklyTask(state, 'wins'), 60);
assert.equal(state.rankPoints, 60);
assert.equal(getWeeklyTasks(state).find((task) => task.id === 'wins').claimed, true);
assert.throws(() => claimWeeklyTask(state, 'wins'), /已领取/);
assert.equal(state.rankHistory.length, 1);
});
// 升级清除旧的一键赛事和相关奖励,同时补记此前已确认的真实赛果。
test('v4 migration keeps confirmed results but removes one-click event points', () => {
const state = createInitialState();
const match = state.matches[0];
const order = createJoinOrder(state, match.id);
payOrder(state, order.id);
order.attendance = 'checked_in';
match.status = 'completed';
match.result = { homeScore: 9, awayScore: 11, status: 'confirmed', confirmedAt: Date.now() };
state.version = 4;
state.rankPoints = 1420;
state.rankHistory = [{ eventId: 'rank-event-1', points: 80, total: 1360, settledAt: Date.now() }];
state.weeklyClaims = [{ taskId: 'competition', weekStart: 0, points: 60, claimedAt: Date.now() }];
assert.equal(upgradeState(state), true);
assert.equal(state.rankPoints, 80);
assert.equal(state.rankHistory[0].eventId, `match-${match.id}`);
assert.equal(state.weeklyClaims.length, 0);
assert.equal(order.status, 'paid');
});
// 时间选择后的时段按演示日期生成;回拨不清空订单或重复生成种子场。
test('selected demo time drives venue slots and preserves existing orders', () => {
const state = createInitialState();
state.assessment = { level: '中级', total: 20, completedAt: Date.now() };
const target = Date.now() + 15 * 24 * 60 * 60 * 1000;
setDemoTime(state, target);
const slots = getVenueSlots('lining', state.matches, mockNow(state));
assert.equal(slots[0].date, getDateAfter(1, target));
const order = createHostOrder(state, { venueId: 'lining', slotId: slots[0].id, level: '中级', capacity: 2 });
const seedCount = state.matches.filter((match) => match.hostId === 'seed').length;
setDemoTime(state, target - 7 * 24 * 60 * 60 * 1000);
assert.equal(state.orders.find((item) => item.id === order.id), order);
assert.equal(state.assessment.level, '中级');
assert.equal(state.matches.filter((match) => match.hostId === 'seed').length, seedCount);
assert.equal(getVenueSlots('lining', state.matches, mockNow(state))[0].date, getDateAfter(1, target - 7 * 24 * 60 * 60 * 1000));
});
// 指定演示周内的场次完赛可完成周任务,跨周与回拨时按所选周重新统计。
test('weekly task follows the selected demo week', () => {
const state = createInitialState();
const today = new Date();
const daysUntilMonday = (8 - today.getDay()) % 7 || 7;
const monday = new Date(today.getFullYear(), today.getMonth(), today.getDate() + daysUntilMonday, 6);
setDemoTime(state, monday.getTime());
const slot = getVenueSlots('lining', state.matches, mockNow(state))[0];
const order = createHostOrder(state, { venueId: 'lining', slotId: slot.id, level: '中级', capacity: 2 });
payOrder(state, order.id);
addDemoGuest(state, order.matchId);
const guest = state.orders.find((item) => item.matchId === order.matchId && item.role === 'guest');
confirmVenue(state, order.matchId);
const match = state.matches.find((item) => item.id === order.matchId);
setDemoTime(state, match.startAt - CHECKIN_OPEN_MS + 1000);
checkIn(state, order.id);
checkInDemoParticipant(state, order.matchId, guest.id);
setDemoTime(state, match.startAt + 2 * 60 * 60 * 1000);
assert.equal(getWeeklyTasks(state).find((task) => task.id === 'matches').progress, 1);
setDemoTime(state, monday.getTime() + 7 * 24 * 60 * 60 * 1000);
assert.equal(getWeeklyTasks(state).find((task) => task.id === 'matches').progress, 0);
setDemoTime(state, match.startAt + 3 * 60 * 60 * 1000);
assert.equal(match.status, 'completed');
assert.equal(getWeeklyTasks(state).find((task) => task.id === 'matches').progress, 1);
});
// 种子球友可逐个模拟签到,未操作的人在窗口关闭后记录未签到。
test('seed participants keep individual attendance', () => {
const state = createInitialState();
const match = state.matches.find((item) => item.id === 'm3');
confirmVenue(state, match.id);
assert.equal(seedParticipantCount(state, match), 4);
assert.throws(() => checkInDemoParticipant(state, match.id, 'seed-0'), /签到时段/);
state.nowOffsetMs += match.startAt - CHECKIN_OPEN_MS - mockNow(state) + 1000;
checkInDemoParticipant(state, match.id, 'seed-0');
assert.equal(match.demoAttendance[0], 'checked_in');
assert.throws(() => checkInDemoParticipant(state, match.id, 'seed-0'), /已完成/);
assert.throws(() => checkInDemoParticipant(state, match.id, 'seed-4'), /没有找到/);
state.nowOffsetMs += match.startAt + CHECKIN_CLOSE_MS - mockNow(state) + 1000;
reconcileState(state);
assert.equal(match.demoAttendance[0], 'checked_in');
assert.equal(match.demoAttendance[1], 'no_show');
assert.throws(() => checkInDemoParticipant(state, match.id, 'seed-1'), /签到时段/);
});
// 后加入的演示球友有独立订单,可签到但不能借此给当前用户签到。
test('added demo guest can check in separately', () => {
const state = createInitialState();
const slot = getVenueSlots('lining', state.matches).find((item) => item.remaining > 0);
const host = createHostOrder(state, { venueId: 'lining', slotId: slot.id, level: '中级', capacity: 2 });
payOrder(state, host.id);
addDemoGuest(state, host.matchId);
const match = state.matches.find((item) => item.id === host.matchId);
const guest = state.orders.find((item) => item.matchId === match.id && item.role === 'guest');
confirmVenue(state, match.id);
state.nowOffsetMs += match.startAt - CHECKIN_OPEN_MS - mockNow(state) + 1000;
checkInDemoParticipant(state, match.id, guest.id);
assert.equal(guest.attendance, 'checked_in');
assert.equal(host.attendance, undefined);
assert.throws(() => checkInDemoParticipant(state, match.id, host.id), /没有找到/);
});