okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
842c79c
main
1// Theme token ownership and ANSI background continuity are cross-screen 2// visual invariants. Both fail silently in a real terminal — a screen that 3// drifts from the palette, a selection tint cut short by a reset — and no 4// daemon e2e observes terminal rendering. 5package tui 6 7import ( 8"go/ast" 9"go/parser" 10"go/token" 11"os" 12"regexp" 13"strconv" 14"strings" 15"testing" 16) 17 18// Colors are chosen in the theme alone. A screen that picks its own drifts 19// from the rest, which is how the session screen grew a second palette. 20func TestColorsComeFromTheTheme (t * testing.T ) { 21owners := map [string ]bool {"theme.go" :true ,"ink.go" :true ,"ink_unix.go" :true ,"ink_other.go" :true } 22positions := token .NewFileSet () 23entries ,err := os .ReadDir ("." ) 24if err != nil { 25t .Fatal (err ) 26 } 27for _ ,e := range entries { 28name := e .Name () 29if ! strings .HasSuffix (name ,".go" )|| strings .HasSuffix (name ,"_test.go" )|| owners [name ] { 30continue 31 } 32file ,err := parser .ParseFile (positions ,name ,nil ,0 ) 33if err != nil { 34t .Fatal (err ) 35 } 36for _ ,position := range themeViolations (file ) { 37t .Errorf ("%s picks its own color, use a theme token" ,positions .Position (position )) 38 } 39 } 40} 41 42func themeViolations (file * ast.File ) []token.Pos { 43imports := make (map [string ]string ) 44for _ ,spec := range file .Imports { 45path ,_ := strconv .Unquote (spec .Path .Value ) 46name := path [strings .LastIndexByte (path ,'/' )+ 1 :] 47if strings .HasSuffix (path ,"/lipgloss/v2" ) { 48name = "lipgloss" 49 } 50if spec .Name != nil { 51name = spec .Name .Name 52 } 53imports [name ]= path 54 } 55palette := regexp .MustCompile (`^#[0-9a-fA-F]{6}$` ) 56ansi := regexp .MustCompile ("\x1b\\[([0-9;]*)m" ) 57var violations []token.Pos 58ast .Inspect (file ,func (node ast.Node )bool { 59switch value := node .(type ) { 60case * ast.SelectorExpr : 61if owner ,ok := value .X .(* ast.Ident );ok && strings .HasSuffix (imports [owner .Name ],"/lipgloss/v2" ) { 62if value .Sel .Name == "Color" || value .Sel .Name == "AdaptiveColor" { 63violations = append (violations ,value .Pos ()) 64 } 65 } 66case * ast.CallExpr : 67function ,ok := ast .Unparen (value .Fun ).(* ast.SelectorExpr ) 68if ! ok { 69break 70 } 71switch function .Sel .Name { 72case "Background" : 73if owner ,ok := function .X .(* ast.Ident );ok && imports [owner .Name ]== "context" { 74break 75 } 76violations = append (violations ,value .Pos ()) 77case "Foreground" : 78violations = append (violations ,value .Pos ()) 79case "Faint" ,"Reverse" : 80if len (value .Args )== 1 { 81if argument ,ok := ast .Unparen (value .Args [0 ]).(* ast.Ident );ok && argument .Name == "true" { 82violations = append (violations ,value .Pos ()) 83 } 84 } 85 } 86case * ast.BasicLit : 87if value .Kind != token .STRING { 88break 89 } 90text ,err := strconv .Unquote (value .Value ) 91if err != nil { 92break 93 } 94if palette .MatchString (text ) { 95violations = append (violations ,value .Pos ()) 96 } 97for _ ,sequence := range ansi .FindAllStringSubmatch (text ,- 1 ) { 98for parameter := range strings .SplitSeq (sequence [1 ],";" ) { 99code ,_ := strconv .Atoi (parameter ) 100if code == 2 || code == 7 || code >= 30 && code <= 49 || code >= 90 && code <= 107 { 101violations = append (violations ,value .Pos ()) 102return false 103 } 104 } 105 } 106 } 107return true 108 }) 109return violations 110} 111 112func TestThemeOwnershipSurvivesFormattingAndIgnoresComments (t * testing.T ) { 113for _ ,scenario := range []struct { 114source string 115bad bool 116 }{ 117 {`// lipgloss.Color("#ffffff") 118var x = context.Background()` ,false }, 119 {`var x = (gl.Color)("2")` ,true }, 120 {`var x = gl.AdaptiveColor{}` ,true }, 121 {`var x = style. 122Foreground(color)` ,true }, 123 {`var x = style.Background(color)` ,true }, 124 {`var x = style.Faint((true))` ,true }, 125 {`var x = style.Reverse(false)` ,false }, 126 {`var x = "\u0023ffffff"` ,true }, 127 {`var x = "\u001b[38;2;1;2;3m"` ,true }, 128 {`var x = "\x1b[0m"` ,false }, 129 } { 130t .Run (scenario .source ,func (t * testing.T ) { 131file ,err := parser .ParseFile (token .NewFileSet (),"screen.go" ,"package tui\nimport gl \"charm.land/lipgloss/v2\"\nimport \"context\"\n" + scenario .source ,0 ) 132if err != nil { 133t .Fatal (err ) 134 } 135if bad := len (themeViolations (file ))> 0 ;bad != scenario .bad { 136t .Fatalf ("color violation = %v, want %v" ,bad ,scenario .bad ) 137 } 138 }) 139 } 140} 141 142// A full reset comes as "ESC[m" from Lip Gloss and "ESC[0m" from the raw ink; 143// the selection surface and a diff row's tint must survive both. 144func TestBackgroundsOutliveStyledSpans (t * testing.T ) { 145span := DefaultStyles .Muted .Render ("a" ) 146marked := DefaultStyles .Selected .Render ("\x00" ) 147open := marked [:strings .IndexByte (marked ,0 )] 148if line := selectedLine (span + " b" ,0 );! strings .Contains (line ,open + " b" ) { 149t .Errorf ("selection surface ends at the first span: %q" ,line ) 150 } 151if kept := keepBackground (span );strings .Contains (kept ,"\x1b[m" )|| strings .Contains (kept ,"\x1b[0m" ) { 152t .Errorf ("a span still resets the row tint: %q" ,kept ) 153 } 154}