import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import type { Event, ScoreChannel, ScoringProfile, TeamRankEntry } from '@/shared' import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring' /** * 「按单位录名次」(scope=team + basis=manual)的预览口径,逐条对齐服务端 * domain/scoring.go 的 collectTeamManualCandidates 与 groupPoints。 * * 现场报的 bug:台球按单位在录入页底部的「团体名次」卡片里录名次(channel_manual_ranks), * 服务端算对了(六个单位合计 40 分),录入页的实时预览却每个单位都是 0——预览只读行上的 * rank,从不读 channel_manual_ranks。而那张预览又是发布确认框里「最大分差」的来源。 */ function makeEvent(overrides: Partial = {}): Event { return { id: 1, sportId: 1, code: 'BALL', name: '台球', eventType: 'team', resultMode: 'manual', resultType: 'none', compare: 'none', 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: 'team', basis: 'manual', profileId: 1, sortOrder: 1, countToTeam: true, ...overrides } } function makeContext(overrides: Partial = {}): ScoreContext { return { event: makeEvent(), category: null, channels: [], profiles: [], weight: 1, ...overrides } } /** 团体名次分表:与规则包 team_top6 同一张表,第 1–6 名 15/10/8/4/2/1(六个单位合计 40 分,正是现场那组数)。 */ const TEAM_PROFILE = makeProfile({ rules: [ { rank: 1, points: 15 }, { rank: 2, points: 10 }, { rank: 3, points: 8 }, { rank: 4, points: 4 }, { rank: 5, points: 2 }, { rank: 6, points: 1 }, ], }) const SIX_UNITS: TeamRankEntry[] = [ { teamId: 100, rank: 1 }, { teamId: 101, rank: 2 }, { teamId: 102, rank: 3 }, { teamId: 103, rank: 4 }, { teamId: 104, rank: 5 }, { teamId: 105, rank: 6 }, ] function close(actual: number, expected: number): void { assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`) } test('名次只填在「团体名次」卡片(channel_manual_ranks):六个单位合计 40 分', () => { // 与服务端 httpapi 侧 team_manual_ranks_score_test.go 同输入同期望:同一份 channel_manual_ranks, // 预览与引擎必须给出同一组数字。 // 团体名次不挂在任何报名项上:六个单位各录一个名次,成绩表里一行都没有 rank。 const rows: ScorableRow[] = [100, 101, 102, 103, 104, 105].map((teamId, index) => ({ entryId: index + 1, teamId, athleteId: index + 1, resultValue: null, rank: null, status: 'ok', isTie: false, })) const result = computeEventScore( rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE], teamRanks: SIX_UNITS }), ) const expected = [15, 10, 8, 4, 2, 1] let total = 0 for (const [index, teamId] of [100, 101, 102, 103, 104, 105].entries()) { const points = result.teamPoints.get(teamId) ?? 0 close(points, expected[index] ?? 0) total += points } close(total, 40) }) test('团体手工通道只认按单位录的名次:填在个人行上的名次一律不进团体通道', () => { // 服务端 collectTeamManualCandidates 只收 athlete_id == 0 的团体行; // 预览若照旧读行上的 rank,就会出现「预览有分(10/9/8…)、服务端 0」。 const rows: ScorableRow[] = [10, 9, 8, 6, 4, 3].map((rank, index) => ({ entryId: index + 1, teamId: 100 + index, athleteId: index + 1, resultValue: null, rank, status: 'ok', isTie: false, })) const result = computeEventScore(rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE] })) assert.equal(result.teamPoints.size, 0) assert.equal(result.rowPoints.size, 0) }) test('两个团体行的名次照旧生效,且被卡片里的名次覆盖', () => { // 纯团体项目(篮球等)的成绩表兜底:团体行(athleteId 为 null)上录的名次。 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 fromRows = computeEventScore(rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE] })) close(fromRows.teamPoints.get(100) ?? 0, 15) close(fromRows.teamPoints.get(101) ?? 0, 10) assert.equal(fromRows.ranks.get(1), 1) // 同一单位两个来源都有时以 channel_manual_ranks 为准(服务端 overrideOf)。 const overridden = computeEventScore( rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE], teamRanks: [ { teamId: 100, rank: 2 }, { teamId: 101, rank: 1 }, ], }), ) close(overridden.teamPoints.get(100) ?? 0, 10) close(overridden.teamPoints.get(101) ?? 0, 15) assert.equal(overridden.ranks.get(1), 2) }) test('联队:按单位录的名次按份额拆给各成员单位(与服务端 jointTeamEntry + ledgerFromRanked 同口径)', () => { // 服务端 joint_team_test.go 的场景:联队报名(A、B 各半)+ TeamRanks{主单位 A 第 1 名}、15 分表 // → A、B 各 7.5。名次挂在联队那条报名上(entryId 1),所以行分是整份 15,单位分才拆。 const profile = makeProfile({ rules: [{ rank: 1, points: 15 }] }) const result = computeEventScore( [ { entryId: 1, teamId: 100, athleteId: 0, resultValue: null, rank: null, status: 'ok', isTie: false, shares: [ { teamId: 100, weight: 1 }, { teamId: 200, weight: 1 }, ], }, ], makeContext({ channels: [makeChannel()], profiles: [profile], teamRanks: [{ teamId: 100, rank: 1 }] }), ) close(result.teamPoints.get(100) ?? 0, 7.5) close(result.teamPoints.get(200) ?? 0, 7.5) close(result.rowPoints.get(1) ?? 0, 15) }) test('lastPlaceZero 只抹「起始名次正好落在末位」的那一组:两人并列第 1 不归零', () => { // 服务端判据是 startRank == lastPlace(scoring.go groupPoints)。两人并列第 1 时 // 占位名次是 1、2,末位档 = 2,起始名次 1 != 2 → 照发第 1 名的分。 const profile = makeProfile({ lastPlaceZero: true, rules: [ { rank: 1, points: 10 }, { rank: 2, points: 6 }, { rank: 3, points: 3 }, ], }) const result = computeEventScore( [makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 1)], makeContext({ event: makeEvent({ eventType: 'individual', resultType: 'none' }), channels: [makeChannel({ scope: 'athlete', basis: 'manual' })], profiles: [profile], }), ) close(result.rowPoints.get(1) ?? 0, 10) close(result.rowPoints.get(2) ?? 0, 10) close(result.teamPoints.get(100) ?? 0, 10) close(result.teamPoints.get(101) ?? 0, 10) }) test('lastPlaceZero 仍要抹掉真正的末位那一组', () => { const profile = makeProfile({ lastPlaceZero: true, rules: [ { rank: 1, points: 10 }, { rank: 2, points: 6 }, { rank: 3, points: 3 }, ], }) const result = computeEventScore( [makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 2), makeRowOf(3, null, 102, 3)], makeContext({ event: makeEvent({ eventType: 'individual', resultType: 'none' }), channels: [makeChannel({ scope: 'athlete', basis: 'manual' })], profiles: [profile], }), ) close(result.rowPoints.get(1) ?? 0, 10) close(result.rowPoints.get(2) ?? 0, 6) close(result.rowPoints.get(3) ?? 0, 0) }) test('compare=none 配 basis=result:预览报错,不静默按录入名次兜底', () => { const profile = makeProfile({ rules: [ { rank: 1, points: 10 }, { rank: 2, points: 6 }, ], }) const result = computeEventScore( [makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 2)], makeContext({ event: makeEvent({ eventType: 'individual', resultMode: 'manual' }), channels: [makeChannel({ scope: 'athlete', basis: 'result' })], profiles: [profile], }), ) assert.ok(result.error, '服务端 domain.ScoreEvent 对这个配置返回 ErrCompareRequired(发布 409),预览必须同样报错') assert.match(result.error.message, /比较方向/) assert.equal(result.error.channelLabel, '个人名次 / 按成绩自动排名') // 报错时不再产出任何分:预览不该给一个服务端算不出来的配置算分。 assert.equal(result.teamPoints.size, 0) assert.equal(result.rowPoints.size, 0) }) test('compare=none 配 basis=aggregate:同样报错', () => { const result = computeEventScore( [makeRowOf(1, 20, 100, null), makeRowOf(2, 16, 101, null)], makeContext({ event: makeEvent({ eventType: 'team', resultType: 'count' }), channels: [makeChannel({ basis: 'aggregate' })], profiles: [TEAM_PROFILE], }), ) assert.ok(result.error, '聚合通道同样要先按成绩排名,compare=none 时服务端返回 ErrCompareRequired') assert.match(result.error.message, /比较方向/) }) test('compare=none 配 basis=manual:照常算分,不报错', () => { const result = computeEventScore( [], makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE], teamRanks: SIX_UNITS }), ) assert.equal(result.error, null) close(result.teamPoints.get(100) ?? 0, 15) }) function makeRowOf(entryId: number, resultValue: number | null, teamId: number, rank: number | null): ScorableRow { return { entryId, teamId, athleteId: entryId, resultValue, rank, status: 'ok', isTie: false } } // 录入页没有组件测试工具,接线部分按仓库既有做法(avg-mode.test.ts)读源码断言: // 纯函数再有分,页面没把「团体名次」卡片喂进去,现场看到的还是 0。 const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8') test('录入页把「团体名次」卡片喂进预览算分(接线掉了就等于没修)', () => { assert.match( ENTRY_SOURCE, /const previewTeamRanks = computed\(\(\) => teamRanksPayload\(teamRankRows\.value\)\.ranks\)/, ) assert.match(ENTRY_SOURCE, /const score = computed\(\(\) => scoreWith\(visibleRows\.value, previewTeamRanks\.value\)\)/) assert.match(ENTRY_SOURCE, /const savedTeamRanks = computed\(\(\) => \{/) assert.match(ENTRY_SOURCE, /teamRanks,\n \}\)/) }) test('录入页把「不挂在行上的单位分」从已发布基线里扣掉,差值才算得对', () => { assert.match(ENTRY_SOURCE, /for \(const \[teamId, points\] of published\?\.unitPoints \?\? \[\]\)/) assert.match(ENTRY_SOURCE, /const scoreConfigError = computed\(\(\) => score\.value\?\.error \?\? null\)/) })