Skip to content

Commit 25f20ec

Browse files
committed
refactor(desktop): move Goal controller ownership below AppShell
Move production Goal controller ownership into GoalProvider and expose reader-local projections for the composer, indicator, and dialog host. Keep AppShell free of Goal controller and model ownership, preserve existing behavior, and lock the boundary with architecture and render-scope coverage. Generated-by: Codex
1 parent 8bc4846 commit 25f20ec

14 files changed

Lines changed: 582 additions & 38 deletions

apps/desktop/e2e/goal-dialog-budget.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,20 @@ test('an unsendable budget blocks Start instead of arming a different one', asyn
8282
};
8383
})
8484
.toEqual({ condition: '所有测试通过', maxIterations: 25, tokenBudget: 5000 });
85+
86+
// The Host read above proves persistence; this proves the same broadcast now
87+
// reaches the provider-owned chat projection without AppShell reading it.
88+
const goalContext = page
89+
.getByRole('region', { name: '任务上下文' })
90+
.filter({ visible: true });
91+
await expect(
92+
goalContext
93+
.getByText(/ 0 \/ 25 · .* · 0 \/ 5k/)
94+
.filter({ visible: true }),
95+
).toBeVisible();
96+
await expect(
97+
goalContext
98+
.getByRole('button', { name: '暂停自主执行目标(已进行 0/25 轮)' })
99+
.filter({ visible: true }),
100+
).toBeVisible();
85101
});

apps/desktop/renderer-architecture.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -921,7 +921,6 @@
921921
"useCommandPalette": 1,
922922
"useComposerAttachments": 1,
923923
"useEffect": 14,
924-
"useGoalController": 1,
925924
"useKeyboardHelp": 1,
926925
"useLayoutEffect": 2,
927926
"useModuleHubController": 1,
@@ -1060,8 +1059,8 @@
10601059
"@maka/ui/icons": 1,
10611060
"react": 1
10621061
},
1063-
"importSpecifiers": 187,
1064-
"nonTriviaTokens": 15855
1062+
"importSpecifiers": 186,
1063+
"nonTriviaTokens": 15851
10651064
},
10661065
"src/renderer/use-app-shell-composer-quotes.ts": {
10671066
"importDeclarations": 3,

apps/desktop/scripts/check-renderer-architecture.test.mjs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,52 @@ describe('renderer architecture checker fixtures', () => {
10541054
);
10551055
});
10561056

