mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(test): remove incidental implementation contracts
This commit is contained in:
parent
a97f80e25e
commit
e4e69ca060
8 changed files with 50 additions and 391 deletions
|
|
@ -43,7 +43,7 @@ interface ThreadCatalogFacts {
|
|||
archivedObservedResult: ObservedResult<readonly Thread[]> | null;
|
||||
}
|
||||
|
||||
export type ThreadCatalogEventObserver = (event: ThreadCatalogEvent) => void;
|
||||
type ThreadCatalogEventObserver = (event: ThreadCatalogEvent) => void;
|
||||
|
||||
export interface ThreadCatalogOptions {
|
||||
store: ThreadCatalogStore;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,21 @@ describe("AppServerQueryCache", () => {
|
|||
expect(fetchThreads).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shares concurrent active thread refreshes within one resource identity", async () => {
|
||||
const pending = deferred<readonly ReturnType<typeof thread>[]>();
|
||||
const fetchThreads = vi.fn(() => pending.promise);
|
||||
const cache = cacheWithThreads(fetchThreads);
|
||||
const context = cacheContext();
|
||||
|
||||
const first = cache.refreshActiveThreads(context);
|
||||
const second = cache.refreshActiveThreads(context);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(fetchThreads).toHaveBeenCalledOnce();
|
||||
pending.resolve([thread("shared")]);
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([[thread("shared")], [thread("shared")]]);
|
||||
});
|
||||
|
||||
it("keeps active and archived thread list snapshots separate", async () => {
|
||||
const fetchThreads = vi.fn((_context: AppServerQueryContext, archived: boolean) =>
|
||||
Promise.resolve(archived ? [thread("archived", true)] : [thread("active")]),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
import type { ThreadStreamItem } from "../../../../../src/features/chat/domain/thread-stream/items";
|
||||
|
||||
describe("thread stream selectors", () => {
|
||||
it("reuses active-segment indexes while appending deltas to an existing item", () => {
|
||||
it("appends deltas to an existing active-segment item", () => {
|
||||
const initial = threadStreamStartActiveSegment(initialChatThreadStreamState(), "turn-1", [
|
||||
{
|
||||
id: "assistant-1",
|
||||
|
|
@ -23,9 +23,6 @@ describe("thread stream selectors", () => {
|
|||
turnId: "turn-1",
|
||||
},
|
||||
]);
|
||||
const previousSegment = initial.activeSegment;
|
||||
if (!previousSegment) throw new Error("Expected active segment");
|
||||
|
||||
const next = reduceThreadStreamSlice(initial, {
|
||||
type: "thread-stream/assistant-delta-appended",
|
||||
itemId: "source-1",
|
||||
|
|
@ -34,8 +31,6 @@ describe("thread stream selectors", () => {
|
|||
});
|
||||
|
||||
expect(next.activeSegment?.items[0]).toMatchObject({ text: "hello world" });
|
||||
expect(next.activeSegment?.indexById).toBe(previousSegment.indexById);
|
||||
expect(next.activeSegment?.indexBySourceItemId).toBe(previousSegment.indexBySourceItemId);
|
||||
});
|
||||
|
||||
it("counts turns after a turn id from thread stream state", () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { StaleAppServerResourceContextError } from "../../../../src/app-server/query/resource-store";
|
||||
import type { ModelMetadata } from "../../../../src/domain/catalog/metadata";
|
||||
import type { Thread } from "../../../../src/domain/threads/model";
|
||||
import { activeThreadId } from "../../../../src/features/chat/application/state/root-reducer";
|
||||
import { type ChatStateStore, createChatStateStore } from "../../../../src/features/chat/application/state/store";
|
||||
|
|
@ -93,60 +92,6 @@ describe("ChatPanelSessionRuntime actions", () => {
|
|||
expect(stateStore.getState().threadList.listedThreads).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies cached shared state from the runtime binding", () => {
|
||||
const thread = threadFixture({ id: "thread-1", preview: "Cached thread" });
|
||||
const model = modelFixture({ id: "model-1", model: "gpt-cached" });
|
||||
const { runtime, stateStore } = sessionRuntimeFixture({
|
||||
environment: {
|
||||
plugin: {
|
||||
threadCatalog: {
|
||||
activeSnapshot: vi.fn(() => [thread]),
|
||||
},
|
||||
appServerQueries: {
|
||||
modelsSnapshot: vi.fn(() => [model]),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
runtime.runtime.sharedState.applyCached();
|
||||
|
||||
expect(stateStore.getState().threadList.listedThreads).toEqual([thread]);
|
||||
expect(stateStore.getState().connection.availableModels).toEqual([model]);
|
||||
});
|
||||
|
||||
it("subscribes and unsubscribes fixed shared state observers", () => {
|
||||
const cleanupThreads = vi.fn();
|
||||
const cleanupMetadata = vi.fn();
|
||||
const cleanupModels = vi.fn();
|
||||
const observeThreads = vi.fn(() => cleanupThreads);
|
||||
const observeMetadata = vi.fn(() => cleanupMetadata);
|
||||
const observeModels = vi.fn(() => cleanupModels);
|
||||
const { runtime } = sessionRuntimeFixture({
|
||||
environment: {
|
||||
plugin: {
|
||||
threadCatalog: {
|
||||
observeActive: observeThreads,
|
||||
},
|
||||
appServerQueries: {
|
||||
observeAppServerMetadataResult: observeMetadata,
|
||||
observeModelsResult: observeModels,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
runtime.runtime.sharedState.subscribe();
|
||||
runtime.runtime.sharedState.unsubscribe();
|
||||
|
||||
expect(observeThreads).toHaveBeenCalledWith(expect.any(Function), { emitCurrent: false });
|
||||
expect(observeMetadata).toHaveBeenCalledWith(expect.any(Function), { emitCurrent: false });
|
||||
expect(observeModels).toHaveBeenCalledWith(expect.any(Function), { emitCurrent: false });
|
||||
expect(cleanupThreads).toHaveBeenCalledOnce();
|
||||
expect(cleanupMetadata).toHaveBeenCalledOnce();
|
||||
expect(cleanupModels).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("starts a new thread from runtime state and composer actions", async () => {
|
||||
const focusComposer = vi.spyOn(ChatComposerController.prototype, "focusComposer").mockImplementation(() => undefined);
|
||||
const refreshLiveState = vi.fn();
|
||||
|
|
@ -413,21 +358,4 @@ describe("ChatPanelSessionRuntime actions", () => {
|
|||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function modelFixture(overrides: Partial<ModelMetadata> = {}): ModelMetadata {
|
||||
return {
|
||||
id: "model",
|
||||
model: "gpt-5",
|
||||
displayName: "GPT-5",
|
||||
description: "",
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: [],
|
||||
serviceTiers: [],
|
||||
defaultServiceTier: null,
|
||||
isDefault: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,75 +5,16 @@ import type { AppServerQueryContext, AppServerQueryContextIdentity } from "../..
|
|||
import type { ObservedResult, ObservedResultListener } from "../../../../src/app-server/query/observed-result";
|
||||
import { AppServerResourceStore } from "../../../../src/app-server/query/resource-store";
|
||||
import type { Thread } from "../../../../src/domain/threads/model";
|
||||
import {
|
||||
createThreadCatalog,
|
||||
type ThreadCatalog,
|
||||
type ThreadCatalogEventObserver,
|
||||
} from "../../../../src/features/threads/catalog/thread-catalog";
|
||||
import { createThreadCatalog, type ThreadCatalog } from "../../../../src/features/threads/catalog/thread-catalog";
|
||||
|
||||
describe("ThreadCatalog", () => {
|
||||
it("projects active snapshots received from the store observer", () => {
|
||||
const { catalog } = catalogFixture();
|
||||
const threads = [thread("thread")];
|
||||
const listener = vi.fn();
|
||||
catalog.observeActive(listener);
|
||||
|
||||
receiveActive(catalog, threads);
|
||||
|
||||
expect(catalog.activeSnapshot()).toEqual(threads);
|
||||
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ value: threads }));
|
||||
});
|
||||
|
||||
it("projects archived snapshots received from the store observer", () => {
|
||||
const { catalog } = catalogFixture();
|
||||
const threads = [thread("thread", true)];
|
||||
const listener = vi.fn();
|
||||
catalog.observeArchived(listener);
|
||||
|
||||
receiveArchived(catalog, threads);
|
||||
|
||||
expect(catalog.archivedSnapshot()).toEqual(threads);
|
||||
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ value: threads }));
|
||||
});
|
||||
|
||||
it("refreshes thread snapshots through the cache single-flight and notifies observers once", async () => {
|
||||
const fetchThreads = vi.fn().mockResolvedValue([thread("thread")]);
|
||||
const { catalog } = catalogFixture({ fetchThreads });
|
||||
const listener = vi.fn();
|
||||
catalog.observeActive(listener);
|
||||
|
||||
const first = catalog.refreshActive();
|
||||
const second = catalog.refreshActive();
|
||||
|
||||
await expect(first).resolves.toEqual([thread("thread")]);
|
||||
await expect(second).resolves.toEqual([thread("thread")]);
|
||||
expect(fetchThreads).toHaveBeenCalledOnce();
|
||||
expect(catalog.activeSnapshot()).toEqual([thread("thread")]);
|
||||
expect(listener.mock.calls.filter(([result]) => result.value !== null)).toHaveLength(1);
|
||||
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [thread("thread")] }));
|
||||
});
|
||||
|
||||
it("notifies applied catalog events through a generic observer", () => {
|
||||
const onEventApplied = vi.fn();
|
||||
const { catalog } = catalogFixture({ onEventApplied });
|
||||
|
||||
catalog.apply({ type: "thread-started", thread: thread("thread") });
|
||||
|
||||
expect(onEventApplied).toHaveBeenCalledWith({ type: "thread-started", thread: thread("thread") });
|
||||
});
|
||||
|
||||
it("applies rename mutations after updating the catalog cache", () => {
|
||||
const { catalog } = catalogFixture();
|
||||
const listener = vi.fn();
|
||||
catalog.observeActive(listener);
|
||||
receiveActive(catalog, [thread("thread"), thread("other")]);
|
||||
|
||||
catalog.apply({ type: "thread-renamed", threadId: "thread", name: "Renamed" });
|
||||
|
||||
expect(catalog.activeSnapshot()).toEqual([{ ...thread("thread"), name: "Renamed" }, thread("other")]);
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ value: [{ ...thread("thread"), name: "Renamed" }, thread("other")] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies archive mutations after updating catalog membership", () => {
|
||||
|
|
@ -123,10 +64,6 @@ describe("ThreadCatalog", () => {
|
|||
|
||||
it("applies known delete mutations to cache", () => {
|
||||
const { catalog } = catalogFixture();
|
||||
const listener = vi.fn();
|
||||
const archivedListener = vi.fn();
|
||||
catalog.observeActive(listener);
|
||||
catalog.observeArchived(archivedListener);
|
||||
receiveActive(catalog, [thread("thread"), thread("other")]);
|
||||
receiveArchived(catalog, [thread("thread", true), thread("archived", true)]);
|
||||
|
||||
|
|
@ -134,16 +71,10 @@ describe("ThreadCatalog", () => {
|
|||
|
||||
expect(catalog.activeSnapshot()).toEqual([thread("other")]);
|
||||
expect(catalog.archivedSnapshot()).toEqual([thread("archived", true)]);
|
||||
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [thread("other")] }));
|
||||
expect(archivedListener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [thread("archived", true)] }));
|
||||
});
|
||||
|
||||
it("records started, forked, and restored thread membership", () => {
|
||||
const { catalog } = catalogFixture();
|
||||
const listener = vi.fn();
|
||||
const archivedListener = vi.fn();
|
||||
catalog.observeActive(listener);
|
||||
catalog.observeArchived(archivedListener);
|
||||
receiveActive(catalog, [thread("existing")]);
|
||||
receiveArchived(catalog, [thread("restored", true), thread("archived", true)]);
|
||||
|
||||
|
|
@ -153,10 +84,6 @@ describe("ThreadCatalog", () => {
|
|||
|
||||
expect(catalog.activeSnapshot()?.map((item) => item.id)).toEqual(["restored", "forked", "started", "existing"]);
|
||||
expect(catalog.archivedSnapshot()?.map((item) => item.id)).toEqual(["archived"]);
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ value: [thread("restored"), thread("forked"), thread("started"), thread("existing")] }),
|
||||
);
|
||||
expect(archivedListener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [thread("archived", true)] }));
|
||||
});
|
||||
|
||||
it("keeps app-server lifecycle threads visible until the server list acknowledges them", async () => {
|
||||
|
|
@ -318,8 +245,6 @@ describe("ThreadCatalog", () => {
|
|||
|
||||
it("records active thread touches as catalog ordering facts", () => {
|
||||
const { catalog } = catalogFixture();
|
||||
const listener = vi.fn();
|
||||
catalog.observeActive(listener);
|
||||
receiveActive(catalog, [
|
||||
thread("active", false, { updatedAt: 1, recencyAt: 1 }),
|
||||
thread("other", false, { updatedAt: 10, recencyAt: 10 }),
|
||||
|
|
@ -331,33 +256,6 @@ describe("ThreadCatalog", () => {
|
|||
thread("active", false, { updatedAt: 1, recencyAt: 20 }),
|
||||
thread("other", false, { updatedAt: 10, recencyAt: 10 }),
|
||||
]);
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
value: [thread("active", false, { updatedAt: 1, recencyAt: 20 }), thread("other", false, { updatedAt: 10, recencyAt: 10 })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies thread lifecycle events through one catalog event boundary", () => {
|
||||
const { catalog } = catalogFixture();
|
||||
const listener = vi.fn();
|
||||
const archivedListener = vi.fn();
|
||||
catalog.observeActive(listener);
|
||||
catalog.observeArchived(archivedListener);
|
||||
receiveActive(catalog, [thread("existing")]);
|
||||
receiveArchived(catalog, [thread("archived", true)]);
|
||||
|
||||
catalog.apply({ type: "thread-started", thread: thread("started") });
|
||||
catalog.apply({ type: "thread-touched", threadId: "existing", recencyAt: 20 });
|
||||
catalog.apply({ type: "thread-renamed", threadId: "started", name: "Started" });
|
||||
catalog.apply({ type: "thread-archived", threadId: "existing" });
|
||||
|
||||
expect(catalog.activeSnapshot()).toEqual([{ ...thread("started"), name: "Started" }]);
|
||||
expect(catalog.archivedSnapshot()).toEqual([thread("existing", true, { recencyAt: 20 }), thread("archived", true)]);
|
||||
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [{ ...thread("started"), name: "Started" }] }));
|
||||
expect(archivedListener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ value: [thread("existing", true, { recencyAt: 20 }), thread("archived", true)] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("model-checks stale snapshots around unacknowledged rename and archive facts", () => {
|
||||
|
|
@ -413,10 +311,6 @@ describe("ThreadCatalog", () => {
|
|||
return Promise.resolve([thread("unknown")]);
|
||||
});
|
||||
const { catalog } = catalogFixture({ fetchThreads });
|
||||
const listener = vi.fn();
|
||||
const archivedListener = vi.fn();
|
||||
catalog.observeActive(listener);
|
||||
catalog.observeArchived(archivedListener);
|
||||
receiveActive(catalog, [thread("active")]);
|
||||
receiveArchived(catalog, [thread("known", true)]);
|
||||
|
||||
|
|
@ -424,8 +318,6 @@ describe("ThreadCatalog", () => {
|
|||
|
||||
expect(catalog.activeSnapshot()).toEqual([thread("known"), thread("active")]);
|
||||
expect(catalog.archivedSnapshot()).toEqual([]);
|
||||
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [thread("known"), thread("active")] }));
|
||||
expect(archivedListener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [] }));
|
||||
|
||||
catalog.apply({ type: "thread-unarchived", threadId: "unknown" });
|
||||
|
||||
|
|
@ -549,7 +441,6 @@ function catalogFixture(
|
|||
options: {
|
||||
fetchThreads?: (context: { codexPath: string; vaultPath: string }, archived: boolean) => Promise<readonly Thread[]>;
|
||||
context?: () => { codexPath: string; vaultPath: string };
|
||||
onEventApplied?: ThreadCatalogEventObserver;
|
||||
} = {},
|
||||
) {
|
||||
const cache = cacheWithThreads(options.fetchThreads ?? (() => Promise.resolve([])));
|
||||
|
|
@ -557,14 +448,7 @@ function catalogFixture(
|
|||
const queries = new AppServerResourceStore({ cache });
|
||||
queries.initialize(context());
|
||||
const store = new TestThreadCatalogStore(queries);
|
||||
const catalog = createThreadCatalog(
|
||||
options.onEventApplied
|
||||
? {
|
||||
store,
|
||||
onEventApplied: options.onEventApplied,
|
||||
}
|
||||
: { store },
|
||||
);
|
||||
const catalog = createThreadCatalog({ store });
|
||||
catalogStores.set(catalog, store);
|
||||
catalog.observeActive(() => undefined);
|
||||
catalog.observeArchived(() => undefined);
|
||||
|
|
|
|||
|
|
@ -594,39 +594,6 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
|
|||
expect(secondRenamed).toHaveBeenCalledWith("thread-1", "Renamed thread");
|
||||
});
|
||||
|
||||
it("single-flights shared thread list refreshes and caches successful results", async () => {
|
||||
const { CodexChatView } = await import("../src/features/chat/host/view.obsidian");
|
||||
const connectedLeaf = leaf();
|
||||
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
|
||||
const connectedView = connectedLeaf.view as CodexChatView;
|
||||
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
|
||||
vi.spyOn(connectedView.surface, "canServeAppServerContext").mockReturnValue(true);
|
||||
let resolveThreads!: (threads: Thread[]) => void;
|
||||
const runWithAppServerClient = vi.spyOn(connectedView.surface, "runWithAppServerClient").mockImplementation((operation) =>
|
||||
operation(
|
||||
threadListClient(
|
||||
() =>
|
||||
new Promise<Thread[]>((resolve) => {
|
||||
resolveThreads = resolve;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
const plugin = await pluginWithLeaves([connectedLeaf]);
|
||||
plugin.settings.codexPath = "codex";
|
||||
|
||||
const first = threadCatalog(plugin).refreshActive();
|
||||
const second = threadCatalog(plugin).refreshActive();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(runWithAppServerClient).toHaveBeenCalledOnce();
|
||||
resolveThreads([thread("first")]);
|
||||
|
||||
await expect(first).resolves.toEqual([thread("first")]);
|
||||
await expect(second).resolves.toEqual([thread("first")]);
|
||||
expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("first")]);
|
||||
});
|
||||
|
||||
it("keeps shared thread list refreshes separate across app-server cache contexts", async () => {
|
||||
const { CodexChatView } = await import("../src/features/chat/host/view.obsidian");
|
||||
const connectedLeaf = leaf();
|
||||
|
|
|
|||
|
|
@ -204,6 +204,37 @@ describe("Biome Grit policies", () => {
|
|||
expect(result.status, result.output).toBe(0);
|
||||
expect(result.pluginErrors, result.output).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the relational :has pseudo-class", ".panel:has(.child) { color: var(--text-normal); }"],
|
||||
["class selectors hidden inside :where", ".panel:where(.child) { color: var(--text-normal); }"],
|
||||
["ID selectors", "#panel { color: var(--text-normal); }"],
|
||||
["universal selectors", ".panel * { color: var(--text-normal); }"],
|
||||
])("no-restricted-css-policy.grit rejects %s", async (_name, invalidSource) => {
|
||||
const result = await lintPolicyCase(
|
||||
{
|
||||
plugin: "no-restricted-css-policy.grit",
|
||||
invalid: { path: "src/styles/invalid.css", source: invalidSource },
|
||||
},
|
||||
"invalid",
|
||||
);
|
||||
|
||||
expect(result.status, result.output).toBe(1);
|
||||
expect(result.pluginErrors, result.output).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("no-restricted-css-policy.grit accepts shallow panel selectors", async () => {
|
||||
const result = await lintPolicyCase(
|
||||
{
|
||||
plugin: "no-restricted-css-policy.grit",
|
||||
valid: { path: "src/styles/valid.css", source: ".panel .child:hover { color: var(--text-normal); }" },
|
||||
},
|
||||
"valid",
|
||||
);
|
||||
|
||||
expect(result.status, result.output).toBe(0);
|
||||
expect(result.pluginErrors, result.output).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function policyCase(plugin, invalidPath, invalidSource, validSource, options = {}) {
|
||||
|
|
|
|||
|
|
@ -12,11 +12,6 @@ function ruleBody(selector: string): string {
|
|||
return new RegExp(`(?:^|\\n)${escapedSelector} \\{(?<body>[^}]+)\\}`).exec(styles)?.groups?.["body"] ?? "";
|
||||
}
|
||||
|
||||
function standaloneRuleBody(selector: string): string {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`(?:^|\\n\\n)${escapedSelector} \\{(?<body>[^}]+)\\}`).exec(styles)?.groups?.["body"] ?? "";
|
||||
}
|
||||
|
||||
describe("panel CSS boundaries", () => {
|
||||
it("defines design tokens on every standalone UI root", () => {
|
||||
const tokenScopeEnd = styles.indexOf(" {");
|
||||
|
|
@ -29,12 +24,6 @@ describe("panel CSS boundaries", () => {
|
|||
expect(tokenScope).toContain(".codex-panel-threads");
|
||||
expect(tokenScope).toContain(".codex-panel-selection-rewrite");
|
||||
});
|
||||
|
||||
it("keeps selectors shallow enough for Obsidian theme compatibility", () => {
|
||||
expect(styles).not.toContain(":has(");
|
||||
expect(styles).not.toMatch(/:where\([^)]*[.#[]/);
|
||||
expect(styles).not.toMatch(/(^|[\s,{])#[\w-]+/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("panel CSS layout invariants", () => {
|
||||
|
|
@ -44,154 +33,4 @@ describe("panel CSS layout invariants", () => {
|
|||
expect(threadStream).toContain("overflow-y: auto");
|
||||
expect(threadStream).not.toMatch(/^\s+height:/m);
|
||||
});
|
||||
|
||||
it("keeps icon-only toolbar actions on Obsidian toolbar geometry", () => {
|
||||
const toolbar = ruleBody(".codex-panel__toolbar");
|
||||
const toolbarAction = ruleBody(".codex-panel-ui__toolbar-action");
|
||||
|
||||
expect(toolbarAction).toContain("--icon-size: var(--codex-panel-control-icon-size)");
|
||||
expect(toolbarAction).toContain("--icon-stroke: var(--icon-m-stroke-width, 1.75px)");
|
||||
expect(toolbarAction).not.toContain("width:");
|
||||
expect(toolbarAction).not.toContain("height:");
|
||||
expect(toolbarAction).not.toContain("padding:");
|
||||
expect(toolbarAction).not.toContain("border-radius:");
|
||||
expect(toolbar).not.toContain("padding:");
|
||||
});
|
||||
|
||||
it("keeps shared nav rows aligned with Obsidian nav item geometry", () => {
|
||||
const navItem = ruleBody(".codex-panel-ui__nav-item");
|
||||
const navRow = ruleBody(".codex-panel-ui__nav-row");
|
||||
|
||||
expect(navItem).toContain("min-height: var(--nav-item-size");
|
||||
expect(navItem).toContain("padding: var(--nav-item-padding");
|
||||
expect(navItem).toContain("border-radius: var(--nav-item-radius");
|
||||
expect(navRow).toContain("padding-inline-end:");
|
||||
});
|
||||
|
||||
it("keeps selected nav items stable when hovered or focused", () => {
|
||||
const selectedNavItem = ruleBody(".codex-panel-ui__nav-item.is-selected:where(:hover, :focus, :focus-visible, :active, :focus-within)");
|
||||
const selectedNavRow = ruleBody(
|
||||
[
|
||||
".codex-panel-ui__nav-row.is-selected,",
|
||||
".codex-panel-ui__nav-row.is-selected:hover,",
|
||||
".codex-panel-ui__nav-row.is-selected:focus-within",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(selectedNavItem).toContain("background: var(--nav-item-background-active, var(--background-modifier-active))");
|
||||
expect(selectedNavRow).toContain("background: var(--nav-item-background-active, var(--background-modifier-active))");
|
||||
expect(selectedNavItem).not.toContain("nav-item-background-active-hover");
|
||||
expect(selectedNavRow).not.toContain("nav-item-background-active-hover");
|
||||
});
|
||||
|
||||
it("keeps nav inline inputs as native inline editors", () => {
|
||||
const navInlineInput = ruleBody(".codex-panel-ui__nav-inline-input.codex-panel-ui__nav-inline-input");
|
||||
const navInlineInputFocus = ruleBody(
|
||||
[
|
||||
".codex-panel-ui__nav-inline-input.codex-panel-ui__nav-inline-input:focus,",
|
||||
".codex-panel-ui__nav-inline-input.codex-panel-ui__nav-inline-input:focus-visible,",
|
||||
".codex-panel-ui__nav-inline-input.codex-panel-ui__nav-inline-input:hover,",
|
||||
".codex-panel-ui__nav-inline-input.codex-panel-ui__nav-inline-input:active",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(navInlineInput).toContain("appearance: none");
|
||||
expect(navInlineInput).toContain("border: 0");
|
||||
expect(navInlineInput).toContain("background: transparent");
|
||||
expect(navInlineInputFocus).toContain("background: transparent");
|
||||
});
|
||||
|
||||
it("keeps framed text inputs native across panel surfaces", () => {
|
||||
const frame = ruleBody(".codex-panel-ui__text-input-frame");
|
||||
const frameFocus = ruleBody(".codex-panel-ui__text-input-frame:focus-within");
|
||||
const input = ruleBody(".codex-panel-ui__text-input");
|
||||
const inputInteraction = ruleBody(
|
||||
[
|
||||
".codex-panel-ui__text-input.codex-panel-ui__text-input:hover,",
|
||||
".codex-panel-ui__text-input.codex-panel-ui__text-input:focus,",
|
||||
".codex-panel-ui__text-input.codex-panel-ui__text-input:focus-visible,",
|
||||
".codex-panel-ui__text-input.codex-panel-ui__text-input:active",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(frame).toContain("background: var(--background-modifier-form-field)");
|
||||
expect(frameFocus).toContain("border-color: var(--background-modifier-border-focus)");
|
||||
expect(input).toContain("border: 0");
|
||||
expect(inputInteraction).toContain("background: transparent");
|
||||
expect(inputInteraction).toContain("outline: none");
|
||||
});
|
||||
|
||||
it("shares activity dot timing across active surfaces", () => {
|
||||
const activityDots = ruleBody(".codex-panel-ui__activity-dots span");
|
||||
const secondDot = ruleBody(".codex-panel-ui__activity-dots span:where(:nth-child(2))");
|
||||
const thirdDot = ruleBody(".codex-panel-ui__activity-dots span:where(:nth-child(3))");
|
||||
|
||||
expect(activityDots).toContain("animation: codex-panel-activity-dot 1.2s infinite ease-in-out");
|
||||
expect(secondDot).toContain("animation-delay: 0.18s");
|
||||
expect(thirdDot).toContain("animation-delay: 0.36s");
|
||||
});
|
||||
|
||||
it("keeps compact status rows from wrapping or stealing pointer interaction", () => {
|
||||
const contextMeter = ruleBody(".codex-panel__composer-meta-context");
|
||||
const summary = ruleBody(".codex-panel__composer-meta-summary");
|
||||
|
||||
expect(contextMeter).toContain("white-space: nowrap");
|
||||
expect(contextMeter).toContain("font-variant-numeric: tabular-nums");
|
||||
expect(summary).toContain("position: absolute");
|
||||
expect(summary).toContain("pointer-events: none");
|
||||
});
|
||||
|
||||
it("lets usage limit meters absorb available width", () => {
|
||||
const limitMeterCell = ruleBody(".codex-panel__limit-panel-meter-cell");
|
||||
const limitValue = ruleBody(".codex-panel__limit-panel-value");
|
||||
const limitReset = standaloneRuleBody(".codex-panel__limit-panel-reset");
|
||||
|
||||
expect(limitMeterCell).toContain("width: 100%");
|
||||
expect(limitMeterCell).toContain("vertical-align: middle");
|
||||
expect(limitValue).toContain("font-variant-numeric: tabular-nums");
|
||||
expect(limitReset).toContain("white-space: nowrap");
|
||||
});
|
||||
|
||||
it("keeps threads view row titles ellipsized while actions stay collapsed until interaction", () => {
|
||||
const rowTitleBase = ruleBody(".codex-panel-threads__row-title");
|
||||
const rowTitleDisplay = standaloneRuleBody(".codex-panel-threads__row-title");
|
||||
const actions = ruleBody(".codex-panel-threads__actions");
|
||||
const actionReveal = ruleBody(
|
||||
[
|
||||
".codex-panel-threads__row:hover .codex-panel-threads__actions,",
|
||||
".codex-panel-threads__row:focus-within .codex-panel-threads__actions",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(rowTitleDisplay).toContain("display: block");
|
||||
expect(rowTitleBase).toContain("white-space: nowrap");
|
||||
expect(rowTitleBase).toContain("text-overflow: ellipsis");
|
||||
expect(actions).toContain("width: 0");
|
||||
expect(actions).toContain("pointer-events: none");
|
||||
expect(actions).toContain("opacity: 0");
|
||||
expect(actionReveal).toContain("width: auto");
|
||||
expect(actionReveal).toContain("pointer-events: auto");
|
||||
expect(actionReveal).toContain("opacity: 1");
|
||||
});
|
||||
|
||||
it("keeps threads view inline rename aligned to nav row height", () => {
|
||||
const renameForm = ruleBody(".codex-panel-threads__rename-form");
|
||||
const renameField = ruleBody(".codex-panel-threads__rename-field");
|
||||
const renameInput = ruleBody(".codex-panel-threads__rename-input");
|
||||
|
||||
expect(renameForm).toContain("min-height: var(--nav-item-size");
|
||||
expect(renameField).toContain("min-height: var(--nav-item-size");
|
||||
expect(renameInput).toContain("line-height: var(--line-height-tight)");
|
||||
});
|
||||
|
||||
it("keeps selection rewrite controls framed as a compact edit-and-review surface", () => {
|
||||
const composerFrame = ruleBody(".codex-panel-selection-rewrite__composer-frame");
|
||||
const sharedFrame = ruleBody(".codex-panel-ui__text-input-frame");
|
||||
const result = ruleBody(".codex-panel-selection-rewrite__result");
|
||||
|
||||
expect(composerFrame).toContain("grid-template-columns:");
|
||||
expect(sharedFrame).toContain("background: var(--background-modifier-form-field)");
|
||||
expect(result).toContain("grid-template-columns:");
|
||||
expect(result).toContain("border: var(--codex-panel-border)");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue