Files
cc-switch/tests/components/providerPresentation.test.ts
Jason 626529451b feat(kimi): edit official account models and remove them from Kimi
Official Kimi accounts added in the Authorization Center were written
straight into config.toml with the model list discovered at sign-in.
Kimi Code only refreshes its own managed:kimi-code node, so separate
accounts kept that snapshot forever, and their cards could neither be
edited nor removed.

Official cards can now be edited. The editor hides the connection and
fetches the account's own catalog through a new kimi_oauth_models
command (token refreshed under Kimi's lock, aliases keyed by the
credential slot), so picked models carry the official context window,
capabilities and efforts. apply_provider pins an official node's
connection to the signed-in one, and ProviderService::update rejects
edits that would turn another card into an official account.

Official cards can also be removed from config.toml like other Kimi
providers: the card and credential stay, services pointing at a
separate account move to Kimi's own login, and "Add" restores it.
Deleting one still requires removing it first or using the
Authorization Center.
2026-10-24 15:44:09 +08:00

886 lines
30 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { TFunction } from "i18next";
import type { Provider } from "@/types";
import type { AppMode } from "@/types/proxy";
import { getOpencodeDeclaredModels } from "@/components/providers/forms/helpers/opencodeFormUtils";
import {
buildAdditiveSections,
buildLaunchSections,
buildSwitchSections,
queueMoveSortUpdates,
type ProviderSection,
type SwitchModeInput,
} from "@/components/providers/presentation";
const t = ((key: string, options?: Record<string, unknown>) =>
options && "count" in options
? `${key}:${String(options.count)}`
: key) as unknown as TFunction;
const provider = (id: string, overrides: Partial<Provider> = {}): Provider => ({
id,
name: id,
settingsConfig: {},
...overrides,
});
const official = provider("official", { category: "official" });
const relay = provider("relay");
const backup = provider("backup");
/** Claude 下走 OpenAI 格式:需要路由 */
const converted = provider("converted", { meta: { apiFormat: "openai_chat" } });
function actions() {
return {
switchDirect: vi.fn(),
needsRouteDialog: vi.fn(),
exitAndUse: vi.fn(),
routeTo: vi.fn(),
queueAdd: vi.fn(),
queueRemove: vi.fn(),
queueMove: vi.fn(),
stackAdd: vi.fn(),
stackRemove: vi.fn(),
stackSetDefault: vi.fn(),
};
}
function build(
overrides: Partial<SwitchModeInput> & { active: AppMode; view: AppMode },
) {
const input: SwitchModeInput = {
app: "claude",
t,
providers: [official, relay, backup, converted],
directId: "relay",
routeId: null,
failoverOn: false,
queue: [],
stackMembers: new Map(),
routingReason: () => "reason",
serviceRunning: true,
actions: actions(),
...overrides,
};
return { sections: buildSwitchSections(input), input };
}
const item = (sections: ProviderSection[], id: string) => {
for (const section of sections) {
const found = section.items.find((entry) => entry.provider.id === id);
if (found) return { section: section.key, ...found.presentation };
}
throw new Error(`no card for ${id}`);
};
const button = (sections: ProviderSection[], id: string, key: string) => {
const found = item(sections, id).buttons.find((b) => b.key === key);
if (!found) throw new Error(`no ${key} button on ${id}`);
return found;
};
/** 「更多」菜单里的「设为默认」 */
const setDefaultItem = (sections: ProviderSection[], id: string) => {
const found = item(sections, id).menuItems?.find(
(m) => m.key === "setDefault",
);
if (!found) throw new Error(`no setDefault menu item on ${id}`);
return found;
};
describe("buildSwitchSections — direct", () => {
it("marks the direct provider in use and asks before switching to one that needs routing", () => {
const { sections, input } = build({ active: "direct", view: "direct" });
expect(item(sections, "relay")).toMatchObject({
tone: "direct",
status: { label: "providerCard.status.inUse", dot: "direct" },
buttons: [],
});
button(sections, "backup", "switch").onClick();
expect(input.actions.switchDirect).toHaveBeenCalledWith(backup);
button(sections, "converted", "switch").onClick();
expect(input.actions.needsRouteDialog).toHaveBeenCalledWith(converted);
expect(item(sections, "converted").chips.map((c) => c.key)).toContain(
"needsRoute",
);
});
it("offers back-to-direct while viewing direct from routing, except for providers that need routing", () => {
const { sections, input } = build({
active: "route",
view: "direct",
routeId: "backup",
});
expect(item(sections, "relay").chips.map((c) => c.key)).toContain("direct");
button(sections, "backup", "exitAndUse").onClick();
expect(input.actions.exitAndUse).toHaveBeenCalledWith(backup);
expect(
button(sections, "converted", "exitAndUse").disabledReason,
).toBeTruthy();
});
});
describe("buildSwitchSections — delete guard", () => {
// 后端 is_referenced:直连指针、路由 / 聚合模式下的当前路由都删不掉,哪个视图都要标出来
it("disables delete on the direct provider in every view", () => {
for (const view of ["direct", "route", "stack"] as AppMode[]) {
const { sections } = build({ active: "direct", view });
expect(item(sections, "relay").deleteDisabledReason).toBe(
"providerCard.reason.inUseCannotDelete",
);
expect(item(sections, "backup").deleteDisabledReason).toBeUndefined();
}
});
it("also disables delete on the provider being routed to while routing", () => {
const { sections } = build({
active: "route",
view: "direct",
routeId: "backup",
});
// 直连那家此时不算在用,原因要说清是「退出路由后会回到它」(#8277)
expect(item(sections, "relay").deleteDisabledReason).toBe(
"providerCard.reason.directDefaultCannotDelete",
);
expect(item(sections, "backup").deleteDisabledReason).toBe(
"providerCard.reason.inUseCannotDelete",
);
expect(item(sections, "converted").deleteDisabledReason).toBeUndefined();
// 直连时 routeId 只是上次的路由,不算在用
const direct = build({
active: "direct",
view: "route",
routeId: "backup",
});
expect(
item(direct.sections, "backup").deleteDisabledReason,
).toBeUndefined();
});
});
describe("buildSwitchSections — route", () => {
it("routes to a provider and keeps official subscriptions out", () => {
const { sections, input } = build({
active: "route",
view: "route",
routeId: "backup",
});
expect(item(sections, "backup")).toMatchObject({
tone: "route",
status: { label: "providerCard.status.routing" },
});
button(sections, "relay", "routeHere").onClick();
expect(input.actions.routeTo).toHaveBeenCalledWith(relay);
// 路由页上没有跟直连那家有关的操作,不标「直连时使用」
expect(item(sections, "relay").chips).toEqual([]);
expect(item(sections, "official").dim).toBe(true);
expect(button(sections, "official", "blocked").disabledReason).toBe(
"providerCard.reason.noRoute",
);
});
it("splits the failover queue from the rest and only moves within the queue", () => {
const { sections, input } = build({
active: "route",
view: "route",
routeId: "relay",
failoverOn: true,
queue: ["relay", "backup"],
});
expect(sections.map((s) => [s.key, s.items.length])).toEqual([
["queue", 2],
["rest", 2],
]);
expect(item(sections, "relay")).toMatchObject({
tone: "route",
showHealth: true,
chips: [{ key: "priority", label: "P1" }],
});
expect(item(sections, "relay").move?.onUp).toBeUndefined();
item(sections, "relay").move?.onDown?.();
expect(input.actions.queueMove).toHaveBeenCalledWith(relay, 1);
expect(item(sections, "backup").move?.onDown).toBeUndefined();
button(sections, "converted", "queueAdd").onClick();
expect(input.actions.queueAdd).toHaveBeenCalledWith(converted);
button(sections, "backup", "queueRemove").onClick();
expect(input.actions.queueRemove).toHaveBeenCalledWith(backup);
// 直连那家不在队列里时也不标「直连时使用」
const outside = build({
active: "route",
view: "route",
routeId: "backup",
failoverOn: true,
queue: ["backup"],
});
expect(item(outside.sections, "relay")).toMatchObject({
section: "rest",
chips: [],
});
});
it("numbers the queue by its full order and marks the recorded route, not the first visible card", () => {
// 搜索把 P1 过滤掉了:剩下的卡序号、上下移仍按完整队列算
const { sections, input } = build({
active: "route",
view: "route",
routeId: "backup",
failoverOn: true,
queue: ["relay", "backup", "converted"],
providers: [backup, converted],
});
expect(item(sections, "backup").chips).toEqual([
{ key: "priority", label: "P2", tone: "outline" },
]);
expect(item(sections, "converted").chips[0].label).toBe("P3");
item(sections, "backup").move?.onUp?.();
expect(input.actions.queueMove).toHaveBeenCalledWith(backup, -1);
expect(item(sections, "converted").move?.onDown).toBeUndefined();
// 转移成功后后端记下的路由是 backup:「路由中」在它身上,不在队首
expect(item(sections, "backup")).toMatchObject({
tone: "route",
status: { label: "providerCard.status.routing" },
});
expect(item(sections, "converted").status).toBeUndefined();
// 记下的路由已不在队列里:退回队首
const fallback = build({
active: "route",
view: "route",
routeId: "official",
failoverOn: true,
queue: ["relay", "backup"],
});
expect(item(fallback.sections, "relay").tone).toBe("route");
expect(item(fallback.sections, "backup").tone).toBeUndefined();
});
it("keeps Codex official accounts out of the failover queue with a reason", () => {
// 后端 require_failover_provider 会拒绝;卡上要先说清楚,而不是点了没反应
const managed = provider("managed", {
settingsConfig: { auth: {}, config: "" },
meta: {
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct",
},
},
} as Partial<Provider>);
const { sections, input } = build({
app: "codex",
active: "route",
view: "route",
routeId: "relay",
failoverOn: true,
queue: ["relay"],
providers: [relay, backup, managed],
});
expect(item(sections, "managed").dim).toBe(true);
expect(button(sections, "managed", "blocked").disabledReason).toBe(
"providerCard.reason.noFailover",
);
button(sections, "backup", "queueAdd").onClick();
expect(input.actions.queueAdd).toHaveBeenCalledWith(backup);
});
it("offers no row action while previewing route from direct", () => {
const { sections } = build({
active: "direct",
view: "route",
routeId: "backup",
});
// 进入路由只有通知条一个入口(目标在确认框里选、预选上次那家),行上没有主操作,
// 也不标上次路由的那家和直连那家
for (const id of ["relay", "backup", "converted"]) {
expect(item(sections, id).buttons).toEqual([]);
expect(item(sections, id).chips).toEqual([]);
expect(item(sections, id).menuItems).toBeUndefined();
}
// 不能路由的官方订阅没有按钮可挂原因,原因在「官方」徽标上
expect(item(sections, "official")).toMatchObject({
dim: true,
buttons: [],
chips: [{ key: "official", title: "providerCard.reason.noRoute" }],
});
});
});
describe("buildSwitchSections — stack", () => {
it("shows the default, members and available providers", () => {
const { sections, input } = build({
active: "stack",
view: "stack",
routeId: "relay",
stackMembers: new Map([
["relay", 2],
["backup", 0],
]),
});
expect(sections.map((s) => [s.key, s.items.length])).toEqual([
["default", 1],
["members", 1],
["available", 2],
]);
expect(item(sections, "relay")).toMatchObject({
section: "default",
tone: "stack",
status: { label: "providerCard.status.currentDefault" },
});
// 发布了 0 个模型的成员要提醒
expect(item(sections, "backup").chips[0]).toMatchObject({
key: "models",
tone: "warning",
});
// 行上只有名单的移除 / 添加;「设为默认」在「更多」菜单里
expect(item(sections, "backup").buttons.map((b) => b.key)).toEqual([
"remove",
]);
button(sections, "backup", "remove").onClick();
expect(input.actions.stackRemove).toHaveBeenCalledWith(backup);
expect(setDefaultItem(sections, "backup")).toMatchObject({
label: "providerCard.action.setDefault",
disabledReason: undefined,
});
setDefaultItem(sections, "backup").onSelect();
expect(input.actions.stackSetDefault).toHaveBeenCalledWith(backup);
button(sections, "converted", "add").onClick();
expect(input.actions.stackAdd).toHaveBeenCalledWith(converted);
// 还没添加的也能直接设为默认
setDefaultItem(sections, "converted").onSelect();
expect(input.actions.stackSetDefault).toHaveBeenCalledWith(converted);
// 默认那家自己没有这一项
expect(item(sections, "relay").menuItems).toBeUndefined();
// Claude 官方订阅不能进聚合,也做不了默认
expect(button(sections, "official", "blocked").disabledReason).toBe(
"providerCard.reason.noStack",
);
expect(item(sections, "official").menuItems).toBeUndefined();
});
it("previews the stack outside Stack mode: rows only edit the list", () => {
const { sections, input } = build({
active: "direct",
view: "stack",
routeId: null,
stackMembers: new Map([["backup", 1]]),
});
// 没有路由目标时,默认那家落在直连那家
expect(item(sections, "relay")).toMatchObject({
section: "default",
tone: undefined,
status: { label: "providerCard.status.defaultAfterSwitch" },
});
// 行上没有会切模式的按钮,只有名单的添加 / 移除
expect(item(sections, "backup").buttons.map((b) => b.key)).toEqual([
"remove",
]);
expect(item(sections, "converted").buttons.map((b) => b.key)).toEqual([
"add",
]);
button(sections, "converted", "add").onClick();
expect(input.actions.stackAdd).toHaveBeenCalledWith(converted);
button(sections, "backup", "remove").onClick();
expect(input.actions.stackRemove).toHaveBeenCalledWith(backup);
expect(input.actions.stackSetDefault).not.toHaveBeenCalled();
// 「设为默认」在「更多」菜单里:直连时能点(只记下选择)
expect(setDefaultItem(sections, "backup").disabledReason).toBeUndefined();
setDefaultItem(sections, "converted").onSelect();
expect(input.actions.stackSetDefault).toHaveBeenCalledWith(converted);
});
it("does not let the Stack default change from the preview while routing", () => {
// 默认和路由目标是同一个指针:路由中改它就是当场换路由
const { sections } = build({
active: "route",
view: "stack",
routeId: "relay",
stackMembers: new Map([["backup", 1]]),
});
expect(item(sections, "relay").section).toBe("default");
for (const id of ["backup", "converted"]) {
expect(setDefaultItem(sections, id).disabledReason).toBe(
"providerCard.reason.defaultWhileRouting",
);
}
});
it("shows a Codex official account outside Stack mode with only the reason it cannot be added", () => {
const account = provider("account", {
category: "official",
settingsConfig: { auth: {}, config: "" },
});
const { sections } = build({
app: "codex",
active: "direct",
view: "stack",
providers: [relay, account],
});
expect(item(sections, "account").section).toBe("available");
expect(item(sections, "account").buttons.map((b) => b.key)).toEqual([
"add",
]);
expect(button(sections, "account", "add").disabledReason).toBe(
"providerCard.reason.officialStack",
);
// 它能做默认
expect(setDefaultItem(sections, "account").disabledReason).toBeUndefined();
});
it("never lets ChatGPT accounts be added in Codex Stack mode, even legacy cards without a category", () => {
const managed = provider("managed", {
settingsConfig: { auth: {}, config: "" },
meta: {
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct",
},
},
} as Partial<Provider>);
const { sections, input } = build({
app: "codex",
active: "stack",
view: "stack",
providers: [relay, backup, managed],
routeId: "relay",
stackMembers: new Map([["relay", 1]]),
});
expect(item(sections, "managed").chips.map((c) => c.key)).toContain(
"official",
);
expect(button(sections, "managed", "add").disabledReason).toBe(
"providerCard.reason.officialStack",
);
setDefaultItem(sections, "managed").onSelect();
expect(input.actions.stackSetDefault).toHaveBeenCalledWith(managed);
});
});
describe("buildAdditiveSections", () => {
const additiveActions = () => ({
add: vi.fn(),
remove: vi.fn(),
disableOmo: vi.fn(),
setDefault: vi.fn(),
});
it("splits added and available providers and keeps OMO to one at a time", () => {
const omo = provider("omo", { category: "omo" });
const otherOmo = provider("omo-2", { category: "omo" });
const actions = additiveActions();
const sections = buildAdditiveSections({
app: "opencode",
t,
providers: [relay, backup, omo, otherOmo],
isInConfig: (p) => p.id === "relay",
currentOmoId: "omo",
actions,
});
expect(sections.map((s) => [s.key, s.items.length])).toEqual([
["added", 2],
["available", 2],
]);
button(sections, "relay", "remove").onClick();
expect(actions.remove).toHaveBeenCalledWith(relay);
// 已添加的卡白底、不再写「● 已添加 / 已启用」:灰底只给默认那一家
for (const id of ["relay", "omo"]) {
expect(item(sections, id).tone).toBeUndefined();
expect(item(sections, id).status).toBeUndefined();
}
button(sections, "backup", "add").onClick();
expect(actions.add).toHaveBeenCalledWith(backup);
button(sections, "omo", "disable").onClick();
expect(actions.disableOmo).toHaveBeenCalledWith(omo);
expect(item(sections, "omo-2").buttons.map((b) => b.key)).toEqual([
"enable",
]);
});
it("lets OpenClaw pick the default model and protects the current default", () => {
const multi = provider("multi", {
settingsConfig: {
models: [{ id: "m-1", name: "Model 1" }, { id: "m-2" }],
},
});
const actions = additiveActions();
const sections = buildAdditiveSections({
app: "openclaw",
t,
providers: [relay, multi],
isInConfig: () => true,
defaultModel: { providerId: "relay", model: "gpt" },
defaultModelOptions: (p) =>
(p.settingsConfig as { models?: { id: string; name?: string }[] })
.models ?? [],
actions,
});
expect(item(sections, "relay")).toMatchObject({
tone: "direct",
status: { label: "providerCard.status.default" },
});
expect(item(sections, "multi").tone).toBeUndefined();
expect(item(sections, "multi").status).toBeUndefined();
expect(button(sections, "relay", "remove").disabledReason).toBe(
"providerCard.reason.defaultCannotRemove",
);
// 「设为默认」在「更多」菜单里,卡片主操作位只剩「移除」
expect(item(sections, "multi").buttons.map((b) => b.key)).toEqual([
"remove",
]);
const setDefault = setDefaultItem(sections, "multi");
expect(setDefault.submenu?.options.map((o) => [o.label, o.detail])).toEqual(
[
["Model 1", "m-1"],
["m-2", undefined],
],
);
setDefault.submenu?.options[1].onSelect();
expect(actions.setDefault).toHaveBeenCalledWith(multi, "m-2");
expect(item(sections, "relay").menuItems).toBeUndefined();
});
it("lets OpenCode set the top-level default model without blocking removal", () => {
const multi = provider("multi");
const actions = additiveActions();
const sections = buildAdditiveSections({
app: "opencode",
t,
providers: [relay, multi],
isInConfig: () => true,
defaultModel: { providerId: "relay", model: "vendor/gpt" },
defaultModelOptions: (p) =>
p.id === "multi" ? [{ id: "m-1", name: "Model 1" }, { id: "m-2" }] : [],
actions,
});
expect(item(sections, "relay")).toMatchObject({
tone: "direct",
status: { label: "providerCard.status.default" },
});
expect(
item(sections, "relay").chips.some((c) => c.key === "defaultModel"),
).toBe(true);
expect(button(sections, "relay", "remove").disabledReason).toBeUndefined();
const setDefault = setDefaultItem(sections, "multi");
setDefault.onSelect();
expect(actions.setDefault).toHaveBeenCalledWith(multi, "m-1");
setDefault.submenu?.options[1].onSelect();
expect(actions.setDefault).toHaveBeenCalledWith(multi, "m-2");
// relay 不是默认时也没有声明模型(内置供应商):不给「设为默认」
const other = buildAdditiveSections({
app: "opencode",
t,
providers: [relay],
isInConfig: () => true,
defaultModel: null,
defaultModelOptions: () => [],
actions,
});
expect(item(other, "relay").buttons.map((b) => b.key)).toEqual(["remove"]);
expect(item(other, "relay").menuItems).toBeUndefined();
});
it("reads OpenCode models from a stored full config as the backend does", () => {
const models = {
"m-1": { name: "Model 1" },
"": {},
off: { disabled: true },
"m-2": {},
};
expect(getOpencodeDeclaredModels("relay", { models })).toEqual([
{ id: "m-1", name: "Model 1" },
{ id: "m-2", name: undefined },
]);
expect(
getOpencodeDeclaredModels("relay", {
$schema: "https://opencode.ai/config.json",
providers: { relay: { models: { native: {} } } },
provider: { relay: { models: { legacy: {} } } },
}).map((m) => m.id),
).toEqual(["native"]);
expect(
getOpencodeDeclaredModels("relay", {
provider: { relay: { models: { legacy: {} } } },
}).map((m) => m.id),
).toEqual(["legacy"]);
expect(getOpencodeDeclaredModels("relay", { options: {} })).toEqual([]);
});
it("keeps Hermes-managed providers read-only", () => {
const managed = provider("managed");
const sections = buildAdditiveSections({
app: "hermes",
t,
providers: [relay, managed],
isInConfig: () => true,
hermesCurrentId: "relay",
isHermesManaged: (p) => p.id === "managed",
actions: additiveActions(),
});
expect(button(sections, "relay", "remove").disabledReason).toBe(
"providerCard.reason.currentCannotRemove",
);
expect(item(sections, "relay").tone).toBe("direct");
expect(item(sections, "managed").tone).toBeUndefined();
expect(item(sections, "managed")).toMatchObject({
dim: true,
editDisabledReason: "provider.managedByHermesHint",
deleteDisabledReason: "provider.managedByHermesHint",
});
expect(button(sections, "managed", "use").disabledReason).toBe(
"provider.managedByHermesHint",
);
});
it("lets official Kimi accounts edit models and leave Kimi, but not be deleted while there", () => {
const accounts = ["managed:kimi-code", "kimi-code-ccs-second"].map((id) =>
provider(id, {
settingsConfig: {
provider: { type: "kimi", oauth: { key: `oauth/${id}` } },
},
}),
);
const sections = buildAdditiveSections({
app: "kimi",
t,
providers: [...accounts, relay],
isInConfig: () => true,
kimiCurrentId: accounts[0].id,
actions: additiveActions(),
});
for (const account of accounts) {
const presentation = item(sections, account.id);
expect(presentation.editDisabledReason).toBeUndefined();
expect(presentation.deleteDisabledReason).toBe("kimi.oauth.managedHint");
}
// 只有正在用的那个号不能移除
expect(button(sections, accounts[0].id, "remove").disabledReason).toBe(
"providerCard.reason.currentCannotRemove",
);
expect(
button(sections, accounts[1].id, "remove").disabledReason,
).toBeUndefined();
expect(
item(sections, accounts[1].id).menuItems?.find(
(action) => action.key === "setDefault",
)?.disabledReason,
).toBeUndefined();
expect(item(sections, "relay").editDisabledReason).toBeUndefined();
});
it("allows deleting a saved official Kimi account after its native node is removed", () => {
const account = provider("managed:kimi-code", {
settingsConfig: {
provider: { type: "kimi", oauth: { key: "oauth/kimi-code" } },
},
});
const sections = buildAdditiveSections({
app: "kimi",
t,
providers: [account],
isInConfig: () => false,
kimiCurrentId: account.id,
actions: additiveActions(),
});
const presentation = item(sections, account.id);
expect(presentation.deleteDisabledReason).toBeUndefined();
expect(presentation.menuItems).toBeUndefined();
expect(presentation.status).toBeUndefined();
expect(presentation.buttons.map((b) => b.key)).toEqual(["add"]);
});
it("gives Kimi add and remove buttons and a set-default menu item", () => {
const current = provider("current");
const other = provider("other");
const saved = provider("saved");
const actions = additiveActions();
const sections = buildAdditiveSections({
app: "kimi",
t,
providers: [current, other, saved],
isInConfig: (p) => p.id !== "saved",
kimiCurrentId: "current",
actions,
});
// 未添加:只有「添加」,不顺带切换
expect(sections[1].items.map((entry) => entry.provider.id)).toEqual([
"saved",
]);
button(sections, "saved", "add").onClick();
expect(actions.add).toHaveBeenCalledWith(saved);
expect(actions.setDefault).not.toHaveBeenCalled();
// 已添加、不在用:卡上只有「移除」,「设为默认」在「更多」菜单里
expect(item(sections, "other").buttons.map((b) => b.key)).toEqual([
"remove",
]);
item(sections, "other")
.menuItems?.find((action) => action.key === "setDefault")
?.onSelect();
expect(actions.setDefault).toHaveBeenCalledWith(other);
button(sections, "other", "remove").onClick();
expect(actions.remove).toHaveBeenCalledWith(other);
// 在用的那家:不能移除
expect(item(sections, "current").buttons.map((b) => b.key)).toEqual([
"remove",
]);
expect(button(sections, "current", "remove").disabledReason).toBe(
"providerCard.reason.currentCannotRemove",
);
expect(item(sections, "current").menuItems).toBeUndefined();
});
it("blocks every Pi change while Pi's current state is unavailable", () => {
const sections = buildAdditiveSections({
app: "pi",
t,
providers: [relay],
isInConfig: () => false,
piStateUnavailable: true,
actions: additiveActions(),
});
expect(button(sections, "relay", "add")).toMatchObject({
label: "providerCard.action.enable",
disabledReason: "pi.current.stateUnavailableHint",
});
expect(item(sections, "relay").deleteDisabledReason).toBe(
"pi.current.stateUnavailableHint",
);
});
it("blocks every Oh My Pi change while its current state is unavailable", () => {
const sections = buildAdditiveSections({
app: "omp",
t,
providers: [relay],
isInConfig: () => false,
piStateUnavailable: true,
actions: additiveActions(),
});
expect(button(sections, "relay", "add")).toMatchObject({
label: "providerCard.action.enable",
disabledReason: "omp.current.stateUnavailableHint",
});
expect(item(sections, "relay").deleteDisabledReason).toBe(
"omp.current.stateUnavailableHint",
);
});
});
describe("queueMoveSortUpdates", () => {
it("swaps the two positions of a sorted queue", () => {
expect(
queueMoveSortUpdates(
[
{ providerId: "a", sortIndex: 1 },
{ providerId: "b", sortIndex: 4 },
],
{},
"b",
-1,
),
).toEqual([
{ id: "b", sortIndex: 1 },
{ id: "a", sortIndex: 4 },
]);
});
it("keeps positions distinct when sort indexes tie", () => {
expect(
queueMoveSortUpdates(
[
{ providerId: "a", sortIndex: 2 },
{ providerId: "b", sortIndex: 2 },
],
{},
"b",
-1,
),
).toEqual([
{ id: "b", sortIndex: 2 },
{ id: "a", sortIndex: 3 },
]);
});
it("keeps positions distinct when an unsorted provider falls back onto a used index", () => {
// b 没有 sortIndex,用下标 1 兜底,正好撞上 a 的 1
expect(
queueMoveSortUpdates(
[{ providerId: "a", sortIndex: 1 }, { providerId: "b" }],
{},
"b",
-1,
),
).toEqual([
{ id: "b", sortIndex: 1 },
{ id: "a", sortIndex: 2 },
]);
});
it("returns null when the move would leave the queue", () => {
const items = [
{ providerId: "a", sortIndex: 0 },
{ providerId: "b", sortIndex: 1 },
];
expect(queueMoveSortUpdates(items, {}, "a", -1)).toBeNull();
expect(queueMoveSortUpdates(items, {}, "b", 1)).toBeNull();
expect(queueMoveSortUpdates(items, {}, "x", 1)).toBeNull();
});
});
describe("buildLaunchSections", () => {
it("gives every Antigravity card Open Terminal instead of enable or in use", () => {
const openTerminal = vi.fn();
const [section] = buildLaunchSections({
app: "agy",
t,
providers: [official, relay],
openTerminal,
});
expect(section.items.map((item) => item.provider.id)).toEqual([
"official",
"relay",
]);
for (const { provider: p, presentation } of section.items) {
expect(presentation.status).toBeUndefined();
expect(presentation.tone).toBeUndefined();
// 没有「使用中」,哪张都能删
expect(presentation.deleteDisabledReason).toBeUndefined();
expect(presentation.buttons.map((button) => button.key)).toEqual([
"openTerminal",
]);
presentation.buttons[0].onClick();
expect(openTerminal).toHaveBeenLastCalledWith(p);
}
expect(section.items[0].presentation.chips.map((chip) => chip.key)).toEqual(
["official"],
);
expect(section.items[1].presentation.chips).toEqual([]);
});
});