修复资源画布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);
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
对应:[里程碑](./【里程碑】AGC资源菜单收纳-2026-09-17.md),Issue #409,产品已确认方案 A。
|
||||
|
||||
## PR #410 CI 修复
|
||||
|
||||
以远端合并提交 959beebf 为基线:修复菜单文件 import 排序、Web 角标结构断言、活动回合空快照与晚到请求竞态;窗口发布次数断言对齐稳定快照合同。原生 HTTP scope 检查对齐官方 updater 当前权限,不恢复退役 OSS 白名单;Rust 图集测试补齐显式切片模式与 strict schema 字段,不放宽正式校验。按故障项定向测试后运行前端全套及原生契约检查;Rust 使用独立 target,实际未执行的检查必须单独列出。推送需再次确认。
|
||||
|
||||
本地修复验证:`npm test` 342 个文件通过(4137 项通过、37 项跳过),窗口与空快照最后一次定向复验 9 项通过;`lint:eslint`、根目录/AGC 类型检查、原生 contract 检查、Rust fmt、编码、文档索引与 diff 检查通过。Rust 工具目录 schema 用例及后台平台美术生成用例均在 Windows 独立 target 下通过;生成用例同时检查真实 mock 请求中的 grid、2×2 参数与响应匹配。未执行全量 Rust 分片、Linux CI、生产服务或真实客户端手感验收。
|
||||
|
||||
1. 在 shared 扩展通用操作收纳及卡片角标控件;共用工具栏只给 AGC 开启 5 项限制,Web 卡片迁移共用角标而不改现有回调。
|
||||
2. AGC 卡片承接类型和信息,保留当前面板与命令链;信息使用资源身份防止换选竞态。
|
||||
3. 补工具栏/工作台定向回归,检查禁用、移入、Escape、换选和卡片事件边界。
|
||||
|
||||
@@ -21,6 +21,8 @@ JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡
|
||||
|
||||
工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。
|
||||
|
||||
活动回合快照的初始签名须与初始空数组一致,首次异步返回空数组不能额外换引用。停用、重新启用或切换读取器时应使旧请求失效,避免晚到结果覆盖新快照;测试需控制 Promise 完成时机,不能用“初始数组已为空”当作请求已结束。无原生读取器时窗口只发布一次空状态。
|
||||
|
||||
## 2026-09-17 工具 schema 声明的上限与真实校验不一致,会表现成「agent 调不动这个功能」
|
||||
|
||||
- **现象**:用户反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。查工具目录时两个工具都在(`agc_edit_image`、`agc_create_or_derive_resource`),图片快速编辑在真实项目日志里还有成功记录;但 agent 侧写一句正常长度的背景音乐描述就失败,而客户端 UI 用同一个提示词却只是被截断加提示。
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
## 资源画布交互与工作台状态同步
|
||||
|
||||
- 活动回合轮询的初始空快照与后续空结果保持同一引用;停用或切换读取器使旧请求失效,晚到快照不得恢复已停用的活动回合或覆盖新轮询结果。无原生读取器时窗口只发布一次空状态,不通过额外空数组触发重复发布。
|
||||
- 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。
|
||||
- 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。
|
||||
- 画布接管的右键手势不弹出原生菜单;输入框、媒体操作、工具条和独立浮层不被画布抢占。指针取消、捕获丢失或窗口失焦后终止平移,不能继续跟随指针。
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import type { CSSProperties, Ref } from 'react';
|
||||
|
||||
import { PlatformIconButton } from './PlatformIconButton';
|
||||
|
||||
/** 画布卡片共用的类型标签与信息入口,不承接资源业务状态。 */
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { OverflowActions } from './OverflowActions';
|
||||
|
||||
import { CanvasCardCornerActions } from './CanvasCardCornerActions';
|
||||
import { OverflowActions } from './OverflowActions';
|
||||
|
||||
afterEach(cleanup);
|
||||
describe('操作收纳与卡片角标', () => {
|
||||
|
||||
@@ -3,12 +3,12 @@ import {
|
||||
cloneElement,
|
||||
Fragment,
|
||||
isValidElement,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
|
||||
@@ -2582,7 +2582,6 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
JSON.stringify([
|
||||
{ url: 'https://dev.genarrative.world/api/*' },
|
||||
{ url: 'https://www.genarrative.world/api/*' },
|
||||
{ url: 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/*' },
|
||||
{ url: 'https://*/api/*' },
|
||||
{ url: 'http://localhost:*/*' },
|
||||
{ url: 'http://127.0.0.1:*/*' },
|
||||
|
||||
@@ -3875,9 +3875,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
if (!metadataCornerButton) {
|
||||
throw new Error('metadata corner button should exist');
|
||||
}
|
||||
expect(metadataCornerButton.className).toContain(
|
||||
'image-canvas-editor__metadata-corner',
|
||||
);
|
||||
expect(metadataCornerButton.className).toContain('shared-canvas-card-info');
|
||||
fireEvent.click(metadataCornerButton);
|
||||
|
||||
const metadataDialog = screen.getByRole('dialog', { name: '图片信息' });
|
||||
|
||||
@@ -1387,9 +1387,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
const infoButton = screen.getByRole('button', {
|
||||
name: '查看拼图素材图片信息',
|
||||
});
|
||||
expect(infoButton.className).toContain(
|
||||
'image-canvas-editor__metadata-corner',
|
||||
);
|
||||
expect(infoButton.className).toContain('shared-canvas-card-info');
|
||||
fireEvent.click(infoButton);
|
||||
|
||||
const infoPanel = screen.getByRole('dialog', { name: '图片信息' });
|
||||
|
||||
Reference in New Issue
Block a user