plugin-structure.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. import { expect, test } from "bun:test"
  2. import type { RegionClaim } from "@opencode-ai/plugin/tui/context"
  3. import { resolveStructure, type Claim, type Part, type Placement } from "../src/plugin/structure"
  4. // Type-level canaries, checked by `bun typecheck`: the placement sum and the
  5. // part union are exclusive — nonsense shapes must not compile.
  6. export const canaries = () => {
  7. const claims: RegionClaim<"prompt.footer">[] = []
  8. claims.push({ at: "end", render: () => null })
  9. // @ts-expect-error two placement keys cannot coexist
  10. claims.push({ at: "end", before: "status", render: () => null })
  11. // @ts-expect-error replace does not combine with an anchor
  12. claims.push({ replace: "status", after: "file", render: () => null })
  13. // @ts-expect-error a part is a leaf or a container, never both
  14. const hybrid: Part<string> = { id: "x", render: "x", parts: [] }
  15. return { claims, hybrid }
  16. }
  17. // The resolver is generic over render types; strings make ordering
  18. // assertions read as layouts.
  19. function claim(plugin: string, placement: Placement, render?: string): Claim<string> {
  20. return { key: `${plugin}/${render ?? JSON.stringify(placement)}`, plugin, placement, render: render ?? plugin }
  21. }
  22. function layout(result: ReturnType<typeof resolveStructure<string, string>>) {
  23. return result.entries.map((entry) => (entry.kind === "part" ? entry.id : entry.claim.render))
  24. }
  25. const footer: Part<string>[] = [
  26. { id: "status", render: "status" },
  27. { id: "file", render: "file" },
  28. ]
  29. const tree: Part<string>[] = [
  30. { id: "left", parts: [{ id: "mode", render: "mode" }] },
  31. {
  32. id: "right",
  33. parts: [
  34. { id: "directory", render: "directory" },
  35. { id: "model", render: "model" },
  36. { id: "tokens", render: "tokens" },
  37. ],
  38. },
  39. ]
  40. test("no claims renders the host parts in order", () => {
  41. const result = resolveStructure<string, string>({ region: "prompt.footer", parts: footer, claims: [] })
  42. expect(layout(result)).toEqual(["status", "file"])
  43. expect(result.suppressed).toEqual([])
  44. expect(result.degraded).toEqual([])
  45. })
  46. test("edge claims land at the region's edges, several in enable order", () => {
  47. const result = resolveStructure({
  48. region: "prompt.footer",
  49. parts: footer,
  50. claims: [
  51. claim("a", { at: "end" }, "a1"),
  52. claim("b", { at: "start" }, "b1"),
  53. claim("a", { at: "end" }, "a2"),
  54. ],
  55. })
  56. expect(layout(result)).toEqual(["b1", "status", "file", "a1", "a2"])
  57. })
  58. test("before and after anchor to a part, wherever the host keeps it", () => {
  59. const result = resolveStructure({
  60. region: "prompt.footer",
  61. parts: footer,
  62. claims: [claim("a", { after: "status" }, "chip"), claim("b", { before: "status" }, "vim")],
  63. })
  64. expect(layout(result)).toEqual(["vim", "status", "chip", "file"])
  65. })
  66. test("a missing anchor degrades to the end instead of disappearing", () => {
  67. const result = resolveStructure({
  68. region: "prompt.footer",
  69. parts: footer,
  70. claims: [claim("a", { after: "tokens" }, "chip")],
  71. })
  72. expect(layout(result)).toEqual(["status", "file", "chip"])
  73. expect(result.degraded.map((item) => item.render)).toEqual(["chip"])
  74. })
  75. test("replacing a part swaps content but keeps the position and its anchors", () => {
  76. const result = resolveStructure({
  77. region: "prompt.footer",
  78. parts: footer,
  79. claims: [claim("a", { replace: "status" }, "fancy-status"), claim("b", { after: "status" }, "chip")],
  80. })
  81. expect(layout(result)).toEqual(["fancy-status", "chip", "file"])
  82. expect(result.suppressed).toEqual([])
  83. })
  84. test("same target: the last-enabled claim wins and the loser is recorded", () => {
  85. const first = claim("a", { replace: "status" }, "first")
  86. const second = claim("b", { replace: "status" }, "second")
  87. const result = resolveStructure({ region: "prompt.footer", parts: footer, claims: [first, second] })
  88. expect(layout(result)).toEqual(["second", "file"])
  89. expect(result.suppressed).toEqual([{ claim: first, by: second }])
  90. })
  91. test("container takeover suppresses everything anchored in the subtree", () => {
  92. const takeover = claim("theme", { replace: "right" }, "my-right")
  93. const chip = claim("pr", { after: "model" }, "chip")
  94. const inner = claim("x", { replace: "tokens" }, "cost")
  95. const result = resolveStructure({ region: "prompt.footer", parts: tree, claims: [takeover, chip, inner] })
  96. expect(layout(result)).toEqual(["mode", "my-right"])
  97. expect(result.suppressed).toEqual([
  98. { claim: chip, by: takeover },
  99. { claim: inner, by: takeover },
  100. ])
  101. })
  102. test("hierarchy beats timeline: an ancestor takeover wins over a later descendant claim", () => {
  103. // The descendant replace was enabled after the container takeover; the
  104. // container still wins because its target contains the descendant's.
  105. const inner = claim("x", { replace: "model" }, "swap-model")
  106. const outer = claim("theme", { replace: "right" }, "my-right")
  107. const result = resolveStructure({ region: "prompt.footer", parts: tree, claims: [outer, inner] })
  108. expect(layout(result)).toEqual(["mode", "my-right"])
  109. expect(result.suppressed).toEqual([{ claim: inner, by: outer }])
  110. })
  111. test("root takeover: nothing original survives, all other claims suppressed", () => {
  112. const theme = claim("powerline", { replace: "prompt.footer" }, "powerline")
  113. const chip = claim("pr", { at: "end" }, "chip")
  114. const result = resolveStructure({ region: "prompt.footer", parts: tree, claims: [chip, theme] })
  115. expect(layout(result)).toEqual(["powerline"])
  116. expect(result.suppressed).toEqual([{ claim: chip, by: theme }])
  117. })
  118. test("root takeover at the same node: last enabled wins", () => {
  119. const first = claim("a", { replace: "home.footer" }, "first")
  120. const second = claim("b", { replace: "home.footer" }, "second")
  121. const result = resolveStructure<string, string>({ region: "home.footer", parts: [], claims: [first, second] })
  122. expect(layout(result)).toEqual(["second"])
  123. expect(result.suppressed).toEqual([{ claim: first, by: second }])
  124. })
  125. test("containers flatten in order and anchors on a container wrap its whole span", () => {
  126. const result = resolveStructure({
  127. region: "prompt.footer",
  128. parts: tree,
  129. claims: [claim("a", { before: "right" }, "divider"), claim("b", { after: "right" }, "clock")],
  130. })
  131. expect(layout(result)).toEqual(["mode", "divider", "directory", "model", "tokens", "clock"])
  132. })