161 lines
8.3 KiB
JavaScript
161 lines
8.3 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { registerHooks, stripTypeScriptTypes } from 'node:module';
|
|
|
|
const profileUrl = new URL('../functions/paymentProfile.ts', import.meta.url);
|
|
registerHooks({ load(url, context, next) {
|
|
if (url === profileUrl.href) return {
|
|
format: 'module', source: stripTypeScriptTypes(readFileSync(profileUrl, 'utf8')), shortCircuit: true,
|
|
};
|
|
return next(url, context);
|
|
} });
|
|
const { buildPaymentProfile, PAYMENT_PROFILE_RULE } = await import(profileUrl.href);
|
|
|
|
const DAY = 86400000;
|
|
const T = Date.parse('2026-09-23T00:00:00+08:00');
|
|
function payment(amountFen, days, sku = 'coin_pack', extra = {}) {
|
|
return { amountFen, paidAt: T - days * DAY + 3600000, sku, ...extra };
|
|
}
|
|
function profile(orders, extra = {}) {
|
|
return buildPaymentProfile({
|
|
cutoff: T, registerTime: T - 90 * DAY, payUser: true, missingOpenid: false,
|
|
confirmedOrders: orders.length, futureOrders: 0, invalidOrders: 0, missingTimeOrders: 0,
|
|
fallbackTimeOrders: 0, duplicateConflicts: 0, previousPaid: false,
|
|
amount15d: orders.filter(o => o.paidAt >= T - 15 * DAY).reduce((sum, o) => sum + o.amountFen, 0),
|
|
amount30d: orders.reduce((sum, o) => sum + o.amountFen, 0),
|
|
lastPaidAt: orders.length ? Math.max(...orders.map(o => o.paidAt)) : null, recentOrders: orders,
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
test('daily 6 yuan remains RFM VIP4, while its separate ticket reference stays 6 yuan', () => {
|
|
const result = profile(Array.from({ length: 30 }, (_, i) => payment(600, i + 1)));
|
|
assert.equal(result.vip_level, 4);
|
|
assert.equal(result.rfm.score, 3.6);
|
|
assert.equal(result.rfm.pay_days_30d, 30);
|
|
assert.equal(result.spending.trend_factor, 1);
|
|
assert.equal(result.spending.daily_reference_fen, 600);
|
|
assert.equal(result.ticket_factors.general.anchor_fen, 600);
|
|
assert.equal(result.net_payment_verified, false);
|
|
assert.equal(result.amount_basis, 'confirmed_order_amount');
|
|
});
|
|
|
|
test('repeated 68/128 yuan purchases establish ticket evidence independently of VIP', () => {
|
|
for (const [amount, days, anchor, vip] of [[6800, 4, 6800, 3], [12800, 5, 12800, 4], [6800, 1, 2150, 2]]) {
|
|
const result = profile(Array.from({ length: days }, (_, i) => payment(amount, i + 1)));
|
|
assert.equal(result.ticket_factors.general.anchor_fen, anchor);
|
|
assert.equal(result.vip_level, vip);
|
|
}
|
|
});
|
|
|
|
test('same-day orders affect medians, not frequency confidence; two equal RFM users have different tickets', () => {
|
|
const large = Array.from({ length: 5 }, (_, i) => payment(3000, i + 1));
|
|
const small = Array.from({ length: 25 }, (_, i) => payment(600, Math.floor(i / 5) + 1));
|
|
const a = profile(large), b = profile(small);
|
|
assert.equal(a.rfm.score, b.rfm.score);
|
|
assert.equal(a.rfm.pay_days_30d, b.rfm.pay_days_30d);
|
|
assert.equal(a.ticket_factors.general.anchor_fen, 3000);
|
|
assert.equal(b.ticket_factors.general.anchor_fen, 600);
|
|
assert.equal(profile(Array.from({ length: 20 }, () => payment(6800, 1))).ticket_factors.general.confidence, 0.25);
|
|
});
|
|
|
|
test('SKU weighting changes ticket evidence only; historical weighting ends at its configured boundary', () => {
|
|
const old = Array.from({ length: 4 }, (_, i) => payment(3000, i + 2, 'unlimited_health_bundle_30'));
|
|
const result = profile(old);
|
|
assert.equal(result.rfm.m30_fen, 12000);
|
|
assert.equal(result.ticket_factors.general.anchor_fen, 1320);
|
|
const current = old.map(o => ({ ...o, paidAt: PAYMENT_PROFILE_RULE.legacyBundle.before + 3600000 }));
|
|
assert.equal(profile(current).ticket_factors.general.adjusted_median_fen, 3000);
|
|
const pass = profile(Array.from({ length: 4 }, (_, i) => payment(3000, i + 1, 'battlepass_30')));
|
|
assert.equal(pass.ticket_factors.general.anchor_fen, 1200);
|
|
assert.equal(pass.rfm.m30_fen, 12000);
|
|
});
|
|
|
|
test('confirmed paid starter tiers provide full lucky evidence while other contexts retain quarter weight', () => {
|
|
assert.equal(PAYMENT_PROFILE_RULE.version, 'payment_profile_v2_20260930');
|
|
assert.equal(PAYMENT_PROFILE_RULE.starterPackRewardVersion, 'starter_pack_v3_20260930');
|
|
for (const [sku, amount] of [['starter_pack_2', 600], ['starter_pack_3', 1200],
|
|
['starter_pack_4', 1800], ['starter_pack_5', 3000]]) {
|
|
const result = profile(Array.from({ length: 4 }, (_, i) => payment(amount, i + 1, sku, {
|
|
activity: 'lucky', rewardVersion: PAYMENT_PROFILE_RULE.starterPackRewardVersion,
|
|
})));
|
|
assert.equal(result.ticket_factors.lucky.anchor_fen, amount);
|
|
for (const context of ['general', 'coin']) {
|
|
assert.equal(result.ticket_factors[context].anchor_fen, 600 + 0.25 * (amount - 600));
|
|
}
|
|
assert.equal(result.rfm.m30_fen, amount * 4);
|
|
assert.equal(result.spending.m30_fen, amount * 4);
|
|
}
|
|
});
|
|
|
|
test('paid starter tiers with missing or mismatched activity and reward versions retain quarter weight', () => {
|
|
for (const extra of [{}, { activity: 'lucky' },
|
|
{ rewardVersion: PAYMENT_PROFILE_RULE.starterPackRewardVersion },
|
|
{ activity: 'lucky', rewardVersion: 'older_reward' },
|
|
{ activity: 'coin', rewardVersion: PAYMENT_PROFILE_RULE.starterPackRewardVersion }]) {
|
|
for (const sku of ['starter_pack_2', 'starter_pack_3', 'starter_pack_4', 'starter_pack_5']) {
|
|
const result = profile(Array.from({ length: 4 }, (_, i) => payment(3000, i + 1, sku, extra)));
|
|
for (const context of ['general', 'coin', 'lucky']) {
|
|
assert.equal(result.ticket_factors[context].anchor_fen, 1200);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('lucky starter reward metadata does not promote first-purchase or other product evidence', () => {
|
|
for (const [sku, amount, adjusted] of [['starter_pack', 300, 525], ['month_card', 3000, 1200],
|
|
['battlepass_30', 3000, 1200], ['reborn_gift', 3000, 1800],
|
|
['jungle_treasure_1', 3000, 1800], ['gold_miner_1', 3000, 1800], ['coin_pack', 3000, 3000]]) {
|
|
const result = profile(Array.from({ length: 4 }, (_, i) => payment(amount, i + 1, sku, {
|
|
activity: 'lucky', rewardVersion: PAYMENT_PROFILE_RULE.starterPackRewardVersion,
|
|
})));
|
|
for (const context of ['general', 'coin', 'lucky']) {
|
|
assert.equal(result.ticket_factors[context].adjusted_median_fen, adjusted);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('new accounts use a minimum seven-day denominator; missing registration uses 30/15', () => {
|
|
const result = profile([payment(7000, 1)], { registerTime: T - DAY });
|
|
assert.equal(result.spending.observed_days30, 7);
|
|
assert.equal(result.spending.observed_days15, 7);
|
|
assert.equal(result.spending.daily_reference_fen, 1000);
|
|
const unknown = profile([payment(7000, 1)], { registerTime: null });
|
|
assert.equal(unknown.spending.observed_days30, 30);
|
|
assert.equal(unknown.spending.observed_days15, 15);
|
|
assert.ok(unknown.quality_warnings.includes('registration_time_missing'));
|
|
});
|
|
|
|
test('recency uses all history and paid users never become VIP0 when the window expires', () => {
|
|
const old = profile([], { confirmedOrders: 1, previousPaid: true, lastPaidAt: T - 40 * DAY });
|
|
assert.equal(old.vip_level, 1);
|
|
assert.equal(old.rfm.r_score, 2);
|
|
assert.equal(old.rfm.recency_days, 40);
|
|
assert.equal(old.spending.daily_reference_fen, 0);
|
|
assert.equal(profile([], { payUser: false }).vip_level, 0);
|
|
assert.equal(profile([]).paid_status, 'unknown');
|
|
assert.equal(profile([]).vip_level, null);
|
|
});
|
|
|
|
test('RFM uses half-up rounding and exact recency thresholds; trend is bounded', () => {
|
|
// M2 F3 R5 = 2.5 -> VIP3, not floor or bankers rounding.
|
|
assert.equal(profile(Array.from({ length: 5 }, (_, i) => payment(600, i + 1))).vip_level, 3);
|
|
for (const [days, score] of [[6, 5], [7, 4], [15, 3], [30, 2], [60, 1]]) {
|
|
assert.equal(profile([], { confirmedOrders: 1, lastPaidAt: T - days * DAY }).rfm.r_score, score);
|
|
}
|
|
assert.equal(profile([payment(600, 1)]).spending.trend_factor, 1.25);
|
|
assert.equal(profile([payment(600, 20)]).spending.trend_factor, 0.75);
|
|
});
|
|
|
|
test('invalid identity, malformed sources and missing history do not create new levels', () => {
|
|
for (const input of [{ payUser: false }, { missingOpenid: true }, { missingTimeOrders: 1 },
|
|
{ invalidOrders: 1 }, { duplicateConflicts: 1 }, { registerTime: T + DAY }]) {
|
|
const result = profile([payment(10000, 1)], input);
|
|
assert.equal(result.data_valid, false);
|
|
assert.equal(result.vip_level, null);
|
|
assert.equal(result.ticket_factors, null);
|
|
}
|
|
assert.equal(profile([], { payUser: false, previousPaid: true }).has_ever_paid, true);
|
|
});
|