107 lines
5.5 KiB
JavaScript
107 lines
5.5 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { buildPaymentProfile, PAYMENT_PROFILE_RULE } from '../functions/paymentProfile.ts';
|
|
|
|
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('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);
|
|
});
|