diff --git a/AGENTS.md b/AGENTS.md index 9812803c..aa69e4bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ ParamsPanel(frontend/src/schema.ts:TOOL_REGISTRY 的 JSON Schema 生成表单 | 目录 | 用途 | |---|---| | `src/model/` `src/engine/` `src/commons/` | Python 领域资产:数据类(`Artifact`/`Project`)、e2m2e 接缝(`facade_bridge`/`catalog_service`/`exceptions`)、单位/常量/路径/内核 | -| `src-tauri/src/` | Rust 壳:`cmd.rs`(22 个 Tauri command)、`sidecar/`(帧协议客户端)、`assistant/`(pi RPC 适配)、`state.rs`、`update.rs` | +| `src-tauri/src/` | Rust 壳:23 个 Tauri command(`cmd.rs` 11 个、`assistant_cmd.rs` 9 个、`update.rs` 3 个)、`sidecar/`(帧协议客户端)、`assistant/`(pi RPC 适配)、`state.rs`、`update.rs` | | `frontend/src/` | React 界面;组件 `PascalCase.tsx`,逻辑模块 `camelCase.ts` | | `tests/` `src-tauri/tests/` `frontend/src/*.test.tsx` | 三套测试,与实现同树 | | `packaging/` | PyInstaller sidecar spec、release 配置、`scripts/validate-release.sh` | @@ -84,9 +84,9 @@ uv run python scripts/smoke_mcp_serve.py # sidecar 打包冒烟(releas - **依赖注入 / 状态**:Tauri managed state(`SidecarState` / `ProjectState` / `AssistantState`)+ `State<'_>`;Python 构造器注入(`FacadeBridge(kernel_dir, catalog_dir)`、`CatalogService(bridge)`);前端无 store 库,`App.tsx` 单组件 `useState` 集中 + `localStorage`(`tod-*` 键)。 - **硬契约**(违反即坏,改动必守): 1. **`import e2m2e` 之前**把内核目录写入 `SPICE_KERNEL_DIR`(见 `src-tauri/src/lib.rs` setup、`tests/conftest.py`); - 2. 单位换算唯一来源 `src/commons/units.py`(`DU_KM=384400`、`TU_SECONDS≈375676.97`),禁止另立换算常量;前端 TU 有两种口径,见 `frontend/src/paramOverlay/index.ts` 注释; + 2. 单位换算唯一来源 `src/commons/units.py`(`DU_KM=384400`、`TU_SECONDS≈375190.26`,由 e2m2e 模板常量导出),禁止另立换算常量;前端 TU 有两种口径,见 `frontend/src/paramOverlay/index.ts` 注释; 3. 时间唯一绝对基准是 et 秒(J2000 TDB),`frontend/src/timeBasis.ts` 是换算唯一出口(ADR 0021); - 4. 质心归一(画布)与地心归一(算法层)的换算点是 `centroid_normalized_states`(减 μ);理想化会合系到惯性系旋转在 `viz_adapter` / `trajectoryParsing` / `cr3bp` 三处必须同口径(#477); + 4. 质心归一(画布)与地心归一(算法层)的换算点是 `centroid_normalized_states`(减 μ);理想化会合系到惯性系旋转在 `viz_adapter` / `trajectoryParsing` 两处必须同口径(#477、#521,`cr3bp` 已不含旋转实现); 5. `serde_json` 的 `preserve_order` 不能关(帧序契约,见 `src-tauri/Cargo.toml` 注释)。 - **领域术语以 `CONTEXT.md` 为正典**(单条轨道、轨道族、参考历元、工具注册……),写代码与文档前先对齐术语表;注释内联 issue/ADR 编号(`#452`、`ADR 0013`)是全仓惯例。 diff --git a/frontend/src/AboutModal.test.tsx b/frontend/src/AboutModal.test.tsx index 81ae1e46..46875170 100644 --- a/frontend/src/AboutModal.test.tsx +++ b/frontend/src/AboutModal.test.tsx @@ -12,7 +12,7 @@ beforeEach(() => { vi.mocked(getVersion).mockClear(); }); -describe("AboutModal component", () => { +describe("AboutModal 组件(#403)", () => { it("缺省显示 Tauri 运行时真实版本(不再硬编码)", async () => { render( @@ -37,7 +37,7 @@ describe("AboutModal component", () => { expect(screen.getByText(/CODE - 地月转移轨道设计系统/)).toBeDefined(); }); - it("checks for updates when user clicks check button", async () => { + it("点击检查按钮时检查更新,上抛更新信息并关闭弹窗", async () => { const mockUpdateInfo: updaterModule.UpdateInfo = { version: "4.2.0", currentVersion: "4.1.2", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index c8c5057f..488f052c 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -125,7 +125,7 @@ describe("App 布局收缩契约(#462 折叠回归)", () => { }); }); -describe("transferTimelineEvents 转移时间轴事件(5.9.7 maneuver_events)", () => { +describe("transferTimelineEvents 转移时间轴事件(5.9.7 maneuver_events,#504)", () => { // t 直通词典:被测的是 kind → 词典键的映射,不是文案本身 // t passes keys through: the kind → dictionary-key mapping is under test, not the wording. const t = (key: string) => key; diff --git a/frontend/src/CanvasOrbitList.test.tsx b/frontend/src/CanvasOrbitList.test.tsx index 47fd17ae..1952d30b 100644 --- a/frontend/src/CanvasOrbitList.test.tsx +++ b/frontend/src/CanvasOrbitList.test.tsx @@ -13,7 +13,7 @@ import { buildOrbitListItems } from "./orbitListItems"; const CYCLE = ["#4c72b0", "#dd8452", "#55a868", "#c44e52", "#8172b3"]; -describe("buildOrbitListItems", () => { +describe("buildOrbitListItems(#469)", () => { it("无标签项不进清单;色样按色环循环取色", () => { const items = buildOrbitListItems({ count: 3, @@ -80,7 +80,7 @@ describe("buildOrbitListItems", () => { }); }); -describe("CanvasOrbitList", () => { +describe("CanvasOrbitList(#469)", () => { const items = buildOrbitListItems({ count: 2, labels: ["甲轨道", "乙轨道"], diff --git a/frontend/src/CanvasToolbar.test.tsx b/frontend/src/CanvasToolbar.test.tsx index cef8da46..205496ff 100644 --- a/frontend/src/CanvasToolbar.test.tsx +++ b/frontend/src/CanvasToolbar.test.tsx @@ -24,7 +24,7 @@ function setup(overrides: Partial = {}) { return props; } -describe("CanvasToolbar component", () => { +describe("CanvasToolbar 组件(#410)", () => { it("渲染全部投影与中心选项及操作按钮", () => { setup(); for (const name of ["3D", "XY", "XZ", "YZ"]) { @@ -122,7 +122,7 @@ describe("CanvasToolbar 绘制内容切换", () => { // —— 视图系切换(#428)—— // The view-frame switch (#428). -describe("CanvasToolbar 视图系切换", () => { +describe("CanvasToolbar 视图系切换(#428)", () => { it("默认渲染会合系/惯性选项,缺省选中会合系", () => { setup(); expect(screen.getByRole("radio", { name: "会合系" })).toBeDefined(); diff --git a/frontend/src/OrbitCanvas.test.tsx b/frontend/src/OrbitCanvas.test.tsx index 42d1cb45..b51516b2 100644 --- a/frontend/src/OrbitCanvas.test.tsx +++ b/frontend/src/OrbitCanvas.test.tsx @@ -162,7 +162,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("OrbitCanvas 轨迹渲染", () => { +describe("OrbitCanvas 轨迹渲染(#405)", () => { it("渲染后场景中存在与 trajectories 等量的轨迹线", () => { renderCanvas(); flushFrames(); @@ -253,7 +253,7 @@ describe("OrbitCanvas 轨迹渲染", () => { }); }); -describe("时刻标记(每条轨迹一个)", () => { +describe("时刻标记(每条轨迹一个,#419)", () => { // 两条不同时刻区间的轨迹:marker 各自沿自己的 times 插值,区间外隐藏 // Two trajectories with disjoint time spans: each marker interpolates over its own times, hidden outside. const MULTI_TRAJ: number[][][] = [ @@ -381,7 +381,7 @@ describe("时刻标记(每条轨迹一个)", () => { }); }); -describe("中心点居中几何", () => { +describe("中心点居中几何(#405)", () => { // 选定中心后,该天体/平动点的世界坐标应为原点(相机 target 默认 0,0,0) // After a center is selected, that body/libration point's world coordinate should be the origin (camera target defaults to 0,0,0). const CASES: { name: string; center: CenterMode; bodyLocalX: number }[] = [ @@ -419,7 +419,7 @@ describe("中心点居中几何", () => { }); }); -describe("中心切换的相机注视点", () => { +describe("中心切换的相机注视点(#405)", () => { // 用户场景:L2 轨道族 + 适配(注视点在轨道盒中心)→ 手动切“质心居中”, // 期望质心(世界原点)成为画面中心,而不是重新适配又盯回轨道盒。 // User scenario: an L2 orbit family + fit (gaze at the orbit-box center), then manually switching to @@ -485,7 +485,7 @@ describe("中心切换的相机注视点", () => { }); }); -describe("坐标轴图层", () => { +describe("坐标轴图层(#405)", () => { it("默认显示:三轴箭头 + X/Y/Z 标注 + 轨道面网格", () => { render( { +describe("OrbitCanvas 分区图层(#432)", () => { it("传入 regions 时渲染 regions 组:圆族折线 + 点标记 + 标注 sprite", () => { render( { const t = (key: string) => translations[key] ?? key; -describe("buildOrbitInfo", () => { +describe("buildOrbitInfo(#476)", () => { it("族成员段:类型/数据系/Jacobi/点数/跨度/来源齐全", () => { const info = buildOrbitInfo({ item: { label: "DRO 成员", frame: "会合系无量纲" }, @@ -83,7 +83,7 @@ describe("buildOrbitInfo", () => { }); }); -describe("OrbitInfoPanel", () => { +describe("OrbitInfoPanel(#476)", () => { it("空态显示操作指引", () => { render(); expect(screen.getByTestId("orbit-info-panel").textContent).toContain( diff --git a/frontend/src/ProjectTree.test.tsx b/frontend/src/ProjectTree.test.tsx index ba4c21af..ae1957fd 100644 --- a/frontend/src/ProjectTree.test.tsx +++ b/frontend/src/ProjectTree.test.tsx @@ -65,7 +65,7 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe("ProjectTree 勾选多选", () => { +describe("ProjectTree 勾选多选(#419)", () => { it("勾选 ≥2 叶子出现“绘制所选”,点击回传勾选集合", async () => { const { props } = setup(); // 只勾 1 条不出现入口 @@ -91,7 +91,7 @@ describe("ProjectTree 勾选多选", () => { }); }); -describe("ProjectTree 星标切换", () => { +describe("ProjectTree 星标切换(#419)", () => { it("行缺 tags 时先查详情再追加 ★(catalog_tag 整体替换,note 不动)", async () => { const { props } = setup(); fireEvent.click(screen.getAllByRole("button", { name: "星标" })[1]); // NRHO B @@ -112,7 +112,7 @@ describe("ProjectTree 星标切换", () => { }); }); -describe("ProjectTree 备注", () => { +describe("ProjectTree 备注(#419)", () => { it("悬停显示备注摘要(超长截断到 80 字符)", async () => { setup(); fireEvent.mouseEnter(screen.getByText("Halo A")); diff --git a/frontend/src/RecordDetailPanel.test.tsx b/frontend/src/RecordDetailPanel.test.tsx index 8cead027..80192ef6 100644 --- a/frontend/src/RecordDetailPanel.test.tsx +++ b/frontend/src/RecordDetailPanel.test.tsx @@ -91,7 +91,7 @@ describe("RecordDetailPanel hooks 规则(#437)", () => { }); }); -describe("RecordDetailPanel 族维度(e2m2e 5.9.3 一轨一记录)", () => { +describe("RecordDetailPanel 族维度(e2m2e 5.9.3 一轨一记录,#486)", () => { it("族成员记录展示族内序号与所属族批次,不再渲染提升区", () => { render(); expect(screen.getByText("#2")).toBeDefined(); diff --git a/frontend/src/RecordDetailPanel.tsx b/frontend/src/RecordDetailPanel.tsx index fdbcb324..64d81458 100644 --- a/frontend/src/RecordDetailPanel.tsx +++ b/frontend/src/RecordDetailPanel.tsx @@ -95,7 +95,7 @@ export function RecordDetailPanel({ record, transferCandidates, selectedLabel, o {transferCandidates && transferCandidates.length > 0 && ( )} - + {record.record_id} @@ -181,7 +181,7 @@ export function RecordDetailPanel({ record, transferCandidates, selectedLabel, o function CandidateComparisonCard({ candidates }: { candidates: TransferCandidateView[] }) { const { t } = useTranslation(); return ( - + {candidates.map((c) => (
{ vi.mocked(invoke).mockResolvedValue({ status: "ok" }); }); -describe("ephemerisSpanDays(纯函数)", () => { +describe("ephemerisSpanDays(纯函数,#416)", () => { it("优先用 eph/times_jd_tdb 帧取极差(天)", () => { expect(ephemerisSpanDays(resultWithJdSpan(30))).toBeCloseTo(30, 9); }); diff --git a/frontend/src/TimelineBar.test.tsx b/frontend/src/TimelineBar.test.tsx index 3bf261e3..3c848822 100644 --- a/frontend/src/TimelineBar.test.tsx +++ b/frontend/src/TimelineBar.test.tsx @@ -101,7 +101,7 @@ describe("TimelineBar 播放(#429 播放配置)", () => { }); }); -describe("TimelineBar 基础交互回归", () => { +describe("TimelineBar 基础交互回归(#429)", () => { it("事件芯片点击跳转到事件时刻", () => { const props = setup({ events: [{ et: 42, label: "出发脉冲", dv: "3.10 km/s" }], diff --git a/frontend/src/UpdateModal.test.tsx b/frontend/src/UpdateModal.test.tsx index fa3ce286..65b062f4 100644 --- a/frontend/src/UpdateModal.test.tsx +++ b/frontend/src/UpdateModal.test.tsx @@ -4,7 +4,7 @@ import { UpdateModal } from "./UpdateModal"; import * as updaterModule from "./updater"; import type { UpdateInfo } from "./updater"; -describe("UpdateModal component", () => { +describe("UpdateModal 组件(#403)", () => { const mockUpdateInfo: UpdateInfo = { version: "4.2.0", currentVersion: "4.1.2", @@ -16,7 +16,7 @@ describe("UpdateModal component", () => { } as any, }; - it("renders modal when open is true", () => { + it("open 为 true 时渲染弹窗,版本号与更新说明可见", () => { render( { expect(screen.getByText("New features added")).toBeDefined(); }); - it("triggers download when clicking download button", async () => { + it("点击下载按钮后调用下载安装,完成后显示立即重启", async () => { const downloadAndInstall = vi.fn().mockImplementation(async (cb) => { cb({ event: "Started", data: { contentLength: 100 } }); cb({ event: "Progress", data: { chunkLength: 100 } }); @@ -60,7 +60,7 @@ describe("UpdateModal component", () => { }); }); - it("shows downloaded size and percent during download", async () => { + it("下载过程中显示已下载大小与总量进度", async () => { const downloadAndInstall = vi.fn().mockImplementation(async (cb) => { cb({ event: "Started", data: { contentLength: 100 } }); cb({ event: "Progress", data: { chunkLength: 40 } }); @@ -91,7 +91,7 @@ describe("UpdateModal component", () => { }); }); - it("falls back to received-only stats when contentLength is missing", async () => { + it("缺少总量时只显示已下载大小,不出现 x / y 形式", async () => { const downloadAndInstall = vi.fn().mockImplementation(async (cb) => { cb({ event: "Started", data: {} }); cb({ event: "Progress", data: { chunkLength: 40 } }); diff --git a/frontend/src/assistant/AssistantConfigStrip.test.tsx b/frontend/src/assistant/AssistantConfigStrip.test.tsx index 6986db83..da1b237f 100644 --- a/frontend/src/assistant/AssistantConfigStrip.test.tsx +++ b/frontend/src/assistant/AssistantConfigStrip.test.tsx @@ -43,7 +43,7 @@ const configOptions = [ }, ]; -describe("AssistantConfigStrip", () => { +describe("AssistantConfigStrip 配置面(#493)", () => { it("渲染模型/思考两个控件,未知配置项不渲染(pi 切换后无模式项)", () => { render( , diff --git a/frontend/src/assistant/SessionSwitcher.test.tsx b/frontend/src/assistant/SessionSwitcher.test.tsx index 0c535342..0a32f999 100644 --- a/frontend/src/assistant/SessionSwitcher.test.tsx +++ b/frontend/src/assistant/SessionSwitcher.test.tsx @@ -46,7 +46,7 @@ function setup(overrides: Partial[0]> = {}) { return props; } -describe("SessionSwitcher", () => { +describe("SessionSwitcher 会话切换(#493)", () => { it("下拉行显示标题与「消息数 · 相对时间」,无标题显示未命名会话", () => { setup(); fireEvent.mouseDown(screen.getByRole("combobox")); diff --git a/frontend/src/assistant/chatModel.test.ts b/frontend/src/assistant/chatModel.test.ts index 34695438..edabcb48 100644 --- a/frontend/src/assistant/chatModel.test.ts +++ b/frontend/src/assistant/chatModel.test.ts @@ -15,7 +15,7 @@ function toolCard(item: ChatItem): ToolCardData { return item.card; } -describe("reset / user_message", () => { +describe("reset / user_message 事件折叠(#492)", () => { it("reset 清空序列(回放开头)", () => { const seeded = foldEvent([], { kind: "user_message", text: "旧问题" }); const out = foldEvent(seeded, { kind: "reset" }); @@ -35,7 +35,7 @@ describe("reset / user_message", () => { }); }); -describe("foldEvent 流式折叠", () => { +describe("foldEvent 流式折叠(#492)", () => { it("delta 增量归并进同一助手气泡", () => { let items = foldEvent([], { kind: "delta", text: "你" }); items = foldEvent(items, { kind: "delta", text: "好" }); @@ -104,7 +104,7 @@ describe("foldEvent 流式折叠", () => { }); }); -describe("回放序列(reset + 逐条重建)", () => { +describe("回放序列(reset + 逐条重建,#492)", () => { it("foldAll 重建完整时间线:用户/正文/思考/工具卡片", () => { const replay: AssistantEventPayload[] = [ { kind: "reset" }, diff --git a/frontend/src/cr3bp.test.ts b/frontend/src/cr3bp.test.ts index b42b75cb..3aafbc8f 100644 --- a/frontend/src/cr3bp.test.ts +++ b/frontend/src/cr3bp.test.ts @@ -14,7 +14,7 @@ const SEED = { ] as [number, number, number, number, number, number], }; -describe("deriv", () => { +describe("deriv(#397)", () => { it("在初值处与 e2m2e equations_of_motion 一致(含科里奥利项)", () => { // e2m2e CR3BP_Dynamics.equations_of_motion(0, SEED) 的参考输出 // Reference output of e2m2e CR3BP_Dynamics.equations_of_motion(0, SEED). @@ -27,7 +27,7 @@ describe("deriv", () => { }); }); -describe("propagate", () => { +describe("propagate(#397)", () => { it("传播一个周期后回到起点(闭合误差 < 1e-8)", () => { const pts = propagate(MU, SEED, 4000); const s = pts[pts.length - 1]; @@ -36,7 +36,7 @@ describe("propagate", () => { }); }); -describe("librationPoint", () => { +describe("librationPoint(#397)", () => { it("地月 L1/L2 与已知值一致", () => { expect(librationPoint(MU, 1)).toBeCloseTo(0.836915, 5); expect(librationPoint(MU, 2)).toBeCloseTo(1.155682, 5); diff --git a/frontend/src/i18n.test.tsx b/frontend/src/i18n.test.tsx index e3112e66..214f3b09 100644 --- a/frontend/src/i18n.test.tsx +++ b/frontend/src/i18n.test.tsx @@ -5,7 +5,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { I18nProvider, useTranslation, translations } from "./i18n"; -describe("i18n 词典(固定中文)", () => { +describe("i18n 词典(固定中文,#447)", () => { it("每个词条非空字符串且键非空", () => { for (const [key, value] of Object.entries(translations)) { expect(typeof value).toBe("string"); @@ -29,7 +29,7 @@ beforeEach(() => { document.documentElement.lang = ""; }); -describe("i18n 中文共享状态", () => { +describe("i18n 中文共享状态(#447)", () => { it("同一 Provider 下两个组件渲染同一中文文案", () => { render( diff --git a/frontend/src/jacobiColormap.test.ts b/frontend/src/jacobiColormap.test.ts index bd2ccc1a..a87c94cd 100644 --- a/frontend/src/jacobiColormap.test.ts +++ b/frontend/src/jacobiColormap.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { COOLWARM_STOPS, jacobiColor, jacobiNorm } from "./jacobiColormap"; -describe("jacobiNorm 归一化范围(matplotlib _get_jacobi_norm 口径)", () => { +describe("jacobiNorm 归一化范围(matplotlib _get_jacobi_norm 口径,#435)", () => { it("空列表 → (0, 1, 1)", () => { expect(jacobiNorm([])).toEqual([0, 1, 1]); }); @@ -36,7 +36,7 @@ describe("jacobiNorm 归一化范围(matplotlib _get_jacobi_norm 口径)", ( }); }); -describe("jacobiColor coolwarm 采样插值", () => { +describe("jacobiColor coolwarm 采样插值(#435)", () => { it("采样表是 coolwarm 蓝端到红端的 9 档", () => { expect(COOLWARM_STOPS).toHaveLength(9); expect(COOLWARM_STOPS[0]).toBe("#3b4cc0"); diff --git a/frontend/src/moonEphemeris.test.ts b/frontend/src/moonEphemeris.test.ts index 2c56b9ec..38e8c944 100644 --- a/frontend/src/moonEphemeris.test.ts +++ b/frontend/src/moonEphemeris.test.ts @@ -18,7 +18,7 @@ import { import { DU_KM, TU_SECONDS } from "./cr3bp"; import { JD_J2000, SECONDS_PER_DAY } from "./timeBasis"; -describe("moonSampleCount", () => { +describe("moonSampleCount(#428)", () => { it("每 0.02 TU 一点,含端点", () => { // 2 TU 跨度 → 101 点 expect(moonSampleCount([0, 2 * TU_SECONDS])).toBe(101); @@ -30,7 +30,7 @@ describe("moonSampleCount", () => { }); }); -describe("moonTrackRequest", () => { +describe("moonTrackRequest(#428)", () => { it("states 是月球会合系固定点(质心归一,速度恒零)", () => { const req = moonTrackRequest([0, TU_SECONDS]); expect(req.states.length).toBeGreaterThan(0); @@ -64,7 +64,7 @@ describe("moonTrackRequest", () => { }); }); -describe("moonTrackFromResponse", () => { +describe("moonTrackFromResponse(#428)", () => { it("J2000 地心 km 状态序列 → DU 点列(只取位置)", () => { const data = { states: [ @@ -91,7 +91,7 @@ describe("moonTrackFromResponse", () => { }); }); -describe("moonPositionAt", () => { +describe("moonPositionAt(#428)", () => { const track = { points: [ [0, 0, 0], diff --git a/frontend/src/paramsPanel.test.ts b/frontend/src/paramsPanel.test.ts index 6bb56e1a..8cdbb0ce 100644 --- a/frontend/src/paramsPanel.test.ts +++ b/frontend/src/paramsPanel.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { getFieldApplicability } from "./paramOverlay"; -describe("ParamsPanel 与 Schema 适用性", () => { +describe("ParamsPanel 与 Schema 适用性(#401)", () => { it("design_orbit 在 HALO 下适用字段包含 10 个公共参数 + 4 个 HALO 特有参数", () => { const fields = getFieldApplicability("design_orbit", "HALO"); expect(fields).toContain("orbit_type"); diff --git a/frontend/src/regionLayer.test.ts b/frontend/src/regionLayer.test.ts index 6648ba4f..6f75b7ac 100644 --- a/frontend/src/regionLayer.test.ts +++ b/frontend/src/regionLayer.test.ts @@ -8,7 +8,7 @@ import { DU_KM } from "./cr3bp"; const MU = 0.01215058560962404; const MOON_X_KM = (1 - 0.012150585350562453) * 383397.7725; // 后端 Primer 口径月心 -describe("boundariesResponseToRegionLayer", () => { +describe("boundariesResponseToRegionLayer(#432)", () => { it("圆元素:km→DU 归一并把月心圆吸附到画布月球位置", () => { const radiusKm = 61364.0; const region = boundariesResponseToRegionLayer( diff --git a/frontend/src/scenario.test.ts b/frontend/src/scenario.test.ts index eaf7d0e5..1817b9c7 100644 --- a/frontend/src/scenario.test.ts +++ b/frontend/src/scenario.test.ts @@ -21,7 +21,7 @@ const SAMPLE: ScenarioContent = { playback: { rate: 86400, loop: false, startOffsetEt: 0 }, }; -describe("serializeScenario / parseScenario 往返", () => { +describe("serializeScenario / parseScenario 往返(#429)", () => { it("保存→打开逐字段复原(固定层记录集、参考历元、播放配置)", () => { const text = serializeScenario(SAMPLE); const parsed = parseScenario(text); @@ -56,7 +56,7 @@ describe("serializeScenario / parseScenario 往返", () => { }); }); -describe("parseScenario 拒绝路径", () => { +describe("parseScenario 拒绝路径(#429)", () => { it("未知版本号拒绝加载并给出升级提示", () => { const text = serializeScenario(SAMPLE).replace('"version": 1', '"version": 99'); const parsed = parseScenario(text); @@ -101,7 +101,7 @@ describe("parseScenario 拒绝路径", () => { }); }); -describe("resolveScenarioRecords 软失败", () => { +describe("resolveScenarioRecords 软失败(#429)", () => { const mk = (id: string) => ({ recordId: id, label: id, data: { trajectories: [], times: [] } }); it("全部可解析:顺序保留", async () => { diff --git a/frontend/src/theme.test.ts b/frontend/src/theme.test.ts index 34fe6d39..6396f964 100644 --- a/frontend/src/theme.test.ts +++ b/frontend/src/theme.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { themeBehavior, themeCssVars, themeTokens } from "./theme"; -describe("themeBehavior", () => { +describe("themeBehavior(#409)", () => { it("motion 关闭——全部过渡动画停用(去卡通感)", () => { expect(themeBehavior.motion).toBe(false); }); @@ -11,7 +11,7 @@ describe("themeBehavior", () => { }); }); -describe("themeTokens", () => { +describe("themeTokens(#409)", () => { it("圆角全系列统一为 2px(严肃直角风格,偏离 antd 默认 6-8px)", () => { expect(themeTokens.borderRadius).toBe(2); expect(themeTokens.borderRadiusLG).toBe(2); @@ -48,7 +48,7 @@ describe("themeTokens", () => { }); }); -describe("themeCssVars(--tod-* 主题变量,修复助手边栏深色缺失)", () => { +describe("themeCssVars(--tod-* 主题变量,修复助手边栏深色缺失,#450)", () => { it("浅色值 = 各消费端 var(--tod-*, fallback) 的既有 fallback,浅色零回归", () => { expect(themeCssVars("light")).toEqual({ "--tod-border": "#e8e8e8", diff --git a/frontend/src/timeBasis.test.ts b/frontend/src/timeBasis.test.ts index db2291ea..0cafc759 100644 --- a/frontend/src/timeBasis.test.ts +++ b/frontend/src/timeBasis.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { etFromEpoch, etFromJdTdb, etToJd, etToUtcLabel, JD_J2000 } from "./timeBasis"; -describe("timeBasis 时间基准换算", () => { +describe("timeBasis 时间基准换算(#421)", () => { it("J2000(JD_TDB 2451545.0)→ et 0", () => { expect(etFromEpoch(2451545.0)).toBe(0); expect(etFromJdTdb(JD_J2000)).toBe(0); diff --git a/frontend/src/trajectoryParsing.test.ts b/frontend/src/trajectoryParsing.test.ts index c7b25626..0a656e34 100644 --- a/frontend/src/trajectoryParsing.test.ts +++ b/frontend/src/trajectoryParsing.test.ts @@ -31,7 +31,7 @@ const SEED = { ] as [number, number, number, number, number, number], }; -describe("framesToTrajectoryData 轨迹解析", () => { +describe("framesToTrajectoryData 轨迹解析(#412)", () => { it("(n,3) 纯位置帧按 shape 解析,不误判为状态序列", () => { // 6 个 xyz 点 = 18 个数,18 % 6 === 0,旧逻辑会当成 3 个状态点 // Six xyz points = 18 numbers; 18 % 6 === 0, so the old logic would have treated it as 3 state points. @@ -114,7 +114,7 @@ describe("framesToTrajectoryData 轨迹解析", () => { }); }); -describe("familyMembersToTrajectoryData 轨迹解析", () => { +describe("familyMembersToTrajectoryData 轨迹解析(#412)", () => { it("完整状态序列取 xyz,成员 times 提供时刻", () => { const states: number[] = []; const times: number[] = []; @@ -250,7 +250,7 @@ describe("Jacobi 常数透传(#435)", () => { }); }); -describe("trajectoryTimeRange", () => { +describe("trajectoryTimeRange(#412)", () => { it("多条时刻取全局端点", () => { expect(trajectoryTimeRange([[0, 1, 2], [5, 10]])).toEqual([0, 10]); }); @@ -279,7 +279,7 @@ describe("trajectoryTimeRange", () => { }); }); -describe("transferTrajectoryToCanvasData 转移轨迹解析", () => { +describe("transferTrajectoryToCanvasData 转移轨迹解析(#420)", () => { // 会合系物理 km(e2m2e ADR 0040):地月质心原点 // Rotating-frame physical km (e2m2e ADR 0040): barycenter origin. const TRAJ = [ @@ -431,7 +431,7 @@ describe("propagationToCanvasData 轨道预报解析(#421)", () => { }); }); -describe("designEphemerisToCanvasData 星历段解析", () => { +describe("designEphemerisToCanvasData 星历段解析(#434)", () => { // EphemerisTable 形状的星历段:会合无量纲位置 + UTC 分量 const EPH = { synodic_position: [[1.1, 0.2, -0.3], [1.2, 0.3, -0.4]], @@ -569,7 +569,7 @@ describe("filterByRole 绘制内容过滤(eph-fig)", () => { }); }); -describe("timelineMode / timesForMode 两级时刻基准(ADR 0021 修订)", () => { +describe("timelineMode / timesForMode 两级时刻基准(ADR 0021 修订,#421)", () => { const etData = { trajectories: [[[0, 0, 0]], [[1, 1, 1]]], times: [[100, 200], [0, 1]], diff --git a/frontend/src/updater.test.ts b/frontend/src/updater.test.ts index db1b6b13..0b40a05e 100644 --- a/frontend/src/updater.test.ts +++ b/frontend/src/updater.test.ts @@ -19,7 +19,7 @@ vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(), })); -describe("updater module", () => { +describe("updater 模块(#403)", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -95,13 +95,13 @@ describe("updater module", () => { expect(invoke).toHaveBeenCalledWith("update_install", { path: "/tmp/app.deb" }); }); - it("returns null when no update is available", async () => { + it("无可用更新时返回 null", async () => { const mockCheck = vi.fn().mockResolvedValue(null); const result = await checkForAppUpdates(mockCheck); expect(result).toBeNull(); }); - it("extracts version and body correctly when update is found", async () => { + it("有更新时正确提取版本号与更新说明,并保留原始更新对象", async () => { const mockUpdate = { version: "4.2.0", currentVersion: "4.1.2", @@ -119,12 +119,12 @@ describe("updater module", () => { }); }); - it("handles errors gracefully and throws formatted updater error", async () => { + it("更新检查失败时原样抛出错误", async () => { const mockCheck = vi.fn().mockRejectedValue(new Error("Network offline")); await expect(checkForAppUpdates(mockCheck)).rejects.toThrow("Network offline"); }); - it("downloads and installs update with progress tracking", async () => { + it("下载并安装更新,进度事件逐个转发", async () => { const mockRawUpdate: any = { version: "4.2.0", currentVersion: "4.1.2", @@ -155,13 +155,13 @@ describe("updater module", () => { }); describe("formatBytes", () => { - it("formats bytes below 1024 as integer B", () => { + it("小于 1024 字节按整数 B 输出", () => { expect(formatBytes(0)).toBe("0 B"); expect(formatBytes(512)).toBe("512 B"); expect(formatBytes(1023)).toBe("1023 B"); }); - it("formats larger units with one decimal", () => { + it("换算为 KB/MB/GB 并保留一位小数,超出单位表不再进位", () => { expect(formatBytes(1024)).toBe("1.0 KB"); expect(formatBytes(1536)).toBe("1.5 KB"); expect(formatBytes(1024 * 1024)).toBe("1.0 MB"); @@ -170,7 +170,7 @@ describe("updater module", () => { expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe("1024.0 GB"); }); - it("treats invalid input as 0 B", () => { + it("负数、NaN、Infinity 按 0 B 处理", () => { expect(formatBytes(-1)).toBe("0 B"); expect(formatBytes(Number.NaN)).toBe("0 B"); expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0 B"); @@ -178,12 +178,12 @@ describe("updater module", () => { }); describe("createSpeedTracker", () => { - it("returns 0 for the first chunk (no time delta yet)", () => { + it("首个数据块返回 0(尚无时间差)", () => { const track = createSpeedTracker(); expect(track(1000, 5000)).toBe(0); }); - it("computes smoothed speed from chunk deltas", () => { + it("按相邻数据块差值计算速度并做指数平滑", () => { const track = createSpeedTracker(); track(1000, 5000); // 首块只记时间 expect(track(2000, 1000)).toBe(1000); // 1000 B / 1s @@ -191,7 +191,7 @@ describe("updater module", () => { expect(track(2100, 1000)).toBe(3700); }); - it("ignores zero/negative time deltas", () => { + it("时间差为零或负时不计算速度,返回 0", () => { const track = createSpeedTracker(); track(1000, 5000); expect(track(1000, 1000)).toBe(0); // 同时刻:无速度,不崩 diff --git a/scripts/download_kernels.py b/scripts/download_kernels.py index af2d5acb..e54fd14c 100644 --- a/scripts/download_kernels.py +++ b/scripts/download_kernels.py @@ -42,7 +42,12 @@ def main() -> None: parser = argparse.ArgumentParser( description="从 kernels-v1 release 下载 SPICE 内核到 kernels/(幂等)" ) - parser.add_argument("--kernel-dir", type=pathlib.Path, default=ROOT / "kernels") + parser.add_argument( + "--kernel-dir", + type=pathlib.Path, + default=ROOT / "kernels", + help="内核下载目录(默认仓库根 kernels/)", + ) args = parser.parse_args() fetched, skipped = download_kernels(args.kernel_dir) diff --git a/scripts/import_baseline.py b/scripts/import_baseline.py index 62e30504..15c6bd23 100644 --- a/scripts/import_baseline.py +++ b/scripts/import_baseline.py @@ -104,14 +104,26 @@ def main() -> int: catalog_dir = args.catalog_dir or _default_catalog_dir() + # 内核目录注入(硬契约 1,AGENTS.md):e2m2e 在 import 期读 + # SPICE_KERNEL_DIR 搜闰秒内核,必须在任何 import e2m2e 之前设置 + # (比照 tests/conftest.py)。 + # Kernel-dir injection (hard contract 1 in AGENTS.md): e2m2e reads + # SPICE_KERNEL_DIR at import time to locate the leap-second kernel; + # set it before any `import e2m2e` (mirrors tests/conftest.py). + from src.commons.paths import detect_kernel_dir + + kernel_dir = detect_kernel_dir() + if kernel_dir: + os.environ.setdefault("SPICE_KERNEL_DIR", kernel_dir) + # 库目录直接传给 CatalogStore,不经 Config:e2m2e 的这两个环境变量只在 # Config 字段的 default_factory 处被读,数据层(data/catalog/*)不读环境, - # 故本脚本不需要写环境变量。E2M2E_CATALOG_DIR 的读取发生在 + # 故 catalog 环境变量本脚本不需要写。E2M2E_CATALOG_DIR 的读取发生在 # _default_catalog_dir 里(作为缺省值来源)。 # The catalog directory goes straight to CatalogStore, bypassing Config: e2m2e # reads those two env vars only in Config's field default_factory, and the data - # layer (data/catalog/*) reads no environment at all, so this script writes - # none. E2M2E_CATALOG_DIR is read in _default_catalog_dir, as the default. + # layer (data/catalog/*) reads no environment at all, so this script writes no + # catalog env vars. E2M2E_CATALOG_DIR is read in _default_catalog_dir, as the default. from e2m2e.data.catalog import CatalogStore from e2m2e.data.catalog.baseline import import_baseline diff --git a/scripts/smoke_mcp_serve.py b/scripts/smoke_mcp_serve.py index 8a57ad93..1533837a 100644 --- a/scripts/smoke_mcp_serve.py +++ b/scripts/smoke_mcp_serve.py @@ -22,12 +22,16 @@ import argparse import json +import logging import os import shutil import subprocess import sys import tempfile +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # design_orbit 冒烟参数:HALO L1 北族,10 天短弧(venv 实测秒级收敛), @@ -61,7 +65,7 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--exe", help="打包 sidecar 可执行文件路径(缺省走 dev uv 拉起)") - parser.add_argument("--cwd", default=REPO_ROOT, help="子进程工作目录") + parser.add_argument("--cwd", default=REPO_ROOT, help="子进程工作目录(默认仓库根)") parser.add_argument( "--kernels", help="SPICE 内核目录,写入子进程 SPICE_KERNEL_DIR(打包冒烟必传)", @@ -87,7 +91,7 @@ def main() -> int: baseline_tmp = None if args.baseline: if not os.path.isfile(args.baseline): - print(f"FAIL: 基线 zip 不存在:{args.baseline}(release 流水线应在构建前下载)") + logger.error("FAIL: 基线 zip 不存在:%s(release 流水线应在构建前下载)", args.baseline) return 1 baseline_tmp = tempfile.mkdtemp(prefix="smoke-catalog-") env["E2M2E_CATALOG_DIR"] = baseline_tmp @@ -135,16 +139,16 @@ def read_until(req_id: int) -> dict: ) init_resp = read_until(1) server = init_resp.get("result", {}).get("serverInfo", {}) - print(f"initialize → serverInfo={json.dumps(server, ensure_ascii=False)}") + logger.info("initialize → serverInfo=%s", json.dumps(server, ensure_ascii=False)) send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}) send({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) tools_resp = read_until(2) tools = tools_resp.get("result", {}).get("tools", []) - print(f"tools/list → {len(tools)} 个工具") + logger.info("tools/list → %d 个工具", len(tools)) if not tools: - print("FAIL: tools/list 为空") + logger.error("FAIL: tools/list 为空") proc.kill() return 1 @@ -161,7 +165,7 @@ def read_until(req_id: int) -> dict: is_error = result.get("isError", False) content = result.get("content", []) text = content[0].get("text", "") if content else "" - print(f"tools/call design_orbit → isError={is_error}, text={text[:400]}") + logger.info("tools/call design_orbit → isError=%s, text=%s", is_error, text[:400]) # 基线断言:条数由子进程自己的首用导入写进库,故这里查到多少条即包内 # 基线数据真实落库多少条(漏带 → 0 条 → 红) @@ -188,10 +192,14 @@ def read_until(req_id: int) -> dict: baseline_count = len(records) baseline_families = len({r.get("family_id") for r in records if r.get("family_id")}) except (json.JSONDecodeError, AttributeError): - print(f"FAIL: catalog_query 返回无法解析:{qtext[:400]}") + logger.error("FAIL: catalog_query 返回无法解析:%s", qtext[:400]) proc.kill() return 1 - print(f"tools/call catalog_query(tag=baseline) → {baseline_count} 条记录 / {baseline_families} 族") + logger.info( + "tools/call catalog_query(tag=baseline) → %d 条记录 / %d 族", + baseline_count, + baseline_families, + ) proc.terminate() try: @@ -203,18 +211,21 @@ def read_until(req_id: int) -> dict: shutil.rmtree(baseline_tmp, ignore_errors=True) if is_error or "converged" not in text: - print("FAIL: design_orbit 未收敛或返回错误") + logger.error("FAIL: design_orbit 未收敛或返回错误") return 1 if baseline_count is not None and ( baseline_count < MIN_BASELINE_RECORDS or baseline_families < MIN_BASELINE_FAMILIES ): - print( - f"FAIL: 库内基线 {baseline_count} 条 / {baseline_families} 族,少于 " - f"{MIN_BASELINE_RECORDS} 条 / {MIN_BASELINE_FAMILIES} 族——包内基线数据集缺失、" - "缺族或导入残缺(检查构建前的下载步骤与 spec 的 datas)" + logger.error( + "FAIL: 库内基线 %d 条 / %d 族,少于 %d 条 / %d 族——包内基线数据集缺失、" + "缺族或导入残缺(检查构建前的下载步骤与 spec 的 datas)", + baseline_count, + baseline_families, + MIN_BASELINE_RECORDS, + MIN_BASELINE_FAMILIES, ) return 1 - print("OK") + logger.info("OK") return 0 diff --git a/scripts/smoke_pi_rpc.py b/scripts/smoke_pi_rpc.py index c6463d07..44ed6548 100644 --- a/scripts/smoke_pi_rpc.py +++ b/scripts/smoke_pi_rpc.py @@ -27,6 +27,7 @@ import argparse import json +import logging import os import subprocess import sys @@ -35,6 +36,9 @@ import time import uuid +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DEFAULT_APP = os.path.join(REPO_ROOT, "src-tauri", "target", "debug", "cislunar-code") DEFAULT_EXT = os.path.join(REPO_ROOT, "src-tauri", "resources", "assistant", "tod-bridge.ts") @@ -185,17 +189,22 @@ def last_assistant_text(client: PiClient) -> str: def main() -> int: parser = argparse.ArgumentParser(description="pi RPC 真实链路冒烟(需本机 pi 已配置 provider)") - parser.add_argument("--app", default=DEFAULT_APP, help="应用二进制路径(先 cargo build)") + parser.add_argument( + "--app", + default=DEFAULT_APP, + help=f"应用二进制路径(默认 {DEFAULT_APP},先 cargo build)", + ) parser.add_argument("--keep", action="store_true", help="保留临时会话目录(排查用)") args = parser.parse_args() if not os.path.isfile(args.app): - print( - f"应用二进制不存在:{args.app}(先 cargo build --manifest-path src-tauri/Cargo.toml)" + logger.error( + "应用二进制不存在:%s(先 cargo build --manifest-path src-tauri/Cargo.toml)", + args.app, ) return 1 if not os.path.isfile(DEFAULT_EXT): - print(f"桥接扩展不存在:{DEFAULT_EXT}") + logger.error("桥接扩展不存在:%s", DEFAULT_EXT) return 1 tmp = tempfile.mkdtemp(prefix="tod-pi-smoke-") @@ -208,12 +217,12 @@ def main() -> int: # --- 1. 握手 --- pi = PiClient(args.app, session_dir, "a") state = pi.request("get_state") - print(f"[1] get_state ok:sessionId={state.get('sessionId')}") + logger.info("[1] get_state ok:sessionId=%s", state.get("sessionId")) pi.request("new_session") state = pi.request("get_state") session_file = state.get("sessionFile") assert session_file, "get_state 缺少 sessionFile(会话文件首条消息后落盘)" - print(f"[1] new_session ok:{session_file}") + logger.info("[1] new_session ok:%s", session_file) # --- 2. 只读工具免确认 --- pi.request( @@ -240,7 +249,7 @@ def main() -> int: ] assert ends and all(not e.get("isError") for e in ends), f"catalog_query 失败:{ends}" assert os.path.isfile(session_file), f"首轮后会话文件应落盘:{session_file}" - print(f"[2] 只读免确认 ok({len(ends)} 次执行,无审批卡,会话文件已落盘)") + logger.info("[2] 只读免确认 ok(%d 次执行,无审批卡,会话文件已落盘)", len(ends)) # --- 3. 写工具审批 --- marker = f"smoke-{uuid.uuid4().hex[:8]}" @@ -270,7 +279,7 @@ def main() -> int: ) from None envelope = json.loads(req["title"][len(APPROVAL_PREFIX) :]) assert envelope.get("tool") == "mcp__tod__scenario_write", f"审批工具异常:{envelope}" - print(f"[3] 审批卡到达:toolCallId={envelope.get('toolCallId')}") + logger.info("[3] 审批卡到达:toolCallId=%s", envelope.get("toolCallId")) pi.send({"type": "extension_ui_response", "id": req["id"], "value": "批准"}) wait_settled(pi) ends = [ @@ -288,7 +297,7 @@ def main() -> int: matches = glob.glob(os.path.join(scenarios, f"*{marker}*")) assert matches, f"情景文件未落盘({scenarios} 下无 *{marker}*)" - print(f"[3] 批准执行 ok:{matches[0]}") + logger.info("[3] 批准执行 ok:%s", matches[0]) # --- 4. steer 引导(上下文保留)--- pi.request( @@ -316,7 +325,7 @@ def main() -> int: wait_settled(pi) text = last_assistant_text(pi) assert marker in text, f"steer 后回应应包含情景文件名 {marker}:{text[:200]}" - print("[4] steer ok(上下文保留,回应含前文要点)") + logger.info("[4] steer ok(上下文保留,回应含前文要点)") # --- 5. abort 中断 --- pi.request("prompt", {"message": "从 1 数到 50,每行一个数字。"}) @@ -328,7 +337,7 @@ def main() -> int: ) pi.request("abort", timeout=60.0) assert pi.events("agent_settled"), "abort 后应有 agent_settled" - print("[5] abort ok") + logger.info("[5] abort ok") pi.close() @@ -343,14 +352,14 @@ def main() -> int: and any(b.get("type") == "text" and b.get("text") for b in m.get("content") or []) for m in msgs ), "应有非空 assistant 文本" - print(f"[6] 会话恢复 ok({len(msgs)} 条消息)") + logger.info("[6] 会话恢复 ok(%d 条消息)", len(msgs)) pi2.close() - print("SMOKE OK:pi RPC 全链路(握手/白名单/审批/steer/abort/会话恢复)通过") + logger.info("SMOKE OK:pi RPC 全链路(握手/白名单/审批/steer/abort/会话恢复)通过") return 0 finally: if args.keep: - print(f"保留现场:{tmp}") + logger.info("保留现场:%s", tmp) else: subprocess.run(["rm", "-rf", tmp], check=False) diff --git a/src-tauri/src/assistant/host_tools.rs b/src-tauri/src/assistant/host_tools.rs index 9c9459e7..cee26773 100644 --- a/src-tauri/src/assistant/host_tools.rs +++ b/src-tauri/src/assistant/host_tools.rs @@ -319,8 +319,17 @@ mod tests { assert_eq!(written["format"], "tod-scenario"); assert_eq!(written["version"], 1); assert_eq!(written["records"], json!(["r1", "r2"])); - assert_eq!(written["referenceEpoch"], json!({"et": 1.2e9})); - assert_eq!(written["playback"], json!({"rate": 86400.0, "loop": true, "startOffsetEt": 0.0})); + // 浮点字段按容差断言(1e-6),结构与布尔字段精确比较 + let et = written["referenceEpoch"]["et"].as_f64().expect("et 应为数值"); + assert!((et - 1.2e9).abs() < 1e-6); + let playback = &written["playback"]; + // 整体比较改逐字段容差后,保留键集合恰为三键的结构检查 + assert_eq!(playback.as_object().expect("playback 应为对象").len(), 3); + assert_eq!(playback["loop"], json!(true)); + let rate = playback["rate"].as_f64().expect("rate 应为数值"); + assert!((rate - 86400.0).abs() < 1e-6); + let start = playback["startOffsetEt"].as_f64().expect("startOffsetEt 应为数值"); + assert!(start.abs() < 1e-6); let listed = parse_envelope(&execute("scenario_list", &json!({}))); assert_eq!(listed["status"], "ok"); @@ -348,8 +357,15 @@ mod tests { let written: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!(written["referenceEpoch"], json!({"utc": "2026-09-01T00:00:00"})); - // 只给部分 playback 字段:给定的用给定值,缺的补默认 - assert_eq!(written["playback"], json!({"rate": 3600.0, "loop": false, "startOffsetEt": 0.0})); + // 只给部分 playback 字段:给定的用给定值,缺的补默认(浮点按容差断言) + let playback = &written["playback"]; + // 整体比较改逐字段容差后,保留键集合恰为三键的结构检查 + assert_eq!(playback.as_object().expect("playback 应为对象").len(), 3); + assert_eq!(playback["loop"], json!(false)); + let rate = playback["rate"].as_f64().expect("rate 应为数值"); + assert!((rate - 3600.0).abs() < 1e-6); + let start = playback["startOffsetEt"].as_f64().expect("startOffsetEt 应为数值"); + assert!(start.abs() < 1e-6); } #[test] diff --git a/src-tauri/src/assistant_cmd.rs b/src-tauri/src/assistant_cmd.rs index fa84e7a5..63bf373d 100644 --- a/src-tauri/src/assistant_cmd.rs +++ b/src-tauri/src/assistant_cmd.rs @@ -1,10 +1,11 @@ //! AI 助手命令:前端经 IPC 调用(pi RPC 适配层,ADR 0032)。 //! //! 命令面:保留 get_state / send / confirm_tool / cancel / new_session / -//! switch_session / clear_history / set_config_option(model/thinking 两项, -//! 值域由 pi 原生配置决定);模型服务、API key、provider 配置由 pi 原生 -//! 配置管理——设置分区只展示入口状态并提供打开 pi 原生命令的按钮 -//!(assistant_open_pi_setup:终端跑交互式 pi,首跑完成 provider 登录)。 +//! switch_session / clear_history / set_config_option / open_pi_setup +//! (model/thinking 两项,值域由 pi 原生配置决定);模型服务、API key、 +//! provider 配置由 pi 原生配置管理——设置分区只展示入口状态并提供打开 +//! pi 原生命令的按钮(assistant_open_pi_setup:终端跑交互式 pi,首跑 +//! 完成 provider 登录)。 use serde::Serialize; use serde_json::Value; diff --git a/src-tauri/src/cmd.rs b/src-tauri/src/cmd.rs index a3061dd3..9163af03 100644 --- a/src-tauri/src/cmd.rs +++ b/src-tauri/src/cmd.rs @@ -557,6 +557,15 @@ mod get_artifact_tests { FrameArray::F32 { shape: shape.to_vec(), data: data.to_vec() } } + /// 浮点按标准容差比较(AGENTS.md Testing & QA:f32 容差 1e-6;按量值 + /// 放缩以覆盖 km 级大数,其 f32 ULP 大于绝对 1e-6)。 + fn assert_close_f32(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() <= 1e-6 * expected.abs().max(1.0), + "{actual} != {expected}" + ); + } + /// 族记录响应:两成员(states 帧 + members 元数据),成员 1 带 jacobi、 /// 成员 2 不带;顶层 jacobi 包络 [2.9, 3.1]。 fn family_record_result() -> JobResult { @@ -590,7 +599,7 @@ mod get_artifact_tests { fn family_member_jacobi_passed_through_per_member() { let artifact = artifact_from_catalog_get("fam-1".into(), family_record_result()); assert_eq!(artifact.family_members.len(), 2); - assert_eq!(artifact.family_members[0].jacobi, Some(3.1)); + assert!(matches!(artifact.family_members[0].jacobi, Some(v) if (v - 3.1).abs() < 1e-6)); assert_eq!(artifact.family_members[1].jacobi, None); } @@ -598,7 +607,7 @@ mod get_artifact_tests { fn family_record_carries_envelope_floor_as_record_jacobi() { let artifact = artifact_from_catalog_get("fam-1".into(), family_record_result()); // 顶层包络下限原样透传;族记录逐成员值优先,成员缺值时回退本值 - assert_eq!(artifact.jacobi, Some(2.9)); + assert!(matches!(artifact.jacobi, Some(v) if (v - 2.9).abs() < 1e-6)); } /// 成员记录响应(5.9.3 一轨一记录):顶层 cr3bp/states(周期成员单点 @@ -629,10 +638,10 @@ mod get_artifact_tests { let artifact = artifact_from_catalog_get("mem-1".into(), member_record_result()); // 单成员携带 period:前端传播器才能重建整条轨迹(缺则成员被跳过) assert_eq!(artifact.family_members.len(), 1); - assert_eq!(artifact.family_members[0].period, Some(2.16)); + assert!(matches!(artifact.family_members[0].period, Some(v) if (v - 2.16).abs() < 1e-6)); // 成员级 jacobi 无 v2 通道(回退记录级单点值,#435 口径) assert_eq!(artifact.family_members[0].jacobi, None); - assert_eq!(artifact.jacobi, Some(3.1)); + assert!(matches!(artifact.jacobi, Some(v) if (v - 3.1).abs() < 1e-6)); } /// 设计轨道记录响应:members 空(非族),顶层 jacobi 包络单轨道两端同值。 @@ -662,7 +671,7 @@ mod get_artifact_tests { #[test] fn design_orbit_record_jacobi_at_record_level() { let artifact = artifact_from_catalog_get("design-1".into(), design_record_result()); - assert_eq!(artifact.jacobi, Some(3.006)); + assert!(matches!(artifact.jacobi, Some(v) if (v - 3.006).abs() < 1e-6)); // 单条记录没有成员元数据表:成员级 jacobi 为 None,由记录级兜底 assert_eq!(artifact.family_members.len(), 1); assert_eq!(artifact.family_members[0].jacobi, None); @@ -721,15 +730,27 @@ mod get_artifact_tests { assert!(artifact.members.is_empty()); let seg = artifact.transfer.as_ref().expect("转移段应存在"); assert_eq!(seg.states.len(), 2); - assert_eq!(seg.states[0], vec![-4670.9, 6578.0, 0.0, 0.0, 7.8, 0.0]); - assert_eq!(seg.states[1][0], 380000.0); - assert_eq!(seg.times, vec![0.0, 200.0]); - assert_eq!( - seg.gcrs_states.as_ref().expect("gcrs 段应存在")[1], - vec![-384400.0, 0.0, 0.0, 0.0, 1.0, 0.0] - ); + let want_row0 = [-4670.9, 6578.0, 0.0, 0.0, 7.8, 0.0]; + for (g, w) in seg.states[0].iter().zip(want_row0) { + assert_close_f32(*g, w); + } + assert_close_f32(seg.states[1][0], 380000.0); + let want_times = [0.0, 200.0]; + for (g, w) in seg.times.iter().zip(want_times) { + assert_close_f32(*g, w); + } + let want_gcrs1 = [-384400.0, 0.0, 0.0, 0.0, 1.0, 0.0]; + for (g, w) in seg + .gcrs_states + .as_ref() + .expect("gcrs 段应存在")[1] + .iter() + .zip(want_gcrs1) + { + assert_close_f32(*g, w); + } assert_eq!(seg.transfer_type.as_deref(), Some("HMN")); - assert_eq!(seg.delta_v_km_s, Some(3.95)); + assert!(matches!(seg.delta_v_km_s, Some(v) if (v - 3.95).abs() < 1e-6)); assert_eq!( seg.tli_epoch.as_ref().and_then(|v| v.as_str()), Some("2026-09-01T00:00:00") diff --git a/src-tauri/src/sidecar/frames.rs b/src-tauri/src/sidecar/frames.rs index 38e9cf07..aaf5f839 100644 --- a/src-tauri/src/sidecar/frames.rs +++ b/src-tauri/src/sidecar/frames.rs @@ -244,7 +244,9 @@ mod tests { assert_eq!(used, frame.len()); match arr { FrameArray::F64 { data, .. } => { - assert_eq!(data, vec![1.5, -2.5, 3.25]); + for (g, w) in data.iter().zip([1.5, -2.5, 3.25]) { + assert!((g - w).abs() < 1e-6); + } } _ => panic!("应为 f64"), } diff --git a/src/commons/units.py b/src/commons/units.py index 45558cbd..bd456ba0 100644 --- a/src/commons/units.py +++ b/src/commons/units.py @@ -1,16 +1,17 @@ """参数单位换算 -- DU/TU 与标准单位(km/年/秒)之间的纯换算。 常量以 ``e2m2e.data.templates`` 为唯一来源:``CHAR_LENGTH_KM=384400`` km、 -``CHAR_PERIOD_SEC=27.32*86400`` s;``TU = CHAR_PERIOD_SEC / (2π)``。算法侧 -``CR3BP_System`` 的默认尺度与这套值对齐,禁止在此之外另立一套换算常量。 +``CHAR_PERIOD_SEC≈2357389.9`` s(约 27.286 天);``TU = CHAR_PERIOD_SEC / +(2π)``,约 375190.26 s。算法侧 ``CR3BP_System`` 的默认尺度与这套值对齐, +禁止在此之外另立一套换算常量。 English: parameter unit conversion — pure conversions between DU/TU and standard units (km/years/seconds). Constants take ``e2m2e.data.templates`` as the single source: ``CHAR_LENGTH_KM=384400`` -km, ``CHAR_PERIOD_SEC=27.32*86400`` s; ``TU = CHAR_PERIOD_SEC / -(2π)``. The default scales of the algorithm-side ``CR3BP_System`` align -with these values; do not invent another set of conversion constants -outside them. +km, ``CHAR_PERIOD_SEC≈2357389.9`` s (about 27.286 days); ``TU = +CHAR_PERIOD_SEC / (2π)``, about 375190.26 s. The default scales of the +algorithm-side ``CR3BP_System`` align with these values; do not invent +another set of conversion constants outside them. """ from __future__ import annotations @@ -20,7 +21,7 @@ from e2m2e.data.templates import CHAR_LENGTH_KM, CHAR_PERIOD_SEC DU_KM: float = CHAR_LENGTH_KM # 384400.0 km -TU_SECONDS: float = CHAR_PERIOD_SEC / (2.0 * math.pi) # ≈ 375676.97 s +TU_SECONDS: float = CHAR_PERIOD_SEC / (2.0 * math.pi) # ≈ 375190.26 s DAYS_PER_YEAR: float = 365.25 # e2m2e design_orbit.DAYS_PER_YEAR SECONDS_PER_YEAR: float = DAYS_PER_YEAR * 86400.0 diff --git a/src/engine/facade_bridge.py b/src/engine/facade_bridge.py index 4146b27c..e2818af0 100644 --- a/src/engine/facade_bridge.py +++ b/src/engine/facade_bridge.py @@ -17,6 +17,7 @@ from __future__ import annotations import json +import logging from dataclasses import dataclass, field from typing import Any @@ -24,6 +25,8 @@ from e2m2e.api.models import FamilyGenerationRequest from pydantic import BaseModel +logger = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # DTO # --------------------------------------------------------------------------- @@ -453,7 +456,8 @@ def _build_tool_registry() -> dict[str, ToolSpec]: for info in inventory.values(): if info.request_model is not None: models[info.request_model.__name__] = info.request_model - except Exception: # noqa: BLE001 -- facade 异常时退回本地最小清单 + except Exception as exc: # noqa: BLE001 -- facade 异常时退回本地最小清单 + logger.warning("工具清单自省失败,退回本地最小清单:%s", exc) inventory = { name: None for name in ("design_orbit", "control_orbit", "orbit_family_generation") } diff --git a/src/engine/viz_adapter.py b/src/engine/viz_adapter.py index f4b6cb15..6cd73402 100644 --- a/src/engine/viz_adapter.py +++ b/src/engine/viz_adapter.py @@ -24,11 +24,14 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING, Any if TYPE_CHECKING: import numpy as np +logger = logging.getLogger(__name__) + def build_cr3bp_system(mu: float) -> Any: """构造 e2m2e CR3BP_System(地月系统,主天体 Earth,次天体 Moon)。Build an e2m2e @@ -86,10 +89,10 @@ def synodic_to_gcrs_km(pos, theta, mu: float) -> Any: arr = np.asarray(pos)[:, :3] th = np.asarray(theta) c, s = np.cos(th), np.sin(th) - x = arr[:, 0] * c - arr[:, 1] * s - y = arr[:, 0] * s + arr[:, 1] * c + x = (arr[:, 0] + mu) * c - arr[:, 1] * s + y = (arr[:, 0] + mu) * s + arr[:, 1] * c z = arr[:, 2] - return np.column_stack([(x + mu) * DU, y * DU, z * DU]) + return np.column_stack([x * DU, y * DU, z * DU]) def approx_moon_gcrs_km(theta) -> Any: @@ -274,7 +277,8 @@ def moon_position_gcrs( moon_pos = np.array( [mgr.get_body_position("MOON", float(et), "J2000", "EARTH") for et in times_et] ) - except Exception: # noqa: BLE001 -- SPICE 查询失败时降级,不阻塞轨道线渲染 + except Exception as exc: # noqa: BLE001 -- SPICE 查询失败时降级,不阻塞轨道线渲染 + logger.warning("月球 SPICE 查询失败,惯性视图跳过月球轨迹:%s", exc) return None return moon_pos @@ -360,9 +364,8 @@ def et_to_utc_label(et: float) -> str: iso = SPICEManager().et_to_utc(float(et)) return f"{iso} UTC" - except Exception: # noqa: BLE001 -- SPICE 不可用时降级为近似 - # noqa: BLE001 -- degrade to approximation when SPICE is unavailable - pass + except Exception as exc: # noqa: BLE001 -- SPICE 不可用时降级为近似 + logger.debug("SPICE et2utc 不可用,回退 J2000 固定偏移近似:%s", exc) # J2000 历元(ET=0)的 UTC 近似时刻 # Approximate UTC instant of the J2000 epoch (ET=0). epoch = datetime(2000, 1, 1, 11, 58, 56, tzinfo=UTC) diff --git a/src/model/artifact.py b/src/model/artifact.py index 405edab3..85fa1cdb 100644 --- a/src/model/artifact.py +++ b/src/model/artifact.py @@ -1,3 +1,23 @@ +"""领域资产:单条计算产物(Artifact)数据类。 + +Artifact 收拢一次工具运行的最小产物描述:类型与标签、轨道类型、来源 +工具、轨道库记录 id(record_id)与两个可懒填充的大数组(state_data、 +times)。catalog 记录的数组在入清单时不落内存,由 +``engine.catalog_service.load_arrays`` 按需回填,需要快照的调用方先 +``copy.deepcopy``。本模块属 Python 领域层,只服务脚本与测试,GUI +运行时链路经 sidecar 协议与 catalog API 取数,不经过这里的对象。 + +English: domain asset — the Artifact dataclass describing one computed +product: type and label, orbit type, source tool, the orbit-library +record id, and two lazily filled arrays (state_data, times). Arrays +behind catalog records are not materialized at listing time; they are +filled on demand by ``engine.catalog_service.load_arrays``, and callers +needing a snapshot should ``copy.deepcopy`` first. This module belongs +to the Python domain layer serving scripts and tests only; the GUI +runtime fetches data through the sidecar protocol and catalog APIs and +never touches these objects. +""" + from __future__ import annotations import uuid diff --git a/src/model/project.py b/src/model/project.py index d1af4df7..079f1156 100644 --- a/src/model/project.py +++ b/src/model/project.py @@ -1,3 +1,21 @@ +"""领域资产:单项目产物容器(Project)。 + +Project 按清单持有 Artifact,提供按 id、artifact_type、orbit_type 的 +查找与谱系判定:find_upstream 沿 extra 的 source_record_id 找上游产 +物,has_broken_lineage 判定上游记录是否已不存在。判定依据是全库记 +录 id 集合(known_record_ids)而非当前过滤视图,过滤把上游筛出清单 +时下游不应被误标为断链。 + +English: domain asset — Project, the per-project container of +artifacts. It holds an Artifact listing with lookups by id, +artifact_type and orbit_type, plus lineage checks: find_upstream +follows extra["source_record_id"], and has_broken_lineage decides +whether the referenced upstream record is gone. The judgement uses the +whole-library record id set (known_record_ids) rather than the current +filtered view, so a filter dropping the upstream from the listing must +not misflag downstream products as broken. +""" + from __future__ import annotations from src.model.artifact import Artifact diff --git a/tests/commons/test_constants.py b/tests/commons/test_constants.py new file mode 100644 index 00000000..e8d9e85b --- /dev/null +++ b/tests/commons/test_constants.py @@ -0,0 +1,32 @@ +"""constants 模块的常量一致性测试(#531)。 + +硬编码常量不依赖 e2m2e 即可用;CR3BP 派生常量惰性计算,其 DU / TU 必须与 +units.py 的模板常量对齐(同源不漂移,AGENTS.md 硬契约 2 的两条口径互证)。 + +English: consistency tests for src.commons.constants (#531). Hardcoded +constants are available without e2m2e; the lazily computed CR3BP-derived +DU / TU must stay aligned with the template-derived constants in +units.py (the two calibers of AGENTS.md hard contract 2 cross-check). +""" + +import pytest + +from src.commons import constants, units + + +class TestHardcodedConstants: + def test_hardcoded_constants_available_without_e2m2e(self): + """M_SUN / OMEGA_SUN / RHO 与模块内声明值一致(导入即可用)。""" + assert constants.M_SUN == pytest.approx(3.28900541e5) + assert constants.OMEGA_SUN == pytest.approx(9.25195985e-1) + assert constants.RHO == pytest.approx(3.88811143e2) + + +class TestCr3bpDerivedConstants: + def test_du_aligns_with_units_module(self): + """惰性 CR3BP 常量 DU 与 units.py 模板常量同源。""" + assert constants.DU == pytest.approx(units.DU_KM) + + def test_tu_in_days_aligns_with_units_module_in_seconds(self): + """CR3BP_System 的 TU 以天计,乘 86400 后与 units.py 的 TU_SECONDS 同源。""" + assert constants.TU * 86400.0 == pytest.approx(units.TU_SECONDS, rel=1e-9) diff --git a/tests/commons/test_units.py b/tests/commons/test_units.py new file mode 100644 index 00000000..73457e9a --- /dev/null +++ b/tests/commons/test_units.py @@ -0,0 +1,52 @@ +"""units 模块的换算与上游常量一致性测试(#531)。 + +units.py 是 AGENTS.md 硬契约 2 的唯一单位换算来源,本模块钉住两件事: +常量取自 e2m2e 模板(不另立一套)、换算函数往返闭合。 + +English: tests for src.commons.units (#531). units.py is the single +source of unit conversion (AGENTS.md hard contract 2); these tests pin +that its constants derive from the e2m2e templates (no second set of +conversions) and that the converters round-trip. +""" + +import math + +import pytest +from e2m2e.data.templates import CHAR_LENGTH_KM, CHAR_PERIOD_SEC + +from src.commons import units + + +class TestUnitsConstants: + def test_du_km_derives_from_template_char_length(self): + """DU_KM 取自 e2m2e CHAR_LENGTH_KM(384400 km),不另立常量。""" + assert units.DU_KM == pytest.approx(CHAR_LENGTH_KM) + assert units.DU_KM == pytest.approx(384400.0) + + def test_tu_seconds_derives_from_template_period_over_two_pi(self): + """TU_SECONDS = CHAR_PERIOD_SEC / (2π),约 375190.26 s。 + + 钉住字面值防口径漂移:本值与 frontend/src/cr3bp.ts 同口径; + frontend/src/paramOverlay 的 375676.97 是另一种口径,勿混用。 + """ + expected = CHAR_PERIOD_SEC / (2.0 * math.pi) + assert units.TU_SECONDS == pytest.approx(expected) + assert units.TU_SECONDS == pytest.approx(375190.26, rel=1e-5) + + +class TestUnitsConversions: + def test_km_du_roundtrip(self): + """km 与 DU 换算往返闭合;1 DU 即 384400 km。""" + assert units.km_to_du(units.du_to_km(2.5)) == pytest.approx(2.5) + assert units.km_to_du(384400.0) == pytest.approx(1.0) + assert units.du_to_km(1.0) == pytest.approx(384400.0) + + def test_seconds_tu_roundtrip(self): + """秒与 TU 换算往返闭合。""" + assert units.seconds_to_tu(units.tu_to_seconds(3.0)) == pytest.approx(3.0) + assert units.tu_to_seconds(1.0) == pytest.approx(units.TU_SECONDS) + + def test_years_tu_roundtrip(self): + """年与 TU 换算往返闭合;换算基数是 SECONDS_PER_YEAR = 365.25 天。""" + assert units.years_to_tu(units.tu_to_years(1.5)) == pytest.approx(1.5) + assert units.SECONDS_PER_YEAR == pytest.approx(365.25 * 86400.0) diff --git a/tests/engine/test_catalog_service.py b/tests/engine/test_catalog_service.py index 2c372c70..1d4c5e58 100644 --- a/tests/engine/test_catalog_service.py +++ b/tests/engine/test_catalog_service.py @@ -175,7 +175,7 @@ def test_design_record_fills_states(self): artifact = record_to_artifact(_summary()) assert CatalogService(bridge).load_arrays(artifact) is True assert artifact.state_data.shape == (5, 6) - assert artifact.extra["mu"] == 0.01215 + assert artifact.extra["mu"] == pytest.approx(0.01215) @pytest.mark.spice def test_design_record_ephemeris_rebuilds_times_et(self): diff --git a/tests/engine/test_facade_bridge_control.py b/tests/engine/test_facade_bridge_control.py index c00b8cf4..4e2c3a00 100644 --- a/tests/engine/test_facade_bridge_control.py +++ b/tests/engine/test_facade_bridge_control.py @@ -205,7 +205,8 @@ def test_control_orbit_returns_control_result_dto(self, monkeypatch, catalog_bri for (y, mo, d, h, mi, s) in times_meta ] ) - np.testing.assert_allclose(data.times_et, expected_et) + # 显式阈值:同串 str2et 应逐位一致;assert_allclose 默认 atol 为 0(纯相对比较),此处显式给 rtol 1e-7、atol 1e-8,防近零元素误报,仍严于标准的 atol 1e-6 + np.testing.assert_allclose(data.times_et, expected_et, rtol=1e-7, atol=1e-8) # controlled_times 现在就是真物理时间(不再是 np.arange) # controlled_times now holds true physical time (no longer np.arange) np.testing.assert_array_equal(data.controlled_times, data.times_et) diff --git a/tests/engine/test_facade_bridge_family.py b/tests/engine/test_facade_bridge_family.py index 6e25157d..2adc091c 100644 --- a/tests/engine/test_facade_bridge_family.py +++ b/tests/engine/test_facade_bridge_family.py @@ -32,7 +32,7 @@ class TestFamilyGenerationRequest: def test_halo_defaults(self): req = FamilyGenerationRequest(orbit_type="HALO") assert req.libration_point == 2 - assert req.max_amplitude_km == 30000.0 + assert req.max_amplitude_km == pytest.approx(30000.0) assert req.n_orbits == 50 def test_constraints(self): @@ -68,21 +68,21 @@ def test_dro_family_defaults(self): """5.8.2 起 DRO(月心族)为合法 orbit_type:不绑定平动点,振幅区间默认 2000-60000 km。""" req = FamilyGenerationRequest(orbit_type="DRO") assert req.libration_point is None - assert req.min_amplitude_km == 2000.0 - assert req.max_amplitude_km == 60000.0 + assert req.min_amplitude_km == pytest.approx(2000.0) + assert req.max_amplitude_km == pytest.approx(60000.0) with pytest.raises(ValidationError): FamilyGenerationRequest(orbit_type="DRO", libration_point=2) def test_per_family_defaults(self): nrho = FamilyGenerationRequest(orbit_type="NRHO") assert nrho.north_south == 2 - assert nrho.perilune_height_max_km == 20000.0 + assert nrho.perilune_height_max_km == pytest.approx(20000.0) assert nrho.continuation_direction == "toward-moon" spo = FamilyGenerationRequest(orbit_type="SPO") assert spo.libration_point == 4 - assert spo.min_amplitude_km == 2000.0 - assert spo.max_amplitude_km == 60000.0 - assert spo.match_tolerance_km == 20.0 + assert spo.min_amplitude_km == pytest.approx(2000.0) + assert spo.max_amplitude_km == pytest.approx(60000.0) + assert spo.match_tolerance_km == pytest.approx(20.0) # --------------------------------------------------------------------------- @@ -265,7 +265,11 @@ def test_dro_family_libration_point_none(self, monkeypatch): assert data.orbit_type == "DRO" assert data.family_type == "dro" assert data.libration_point is None - assert data.member_parameters == [{"amplitude_km": 20000.0}] * 2 + assert data.member_parameters is not None + assert len(data.member_parameters) == 2 + for params in data.member_parameters: + assert set(params) == {"amplitude_km"} + assert params["amplitude_km"] == pytest.approx(20000.0) def test_invalid_params_translated(self): """非法参数经真 Catalog 的 FamilyGenerationRequest 校验 → OrbitError(INVALID_PARAMS)。""" diff --git a/tests/engine/test_integration.py b/tests/engine/test_facade_bridge_integration.py similarity index 100% rename from tests/engine/test_integration.py rename to tests/engine/test_facade_bridge_integration.py diff --git a/tests/engine/test_facade_bridge_transfer.py b/tests/engine/test_facade_bridge_transfer.py index e6e92c50..0c6afc31 100644 --- a/tests/engine/test_facade_bridge_transfer.py +++ b/tests/engine/test_facade_bridge_transfer.py @@ -101,8 +101,12 @@ def test_target_states_converted_to_synodic_physical(self, monkeypatch, bridge): ) eph = captured["kwargs"]["target_ephemeris"] assert eph.shape == (1, 6) + # 显式阈值:乘 DU_KM/TU 的量级换算;assert_allclose 默认 atol 为 0(纯相对比较),此处显式给 rtol 1e-7、atol 1e-8,防近零元素误报,仍严于标准的 atol 1e-6 np.testing.assert_allclose( - eph[0], np.r_[states[-1][:3] * DU_KM, states[-1][3:] * (DU_KM / TU_SECONDS)] + eph[0], + np.r_[states[-1][:3] * DU_KM, states[-1][3:] * (DU_KM / TU_SECONDS)], + rtol=1e-7, + atol=1e-8, ) def test_tli_epoch_list_to_iso(self, monkeypatch, bridge): diff --git a/tests/engine/test_viz_adapter.py b/tests/engine/test_viz_adapter.py index 9863ad87..8a791f34 100644 --- a/tests/engine/test_viz_adapter.py +++ b/tests/engine/test_viz_adapter.py @@ -13,6 +13,7 @@ from __future__ import annotations import matplotlib +import numpy as np import pytest from e2m2e.data.templates.seed import EARTH_MOON_MU @@ -200,3 +201,56 @@ def test_importing_module_does_not_import_e2m2e(self): assert result.stdout.strip() == "CLEAN", ( f"viz_adapter 模块级 import 触发了 e2m2e 加载: {result.stdout.strip()}" ) + + +class TestSynodicToGcrs: + def test_synodic_to_gcrs_km_matches_frontend_idealized_geometry(self): + """换算与前端 idealizedInertialGeometry 同口径:先平移后旋转(#521)。 + + frontend/src/trajectoryParsing.ts 的 idealizedInertialGeometry 按 + Rz(θ)·(p + (μ,0,0)) 实现;本测试按同一公式复现期望值逐点对照, + 守住 AGENTS.md 硬契约 4(#477)的多实现同口径。 + """ + from src.commons.units import DU_KM + + from src.engine.viz_adapter import synodic_to_gcrs_km + + pos = np.array([[0.1, -0.2, 0.3], [-1.5, 0.8, -0.1], [0.0, 0.0, 0.0]]) + theta = np.array([0.0, 0.7, 2.4]) + got = synodic_to_gcrs_km(pos, theta, _MU) + c, s = np.cos(theta), np.sin(theta) + x = pos[:, 0] + _MU + y = pos[:, 1] + expected = np.column_stack( + [(c * x - s * y) * DU_KM, (s * x + c * y) * DU_KM, pos[:, 2] * DU_KM] + ) + np.testing.assert_allclose(got, expected, atol=1e-6) + + def test_synodic_to_gcrs_km_moon_point_falls_on_approx_moon_orbit(self): + """会合系月球点 (1-μ,0,0) 换算后落在 approx_moon_gcrs_km 正圆轨道上(#521)。 + + 月球特例的一致性同时锁定两个函数的口径:先平移口径下 + R(θ)·((1-μ)+μ,0,0) 化为 R(θ)·(1,0,0),与 approx_moon_gcrs_km 重合。 + """ + from src.engine.viz_adapter import approx_moon_gcrs_km, synodic_to_gcrs_km + + theta = np.array([0.0, 1.3, 4.1]) + moon_synodic = np.tile([1.0 - _MU, 0.0, 0.0], (3, 1)) + got = synodic_to_gcrs_km(moon_synodic, theta, _MU) + np.testing.assert_allclose(got, approx_moon_gcrs_km(theta), atol=1e-6) + + def test_synodic_to_gcrs_km_accepts_scalar_theta(self): + """theta 为标量时按同口径广播到全部点(#521)。""" + from src.commons.units import DU_KM + + from src.engine.viz_adapter import synodic_to_gcrs_km + + pos = np.array([[0.5, 0.25, -0.75], [1.0, -1.0, 0.0]]) + got = synodic_to_gcrs_km(pos, 1.1, _MU) + c, s = np.cos(1.1), np.sin(1.1) + x = pos[:, 0] + _MU + y = pos[:, 1] + expected = np.column_stack( + [(c * x - s * y) * DU_KM, (s * x + c * y) * DU_KM, pos[:, 2] * DU_KM] + ) + np.testing.assert_allclose(got, expected, atol=1e-6) diff --git a/tools/export_tool_schemas.py b/tools/export_tool_schemas.py index 45988273..e964fb36 100644 --- a/tools/export_tool_schemas.py +++ b/tools/export_tool_schemas.py @@ -17,8 +17,20 @@ """ import json +import os from pathlib import Path +from src.commons.paths import detect_kernel_dir + +# 硬契约 1(AGENTS.md):e2m2e 在 import 期读 SPICE_KERNEL_DIR 搜闰秒内核, +# 必须在任何 import e2m2e 之前设置(比照 tests/conftest.py)。 +# Hard contract 1 (AGENTS.md): e2m2e reads SPICE_KERNEL_DIR at import time +# to locate the leap-second kernel; set it before any `import e2m2e` +# (mirrors tests/conftest.py). +_kernel_dir = detect_kernel_dir() +if _kernel_dir: + os.environ.setdefault("SPICE_KERNEL_DIR", _kernel_dir) + from e2m2e.api.facade import Facade from e2m2e.api.mcp import tools