Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,6 @@ The CI-only lifecycle scenario forcibly ends its own installed Desktop process a

Installed boundary checks exercise an 8192-byte Unicode note, reject over-budget input, display zero and capped-ten search results, and verify cancel/confirm behavior when disconnecting with an unsaved draft. Each result requires its matching remote report.

After upgrading from the earlier preview, select `sqlite-6e237568-v1` and recheck your connection. The previous `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` selection is not silently upgraded to a different contract. See [current qualification](VALIDATION.md).
After upgrading from an earlier preview, open Connections and select your saved connection. Compare the tested Server build with your deployment, choose `sqlite-legacy-58f7f4f6-v1` under “Available for this Desktop build”, save, then check and explicitly use the connection again. This profile matches the bundled Desktop contract; new connections still start with no compatibility selection.

Profiles for earlier contracts, including `sqlite-6e237568-v1`, appear as disabled historical records. A saved incompatible or unavailable selection remains visible with an explanation until you explicitly replace it; it is not silently upgraded. The earlier `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` selections also require explicit replacement. Historical qualification records and evidence are retained; see the [validation guide](VALIDATION.md).
4 changes: 3 additions & 1 deletion desktop/README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,6 @@ Windows GitHub Actions 会构建未签名安装包,安装到临时中文路径

安装版边界测试覆盖 8192 字节 Unicode 正文、超限输入禁止提交、搜索无结果和最多 10 条的提示,以及未保存草稿时取消或确认断开连接的行为。是否通过以对应远程报告为准。

从早期预览版升级后,请选择 `sqlite-6e237568-v1` 并重新检查连接。旧的 `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` 选择不会静默升级为另一份契约。当前验证范围见[验证说明](VALIDATION.md)。
从早期预览版升级后,请打开「连接」并选中已保存的连接。先核对已测试的 Server 构建与实际部署,再从「此 Desktop 构建可用」中选择 `sqlite-legacy-58f7f4f6-v1`,保存后重新检查并明确使用此连接。该配置与当前 Desktop 打包的契约匹配;新连接仍默认不选择兼容配置。

`sqlite-6e237568-v1` 等使用旧契约的配置会显示为不可选择的历史验证记录。已保存的不匹配或不可用配置仍会显示,并说明原因,须由你明确改选,不会静默升级。更早的 `sqlite-1.1.1-v1` / `sqlite-63f918b7-v1` / `sqlite-ab43e3a7-v1` / `sqlite-f1089f4e-v1` 选择也须明确替换。完整历史验证记录和证据予以保留,详见[验证说明](VALIDATION.md)。
2 changes: 1 addition & 1 deletion desktop/tests/installed_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def connect(self, name: str, endpoint: str) -> None:
self.type("连接名称", name)
self.type("Server 地址", endpoint)
compatibility = fixture_compatibility_profile()
self.click(f"//label[normalize-space(text())='已验证兼容配置']/select/option[@value='{compatibility}']")
self.click(f"//label[normalize-space(text())='已验证兼容配置']/select//option[@value='{compatibility}']")
self.button("保存配置")
self.activate(name)

