Spaces:
Sleeping
Sleeping
| // Copyright (c) 2025-2026, RTE (https://www.rte-france.com) | |
| // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. | |
| // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, | |
| // you can obtain one at http://mozilla.org/MPL/2.0/. | |
| // SPDX-License-Identifier: MPL-2.0 | |
| import { describe, it, expect } from 'vitest'; | |
| import { readFileSync } from 'node:fs'; | |
| import { resolve } from 'node:path'; | |
| import { scoreStudy, scoreSession } from './scoring'; | |
| import { buildSessionLog, buildSessionCsv } from './gameLog'; | |
| import type { GameSessionConfig, GameSessionLog, GameStudyResult } from './types'; | |
| function study(over: Partial<GameStudyResult> = {}): GameStudyResult { | |
| return { | |
| studyId: 's', label: 'S', contingencyElementId: 'c', | |
| startedAt: '2026-01-01T00:00:00.000Z', endedAt: '2026-01-01T00:00:00.000Z', | |
| durationMs: 0, timedOut: false, timeLimitSeconds: 180, maxActions: 3, | |
| actionsChosen: [], numActions: 1, baselineMaxRho: 1.3, finalMaxRho: 0.9, | |
| solved: true, ...over, | |
| }; | |
| } | |
| describe('scoreStudy — mirrors scoring_program/score.py', () => { | |
| it('perfect study scores 100', () => { | |
| expect(scoreStudy(study({ finalMaxRho: 0.9, solved: true, numActions: 1, durationMs: 0 })).total) | |
| .toBeCloseTo(100, 5); | |
| }); | |
| it('no action scores 0', () => { | |
| expect(scoreStudy(study({ numActions: 0, finalMaxRho: null, solved: false })).total) | |
| .toBeCloseTo(0, 5); | |
| }); | |
| it('partial relief gets physical credit only (unsolved, timed out)', () => { | |
| const sc = scoreStudy(study({ | |
| solved: false, finalMaxRho: 1.18, numActions: 1, | |
| durationMs: 180000, timeLimitSeconds: 180, | |
| })); | |
| expect(sc.remediationFraction).toBeCloseTo(0.4, 5); | |
| expect(sc.total).toBeCloseTo(34, 5); // 60*.4 + 25*.4 + 0 | |
| }); | |
| it('action economy penalizes more actions', () => { | |
| const one = scoreStudy(study({ numActions: 1, durationMs: 0 })); | |
| const three = scoreStudy(study({ numActions: 3, durationMs: 0 })); | |
| expect(one.actions).toBeCloseTo(25, 5); | |
| expect(three.actions).toBeCloseTo(25 * (1 - 2 / 3), 5); | |
| expect(one.total).toBeGreaterThan(three.total); | |
| }); | |
| it('time efficiency penalizes slow solves', () => { | |
| expect(scoreStudy(study({ durationMs: 0 })).time).toBeCloseTo(15, 5); | |
| expect(scoreStudy(study({ durationMs: 180000, timeLimitSeconds: 180 })).time).toBeCloseTo(0, 5); | |
| }); | |
| }); | |
| describe('scoreSession + log/csv', () => { | |
| const cfg: GameSessionConfig = { | |
| sessionName: 'T', timerSeconds: 180, maxActions: 3, studies: [], | |
| }; | |
| it('session score is the mean of per-study scores', () => { | |
| const log = buildSessionLog(cfg, [ | |
| study({ studyId: 'a', finalMaxRho: 0.9, durationMs: 0, numActions: 1 }), // 100 | |
| study({ studyId: 'b', numActions: 0, finalMaxRho: null, solved: false }), // 0 | |
| ], 'start', 'end'); | |
| expect(scoreSession(log).finalScore).toBeCloseTo(50, 5); | |
| expect(scoreSession(log).solvedCount).toBe(1); | |
| }); | |
| it('CSV has a header + one row per study', () => { | |
| const log = buildSessionLog(cfg, [study({ studyId: 'a' })], 'start', 'end'); | |
| const lines = buildSessionCsv(log).trim().split('\n'); | |
| expect(lines).toHaveLength(2); | |
| expect(lines[0]).toContain('final_max_rho'); | |
| expect(lines[1]).toContain('a'); | |
| }); | |
| }); | |
| // D8 — cross-language scoring parity. The frontend scorer (scoring.ts) and the | |
| // in-repo Codabench Python twin (scripts/game_mode/scoring_program/score.py) | |
| // are both pinned to ONE shared golden fixture; scripts/game_mode/test_score.py | |
| // asserts the SAME expected values on the Python side. A change to either | |
| // scorer that breaks numerical parity fails one of the two suites. | |
| interface GoldenStudyExpected { | |
| physical: number; actions: number; time: number; total: number; | |
| remediationFraction: number; solved: boolean; | |
| } | |
| interface GoldenFixture { | |
| studies: { name: string; input: Partial<GameStudyResult>; expected: GoldenStudyExpected }[]; | |
| sessions: { | |
| name: string; | |
| input: { studies: Partial<GameStudyResult>[] }; | |
| expected: { finalScore: number; solvedCount: number; nStudies: number }; | |
| }[]; | |
| } | |
| const golden = JSON.parse( | |
| readFileSync(resolve(__dirname, '../../../scripts/game_mode/scoring_golden.json'), 'utf-8'), | |
| ) as GoldenFixture; | |
| function sessionLog(studies: GameStudyResult[]): GameSessionLog { | |
| return { | |
| schemaVersion: '1.0', sessionName: 'golden', startedAt: 's', endedAt: 'e', | |
| config: { timerSeconds: 300, maxActions: 3, nStudies: studies.length }, | |
| studies, | |
| }; | |
| } | |
| describe('scoring parity with the Python twin (shared golden fixture, D8)', () => { | |
| it.each(golden.studies)('study "$name" matches score.py', (c) => { | |
| const got = scoreStudy(study(c.input)); | |
| expect(got.physical).toBeCloseTo(c.expected.physical, 6); | |
| expect(got.actions).toBeCloseTo(c.expected.actions, 6); | |
| expect(got.time).toBeCloseTo(c.expected.time, 6); | |
| expect(got.total).toBeCloseTo(c.expected.total, 6); | |
| expect(got.remediationFraction).toBeCloseTo(c.expected.remediationFraction, 6); | |
| expect(got.solved).toBe(c.expected.solved); | |
| }); | |
| it.each(golden.sessions)('session "$name" matches score.py', (c) => { | |
| const got = scoreSession(sessionLog(c.input.studies.map((s) => study(s)))); | |
| expect(got.finalScore).toBeCloseTo(c.expected.finalScore, 6); | |
| expect(got.solvedCount).toBe(c.expected.solvedCount); | |
| expect(got.nStudies).toBe(c.expected.nStudies); | |
| }); | |
| }); | |