{
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