Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1202,7 +1202,7 @@ function App() {
// Every action that has a materialised result — recommender-suggested,
// manually simulated, or lever-driven. The hints panel marks these
// levers "simulated" and blocks a redundant re-run.
simulatedActionIds: result ? Object.keys(result.actions) : [],
simulatedActionIds: result?.actions ? Object.keys(result.actions) : [],
});
}, [result, selectedActionIds, selectedContingency, n1Diagram]);

Expand Down
10 changes: 10 additions & 0 deletions frontend/src/game/solutionLog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,16 @@ describe('buildChosenActionRecord', () => {
expect(rec.effective).toBe(false);
});

it('tolerates a partial result with no actions map (pdf-only stream window)', () => {
// The step-2 stream publishes the overflow-graph `pdf` event before the
// `result` event, so a non-null result with no `actions` map is a real
// intermediate state the Game Mode snapshot effect renders through.
const partial = { pdf_path: null, pdf_url: '/results/pdf/g.html' } as unknown as AnalysisResult;
expect(() => buildChosenActionRecord('a1', partial, 1.2)).not.toThrow();
expect(() => buildChosenActionRecord('a1+a2', partial, 1.2)).not.toThrow();
expect(buildChosenActionRecord('a1', partial, 1.2).maxRho).toBeNull();
});

it('marks an action ineffective when it does not beat the baseline', () => {
const result = analysisResult({
// Still overloaded AND worse than doing nothing.
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/game/solutionLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ function combinedBeatsUnderlying(
): boolean {
if (!actionId.includes('+')) return true;
const partRhos = actionId.split('+')
.map((part) => result?.actions[part.trim()]?.max_rho)
.map((part) => result?.actions?.[part.trim()]?.max_rho)
.filter((rho): rho is number => typeof rho === 'number');
if (!partRhos.length) return true;
return maxRho <= Math.min(...partRhos) - COMBINED_MIN_RHO_GAIN;
Expand All @@ -119,7 +119,7 @@ export function buildChosenActionRecord(
result: AnalysisResult | null,
baselineMaxRho: number | null,
): ChosenActionRecord {
const detail = result?.actions[actionId];
const detail = result?.actions?.[actionId];
const maxRho = detail?.max_rho ?? null;
const after = detail?.lines_overloaded_after;
const solved = maxRho != null && maxRho < 1.0 && (!after || after.length === 0);
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/hooks/useAnalysis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,35 @@ describe('useAnalysis', () => {
expect(result.current.result?.pdf_url).toBe('/results/pdf/graph.pdf');
});

it('keeps an `actions` map on the intermediate pdf-only result', async () => {
// The ``pdf`` event lands seconds before the ``result`` event (the
// overflow graph is written while the recommender still filters
// actions). On a first analysis the previous result is null, so the
// merged object used to carry NO `actions` key — and every consumer
// reading `result.actions` unguarded (Game Mode's snapshot effect
// published `Object.keys(result.actions)`) crashed on that window.
mockRunAnalysisStep1.mockResolvedValue({
can_proceed: true, message: '', lines_overloaded: ['LINE_A'],
});

const pdfEvent = JSON.stringify({
type: 'pdf', pdf_url: '/results/pdf/graph.html', pdf_path: '/tmp/graph.html',
});
mockRunAnalysisStep2Stream.mockResolvedValue({
ok: true, body: makeStream(`${pdfEvent}\n`),
});

const { result } = renderHook(() => useAnalysis());

await act(async () => {
await result.current.handleRunAnalysis(['LINE_X'], vi.fn(), vi.fn());
});

expect(result.current.result?.pdf_url).toBe('/results/pdf/graph.html');
expect(result.current.result?.actions).toEqual({});
expect(() => Object.keys(result.current.result!.actions)).not.toThrow();
});

it('sets error on stream error event', async () => {
const detected = ['LINE_A'];
mockRunAnalysisStep1.mockResolvedValue({
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/hooks/useAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,14 @@ export function useAnalysis(): AnalysisState {
if (event.type === 'pdf') {
setResult((p: AnalysisResult | null) => ({
...(p || {}),
// The ``pdf`` event lands BEFORE the ``result`` event, so on a
// first analysis `p` is null and the merged object would carry
// no `actions` map at all — even though `AnalysisResult` declares
// it required. Every consumer that reads `result.actions` without
// a guard (the Game Mode snapshot effect, `buildChosenActionRecord`,
// `buildSessionResult`) then crashes on this intermediate state.
// Seed it so a partial result is always structurally valid.
actions: p?.actions ?? {},
pdf_url: event.pdf_url,
pdf_path: event.pdf_path,
// The overflow-graph build time is emitted on the
Expand Down
Loading