修复资源画布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
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user