import { describe, expect, it } from 'vitest' import type { PositionUpdate } from '@vpp/domain' import { CascadeViolation, LedgerConcurrencyError, LedgerService } from '../src/ledger.js' const clock = () => '2026-03-14T08:00:00Z' const monthlyContract: PositionUpdate = { id: 'pos-monthly', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT', energy_mwh: '37200.0', // 1200 MWh/day over 31 days curve: null, source_ref: 'contract-2026-03-001', expected_version: 0, } const daBid = (energy: string, expectedVersion: number): PositionUpdate => ({ id: `pos-da-${energy}`, timescale: 'DAY_AHEAD', period: '2026-03-15', kind: 'BID_SUBMITTED', energy_mwh: energy, curve: null, source_ref: 'b'.repeat(64), expected_version: expectedVersion, }) const makeLedger = () => new LedgerService({ daMonthlyDeviationBand: '0.05', clock }) describe('constraint cascade (P7): monthly position bounds day-ahead bids', () => { it('accepts a bid within the ±5% band of the pro-rata daily share', () => { const ledger = makeLedger() ledger.append(monthlyContract) const view = ledger.append(daBid('1240.0', 1)) // daily share 1200, band [1140, 1260] expect(view.version).toBe(2) }) it('rejects a bid above the band', () => { const ledger = makeLedger() ledger.append(monthlyContract) expect(() => ledger.append(daBid('1300.0', 1))).toThrow(CascadeViolation) }) it('rejects a bid below the band', () => { const ledger = makeLedger() ledger.append(monthlyContract) expect(() => ledger.append(daBid('1000.0', 1))).toThrow(CascadeViolation) }) it('rejects a day-ahead bid with no monthly anchor (no default-allow)', () => { const ledger = makeLedger() expect(() => ledger.append(daBid('1200.0', 0))).toThrow(CascadeViolation) }) }) describe('optimistic concurrency', () => { it('rejects an append against a stale version', () => { const ledger = makeLedger() ledger.append(monthlyContract) expect(() => ledger.append({ ...monthlyContract, id: 'pos-2', expected_version: 0 })).toThrow( LedgerConcurrencyError, ) }) }) describe('views', () => { it('filters by timescale and stamps recorded_at from the injected clock', () => { const ledger = makeLedger() ledger.append(monthlyContract) ledger.append(daBid('1200.0', 1)) const monthly = ledger.viewByTimescale('MONTHLY') expect(monthly).toHaveLength(1) expect(monthly[0]!.recorded_at).toBe(clock()) expect(ledger.viewByTimescale('DAY_AHEAD')).toHaveLength(1) }) }) describe('dayAheadBounds: the cascade band handed to the bid optimizer', () => { it('returns the same band checkCascade enforces, stamped with the ledger version', () => { const ledger = makeLedger() ledger.append(monthlyContract) expect(ledger.dayAheadBounds('2026-03-15')).toEqual({ ledger_version: 1, daily_energy_min_mwh: '1140', daily_energy_max_mwh: '1260', }) // A bid exactly at each bound is accepted by append — bounds are inclusive. ledger.append(daBid('1140', 1)) ledger.append({ ...daBid('1260', 2), id: 'pos-da-max' }) }) it('throws when there is no monthly anchor', () => { expect(() => makeLedger().dayAheadBounds('2026-03-15')).toThrow(CascadeViolation) }) })