修复资源画布PR的CI回归(#410)
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled

修复活动回合空快照引用及停用后晚到请求覆盖问题
对齐共享卡片角标与窗口发布次数回归断言
同步原生HTTP权限检查与图集显式切片测试契约
整理菜单组件导入顺序并记录本地验证范围
This commit is contained in:
2026-09-17 20:17:26 +08:00
parent 959beebf63
commit c329c438d9
15 changed files with 123 additions and 29 deletions
@@ -728,13 +728,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
"tool": "canvas.asset_generate",
"reason": "生成可用于首版原型的主角素材",
"input": {
"prompt": "透明 PNG 像素月光主角,适合厨房弹幕游戏",
"prompt": "透明 PNG 像素月光主角图集,按 2 行 2 列等分网格排布,适合厨房弹幕游戏",
"outputPath": "assets/art-spritesheet.png",
"aspectRatio": "1:1",
"imageSize": "1K",
"assetKind": "art-spritesheet",
"assetLabel": "游戏首版核心美术素材",
"replaceExisting": false
"replaceExisting": false,
"sliceMode": "grid",
"gridX": 2,
"gridY": 2,
"sliceCount": null
}
}
],
@@ -775,7 +779,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
start_game_creator_agent_background_task_at(
&root,
"art-asset-plan",
"为月光厨房生成首版主角素材",
"为月光厨房生成首版主角素材图集,按 2 行 2 列等分网格排布",
"art-generate-run",
)
.expect("start background task");
@@ -933,6 +937,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
let generation_idempotency_key =
request_header(generation_request, "idempotency-key").expect("generation idempotency key");
assert!(uuid::Uuid::parse_str(&generation_idempotency_key).is_ok());
let generation_body: Value = serde_json::from_str(
generation_request
.split_once("\r\n\r\n")
.expect("generation request body")
.1,
)
.expect("generation request json");
assert_eq!(generation_body["sliceMode"], "grid");
assert_eq!(generation_body["gridX"], 2);
assert_eq!(generation_body["gridY"], 2);
assert!(generation_body["sliceCount"].is_null());
assert!(generation_request.contains(r#""source":"ai-game-creator-client""#));
assert_eq!(
canvas_requests
@@ -6916,7 +6916,11 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() {
"imageSize",
"assetKind",
"assetLabel",
"replaceExisting"
"replaceExisting",
"sliceMode",
"gridX",
"gridY",
"sliceCount"
])
);
assert_eq!(
@@ -6927,6 +6931,10 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() {
canvas_asset.parameters["properties"]["input"]["properties"]["imageSize"]["enum"],
serde_json::json!(["0.5K", "1K", "2K", null])
);
assert_eq!(
canvas_asset.parameters["properties"]["input"]["properties"]["sliceMode"]["enum"],
serde_json::json!(["connected-components", "grid", null])
);
assert_eq!(
canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"],
serde_json::json!([
@@ -45,7 +45,8 @@ export function useDirectActiveTurns({
* 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目
* 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。
*/
const lastSnapshotSignatureRef = useRef<string>('');
const lastSnapshotSignatureRef = useRef<string>('[]');
const requestGenerationRef = useRef(0);
const retryTimerRef = useRef<number | null>(null);
useEffect(() => {
@@ -60,13 +61,16 @@ export function useDirectActiveTurns({
}, []);
const refreshActiveTurns = useCallback(async () => {
if (!invoke) {
if (!enabled || !invoke) {
return;
}
// 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。
if (inFlightRef.current) {
return inFlightRef.current;
}
const generation = requestGenerationRef.current;
const isCurrent = () =>
mountedRef.current && generation === requestGenerationRef.current;
const request = (async () => {
for (
let attempt = 1;
@@ -77,7 +81,7 @@ export function useDirectActiveTurns({
const turns = await invoke<GameCreatorDirectActiveTurn[]>(
'list_game_creator_direct_active_turns',
);
if (!mountedRef.current) {
if (!isCurrent()) {
return;
}
const nextTurns = Array.isArray(turns) ? turns : [];
@@ -90,6 +94,7 @@ export function useDirectActiveTurns({
inFlightRef.current = null;
return;
} catch {
if (!isCurrent()) return;
if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) {
await new Promise<void>((resolve) => {
retryTimerRef.current = window.setTimeout(() => {
@@ -97,22 +102,23 @@ export function useDirectActiveTurns({
resolve();
}, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt);
});
if (!isCurrent()) return;
}
}
}
// 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。
if (mountedRef.current) {
if (isCurrent()) {
setSnapshotReadFailed(true);
inFlightRef.current = null;
}
inFlightRef.current = null;
})();
inFlightRef.current = request;
return request;
}, [invoke]);
}, [enabled, invoke]);
useEffect(() => {
if (!enabled || !invoke) {
lastSnapshotSignatureRef.current = '';
lastSnapshotSignatureRef.current = '[]';
// 空态也要保持引用稳定:已经空了就不要再换一个新数组。
setActiveTurns((current) => (current.length === 0 ? current : []));
setSnapshotReadFailed((current) => (current ? false : current));
@@ -123,7 +129,12 @@ export function useDirectActiveTurns({
() => void refreshActiveTurns(),
Math.max(1_000, pollIntervalMs),
);
return () => window.clearInterval(timer);
return () => {
window.clearInterval(timer);
// 停用或切换读取器后,旧请求不得覆盖新状态,也不能占住新一轮单飞。
requestGenerationRef.current += 1;
inFlightRef.current = null;
};
}, [enabled, invoke, pollIntervalMs, refreshActiveTurns]);
return { activeTurns, refreshActiveTurns, snapshotReadFailed };
@@ -14,8 +14,8 @@ import {
CanvasChromeButton,
SelectionOverlay,
} from '@genarrative/image-canvas-react';
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
import { CanvasCardCornerActions } from '@genarrative/shared/components';
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
import {
AtSign,
Crosshair,
@@ -60,10 +60,11 @@ describe('useDirectActiveTurns', () => {
{ initialProps: { enabled: true } },
);
await waitFor(() => {
expect(result.current.activeTurns).toEqual([]);
});
const emptySnapshot = result.current.activeTurns;
await act(async () => {
await result.current.refreshActiveTurns();
});
expect(result.current.activeTurns).toBe(emptySnapshot);
rerender({ enabled: false });
expect(result.current.activeTurns).toBe(emptySnapshot);
});
@@ -96,6 +97,59 @@ describe('useDirectActiveTurns', () => {
clearTimeoutSpy.mockRestore();
}
});
it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => {
let complete!: (turns: GameCreatorDirectActiveTurn[]) => void;
const invoke = vi.fn(
() =>
new Promise<GameCreatorDirectActiveTurn[]>((resolve) => {
complete = resolve;
}),
);
const { result, rerender } = renderHook(
({ enabled }) =>
useDirectActiveTurns({ invoke: invoke as never, enabled }),
{ initialProps: { enabled: true } },
);
rerender({ enabled: false });
const empty = result.current.activeTurns;
await act(async () => {
complete([ACTIVE_TURN]);
await result.current.refreshActiveTurns();
});
expect(result.current.activeTurns).toBe(empty);
expect(result.current.snapshotReadFailed).toBe(false);
expect(invoke).toHaveBeenCalledTimes(1);
});
it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => {
let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void;
const invoke = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<GameCreatorDirectActiveTurn[]>((resolve) => {
completeOld = resolve;
}),
)
.mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]);
const { result, rerender } = renderHook(
({ enabled }) =>
useDirectActiveTurns({ invoke: invoke as never, enabled }),
{ initialProps: { enabled: true } },
);
rerender({ enabled: false });
rerender({ enabled: true });
await waitFor(() =>
expect(result.current.activeTurns[0]?.runId).toBe('new-run'),
);
const current = result.current.activeTurns;
await act(async () => {
completeOld([ACTIVE_TURN]);
});
expect(result.current.activeTurns).toBe(current);
expect(invoke).toHaveBeenCalledTimes(2);
});
});
describe('ActiveProjectRunsPanel', () => {
@@ -84,8 +84,8 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且
await act(async () => {
await Promise.resolve();
});
// 无原生 invoke 时 active-turn Hook 会把初始快照归一为空数组一次。
expect(publications).toHaveLength(2);
// 无原生 invoke 时空快照引用不变,只发布一次。
expect(publications).toHaveLength(1);
expect(cleanups).toBe(0);
expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe(
1,
@@ -94,13 +94,13 @@ it('真实窗口与工作台状态同步收敛,回调读取最新处理器且
const latestOpen = vi.fn(async () => undefined);
homeProjectOverride.openProject = latestOpen;
rendered.rerender(view('更改显示名'));
expect(publications).toHaveLength(2);
expect(publications).toHaveLength(1);
act(() => openProject('/tmp/window-latest-project'));
expect(latestOpen).toHaveBeenCalledWith(
'/tmp/window-latest-project',
'open',
);
expect(publications).toHaveLength(2);
expect(publications).toHaveLength(1);
expect(cleanups).toBe(0);
rendered.unmount();
expect(cleanups).toBe(1);