| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208 |
- 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<Record<PlacementKind, string>>, render: string): Claim<string> {
- 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 <Slot> components mount.
- type Node = { readonly path: string; readonly children?: ReadonlyArray<Node> }
- function paths(nodes: ReadonlyArray<Node>, into = new Set<string>()): Set<string> {
- 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
- // <Slot> component: before + (replace | prepend + own content + append) + after.
- function layout(nodes: ReadonlyArray<Node>, resolved: ReturnType<typeof resolveSlots<string>>): ReadonlyArray<string> {
- 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<Node>, claims: ReadonlyArray<Claim<string>>) {
- 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"])
- })
|