1057+
it('rejects a feature controller Hook returning to AppShell after provider migration', async () => {
1058+
const providerOwnedAppShell = `
1059+
import { GoalProvider } from './features/goals/index.js';
1060+
export const AppShell = GoalProvider;
1061+
`;
1062+
await withDesktopFixture(
1063+
{
1064+
[TRANSITIVE_APP_SHELL_PATH]: providerOwnedAppShell,
1065+
'src/renderer/features/goals/index.ts': `
1066+
export const GoalProvider = true;
1067+
export function useGoalController() { return true; }
1068+
`,
1069+
},
1070+
async (desktopRoot) => {
1071+
const seedConfig = transitiveAppShellSeedConfig();
1072+
const providerOwnedConfig = generateArchitectureConfig(desktopRoot, seedConfig);
1073+
1074+
await writeFile(
1075+
join(desktopRoot, TRANSITIVE_APP_SHELL_PATH),
1076+
`
1077+
import { GoalProvider, useGoalController } from './features/goals/index.js';
1078+
export const AppShell = [GoalProvider, useGoalController()];
1079+
`,
1080+
'utf8',
1081+
);
1082+
const regressedConfig = generateArchitectureConfig(
1083+
desktopRoot,
1084+
providerOwnedConfig,
1085+
);
1086+
const violations = violationsFor(
1087+
desktopRoot,
1088+
regressedConfig,
1089+
providerOwnedConfig,
1090+
);
1091+
1092+
assertHasViolation(
1093+
violations,
1094+
/^src\/renderer\/app-shell\.ts: hookCalls debt increased from 0 to 1$/u,
1095+
);
1096+
assertHasViolation(
1097+
violations,
1098+
/^src\/renderer\/app-shell\.ts: new or increased hookCalls debt useGoalController$/u,
1099+
);
1100+
},
1101+
);
1102+
});
10571103
it('rejects bridge and environment capability growth inside a transitive legacy AppShell helper', async () => {
10581104
await withDesktopFixture(
10591105
transitiveAppShellFiles(`
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { strict as assert } from 'node:assert';
21+
import { afterEach, describe, it } from 'node:test';
22+
import { act, createElement, Fragment } from 'react';
23+
import type { GoalState } from '@maka/core/goal';
24+
import { LocaleProvider } from '@maka/ui';
25+
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
26+
import {
27+
createFakeGoalServices,
28+
GoalComposerBoundary,
29+
GoalIndicatorBoundary,
30+
GoalProvider,
31+
GoalServicesProvider,
32+
type GoalController,
33+
type GoalServices,
34+
} from '../../renderer/features/goals/testing.js';
35+
36+
type GoalIndicator = GoalController['selectors']['indicator'];
37+
38+
interface ComposerProbeProps {
39+
goalActive?: boolean;
40+
onSetGoal?(): void | Promise<void>;
41+
}
42+
43+
interface IndicatorProbeProps {
44+
goalIndicator?: GoalIndicator;
45+
}
46+
47+
let shellRenders = 0;
48+
let composerRenders = 0;
49+
let indicatorRenders = 0;
50+
let latestComposer: ComposerProbeProps | undefined;
51+
let latestIndicator: IndicatorProbeProps | undefined;
52+
53+
function ComposerProbe(props: ComposerProbeProps) {
54+
composerRenders += 1;
55+
latestComposer = props;
56+
return null;
57+
}
58+
59+
function IndicatorProbe(props: IndicatorProbeProps) {
60+
indicatorRenders += 1;
61+
latestIndicator = props;
62+
return null;
63+
}
64+
65+
function ShellProbe() {
66+
shellRenders += 1;
67+
return createElement(
68+
Fragment,
69+
null,
70+
createElement(
71+
GoalComposerBoundary,
72+
{ children: createElement(ComposerProbe) },
73+
),
74+
createElement(
75+
GoalIndicatorBoundary,
76+
{ children: createElement(IndicatorProbe) },
77+
),
78+
);
79+
}
80+
81+
function goal(tokensNow: number): GoalState {
82+
return {
83+
id: 'goal-a',
84+
revision: tokensNow,
85+
sessionId: 'a',
86+
condition: 'Finish a',
87+
status: 'active',
88+
setAt: 100,
89+
iterations: 2,
90+
maxIterations: 9,
91+
consecutiveNoProgress: 0,
92+
blockCap: 3,
93+
tokenBudget: 500,
94+
tokensAtStart: 10,
95+
tokensNow,
96+
tokensBaselinePending: false,
97+
};
98+
}
99+
100+
function renderProvider(
101+
root: ReturnType<typeof installReactRenderer>['root'],
102+
services: GoalServices,
103+
reportError: (sessionId: string, title: string, description?: string) => void,
104+
enabled = true,
105+
) {
106+
root.render(
107+
createElement(LocaleProvider, {
108+
locale: 'en',
109+
children: createElement(
110+
GoalServicesProvider,
111+
{ services },
112+
createElement(
113+
GoalProvider,
114+
{ activeSessionId: 'a', canOpenDialog: enabled, reportError },
115+
createElement(ShellProbe),
116+
),
117+
),
118+
}),
119+
);
120+
}
121+
122+
afterEach(() => {
123+
shellRenders = 0;
124+
composerRenders = 0;
125+
indicatorRenders = 0;
126+
latestComposer = undefined;
127+
latestIndicator = undefined;
128+
cleanupFakeDom();
129+
});
130+
131+
describe('GoalProvider render scope', () => {
132+
it('updates only the projection whose reader changed', async () => {
133+
const { root } = installReactRenderer();
134+
let current = goal(60);
135+
let emit: ((sessionId: string | undefined) => void) | undefined;
136+
const defaults = createFakeGoalServices();
137+
const services = createFakeGoalServices({
138+
goal: {
139+
...defaults.goal,
140+
get: async () => current,
141+
subscribeChanges: (handler) => {
142+
emit = handler;
143+
return () => undefined;
144+
},
145+
},
146+
});
147+
148+
await act(async () => renderProvider(root, services, () => undefined));
149+
assert.equal(latestComposer?.goalActive, true);
150+
assert.equal(latestIndicator?.goalIndicator?.tokensSpent, 60);
151+
assert.equal(shellRenders, 1);
152+
153+
const composerBeforeRefresh = composerRenders;
154+
const indicatorBeforeRefresh = indicatorRenders;
155+
current = goal(75);
156+
await act(async () => emit?.('a'));
157+
158+
assert.equal(shellRenders, 1);
159+
assert.equal(composerRenders, composerBeforeRefresh);
160+
assert.equal(indicatorRenders, indicatorBeforeRefresh + 1);
161+
assert.equal(latestIndicator?.goalIndicator?.tokensSpent, 75);
162+
163+
const composerBeforeDialog = composerRenders;
164+
const indicatorBeforeDialog = indicatorRenders;
165+
await act(async () => latestComposer?.onSetGoal?.());
166+
assert.equal(shellRenders, 1);
167+
assert.equal(composerRenders, composerBeforeDialog);
168+
assert.equal(indicatorRenders, indicatorBeforeDialog);
169+
170+
await act(async () => root.unmount());
171+
});
172+
173+
it('withholds the command when disabled and reports failures to the latest owner', async () => {
174+
const { root } = installReactRenderer();
175+
const firstErrors: string[] = [];
176+
const latestErrors: string[] = [];
177+
const defaults = createFakeGoalServices();
178+
const services = createFakeGoalServices({
179+
goal: {
180+
...defaults.goal,
181+
get: async () => goal(60),
182+
pause: async () => {
183+
throw new Error('offline');
184+
},
185+
},
186+
});
187+
188+
await act(async () =>
189+
renderProvider(
190+
root,
191+
services,
192+
(_sessionId, _title, description) => firstErrors.push(description ?? ''),
193+
false,
194+
),
195+
);
196+
assert.equal(latestComposer?.goalActive, true);
197+
assert.equal(latestComposer?.onSetGoal, undefined);
198+
199+
await act(async () =>
200+
renderProvider(
201+
root,
202+
services,
203+
(_sessionId, _title, description) => latestErrors.push(description ?? ''),
204+
),
205+
);
206+
assert.equal(typeof latestComposer?.onSetGoal, 'function');
207+
await act(async () => {
208+
latestIndicator?.goalIndicator?.onPause?.();
209+
await Promise.resolve();
210+
});
211+
212+
assert.deepEqual(firstErrors, []);
213+
assert.equal(latestErrors.length, 1);
214+
assert.match(latestErrors[0] ?? '', /still be continuing/);
215+
216+
await act(async () => root.unmount());
217+
});
218+
219+
it('fails closed when a boundary is mounted without its provider', async () => {
220+
const { root } = installReactRenderer();
221+
await assert.rejects(
222+
async () => {
223+
await act(async () => {
224+
root.render(
225+
createElement(
226+
GoalComposerBoundary,
227+
{ children: createElement(ComposerProbe) },
228+
),
229+
);
230+
});
231+
},
232+
/GoalProvider is missing/,
233+
);
234+
});
235+
});

0 commit comments

Comments
 (0)