import { expect, test } from "bun:test" import type { SlotClaim } from "@opencode-ai/plugin/tui/context" import { resolveSlots, type Claim, type PlacementKind } from "../src/plugin/structure" // Type-level canaries, checked by `bun typecheck`: exactly one placement key, // absolute paths only, and the render input follows the targeted path. export const canaries = () => { const claims: SlotClaim[] = [] claims.push({ append: "prompt.footer", render: (input) => (input.mode === "shell" ? null : null) }) claims.push({ after: "prompt.footer.status", render: () => null }) // @ts-expect-error two placement keys cannot coexist claims.push({ append: "prompt.footer", before: "prompt.footer.status", render: () => null }) // @ts-expect-error replace does not combine with an anchor claims.push({ replace: "prompt.footer.status", after: "prompt.footer.file", render: () => null }) // @ts-expect-error targets must be absolute published paths claims.push({ after: "status", render: () => null }) // @ts-expect-error the render input is the targeted slot's input claims.push({ append: "prompt.footer", render: (input: { mode: number }) => null }) return claims } // The resolver is generic over render values; strings make layout assertions // read as layouts. Placements are written in the public claim shape and // normalized here, like the plugin API does. function claim(plugin: string, placement: Partial>, render: string): Claim { const kind = (["prepend", "append", "before", "after", "replace"] as const).find((item) => placement[item])! return { key: `${plugin}/${render}`, plugin, placement: { kind, target: placement[kind]! }, render } } // A host slot tree for tests: a node's children are its child slots, a leaf's // content is its own name. Mirrors how nested components mount. type Node = { readonly path: string; readonly children?: ReadonlyArray } function paths(nodes: ReadonlyArray, into = new Set()): Set { for (const node of nodes) { into.add(node.path) paths(node.children ?? [], into) } return into } // Fold the tree with a resolution into the flat render order, mirroring the // component: before + (replace | prepend + own content + append) + after. function layout(nodes: ReadonlyArray, resolved: ReturnType>): ReadonlyArray { return nodes.flatMap((node) => { const slotted = resolved.slotted.get(node.path) const own = node.children ? layout(node.children, resolved) : [leafName(node.path)] const inside = slotted?.replace ? [slotted.replace.render] : [ ...(slotted?.prepend ?? []).map((item) => item.render), ...own, ...(slotted?.append ?? []).map((item) => item.render), ] return [ ...(slotted?.before ?? []).map((item) => item.render), ...inside, ...(slotted?.after ?? []).map((item) => item.render), ] }) } function leafName(path: string) { return path.slice(path.lastIndexOf(".") + 1) } function resolve(tree: ReadonlyArray, claims: ReadonlyArray>) { return resolveSlots({ paths: paths(tree), claims }) } const footer: Node[] = [ { path: "prompt.footer", children: [{ path: "prompt.footer.status" }, { path: "prompt.footer.file" }], }, ] const tree: Node[] = [ { path: "prompt.footer", children: [ { path: "prompt.footer.left", children: [{ path: "prompt.footer.left.mode" }] }, { path: "prompt.footer.right", children: [ { path: "prompt.footer.right.directory" }, { path: "prompt.footer.right.model" }, { path: "prompt.footer.right.tokens" }, ], }, ], }, ] test("no claims renders the host tree in order", () => { const result = resolve(footer, []) expect(layout(footer, result)).toEqual(["status", "file"]) expect(result.suppressed).toEqual([]) expect(result.degraded).toEqual([]) }) test("prepend and append land inside a boundary's edges, several in enable order", () => { const result = resolve(footer, [ claim("a", { append: "prompt.footer" }, "a1"), claim("b", { prepend: "prompt.footer" }, "b1"), claim("a", { append: "prompt.footer" }, "a2"), ]) expect(layout(footer, result)).toEqual(["b1", "status", "file", "a1", "a2"]) }) test("before and after anchor to a slot, wherever the host keeps it", () => { const result = resolve(footer, [ claim("a", { after: "prompt.footer.status" }, "chip"), claim("b", { before: "prompt.footer.status" }, "vim"), ]) expect(layout(footer, result)).toEqual(["vim", "status", "chip", "file"]) }) test("a missing anchor degrades to the nearest surviving ancestor's end", () => { const result = resolve(footer, [claim("a", { after: "prompt.footer.tokens" }, "chip")]) expect(layout(footer, result)).toEqual(["status", "file", "chip"]) expect(result.degraded).toEqual([ { claim: claim("a", { after: "prompt.footer.tokens" }, "chip"), to: "prompt.footer" }, ]) }) test("an additive claim with no surviving ancestor is suppressed", () => { const result = resolve(footer, [claim("a", { append: "session.composer.top" }, "chip")]) expect(layout(footer, result)).toEqual(["status", "file"]) expect(result.suppressed).toEqual([{ claim: claim("a", { append: "session.composer.top" }, "chip") }]) }) test("a missing replacement is suppressed, never degraded into a widget", () => { const result = resolve(footer, [claim("a", { replace: "prompt.footer.tokens" }, "cost")]) expect(layout(footer, result)).toEqual(["status", "file"]) expect(result.suppressed).toEqual([{ claim: claim("a", { replace: "prompt.footer.tokens" }, "cost") }]) expect(result.degraded).toEqual([]) }) test("replacing a slot swaps content but keeps the boundary and its outside anchors", () => { const fancy = claim("a", { replace: "prompt.footer.status" }, "fancy-status") const result = resolve(footer, [fancy, claim("b", { after: "prompt.footer.status" }, "chip")]) expect(layout(footer, result)).toEqual(["fancy-status", "chip", "file"]) expect(result.suppressed).toEqual([]) }) test("inside contributions to a replaced boundary are suppressed", () => { const takeover = claim("a", { replace: "prompt.footer" }, "powerline") const badge = claim("b", { append: "prompt.footer" }, "badge") const result = resolve(footer, [badge, takeover]) expect(layout(footer, result)).toEqual(["powerline"]) expect(result.suppressed).toEqual([{ claim: badge, by: takeover }]) }) test("same target: the last-enabled replacement wins and the loser is recorded", () => { const first = claim("a", { replace: "prompt.footer.status" }, "first") const second = claim("b", { replace: "prompt.footer.status" }, "second") const result = resolve(footer, [first, second]) expect(layout(footer, result)).toEqual(["second", "file"]) expect(result.suppressed).toEqual([{ claim: first, by: second }]) }) test("container takeover suppresses everything anchored in the subtree", () => { const takeover = claim("theme", { replace: "prompt.footer.right" }, "my-right") const chip = claim("pr", { after: "prompt.footer.right.model" }, "chip") const inner = claim("x", { replace: "prompt.footer.right.tokens" }, "cost") const result = resolve(tree, [takeover, chip, inner]) expect(layout(tree, result)).toEqual(["mode", "my-right"]) expect(result.suppressed).toEqual([ { claim: inner, by: takeover }, { claim: chip, by: takeover }, ]) }) test("hierarchy beats timeline: an ancestor takeover wins over a later descendant claim", () => { // The descendant replace was enabled after the container takeover; the // container still wins because its path contains the descendant's. const inner = claim("x", { replace: "prompt.footer.right.model" }, "swap-model") const outer = claim("theme", { replace: "prompt.footer.right" }, "my-right") const result = resolve(tree, [outer, inner]) expect(layout(tree, result)).toEqual(["mode", "my-right"]) expect(result.suppressed).toEqual([{ claim: inner, by: outer }]) }) test("root takeover: nothing original survives, all inside claims suppressed", () => { const theme = claim("powerline", { replace: "prompt.footer" }, "powerline") const chip = claim("pr", { append: "prompt.footer" }, "chip") const result = resolve(tree, [chip, theme]) expect(layout(tree, result)).toEqual(["powerline"]) expect(result.suppressed).toEqual([{ claim: chip, by: theme }]) }) test("a degraded claim landing inside a replaced boundary is suppressed, not shown", () => { const takeover = claim("theme", { replace: "prompt.footer.right" }, "my-right") const stray = claim("pr", { after: "prompt.footer.right.gone" }, "chip") const result = resolve(tree, [takeover, stray]) expect(layout(tree, result)).toEqual(["mode", "my-right"]) expect(result.suppressed).toEqual([{ claim: stray, by: takeover }]) expect(result.degraded).toEqual([]) }) test("anchors on a container wrap its whole span", () => { const result = resolve(tree, [ claim("a", { before: "prompt.footer.right" }, "divider"), claim("b", { after: "prompt.footer.right" }, "clock"), ]) expect(layout(tree, result)).toEqual(["mode", "divider", "directory", "model", "tokens", "clock"]) })