import { test } from 'node:test' import assert from 'node:assert/strict' import type { Event, EventCategory, ScoreChannel, ScoringProfile } from '@/shared' import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring' // 逐条对齐服务端 internal/domain/scoring.go 的 ScoreEvent 口径。 // 运行:node --test --import ./src/shared/tests/register.mjs src/admin/tests/ function makeEvent(overrides: Partial = {}): Event { return { id: 1, sportId: 1, code: 'M100', name: '100 米', eventType: 'individual', resultMode: 'auto', resultType: 'time', compare: 'asc', recordEligible: false, scored: true, awardMedals: true, multiplier: 1, status: 'pending', scheduledAt: null, venue: null, sortOrder: 1, remark: null, avgMode: 'valid', ...overrides, } } function makeProfile(overrides: Partial = {}): ScoringProfile { return { id: 1, code: 'p', name: 'p', tieMode: 'skip', recordBonusMode: 'max', rules: [], ...overrides } } function makeChannel(overrides: Partial = {}): ScoreChannel { return { id: 1, eventId: 1, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: true, ...overrides } } function makeRow(entryId: number, resultValue: number | null, teamId: number, rank: number | null = null): ScorableRow { return { entryId, teamId, athleteId: entryId, resultValue, rank, status: 'ok', isTie: false } } function makeContext(overrides: Partial = {}): ScoreContext { return { event: makeEvent(), category: null, channels: [], profiles: [], weight: 1, ...overrides } } function close(actual: number, expected: number): void { assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`) } test('awardRanks 之外的名次一律 0 分', () => { const profile = makeProfile({ awardRanks: 2, rules: [ { rank: 1, points: 9 }, { rank: 2, points: 6 }, { rank: 3, points: 3 }, ], }) const result = computeEventScore( [makeRow(1, 10, 100), makeRow(2, 11, 101), makeRow(3, 12, 102)], makeContext({ channels: [makeChannel()], profiles: [profile] }), ) close(result.rowPoints.get(1) ?? 0, 9) close(result.rowPoints.get(2) ?? 0, 6) close(result.rowPoints.get(3) ?? 0, 0) close(result.teamPoints.get(102) ?? 0, 0) }) test('lastPlaceZero 让末位整组 0 分', () => { const profile = makeProfile({ lastPlaceZero: true, rules: [ { rank: 1, points: 9 }, { rank: 2, points: 6 }, { rank: 3, points: 3 }, ], }) const result = computeEventScore( [makeRow(1, 10, 100), makeRow(2, 11, 101), makeRow(3, 12, 102)], makeContext({ channels: [makeChannel()], profiles: [profile] }), ) close(result.rowPoints.get(1) ?? 0, 9) close(result.rowPoints.get(2) ?? 0, 6) close(result.rowPoints.get(3) ?? 0, 0) }) test('跨末位的并列组不整组归零:只有起始名次正好落在末位的那一组才 0 分', () => { // 与服务端 domain/scoring.go groupPoints 逐字同口径(Go 用例「lastPlaceZero 不牵连跨末位的并列组」): // lastPlaceZero 的判据是 startRank == lastPlace。3 人里两人并列第 2(占 2、3),末位档 = 3, // 起始名次 2 != 3 → 第 2 名的 6 分照发;整组归零会连带抹掉本可拿分的第 2 名。 const profile = makeProfile({ lastPlaceZero: true, rules: [ { rank: 1, points: 9 }, { rank: 2, points: 6 }, { rank: 3, points: 3 }, ], }) const result = computeEventScore( [makeRow(1, 10, 100), makeRow(2, 11, 101), makeRow(3, 11, 102)], makeContext({ channels: [makeChannel()], profiles: [profile] }), ) close(result.rowPoints.get(1) ?? 0, 9) close(result.rowPoints.get(2) ?? 0, 6) close(result.rowPoints.get(3) ?? 0, 6) }) test('scored=false 只排名不计分', () => { const profile = makeProfile({ rules: [ { rank: 1, points: 9 }, { rank: 2, points: 6 }, ], }) const result = computeEventScore( [makeRow(1, 10, 100), makeRow(2, 11, 101)], makeContext({ event: makeEvent({ scored: false }), channels: [makeChannel()], profiles: [profile] }), ) assert.equal(result.ranks.get(1), 1) assert.equal(result.ranks.get(2), 2) close(result.rowPoints.get(1) ?? 0, 0) close(result.teamPoints.get(100) ?? 0, 0) }) test('多通道叠加,个人通道分按权重折算进单位总分', () => { const athleteProfile = makeProfile({ id: 1, rules: [{ rank: 1, points: 10 }] }) const teamProfile = makeProfile({ id: 2, rules: [{ rank: 1, points: 4 }] }) const result = computeEventScore( [makeRow(1, 10, 100)], makeContext({ channels: [ makeChannel({ id: 1, scope: 'athlete', profileId: 1 }), makeChannel({ id: 2, scope: 'team', profileId: 2 }), ], profiles: [athleteProfile, teamProfile], weight: 2, }), ) // 行分是两条通道的原始分之和;折算到单位时只有个人通道乘系数。 close(result.rowPoints.get(1) ?? 0, 14) close(result.rowTeamPoints.get(1) ?? 0, 24) close(result.teamPoints.get(100) ?? 0, 24) }) test('并列 average 取占用名次区间的均值', () => { const profile = makeProfile({ tieMode: 'average', rules: [ { rank: 1, points: 10 }, { rank: 2, points: 6 }, { rank: 3, points: 3 }, { rank: 4, points: 1 }, ], }) const result = computeEventScore( [makeRow(1, 10, 100), makeRow(2, 10, 101), makeRow(3, 10, 102)], makeContext({ channels: [makeChannel()], profiles: [profile] }), ) close(result.rowPoints.get(1) ?? 0, (10 + 6 + 3) / 3) close(result.rowPoints.get(2) ?? 0, (10 + 6 + 3) / 3) close(result.rowPoints.get(3) ?? 0, (10 + 6 + 3) / 3) assert.equal(result.ranks.get(1), 1) }) test('并列 higher 取区间最高档', () => { const profile = makeProfile({ tieMode: 'higher', rules: [ { rank: 1, points: 10 }, { rank: 2, points: 6 }, { rank: 3, points: 3 }, ], }) const result = computeEventScore( [makeRow(1, 10, 100), makeRow(2, 10, 101), makeRow(3, 10, 102)], makeContext({ channels: [makeChannel()], profiles: [profile] }), ) close(result.rowPoints.get(1) ?? 0, 10) close(result.rowPoints.get(2) ?? 0, 10) close(result.rowPoints.get(3) ?? 0, 10) }) test('没有计分通道时算出名次但一律 0 分', () => { const result = computeEventScore( [makeRow(1, 10, 100), makeRow(2, 11, 101)], makeContext({ channels: [], profiles: [] }), ) assert.equal(result.ranks.get(1), 1) assert.equal(result.ranks.get(2), 2) close(result.rowPoints.get(1) ?? 0, 0) close(result.teamPoints.get(100) ?? 0, 0) }) test('manual 通道使用录入名次,不做顺推改写', () => { const profile = makeProfile({ rules: [ { rank: 1, points: 10 }, { rank: 2, points: 6 }, ], }) const result = computeEventScore( [makeRow(1, null, 100, 2), makeRow(2, null, 101, 1)], makeContext({ event: makeEvent({ resultMode: 'manual', compare: 'none' }), channels: [makeChannel({ basis: 'manual' })], profiles: [profile], }), ) close(result.rowPoints.get(1) ?? 0, 6) close(result.rowPoints.get(2) ?? 0, 10) assert.equal(result.ranks.get(1), 2) assert.equal(result.ranks.get(2), 1) }) test('项目与组别权重相乘', () => { const profile = makeProfile({ rules: [{ rank: 1, points: 10 }] }) const category: EventCategory = { id: 7, eventId: 1, name: '甲组', multiplier: 3, gender: null, ageMin: null, ageMax: null, sortOrder: 1, } const result = computeEventScore( [makeRow(1, 10, 100)], makeContext({ event: makeEvent({ multiplier: 2 }), category, channels: [makeChannel()], profiles: [profile] }), ) close(result.rowPoints.get(1) ?? 0, 60) }) // 团体行的 athleteId 是 null(服务端 entries.athlete_id 为空,0 队员的「只报单位」报名也是这种行), // 团体通道的排名主体是单位,不能因为没有运动员就把行丢掉。 test('团体行(athleteId 为 null)按单位通道正常拿名次与积分', () => { const profile = makeProfile({ rules: [ { rank: 1, points: 9 }, { rank: 2, points: 6 }, ], }) const rows: ScorableRow[] = [ { entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false }, { entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false }, ] const result = computeEventScore( rows, makeContext({ event: makeEvent({ eventType: 'team', resultMode: 'manual', compare: 'none', resultType: 'none' }), channels: [makeChannel({ scope: 'team', basis: 'manual' })], profiles: [profile], }), ) assert.equal(result.ranks.get(1), 1) assert.equal(result.ranks.get(2), 2) close(result.rowPoints.get(1) ?? 0, 9) close(result.rowTeamPoints.get(1) ?? 0, 9) close(result.teamPoints.get(100) ?? 0, 9) close(result.teamPoints.get(101) ?? 0, 6) }) test('countToTeam=false 的通道只算个人分,不进单位总分', () => { const profile = makeProfile({ rules: [{ rank: 1, points: 10 }] }) const result = computeEventScore( [makeRow(1, 10, 100)], makeContext({ channels: [makeChannel({ basis: 'result', countToTeam: false })], profiles: [profile], weight: 2, }), ) close(result.rowPoints.get(1) ?? 0, 10) close(result.rowTeamPoints.get(1) ?? 0, 0) close(result.teamPoints.get(100) ?? 0, 0) // 名次照算,只是分不落到单位头上。 assert.equal(result.ranks.get(1), 1) }) test('「个人+团体」:个人通道不进单位总分,团体通道按队内平均记分', () => { const athleteProfile = makeProfile({ id: 1, rules: [{ rank: 1, points: 10 }, { rank: 2, points: 8 }] }) const teamProfile = makeProfile({ id: 2, rules: [{ rank: 1, points: 15 }, { rank: 2, points: 8 }] }) const rows = [makeRow(1, 18, 100), makeRow(2, 20, 100), makeRow(3, 16, 101)] const result = computeEventScore( rows, makeContext({ event: makeEvent({ eventType: 'individual_team', resultType: 'count', compare: 'desc' }), channels: [ makeChannel({ id: 1, scope: 'athlete', basis: 'result', profileId: 1, countToTeam: false }), makeChannel({ id: 2, scope: 'team', basis: 'aggregate', profileId: 2, countToTeam: true }), ], profiles: [athleteProfile, teamProfile], }), ) // 个人分照给:20 排第 1、18 排第 2、16 排第 3(录取前 2 名)。 close(result.rowPoints.get(2) ?? 0, 10) close(result.rowPoints.get(1) ?? 0, 8) // 单位总分只来自团体通道:100 队平均 19 排第 1,101 队平均 16 排第 2。 // 个人通道若也计入,100 队会多出个人第 1 名的 10 分。 close(result.teamPoints.get(100) ?? 0, 15) close(result.teamPoints.get(101) ?? 0, 8) }) test('aggregate 通道按队内平均排名(valid 只算有效成绩)', () => { const profile = makeProfile({ rules: [{ rank: 1, points: 10 }, { rank: 2, points: 5 }] }) const result = computeEventScore( [makeRow(1, 10, 100), makeRow(2, 20, 100), makeRow(3, 16, 101)], makeContext({ event: makeEvent({ eventType: 'team', resultType: 'count', compare: 'desc' }), channels: [makeChannel({ scope: 'team', basis: 'aggregate' })], profiles: [profile], }), ) // 100 队平均 15 < 101 队平均 16,desc 下 101 队第一。 close(result.teamPoints.get(101) ?? 0, 10) close(result.teamPoints.get(100) ?? 0, 5) }) test('avgMode=all 把没成绩的报名项按 0 计入平均分母', () => { const profile = makeProfile({ rules: [{ rank: 1, points: 10 }, { rank: 2, points: 5 }] }) const makeRows = (): ScorableRow[] => [makeRow(1, 20, 100), makeRow(2, 20, 100), makeRow(3, null, 100), makeRow(4, 16, 101)] const context = (avgMode: Event['avgMode']): ScoreContext => makeContext({ event: makeEvent({ eventType: 'team', resultType: 'count', compare: 'desc', avgMode }), channels: [makeChannel({ scope: 'team', basis: 'aggregate' })], profiles: [profile], }) // valid:100 队只按两条有效成绩平均(20)→ 第一。 const valid = computeEventScore(makeRows(), context('valid')) close(valid.teamPoints.get(100) ?? 0, 10) close(valid.teamPoints.get(101) ?? 0, 5) // all:第 3 条没成绩按 0 计入,100 队平均 40/3 → 落到第二。 const all = computeEventScore(makeRows(), context('all')) close(all.teamPoints.get(101) ?? 0, 10) close(all.teamPoints.get(100) ?? 0, 5) }) test('团体行只配了录入名次(无计分通道)时也能算出名次', () => { const rows: ScorableRow[] = [ { entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false }, { entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false }, ] const result = computeEventScore( rows, makeContext({ event: makeEvent({ eventType: 'team', resultMode: 'manual', compare: 'none', resultType: 'none' }) }), ) assert.equal(result.ranks.get(1), 2) assert.equal(result.ranks.get(2), 1) })