theme.test.ts 5.8 KB

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