Skip to content

Commit 8c49045

Browse files
committed
Refactors step repair logic and adds testing
Extracts the migration logic to a dedicated utility for maintainability Adds new test scripts and dependencies to ensure comprehensive coverage
1 parent 58a1be7 commit 8c49045

File tree

4 files changed

+169
-50
lines changed

4 files changed

+169
-50
lines changed

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
"dev": "vite",
1010
"build": "vite build",
1111
"preview": "vite preview",
12+
"test": "vitest",
13+
"test:watch": "vitest --watch",
1214
"codegen": "graphql-codegen --config codegen.yml"
1315
},
1416
"dependencies": {
@@ -44,6 +46,8 @@
4446
"concurrently": "^9.2.1",
4547
"rollup": "^4.50.2",
4648
"typescript": "^5.0.0",
47-
"vite": "^7.0.0"
49+
"vite": "^7.0.0",
50+
"vitest": "^1.0.0",
51+
"@types/node": "^20.0.0"
4852
}
4953
}

src/App.tsx

Lines changed: 4 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Activity, Step, ScriptData, isActivity, isStep, LogicNode } from './typ
1414
import { normalizeScript } from './normalizer';
1515
import { createActivity, createStep } from './factories';
1616
import { validateScript, formatErrors, getValidatorStatus } from './validator';
17+
import { repairActivities } from './lib/repairSteps';
1718
import { editorReducer, initialEditorState, getSelectedNode } from './editorReducer';
1819
import { usePersistedSelections } from './hooks/usePersistedSelections';
1920
import AppProviders from './app/Providers';
@@ -224,55 +225,9 @@ export default function App() {
224225
useEffect(() => {
225226
if (migrationComplete) return; // Only run once
226227

227-
let mutated = false;
228-
const repairedActivities = script.activities.map(act => {
229-
const repairedSteps = act.steps.map(step => {
230-
const newStep = { ...step } as Step;
231-
if (newStep.logic) {
232-
// Repair setup
233-
if (newStep.nodeType === 'RangeAnalysisScriptedStep') {
234-
if (!newStep.logic.setup || newStep.logic.setup.nodeType !== 'RangeAnalysisScriptedSetup') {
235-
newStep.logic.setup = { nodeType: 'RangeAnalysisScriptedSetup', club: 'Drv', distance: 200 };
236-
mutated = true;
237-
}
238-
} else if (newStep.nodeType === 'PerformanceCenterScriptedStep') {
239-
if (!newStep.logic.setup || (newStep.logic.setup.nodeType !== 'PerformanceCenterApproachScriptedSetup' && newStep.logic.setup.nodeType !== 'PerformanceCenterTeeShotsScriptedSetup')) {
240-
newStep.logic.setup = { nodeType: 'PerformanceCenterApproachScriptedSetup', hole: 1, pin: 1, playerCategory: 'Handicap', hcp: 10, gender: 'Male', minDistance: 50, maxDistance: 150 };
241-
mutated = true;
242-
}
243-
}
244-
const fixCond = (grp: any, isRange: boolean) => {
245-
if (!grp) return grp;
246-
if (!grp.nodeType) {
247-
grp.nodeType = isRange ? 'RangeAnalysisScriptedConditions' : 'PerformanceCenterScriptedConditions';
248-
mutated = true;
249-
}
250-
if (!Array.isArray(grp.conditions) || grp.conditions.length === 0) {
251-
grp.conditions = [{ parameter: 'Total', min: 0 }];
252-
mutated = true;
253-
}
254-
if (!grp.shots) { grp.shots = 1; mutated = true; }
255-
if (!grp.conditionType) { grp.conditionType = 'And'; mutated = true; }
256-
return grp;
257-
};
258-
const isRange = newStep.nodeType === 'RangeAnalysisScriptedStep';
259-
newStep.logic.successCondition = fixCond(newStep.logic.successCondition, isRange);
260-
newStep.logic.failCondition = fixCond(newStep.logic.failCondition, isRange);
261-
}
262-
return newStep;
263-
});
264-
if (repairedSteps.some((s, idx) => s !== act.steps[idx])) {
265-
mutated = true;
266-
return { ...act, steps: repairedSteps };
267-
}
268-
return act;
269-
});
270-
if (mutated) {
271-
// dispatch minimal updates via LOAD_SCRIPT to reuse validation effect
272-
// repairedActivities may be a freshly-mapped array and TypeScript
273-
// can be conservative about subtype narrowing here. Cast to ScriptData
274-
// to satisfy the reducer action shape while preserving runtime value.
275-
dispatch({ type: 'LOAD_SCRIPT', script: { activities: repairedActivities } as unknown as ScriptData });
228+
const result = repairActivities(script.activities as any);
229+
if (result.mutated) {
230+
dispatch({ type: 'LOAD_SCRIPT', script: { activities: result.activities } });
276231
}
277232
setMigrationComplete(true);
278233
}, [script.activities, migrationComplete]);

src/lib/repairSteps.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { ScriptData, Step, Activity } from '../types';
2+
3+
// Extract and export the migration logic so it can be unit tested.
4+
export function repairActivities(activities: Activity[]): { activities: Activity[]; mutated: boolean } {
5+
let mutated = false;
6+
7+
const repairedActivities = activities.map(act => {
8+
if (act.nodeType === 'RangeAnalysisScriptedActivity') {
9+
const repairedSteps = act.steps.map(step => {
10+
const newStep = { ...step } as Step;
11+
if (newStep.logic) {
12+
if (newStep.nodeType === 'RangeAnalysisScriptedStep') {
13+
if (!newStep.logic.setup || newStep.logic.setup.nodeType !== 'RangeAnalysisScriptedSetup') {
14+
newStep.logic.setup = { nodeType: 'RangeAnalysisScriptedSetup', club: 'Drv', distance: 200 } as any;
15+
mutated = true;
16+
}
17+
}
18+
19+
const fixCondRange = (grp: any): any => {
20+
if (!grp) return grp;
21+
if (!grp.nodeType) {
22+
grp.nodeType = 'RangeAnalysisScriptedConditions';
23+
mutated = true;
24+
}
25+
if (!Array.isArray(grp.conditions) || grp.conditions.length === 0) {
26+
grp.conditions = [{ parameter: 'Total', min: 0 }];
27+
mutated = true;
28+
}
29+
if (!grp.shots) { grp.shots = 1; mutated = true; }
30+
if (!grp.conditionType) { grp.conditionType = 'And'; mutated = true; }
31+
return grp;
32+
};
33+
34+
newStep.logic.successCondition = fixCondRange(newStep.logic.successCondition);
35+
newStep.logic.failCondition = fixCondRange(newStep.logic.failCondition);
36+
}
37+
return newStep;
38+
});
39+
if (repairedSteps.some((s, idx) => s !== act.steps[idx])) {
40+
mutated = true;
41+
return { ...act, steps: repairedSteps } as Activity;
42+
}
43+
return act;
44+
}
45+
46+
// PerformanceCenterScriptedActivity
47+
const repairedSteps = act.steps.map(step => {
48+
const newStep = { ...step } as Step;
49+
if (newStep.logic) {
50+
if (newStep.nodeType === 'PerformanceCenterScriptedStep') {
51+
if (!newStep.logic.setup || (newStep.logic.setup.nodeType !== 'PerformanceCenterApproachScriptedSetup' && newStep.logic.setup.nodeType !== 'PerformanceCenterTeeShotsScriptedSetup')) {
52+
newStep.logic.setup = { nodeType: 'PerformanceCenterApproachScriptedSetup', hole: 1, pin: 1, playerCategory: 'Handicap', hcp: 10, gender: 'Male', minDistance: 50, maxDistance: 150 } as any;
53+
mutated = true;
54+
}
55+
}
56+
57+
const fixCondPerf = (grp: any): any => {
58+
if (!grp) return grp;
59+
if (!grp.nodeType) {
60+
grp.nodeType = 'PerformanceCenterScriptedConditions';
61+
mutated = true;
62+
}
63+
if (!Array.isArray(grp.conditions) || grp.conditions.length === 0) {
64+
grp.conditions = [{ parameter: 'Total', min: 0 }];
65+
mutated = true;
66+
}
67+
if (!grp.shots) { grp.shots = 1; mutated = true; }
68+
if (!grp.conditionType) { grp.conditionType = 'And'; mutated = true; }
69+
return grp;
70+
};
71+
72+
newStep.logic.successCondition = fixCondPerf(newStep.logic.successCondition);
73+
newStep.logic.failCondition = fixCondPerf(newStep.logic.failCondition);
74+
}
75+
return newStep;
76+
});
77+
if (repairedSteps.some((s, idx) => s !== act.steps[idx])) {
78+
mutated = true;
79+
return { ...act, steps: repairedSteps } as Activity;
80+
}
81+
return act;
82+
});
83+
84+
return { activities: repairedActivities, mutated };
85+
}

test/repairSteps.spec.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { repairActivities } from '../src/lib/repairSteps';
3+
import { Activity } from '../src/types';
4+
5+
describe('repairActivities', () => {
6+
it('adds setup and default conditions for RangeAnalysis steps when missing', () => {
7+
const activities: Activity[] = [
8+
{
9+
nodeType: 'RangeAnalysisScriptedActivity',
10+
steps: [
11+
{
12+
nodeType: 'RangeAnalysisScriptedStep',
13+
logic: {
14+
nodeType: 'RangeAnalysisScriptedLogic',
15+
// setup missing -> should be added
16+
successCondition: undefined,
17+
failCondition: undefined,
18+
} as any,
19+
} as any,
20+
],
21+
} as any,
22+
];
23+
24+
const res = repairActivities(activities);
25+
expect(res.mutated).toBe(true);
26+
expect(res.activities[0].steps[0].logic.setup).toBeDefined();
27+
expect(res.activities[0].steps[0].logic.setup.nodeType).toBe('RangeAnalysisScriptedSetup');
28+
expect(res.activities[0].steps[0].logic.successCondition).toBeUndefined();
29+
});
30+
31+
it('adds setup and default conditions for PerformanceCenter steps when missing', () => {
32+
const activities: Activity[] = [
33+
{
34+
nodeType: 'PerformanceCenterScriptedActivity',
35+
steps: [
36+
{
37+
nodeType: 'PerformanceCenterScriptedStep',
38+
logic: {
39+
nodeType: 'PerformanceCenterScriptedLogic',
40+
// setup missing -> should be added
41+
successCondition: undefined,
42+
failCondition: undefined,
43+
} as any,
44+
} as any,
45+
],
46+
} as any,
47+
];
48+
49+
const res = repairActivities(activities);
50+
expect(res.mutated).toBe(true);
51+
expect(res.activities[0].steps[0].logic.setup).toBeDefined();
52+
expect(res.activities[0].steps[0].logic.setup.nodeType).toBe('PerformanceCenterApproachScriptedSetup');
53+
});
54+
55+
it('does not mutate already-correct activities', () => {
56+
const activities: Activity[] = [
57+
{
58+
nodeType: 'RangeAnalysisScriptedActivity',
59+
steps: [
60+
{
61+
nodeType: 'RangeAnalysisScriptedStep',
62+
logic: {
63+
nodeType: 'RangeAnalysisScriptedLogic',
64+
setup: { nodeType: 'RangeAnalysisScriptedSetup', club: 'Drv', distance: 100 },
65+
successCondition: { nodeType: 'RangeAnalysisScriptedConditions', shots: 1, conditionType: 'And', conditions: [{ parameter: 'Total', min: 0 }] },
66+
} as any,
67+
} as any,
68+
],
69+
} as any,
70+
];
71+
72+
const res = repairActivities(activities);
73+
expect(res.mutated).toBe(false);
74+
});
75+
});

0 commit comments

Comments
 (0)