okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffauftest(tui): check theme ownership through Go syntax842c79c

main
4.8 KiB154 linesraw
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) {
21	owners := map[string]bool{"theme.go": true, "ink.go": true, "ink_unix.go": true, "ink_other.go": true}
22	positions := token.NewFileSet()
23	entries, err := os.ReadDir(".")
24	if err != nil {
25		t.Fatal(err)
26	}
27	for _, e := range entries {
28		name := e.Name()
29		if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") || owners[name] {
30			continue
31		}
32		file, err := parser.ParseFile(positions, name, nil, 0)
33		if err != nil {
34			t.Fatal(err)
35		}
36		for _, position := range themeViolations(file) {
37			t.Errorf("%s picks its own color, use a theme token", positions.Position(position))
38		}
39	}
40}
41
42func themeViolations(file *ast.File) []token.Pos {
43	imports := make(map[string]string)
44	for _, spec := range file.Imports {
45		path, _ := strconv.Unquote(spec.Path.Value)
46		name := path[strings.LastIndexByte(path, '/')+1:]
47		if strings.HasSuffix(path, "/lipgloss/v2") {
48			name = "lipgloss"
49		}
50		if spec.Name != nil {
51			name = spec.Name.Name
52		}
53		imports[name] = path
54	}
55	palette := regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
56	ansi := regexp.MustCompile("\x1b\\[([0-9;]*)m")
57	var violations []token.Pos
58	ast.Inspect(file, func(node ast.Node) bool {
59		switch value := node.(type) {
60		case *ast.SelectorExpr:
61			if owner, ok := value.X.(*ast.Ident); ok && strings.HasSuffix(imports[owner.Name], "/lipgloss/v2") {
62				if value.Sel.Name == "Color" || value.Sel.Name == "AdaptiveColor" {
63					violations = append(violations, value.Pos())
64				}
65			}
66		case *ast.CallExpr:
67			function, ok := ast.Unparen(value.Fun).(*ast.SelectorExpr)
68			if !ok {
69				break
70			}
71			switch function.Sel.Name {
72			case "Background":
73				if owner, ok := function.X.(*ast.Ident); ok && imports[owner.Name] == "context" {
74					break
75				}
76				violations = append(violations, value.Pos())
77			case "Foreground":
78				violations = append(violations, value.Pos())
79			case "Faint", "Reverse":
80				if len(value.Args) == 1 {
81					if argument, ok := ast.Unparen(value.Args[0]).(*ast.Ident); ok && argument.Name == "true" {
82						violations = append(violations, value.Pos())
83					}
84				}
85			}
86		case *ast.BasicLit:
87			if value.Kind != token.STRING {
88				break
89			}
90			text, err := strconv.Unquote(value.Value)
91			if err != nil {
92				break
93			}
94			if palette.MatchString(text) {
95				violations = append(violations, value.Pos())
96			}
97			for _, sequence := range ansi.FindAllStringSubmatch(text, -1) {
98				for parameter := range strings.SplitSeq(sequence[1], ";") {
99					code, _ := strconv.Atoi(parameter)
100					if code == 2 || code == 7 || code >= 30 && code <= 49 || code >= 90 && code <= 107 {
101						violations = append(violations, value.Pos())
102						return false
103					}
104				}
105			}
106		}
107		return true
108	})
109	return violations
110}
111
112func TestThemeOwnershipSurvivesFormattingAndIgnoresComments(t *testing.T) {
113	for _, scenario := range []struct {
114		source string
115		bad    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	} {
130		t.Run(scenario.source, func(t *testing.T) {
131			file, err := parser.ParseFile(token.NewFileSet(), "screen.go", "package tui\nimport gl \"charm.land/lipgloss/v2\"\nimport \"context\"\n"+scenario.source, 0)
132			if err != nil {
133				t.Fatal(err)
134			}
135			if bad := len(themeViolations(file)) > 0; bad != scenario.bad {
136				t.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) {
145	span := DefaultStyles.Muted.Render("a")
146	marked := DefaultStyles.Selected.Render("\x00")
147	open := marked[:strings.IndexByte(marked, 0)]
148	if line := selectedLine(span+" b", 0); !strings.Contains(line, open+" b") {
149		t.Errorf("selection surface ends at the first span: %q", line)
150	}
151	if kept := keepBackground(span); strings.Contains(kept, "\x1b[m") || strings.Contains(kept, "\x1b[0m") {
152		t.Errorf("a span still resets the row tint: %q", kept)
153	}
154}