Expand Down
46 changes: 42 additions & 4 deletions desktop/ui/src/app/Connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
StorageChoice,
Fact,
} from "../generated/ipc";
import { contractSha256 } from "../generated/operations";
import { desktopApi } from "../shared/ipc";
import { connectionMessages, connectionError } from "./connection-messages";
import { messages, type Language } from "./messages";
Expand Down Expand Up @@ -155,6 +156,18 @@ export function Connections({ state, language, onState, onDirty }: Props) {
(profile.credentialState === "stored" ||
profile.credentialState === "session_only") &&
!invalidated.current;
const availableProfiles = state?.compatibilityProfiles.filter(
(p) => p.contractSha256 === contractSha256,
);
const historicalProfiles = state?.compatibilityProfiles.filter(
(p) => p.contractSha256 !== contractSha256,
);
const selectedCompatibility = state?.compatibilityProfiles.find(
(p) => p.id === compatibility,
);
const unavailableCompatibility =
compatibility !== "" &&
selectedCompatibility?.contractSha256 !== contractSha256;
return (
<div className="connection-grid">
<section className="card stack">
Expand Down Expand Up @@ -315,14 +328,39 @@ export function Connections({ state, language, onState, onDirty }: Props) {
}}
>
<option value="">{t.unselected}</option>
{state?.compatibilityProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.id}
{!!availableProfiles?.length && (
<optgroup label={t.availableProfiles}>
{availableProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.id}
</option>
))}
</optgroup>
)}
{!!historicalProfiles?.length && (
<optgroup label={t.historicalProfiles}>
{historicalProfiles.map((p) => (
<option key={p.id} value={p.id} disabled>
{p.id} ({t.unavailableProfile})
</option>
))}
</optgroup>
)}
{compatibility && !selectedCompatibility && (
<option value={compatibility} disabled>
{compatibility} ({t.unavailableProfile})
</option>
))}
)}
</select>
</label>
<p className="field-hint">{t.compatibilityHint}</p>
{unavailableCompatibility && (
<p role="alert">
{selectedCompatibility
? t.profileContractMismatch
: t.profileMissing}
</p>
)}
<details>
<summary>{t.ca}</summary>
<label>
Expand Down
14 changes: 14 additions & 0 deletions desktop/ui/src/app/connection-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ export const connectionMessages = {
systemTrust: "留空使用系统证书信任;不支持跳过证书校验。",
compatibility: "已验证兼容配置",
unselected: "未选择:仅检查连接",
availableProfiles: "此 Desktop 构建可用",
historicalProfiles: "历史验证记录:此构建不可用",
unavailableProfile: "此构建不可用",
profileContractMismatch:
"该配置的 API 契约与此 Desktop 构建不同。请选择可用配置,保存后重新检查连接。",
profileMissing:
"此 Desktop 未包含该兼容配置的验证记录。请选择可用配置,保存后重新检查连接。",
compatibilityHint:
"配置表示已测试的 Server 构建,不证明远端二进制身份。请先核对部署版本。",
save: "保存配置",
Expand Down Expand Up @@ -156,6 +163,13 @@ export const connectionMessages = {
"Leave blank for system trust. Certificate verification cannot be disabled.",
compatibility: "Verified compatibility profile",
unselected: "Not selected: connection checks only",
availableProfiles: "Available for this Desktop build",
historicalProfiles: "Historical records: unavailable for this build",
unavailableProfile: "Unavailable for this build",
profileContractMismatch:
"This profile uses a different API contract from this Desktop build. Select an available profile, save, and check the connection again.",
profileMissing:
"This Desktop does not include a qualification record for this profile. Select an available profile, save, and check the connection again.",
compatibilityHint:
"A profile identifies a tested Server build, not the identity of the remote binary. Check your deployment version.",
save: "Save configuration",
Expand Down
149 changes: 148 additions & 1 deletion desktop/ui/tests/connections.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,21 @@
*/

import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import {
act,
cleanup,
render,
screen,
within,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { App } from "../src/app/App";
import { Connections } from "../src/app/Connections";
import { desktopApi } from "../src/shared/ipc";
import type { DesktopState, ProfileView } from "../src/generated/ipc";
import { contractSha256 } from "../src/generated/operations";
import qualifications from "../../src-tauri/src/connections/compatibility.json";
vi.mock("../src/shared/ipc", () => ({
getFoundationInfo: vi.fn().mockResolvedValue(null),
desktopApi: {
Expand Down Expand Up @@ -64,13 +73,136 @@ function show(p = profile) {
);
return onState;
}
const currentCompatibility = qualifications.find(
(p) => p.contractSha256 === contractSha256,
)!;
const historicalCompatibility = qualifications.find(
(p) => p.contractSha256 !== contractSha256,
)!;
function qualifiedState(p = profile): DesktopState {
return {
...state(p),
compatibilityProfiles: [historicalCompatibility, currentCompatibility],
};
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
test("new connections offer only current-contract profiles and keep no selection by default", async () => {
const user = userEvent.setup();
render(
<Connections
state={qualifiedState()}
language="en"
onState={vi.fn()}
onDirty={vi.fn()}
/>,
);
const selector = screen.getByLabelText(
"Verified compatibility profile",
) as HTMLSelectElement;
expect(selector.value).toBe("");
expect(
(
within(selector).getByRole("option", {
name: (text) => text.includes(historicalCompatibility.id),
}) as HTMLOptionElement
).disabled,
).toBe(true);
const current = within(selector).getByRole("option", {
name: currentCompatibility.id,
}) as HTMLOptionElement;
expect(current.disabled).toBe(false);
expect(current.closest("optgroup")?.label).toBe(
"Available for this Desktop build",
);
await user.selectOptions(selector, current);
expect(selector.value).toBe(currentCompatibility.id);
expect(desktopApi.save).not.toHaveBeenCalled();
expect(desktopApi.check).not.toHaveBeenCalled();
});
test("a saved historical profile shows the contract mismatch and can be explicitly replaced before rechecking", async () => {
const user = userEvent.setup();
const saved = { ...profile, compatibility: historicalCompatibility.id };
const next = qualifiedState({
...saved,
revision: 2,
compatibility: currentCompatibility.id,
});
const onState = vi.fn();
vi.mocked(desktopApi.save).mockResolvedValue(next);
vi.mocked(desktopApi.check).mockResolvedValue(next);
render(
<Connections
state={qualifiedState(saved)}
language="en"
onState={onState}
onDirty={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: /Original/ }));
const selector = screen.getByLabelText(
"Verified compatibility profile",
) as HTMLSelectElement;
expect(selector.value).toBe(historicalCompatibility.id);
expect(screen.getByRole("alert").textContent).toMatch(
/different API contract from this Desktop build/,
);
expect(desktopApi.save).not.toHaveBeenCalled();
expect(desktopApi.check).not.toHaveBeenCalled();
await user.selectOptions(selector, currentCompatibility.id);
expect(screen.queryByRole("alert")).toBeNull();
expect(
(
screen.getByRole("button", {
name: "Check connection",
}) as HTMLButtonElement
).disabled,
).toBe(true);
await user.click(screen.getByRole("button", { name: "Save configuration" }));
expect(desktopApi.save).toHaveBeenCalledWith(
expect.objectContaining({
id: saved.id,
revision: saved.revision,
compatibility: currentCompatibility.id,
}),
);
expect(onState).toHaveBeenCalledWith(next);
await user.click(screen.getByRole("button", { name: "Check connection" }));
expect(desktopApi.check).toHaveBeenCalledWith(saved.id, false);
});
test("an unrecognized saved profile remains visible and requires explicit replacement", async () => {
const user = userEvent.setup();
render(
<Connections
state={qualifiedState({ ...profile, compatibility: "removed-profile" })}
language="zh"
onState={vi.fn()}
onDirty={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: /Original/ }));
const selector = screen.getByLabelText("已验证兼容配置") as HTMLSelectElement;
expect(selector.value).toBe("removed-profile");
expect(
(
within(selector).getByRole("option", {
name: /removed-profile/,
}) as HTMLOptionElement
).disabled,
).toBe(true);
expect(screen.getByRole("alert").textContent).toMatch(
/此 Desktop 未包含该兼容配置的验证记录/,
);
await user.selectOptions(selector, currentCompatibility.id);
expect(selector.value).toBe(currentCompatibility.id);
expect(screen.queryByRole("alert")).toBeNull();
expect(desktopApi.save).not.toHaveBeenCalled();
});
test("canceling discard leaves the saved connection and its draft intact", async () => {
const user = userEvent.setup();
show();
Expand Down Expand Up @@ -157,14 +289,29 @@ test("a save from the previous connection editor cannot dismiss a new draft", as
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
render(<App />);
await user.click(screen.getByRole("button", { name: "连接" }));
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("heading", { level: 1, name: "连接" }),
),
);
await user.type(screen.getByLabelText("连接名称"), "First");
await user.type(
screen.getByLabelText("Server 地址"),
"http://localhost:8000",
);
await user.click(screen.getByRole("button", { name: "保存配置" }));
await user.click(screen.getByRole("button", { name: "总览" }));
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("heading", { level: 1, name: "总览" }),
),
);
await user.click(screen.getByRole("button", { name: "连接" }));
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("heading", { level: 1, name: "连接" }),
),
);
await user.type(screen.getByLabelText("连接名称"), "New draft");
await act(async () => {
finish(state({ ...profile, name: "First" }));
Expand Down
16 changes: 16 additions & 0 deletions desktop/ui/tests/memory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
screen,
fireEvent,
act,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
Expand Down Expand Up @@ -343,6 +344,11 @@ test.each(["scope", "page"])(
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
render(<App />);
await user.click(screen.getByRole("button", { name: "记忆" }));
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("heading", { level: 1, name: "记忆" }),
),
);
await user.type(screen.getByLabelText("记忆内容"), "draft A");
await user.click(screen.getByRole("button", { name: "保存" }));
if (switchKind === "scope") {
Expand All @@ -363,7 +369,17 @@ test.each(["scope", "page"])(
await user.click(screen.getByRole("button", { name: "Scope B" }));
} else {
await user.click(screen.getByRole("button", { name: "总览" }));
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("heading", { level: 1, name: "总览" }),
),
);
await user.click(screen.getByRole("button", { name: "记忆" }));
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("heading", { level: 1, name: "记忆" }),
),
);
}
await user.type(screen.getByLabelText("记忆内容"), "draft B");
await act(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ async def search(
if request.mode not in {"fts", "hybrid"} or not request.analyzed_query:
return TopicMemorySearchChannels()
query_terms, coverage_required = fts_query_requirements(request.query, floor=request.admission)
if coverage_required > len(query_terms):
return TopicMemorySearchChannels()
topic_score = match(TOPIC_MEMORY_ACTIVE_TOPICS_TABLE.c.searchable_text, against=request.analyzed_query)
topic_coverage = _coverage_expression(
TOPIC_MEMORY_ACTIVE_TOPICS_TABLE.c.searchable_text,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,8 @@ async def search(
if query is None:
return TopicMemorySearchChannels()
query_terms, required_matches = fts_query_requirements(request.query, floor=request.admission)
if required_matches > len(query_terms):
return TopicMemorySearchChannels()
coverage = " + ".join(
f"CASE WHEN instr(' ' || searchable_text || ' ', :coverage_term_{position}) > 0 THEN 1 ELSE 0 END"
for position, _term in enumerate(query_terms)
Expand Down
Loading
Loading