Author SHA1 Message Date
Jayway Liu 3d3f7e2434 merge: 同步 main 的 CI 基建(.gitea/workflows 迁移、PG 咨询锁测试适配、artifact 容忍、audit 重试)
Verify / verify (pull_request) Failing after 1s
AI Review / review (pull_request) Successful in 12s
Verify / verify (push) Failing after 34s
2026-09-26 17:02:07 +08:00
Jayway Liu 05168f671e test(worker): 适配 PostgreSQL 咨询锁下的立即重认领语义
Verify / verify (push) Failing after 1s
Verify / verify (pull_request) Failing after 1s
PG 上 worker 持咨询锁会立即重认领 processing 任务,第二次 --once 被导入任务
抢占导致导出断言失败(该测试此前从未在 PG 上真正运行过)。第一次调用后把导入
任务置为 ready 模拟完成,两种后端行为一致
2026-09-26 16:19:26 +08:00
5 changed files with 40 additions and 245 deletions
+1 -2
View File
@@ -22,8 +22,7 @@ def design_column(messages, ds, ctx):
or set(item) != {"role", "content"}
or item["role"] not in ("user", "assistant")
or not isinstance(item["content"], str)
# 2000 字符容纳载入已保存定义后的完整对话上下文
or len(item["content"]) > 2000
or len(item["content"]) > 1000
for item in messages
):
raise APIError("对话消息内容无效")
+7 -59
View File
@@ -1,12 +1,5 @@
import { useEffect, useRef, useState } from "react";
import {
ChevronLeft,
ChevronRight,
Download,
Save,
Trash2,
X,
} from "lucide-react";
import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-react";
import { api } from "./api";
import {
labels,
@@ -41,19 +34,6 @@ function describeExpression(expression: FormulaExpression): string {
return `(${describeExpression(expression.left)} ${operators[expression.operator]} ${describeExpression(expression.right)})`;
}
function definitionSummary(definition: Record<string, unknown>): string {
const text = [
`已载入已保存定义「${String(definition.name)}」`,
`含义:${String(definition.meaning)}`,
`适用范围:${String(definition.scope)}`,
`缺失/零分母处理:${String(definition.missing)}`,
`计算表达式:${JSON.stringify(definition.expression)}`,
].join(";");
return text.length <= 2000
? text
: `${text.slice(0, 2000)}…(内容过长已截断)`;
}
export function Modal({
title,
onClose,
@@ -369,8 +349,7 @@ export function EvidenceDialog({
}>(),
[side, setSide] = useState<"current" | "baseline">("current"),
[page, setPage] = useState(0),
[error, setError] = useState(""),
[reloadKey, setReloadKey] = useState(0);
[error, setError] = useState("");
useEffect(() => {
let active = true;
setResult(undefined);
@@ -385,14 +364,8 @@ export function EvidenceDialog({
return () => {
active = false;
};
}, [ds.id, ctx, side, page, reloadKey]);
}, [ds.id, ctx, side, page]);
function selectSide(value: "current" | "baseline") {
if (value === side) {
// 重复点击当前期间:仅在不在第一页时回到第一页触发刷新,
// 同期间同页直接返回,避免清空列表后停在“加载中”。
if (page !== 0) setPage(0);
return;
}
setResult(undefined);
setPage(0);
setSide(value);
@@ -428,8 +401,7 @@ export function EvidenceDialog({
{user.role !== "admin" && <p>客户原始标识不包含在明细中。</p>}
{error && (
<p role="alert" className="error">
{error}{" "}
<button onClick={() => setReloadKey((n) => n + 1)}>重试</button>
{error}
</p>
)}
<div className="table-scroll">
@@ -463,11 +435,6 @@ export function EvidenceDialog({
<td>{String(r.gross ?? "")}</td>
</tr>
))}
{result && result.rows.length === 0 && (
<tr>
<td colSpan={3 + ds.dimensions.length}>本期范围内没有数据</td>
</tr>
)}
</tbody>
</table>
</div>
@@ -809,10 +776,7 @@ export function FormulaExportDialog({
);
setDefinitions((items) =>
[...items.filter((item) => item.id !== saved.id), saved].sort((a, b) =>
String(a.definition.name).localeCompare(
String(b.definition.name),
"zh-CN",
),
String(a.definition.name).localeCompare(String(b.definition.name), "zh-CN"),
),
);
setSelectedDefinition(saved.id);
@@ -825,25 +789,9 @@ export function FormulaExportDialog({
function loadDefinition(id: string) {
setSelectedDefinition(id);
const saved = definitions.find((item) => item.id === id);
if (!saved) {
// 选择空选项时同步清理当前定义,避免下拉选择与预览不一致
setColumn(null);
setMessages([]);
setQuestions([]);
return;
}
if (!saved) return;
setColumn(saved.definition);
// 载入定义后把原定义显式放进可见、经同意的对话上下文,
// 使“把刚才公式改成乘以 100”这类指代能关联到已载入表达式;
// 修改结果仍需重新确认,不会静默沿用旧定义。
setMessages([
{ role: "user", content: definitionSummary(saved.definition) },
{
role: "assistant",
content:
"已载入该定义作为当前基准。请描述要调整的内容,我会在该定义基础上生成新的列定义,仍需你确认后再生成。",
},
]);
setMessages([]);
setQuestions([]);
setDraft("");
}
+9 -42
View File
@@ -59,22 +59,11 @@ test("新增列:补充信息触发追问并重新确认后生成公式工作
meaning: "实际收入减成本",
scope: "所选分组与期间",
missing: "结果不可用(#N/A);零分母不可用",
expression:
designCalls === 5
? {
operator: "multiply",
left: {
operator: "subtract",
left: { metric: "revenue" },
right: { metric: "cost" },
},
right: { number: 100 },
}
: {
operator: "subtract",
left: { metric: "revenue" },
right: { metric: "cost" },
},
expression: {
operator: "subtract",
left: { metric: "revenue" },
right: { metric: "cost" },
},
},
},
});
@@ -134,33 +123,11 @@ test("新增列:补充信息触发追问并重新确认后生成公式工作
const reopened = page.getByRole("dialog", { name: "生成公式工作簿" });
await reopened.getByLabel("已保存定义").selectOption({ label: "收入成本差" });
await expect(reopened).toContainText("计算表达式:(实际收入 − 成本)");
// F04 回归:载入定义后,原定义作为可见上下文进入对话
await expect(reopened).toContainText("已载入已保存定义「收入成本差」");
await expect(reopened).toContainText("我:已载入已保存定义");
await expect(reopened).toContainText("助手:已载入该定义作为当前基准");
// F04 回归:选择空选项时同步清理当前定义与上下文,选择状态与预览一致
await reopened.getByLabel("已保存定义").selectOption({ label: "选择定义" });
await expect(reopened.getByText("计算表达式:(实际收入 − 成本)")).toHaveCount(
0,
);
await expect(reopened.getByText("已载入已保存定义")).toHaveCount(0);
await reopened.getByLabel("已保存定义").selectOption({ label: "收入成本差" });
await reopened.getByRole("checkbox").check();
await reopened.getByLabel("列计算描述").fill("把刚才公式改成乘以 100");
await reopened.getByRole("button", { name: "发送描述" }).click();
await expect(reopened).toContainText(
"计算表达式:((实际收入 − 成本) × 100)",
);
// 模型请求必须包含载入的原定义上下文和新的修改描述
expect(sentMessages.at(0)?.content).toContain(
"已载入已保存定义「收入成本差」",
);
expect(sentMessages.at(0)?.content).toContain('"operator":"subtract"');
expect(sentMessages.at(-1)?.content).toBe("把刚才公式改成乘以 100");
await reopened.getByRole("button", { name: "确认并生成公式工作簿" }).click();
await reopened
.getByRole("button", { name: "确认并生成公式工作簿" })
.click();
await expect(reopened.getByRole("status")).toContainText("生成完成", {
timeout: 30000,
});
expect(designCalls).toBe(5);
expect(designCalls).toBe(4);
});
+23 -94
View File
@@ -30,9 +30,7 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
});
await uploaded;
const dialog = page.getByRole("dialog");
await expect(
dialog.getByRole("heading", { name: "确认导入字段" }),
).toBeVisible();
await expect(dialog.getByRole("heading", { name: "确认导入字段" })).toBeVisible();
await dialog.getByRole("button", { name: "确认并导入" }).click();
await expect(
page.getByRole("heading", { name: "集团经营总览", exact: true }),
@@ -87,27 +85,14 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
await expect(evidenceDialog).toContainText("1 条");
await expect(evidenceDialog).toContainText("2026-08");
await expect(evidenceDialog).toContainText("CSV");
await evidenceDialog
.getByRole("button", { name: "基期", exact: true })
.click();
await evidenceDialog.getByRole("button", { name: "基期", exact: true }).click();
await expect(evidenceDialog).toContainText("2025-08");
await evidenceDialog.getByLabel("关闭", { exact: true }).click();
const evidenceOffsets: number[] = [];
let evidenceFailOnce = false;
let evidenceEmpty = false;
await page.route("**/api/datasets/*/evidence", async (route) => {
const request = route.request().postDataJSON() as { offset?: number };
const offset = request.offset || 0;
if (evidenceFailOnce) {
evidenceFailOnce = false;
await route.fulfill({ status: 500, json: { error: "服务暂不可用" } });
return;
}
evidenceOffsets.push(offset);
if (evidenceEmpty) {
await route.fulfill({ json: { total: 0, sheet: "CSV", rows: [] } });
return;
}
const count = offset === 0 ? 50 : 1;
await route.fulfill({
json: {
@@ -126,51 +111,10 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
});
await page.getByLabel("查看医生A证据", { exact: true }).click();
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
await evidenceDialog
.getByRole("button", { name: "下一页", exact: true })
.click();
await evidenceDialog.getByRole("button", { name: "下一页", exact: true }).click();
await expect(evidenceDialog).toContainText("第 51-51 条,共 51 条");
await expect(evidenceDialog).toContainText("2026-09");
expect(evidenceOffsets).toEqual([0, 50]);
// F03 回归:第二页时重复点击当前期间应回到第一页并重新加载
await evidenceDialog
.getByRole("button", { name: "本期", exact: true })
.click();
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
expect(evidenceOffsets).toEqual([0, 50, 0]);
// F03 回归:第一页重复点击当前期间不清空列表、不重新请求、不停在“加载中”
await evidenceDialog
.getByRole("button", { name: "本期", exact: true })
.click();
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
await expect(evidenceDialog).not.toContainText("加载中");
expect(evidenceOffsets).toEqual([0, 50, 0]);
// F03 回归:快速本期/基期切换最终停留在当前期间的数据
await evidenceDialog
.getByRole("button", { name: "基期", exact: true })
.click();
await evidenceDialog
.getByRole("button", { name: "本期", exact: true })
.click();
await expect(evidenceDialog).toContainText("第 1-50 条,共 51 条");
await expect(evidenceDialog).not.toContainText("加载中");
// F03 回归:请求失败显示错误并可重试恢复
evidenceFailOnce = true;
await evidenceDialog
.getByRole("button", { name: "下一页", exact: true })
.click();
await expect(evidenceDialog).toContainText("服务暂不可用");
await expect(evidenceDialog).toContainText("加载失败");
await evidenceDialog.getByRole("button", { name: "重试" }).click();
await expect(evidenceDialog).toContainText("第 51-51 条,共 51 条");
// F03 回归:空结果显示空态而不是过期数据或加载中
evidenceEmpty = true;
await evidenceDialog
.getByRole("button", { name: "上一页", exact: true })
.click();
await expect(evidenceDialog).toContainText("本期范围内没有数据");
await expect(evidenceDialog).toContainText("共 0 条");
await expect(evidenceDialog).not.toContainText("加载中");
await evidenceDialog.getByLabel("关闭", { exact: true }).click();
await page
.getByRole("navigation", { name: "分析路径" })
@@ -220,53 +164,38 @@ test("桌面:真实导入、异常、下钻、证据、设置、Copilot、导
await page.route("**/formula-design", async (route) => {
designCalls++;
await route.fulfill({
json:
designCalls === 1
? {
complete: false,
questions: ["缺失值和零分母如何处理?"],
column: null,
}
: {
complete: true,
questions: [],
column: {
name: "收入成本差",
meaning: "实际收入减成本",
scope: "所选分组与期间",
missing: "结果不可用(#N/A);零分母不可用",
expression: {
operator: "subtract",
left: { metric: "revenue" },
right: { metric: "cost" },
},
json: designCalls === 1
? { complete: false, questions: ["缺失值和零分母如何处理?"], column: null }
: {
complete: true,
questions: [],
column: {
name: "收入成本差",
meaning: "实际收入减成本",
scope: "所选分组与期间",
missing: "结果不可用(#N/A);零分母不可用",
expression: {
operator: "subtract",
left: { metric: "revenue" },
right: { metric: "cost" },
},
},
},
});
});
await page.getByRole("button", { name: "生成公式工作簿" }).click();
const formulaDialog = page.getByRole("dialog", { name: "生成公式工作簿" });
await formulaDialog.getByRole("checkbox").check();
await formulaDialog
.getByLabel("列计算描述")
.fill("新增收入成本差,缺失或零分母显示不可用");
await formulaDialog.getByLabel("列计算描述").fill("新增收入成本差,缺失或零分母显示不可用");
await formulaDialog.getByRole("button", { name: "发送描述" }).click();
await expect(formulaDialog).toContainText("缺失值和零分母如何处理?");
await formulaDialog
.getByLabel("列计算描述")
.fill("缺失项和零分母都按不可用处理");
await formulaDialog.getByLabel("列计算描述").fill("缺失项和零分母都按不可用处理");
await formulaDialog.getByRole("button", { name: "发送描述" }).click();
await formulaDialog
.getByRole("button", { name: "确认并生成公式工作簿" })
.click();
await expect(formulaDialog.getByRole("status")).toContainText("生成完成", {
timeout: 30000,
});
await formulaDialog.getByRole("button", { name: "确认并生成公式工作簿" }).click();
await expect(formulaDialog.getByRole("status")).toContainText("生成完成", { timeout: 30000 });
const formulaDownload = page.waitForEvent("download");
await formulaDialog.getByRole("button", { name: "下载公式工作簿" }).click();
expect((await formulaDownload).suggestedFilename()).toBe(
"traceiq-formula-analysis.xlsx",
);
expect((await formulaDownload).suggestedFilename()).toBe("traceiq-formula-analysis.xlsx");
await formulaDialog.getByLabel("关闭").click();
});
test("手机:登录、总览、设置、Copilot 布局无横向溢出", async ({ page }) => {
-48
View File
@@ -200,54 +200,6 @@ def test_design_column_preserves_clarification_branch_and_full_conversation(form
assert payload["input"][1:] == messages
def test_design_column_accepts_loaded_definition_context(formula_data, settings):
ds, _ = formula_data
settings.AI_ENABLED = True
settings.OPENAI_API_KEY = "test-key"
settings.OPENAI_MODEL = "test-model"
context = "已载入已保存定义「收入成本差」;计算表达式:" + '{"operator":"subtract",' * 45
assert 1000 < len(context) <= 2000
messages = [
{"role": "user", "content": context},
{"role": "assistant", "content": "已载入该定义作为当前基准。"},
{"role": "user", "content": "把刚才公式改成乘以 100"},
]
clarification = {
"status": "completed",
"output": [
{
"content": [
{
"type": "output_text",
"text": '{"complete":false,"questions":["要乘在整个表达式上吗?"],"column":null}',
}
]
}
],
}
with patch("finance.formula_views.httpx.Client") as http:
http.return_value.__enter__.return_value.post.return_value = type(
"Response",
(),
{"raise_for_status": lambda self: None, "json": lambda self: clarification},
)()
result = design_column(
messages,
ds,
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
)
assert result["complete"] is False
payload = http.return_value.__enter__.return_value.post.call_args.kwargs["json"]
assert payload["input"][1:] == messages
with pytest.raises(Exception, match="对话消息内容无效"):
design_column(
[{"role": "user", "content": "y" * 2001}],
ds,
{"current": ["2026-08"], "baseline": ["2025-08"], "filters": {}, "group": "department"},
)
def test_design_column_returns_only_validated_formula_definition(formula_data, settings):
ds, _ = formula_data
settings.AI_ENABLED = True