theme.test.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import { expect, test } from "bun:test"
  2. import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
  3. import { RUN_THEME_MONO, RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme"
  4. import { DEFAULT_THEMES } from "../../src/theme"
  5. const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
  6. function terminalColors(input: Partial<TerminalColors> = {}): TerminalColors {
  7. return {
  8. palette: Array.from({ length: 256 }, (_, index) => input.palette?.[index] ?? palette[index % palette.length]!),
  9. defaultBackground: input.defaultBackground ?? "#1a1b26",
  10. defaultForeground: input.defaultForeground ?? "#c0caf5",
  11. cursorColor: input.cursorColor ?? "#ff9e64",
  12. mouseForeground: input.mouseForeground ?? null,
  13. mouseBackground: input.mouseBackground ?? null,
  14. tekForeground: input.tekForeground ?? null,
  15. tekBackground: input.tekBackground ?? null,
  16. highlightBackground: input.highlightBackground ?? "#33467c",
  17. highlightForeground: input.highlightForeground ?? "#c0caf5",
  18. }
  19. }
  20. function renderer(
  21. input: {
  22. themeMode?: "dark" | "light"
  23. resolvedThemeMode?: "dark" | "light"
  24. colors?: TerminalColors
  25. fail?: boolean
  26. } = {},
  27. ) {
  28. return {
  29. themeMode: input.themeMode,
  30. waitForThemeMode: async () => input.resolvedThemeMode ?? input.themeMode ?? null,
  31. getPalette: async () => {
  32. if (input.fail) {
  33. throw new Error("boom")
  34. }
  35. return input.colors ?? terminalColors()
  36. },
  37. } as CliRenderer
  38. }
  39. function expectRgba(color: unknown) {
  40. expect(color).toBeInstanceOf(RGBA)
  41. if (!(color instanceof RGBA)) {
  42. throw new Error("expected RGBA")
  43. }
  44. return color
  45. }
  46. function expectIndexed(color: unknown) {
  47. const rgba = expectRgba(color)
  48. expect(rgba.intent).toBe("indexed")
  49. expect(rgba.slot).toBeLessThan(256)
  50. }
  51. function spread(color: RGBA) {
  52. const [r, g, b] = color.toInts()
  53. return Math.max(r, g, b) - Math.min(r, g, b)
  54. }
  55. test("falls back when palette lookup fails", async () => {
  56. expect(await resolveRunTheme(renderer({ fail: true }))).toBe(RUN_THEME_FALLBACK)
  57. expect(await resolveRunTheme(renderer({ fail: true }), undefined, true)).toBe(RUN_THEME_MONO)
  58. const light = await resolveRunTheme(renderer({ resolvedThemeMode: "light" }), undefined, true)
  59. expect(expectRgba(light.footer.text).toInts().slice(0, 3)).toEqual([0, 0, 0])
  60. expect(RUN_THEME_MONO.block.syntax).toBeUndefined()
  61. for (const color of [
  62. RUN_THEME_MONO.background,
  63. ...Object.values(RUN_THEME_MONO.footer),
  64. ...Object.values(RUN_THEME_MONO.splash),
  65. ...Object.values(RUN_THEME_MONO.entry).flatMap((tone) => [tone.body, tone.start].filter(Boolean)),
  66. ...Object.entries(RUN_THEME_MONO.block)
  67. .filter(([key]) => key !== "syntax")
  68. .map(([, value]) => value),
  69. ]) {
  70. expect(expectRgba(color).intent).toBe("default")
  71. }
  72. })
  73. test("resolveTheme preserves Mini indexed color and result shape semantics", () => {
  74. const item = structuredClone(DEFAULT_THEMES.opencode)
  75. item.theme.primary = 6
  76. delete item.theme.selectedListItemText
  77. const theme = resolveTheme(item, "dark")
  78. expect(theme.primary.intent).toBe("indexed")
  79. expect(theme.primary.slot).toBe(6)
  80. expect(theme.selectedListItemText).toBe(theme.background)
  81. expect("_hasSelectedListItemText" in theme).toBe(false)
  82. })
  83. test("returns syntax styles and indexed splash colors", async () => {
  84. const theme = await resolveRunTheme(renderer({ themeMode: "dark" }))
  85. try {
  86. expect(theme.block.syntax).toBeDefined()
  87. expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0)
  88. expectIndexed(theme.splash.left)
  89. expectIndexed(theme.splash.right)
  90. expectIndexed(theme.splash.leftShadow)
  91. expectRgba(theme.footer.highlight)
  92. expectRgba(theme.footer.statusAccent)
  93. expectRgba(theme.footer.surface)
  94. expect(expectRgba(theme.footer.statusAccent).toInts()).not.toEqual(expectRgba(theme.footer.status).toInts())
  95. } finally {
  96. theme.block.syntax?.destroy()
  97. }
  98. })
  99. test("keeps footer surfaces exact while scrollback stays palette matched", async () => {
  100. const colors = terminalColors({
  101. defaultBackground: "#0f172a",
  102. defaultForeground: "#e2e8f0",
  103. })
  104. const theme = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
  105. const exact = resolveTheme(generateSystem(colors, "dark"), "dark")
  106. try {
  107. expect(expectRgba(theme.footer.selected).toInts()).toEqual(expectRgba(exact.backgroundElement).toInts())
  108. expect(expectRgba(theme.footer.border).toInts()).toEqual(expectRgba(exact.border).toInts())
  109. expect(expectRgba(theme.footer.pane).toInts()).toEqual(expectRgba(exact.backgroundMenu).toInts())
  110. expect(expectRgba(theme.footer.selected).intent).toBe("rgb")
  111. } finally {
  112. theme.block.syntax?.destroy()
  113. }
  114. })
  115. test("uses refreshed background brightness when cached renderer mode is stale", async () => {
  116. const colors = terminalColors({
  117. defaultBackground: "#fbf1c7",
  118. defaultForeground: "#3c3836",
  119. })
  120. const stale = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
  121. const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
  122. try {
  123. expect(expectRgba(stale.footer.surface).toInts()).toEqual(expectRgba(light.footer.surface).toInts())
  124. } finally {
  125. stale.block.syntax?.destroy()
  126. light.block.syntax?.destroy()
  127. }
  128. })
  129. test("keeps renderer mode when refreshed default background is unavailable", async () => {
  130. const colors = {
  131. ...terminalColors(),
  132. defaultBackground: null,
  133. palette: ["#000000", ...terminalColors().palette.slice(1)],
  134. }
  135. const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
  136. const dark = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
  137. try {
  138. expect(expectRgba(light.footer.surface).toInts()).not.toEqual(expectRgba(dark.footer.surface).toInts())
  139. } finally {
  140. light.block.syntax?.destroy()
  141. dark.block.syntax?.destroy()
  142. }
  143. })
  144. test("keeps dark surfaces neutral on saturated backgrounds", () => {
  145. const theme = resolveTheme(
  146. generateSystem(
  147. terminalColors({
  148. defaultBackground: "#0000ff",
  149. defaultForeground: "#ffffff",
  150. }),
  151. "dark",
  152. ),
  153. "dark",
  154. )
  155. expect(spread(theme.backgroundPanel)).toBeLessThan(10)
  156. expect(spread(theme.backgroundElement)).toBeLessThan(10)
  157. })
  158. test("keeps light surfaces close to neutral on warm backgrounds", () => {
  159. const theme = resolveTheme(
  160. generateSystem(
  161. terminalColors({
  162. defaultBackground: "#fbf1c7",
  163. defaultForeground: "#3c3836",
  164. }),
  165. "light",
  166. ),
  167. "light",
  168. )
  169. expect(spread(theme.backgroundPanel)).toBeLessThan(60)
  170. expect(spread(theme.backgroundElement)).toBeLessThan(60)
  171. })