diff --git a/go.mod b/go.mod index a38f291..e0222db 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/modelcontextprotocol/go-sdk v1.6.1 + golang.org/x/text v0.41.0 modernc.org/sqlite v1.56.0 ) @@ -57,7 +58,6 @@ require ( golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index cf43c53..ae385d8 100644 --- a/go.sum +++ b/go.sum @@ -107,8 +107,8 @@ github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= @@ -117,10 +117,10 @@ golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= diff --git a/internal/tui/board_view.go b/internal/tui/board_view.go index 03c6c4c..e05a63e 100644 --- a/internal/tui/board_view.go +++ b/internal/tui/board_view.go @@ -36,12 +36,26 @@ type boardViewState struct { type boardCardClickedMsg struct{ taskID string } type boardColumnClickedMsg struct{ status board.Status } +type filterTextClickedMsg struct{} +type filterLabelClickedMsg struct{ tag string } +type filterClearClickedMsg struct{} + +type boardHitKind uint8 + +const ( + boardHitDefault boardHitKind = iota + boardHitFilterText + boardHitFilterLabel + boardHitFilterClear +) type boardHit struct { x0, x1 int y0, y1 int status board.Status taskID string + kind boardHitKind + tag string } type renderedColumn struct { @@ -49,6 +63,11 @@ type renderedColumn struct { hits []boardHit } +type labelSpan struct { + x0, x1 int + tag string +} + func (s boardViewState) visibleStatuses() []board.Status { limit := len(boardStatuses) if !s.showCancelled { @@ -191,7 +210,7 @@ func (s boardViewState) selectedTask(current board.Board) (board.Task, bool) { // selectedTask is the narrow handoff used by the card-detail overlay. func (m Model) selectedTask() (board.Task, bool) { - return m.boardView.selectedTask(m.board) + return m.boardView.selectedTask(m.filteredBoard()) } func taskCount(current board.Board, status board.Status) int { @@ -226,12 +245,13 @@ func (m Model) renderBoard() (string, []boardHit) { title = "Board" } header := fitLine(fmt.Sprintf("kb / %s / %s", title, m.user), width) + filterLine, filterHits := m.renderFilterBar(width) statuses := m.boardView.visibleStatuses() if width < wideBoardWidth { statuses = statuses[m.boardView.column : m.boardView.column+1] } - bodyHeight := height - 2 + bodyHeight := height - 4 columnWidths := splitWidths(width, len(statuses)) columns := make([]renderedColumn, 0, len(statuses)) for i, status := range statuses { @@ -239,9 +259,10 @@ func (m Model) renderBoard() (string, []boardHit) { } body, hits := joinColumns(columns) for i := range hits { - hits[i].y0++ - hits[i].y1++ + hits[i].y0 += 3 + hits[i].y1 += 3 } + hits = append(filterHits, hits...) state := "ready" if m.loading || (m.watcher != nil && !m.haveVersion) { @@ -261,10 +282,85 @@ func (m Model) renderBoard() (string, []boardHit) { help := "j/k cards | h/l/tab columns | 1-4 jump | c cancelled:" + cancelled + " | q quit" if m.settingsNew != nil { footer := settingsBoardFooter(state, cancelled, width) - return strings.Join([]string{header, body, footer}, "\n"), hits + return strings.Join([]string{header, filterLine, body, footer}, "\n"), hits } footer := fitLine(state+" | "+help, width) - return strings.Join([]string{header, body, footer}, "\n"), hits + return strings.Join([]string{header, filterLine, body, footer}, "\n"), hits +} + +func (m Model) renderFilterBar(width int) (string, []boardHit) { + width = max(width, 1) + hits := make([]boardHit, 0, 2+len(m.filterLabels())) + lines := [2][]string{} + appendPart := func(row int, part string, kind boardHitKind, tag string) { + x := ansi.StringWidth(strings.Join(lines[row], "")) + if len(lines[row]) > 0 { + lines[row] = append(lines[row], " | ") + x += 3 + } + start := x + lines[row] = append(lines[row], part) + x += ansi.StringWidth(part) + if kind != boardHitDefault && start < width { + hits = append(hits, boardHit{x0: start, x1: min(x, width), y0: row + 1, y1: row + 2, kind: kind, tag: tag}) + } + } + + value := sanitizeTerminal(m.filter.input.Value()) + if value == "" { + value = "Filter cards" + } + text := "/ " + value + if m.filter.focus == filterText { + text = "> " + settingsInputDisplay(m.filter.input, false, true, max(min(width-2, 40), 1)) + } + labels := m.filterLabels() + focusTag := "" + if m.filter.focus == filterLabels && len(labels) > 0 { + focusTag = labels[min(max(m.filter.labelIndex, 0), len(labels)-1)] + } + appendLabel := func(tag string) { + marker := "+" + if m.filter.hasTag(tag) { + marker = "x" + } + label := "[" + marker + " " + sanitizeTerminal(tag) + "]" + if tag == focusTag { + label = ">" + label + "<" + } + appendPart(0, label, boardHitFilterLabel, tag) + } + if focusTag != "" { + appendLabel(focusTag) + } + appendPart(0, text, boardHitFilterText, "") + if m.filter.active() { + appendPart(1, fmt.Sprintf("%d of %d cards", len(m.filteredBoard().Tasks), len(m.board.Tasks)), boardHitDefault, "") + } + appendLabelOnControls := func(tag string) { + marker := "+" + if m.filter.hasTag(tag) { + marker = "x" + } + appendPart(1, "["+marker+" "+sanitizeTerminal(tag)+"]", boardHitFilterLabel, tag) + } + for _, tag := range labels { + if tag != focusTag && m.filter.hasTag(tag) { + appendLabelOnControls(tag) + } + } + if m.filter.active() { + appendPart(1, "[clear]", boardHitFilterClear, "") + } + for _, tag := range labels { + if tag != focusTag && !m.filter.hasTag(tag) { + appendLabelOnControls(tag) + } + } + return strings.Join([]string{ + fitLine(strings.Join(lines[0], ""), width), + fitLine(strings.Join(lines[1], ""), width), + }, "\n"), hits } func settingsBoardFooter(state, cancelled string, width int) string { @@ -320,7 +416,7 @@ func (m Model) renderBoardColumn(status board.Status, width, height int) rendere return renderedColumn{lines: lines, hits: []boardHit{{x1: width, y1: height, status: status}}} } inner := width - 2 - tasks := tasksInStatus(m.board, status) + tasks := tasksInStatus(m.filteredBoard(), status) focused := m.boardView.column == statusIndex(status) heading := fmt.Sprintf("%d %s %d", statusIndex(status)+1, statusLabel(status), len(tasks)) if focused { @@ -330,7 +426,7 @@ func (m Model) renderBoardColumn(status board.Status, width, height int) rendere hits := []boardHit{{x1: width, y1: height, status: status}} contentHeight := max(height-2, 0) - cardLines, owners := m.renderTaskLines(tasks, status, inner) + cardLines, owners, labelSpans := m.renderTaskLines(tasks, status, inner) start := visibleCardStart(cardLines, owners, m.boardView.rows[statusIndex(status)], contentHeight) for row := 0; row < contentHeight; row++ { source := start + row @@ -342,17 +438,27 @@ func (m Model) renderBoardColumn(status board.Status, width, height int) rendere if source < len(owners) && owners[source] != "" { hits = append(hits, boardHit{x1: width, y0: row + 1, y1: row + 2, status: status, taskID: owners[source]}) } + if source < len(labelSpans) { + for _, span := range labelSpans[source] { + hits = append(hits, boardHit{ + x0: 1 + span.x0, x1: min(1+span.x1, width-1), + y0: row + 1, y1: row + 2, status: status, + kind: boardHitFilterLabel, tag: span.tag, + }) + } + } } lines = append(lines, "└"+strings.Repeat("─", inner)+"┘") return renderedColumn{lines: lines, hits: hits} } -func (m Model) renderTaskLines(tasks []board.Task, status board.Status, width int) ([]string, []string) { +func (m Model) renderTaskLines(tasks []board.Task, status board.Status, width int) ([]string, []string, [][]labelSpan) { if len(tasks) == 0 { - return []string{"(empty)"}, []string{""} + return []string{"(empty)"}, []string{""}, [][]labelSpan{nil} } lines := make([]string, 0, len(tasks)*3) owners := make([]string, 0, len(tasks)*3) + spans := make([][]labelSpan, 0, len(tasks)*3) selected := m.boardView.rows[statusIndex(status)] for i, task := range tasks { marker := " " @@ -367,17 +473,27 @@ func (m Model) renderTaskLines(tasks []board.Task, status board.Status, width in } lines = append(lines, prefix+line) owners = append(owners, task.ID) + spans = append(spans, nil) } - for _, line := range wrapTokens(cardMeta(task, m.now()), max(width-2, 1)) { + metaLines, metaSpans := wrapMeta(cardMetaEntries(task, m.now()), max(width-2, 1)) + for lineIndex, line := range metaLines { lines = append(lines, " "+line) owners = append(owners, task.ID) + lineSpans := make([]labelSpan, len(metaSpans[lineIndex])) + for spanIndex, span := range metaSpans[lineIndex] { + span.x0 += 2 + span.x1 += 2 + lineSpans[spanIndex] = span + } + spans = append(spans, lineSpans) } if i+1 < len(tasks) { lines = append(lines, "") owners = append(owners, "") + spans = append(spans, nil) } } - return lines, owners + return lines, owners, spans } func visibleCardStart(lines, owners []string, selected, height int) int { @@ -443,6 +559,14 @@ func boardMouseHandler(hits []boardHit) func(tea.MouseMsg) tea.Cmd { if mouse.X < hit.x0 || mouse.X >= hit.x1 || mouse.Y < hit.y0 || mouse.Y >= hit.y1 { continue } + switch hit.kind { + case boardHitFilterText: + return func() tea.Msg { return filterTextClickedMsg{} } + case boardHitFilterLabel: + return func() tea.Msg { return filterLabelClickedMsg{tag: hit.tag} } + case boardHitFilterClear: + return func() tea.Msg { return filterClearClickedMsg{} } + } if hit.taskID != "" { return func() tea.Msg { return boardCardClickedMsg{taskID: hit.taskID} } } @@ -493,10 +617,15 @@ func cardHeading(task board.Task, now time.Time) []string { return tokens } -func cardMeta(task board.Task, now time.Time) []string { - tokens := []string{priorityChip(task.Prio)} +type metaEntry struct { + text string + tag string +} + +func cardMetaEntries(task board.Task, now time.Time) []metaEntry { + tokens := []metaEntry{{text: priorityChip(task.Prio)}} if task.Blocked { - tokens = append(tokens, chip("⛔ blocked", lipgloss.Color("#ffb020"))) + tokens = append(tokens, metaEntry{text: chip("⛔ blocked", lipgloss.Color("#ffb020"))}) } if task.Due != "" { label, overdue := dueChip(task.Due, now) @@ -504,17 +633,56 @@ func cardMeta(task board.Task, now time.Time) []string { if overdue { color = lipgloss.Color("#ffe0dc") } - tokens = append(tokens, chip(label, color)) + tokens = append(tokens, metaEntry{text: chip(label, color)}) } if task.Effort != "" { - tokens = append(tokens, "["+task.Effort+"]") + tokens = append(tokens, metaEntry{text: "[" + task.Effort + "]"}) } for _, tag := range task.Tags { - tokens = append(tokens, labelChip(tag)) + tokens = append(tokens, metaEntry{text: labelChip(tag), tag: tag}) } return tokens } +func wrapMeta(entries []metaEntry, width int) ([]string, [][]labelSpan) { + if width <= 0 { + return []string{""}, [][]labelSpan{nil} + } + lines := make([]string, 0, 2) + spans := make([][]labelSpan, 0, 2) + line := "" + lineSpans := make([]labelSpan, 0) + flush := func() { + lines = append(lines, line) + spans = append(spans, lineSpans) + line = "" + lineSpans = nil + } + for _, entry := range entries { + if entry.text == "" { + continue + } + separator := "" + if line != "" { + separator = " " + } + if line != "" && ansi.StringWidth(line+separator+entry.text) > width { + flush() + separator = "" + } + start := ansi.StringWidth(line) + ansi.StringWidth(separator) + visible := ansi.Truncate(entry.text, max(width-start, 0), "") + line += separator + visible + if entry.tag != "" && ansi.StringWidth(visible) > 0 { + lineSpans = append(lineSpans, labelSpan{x0: start, x1: start + ansi.StringWidth(visible), tag: entry.tag}) + } + } + if line != "" || len(lines) == 0 { + flush() + } + return lines, spans +} + var priorityColors = map[int]color.Color{ 1: lipgloss.Color("#ff5a48"), 2: lipgloss.Color("#ffb020"), diff --git a/internal/tui/board_view_test.go b/internal/tui/board_view_test.go index c5dd625..a8468f1 100644 --- a/internal/tui/board_view_test.go +++ b/internal/tui/board_view_test.go @@ -434,33 +434,34 @@ func TestCancelledPreferencePathAndIsolation(t *testing.T) { t.Fatalf("preference path %q is not under board data %q", pathA, wantRoot) } - if got, err := loadCancelledPreference(pathA); err != nil || got { + if got, err := loadTUIPreferences(pathA); err != nil || got.ShowCancelled || got.Filter.Text != "" || len(got.Filter.Tags) != 0 { t.Fatalf("missing preference = %v,%v", got, err) } - if err := saveCancelledPreference(pathA, true); err != nil { + want := tuiPreferences{ShowCancelled: true, Filter: boardFilter{Text: "fix", Tags: []string{"bug", "auth"}}} + if err := saveTUIPreferences(pathA, want); err != nil { t.Fatal(err) } - if got, err := loadCancelledPreference(pathA); err != nil || !got { + if got, err := loadTUIPreferences(pathA); err != nil || !reflect.DeepEqual(got, want) { t.Fatalf("saved preference = %v,%v", got, err) } for _, isolated := range paths[1:] { - if got, readErr := loadCancelledPreference(isolated); readErr != nil || got { + if got, readErr := loadTUIPreferences(isolated); readErr != nil || got.ShowCancelled || got.Filter.Text != "" || len(got.Filter.Tags) != 0 { t.Fatalf("isolated preference %q = %v,%v", isolated, got, readErr) } } if info, err := os.Stat(pathA); err != nil || info.Mode().Perm() != 0o600 { t.Fatalf("preference mode = %v,%v", info, err) } - if err := saveCancelledPreference(pathA, false); err != nil { + if err := saveTUIPreferences(pathA, tuiPreferences{}); err != nil { t.Fatal(err) } - if got, err := loadCancelledPreference(pathA); err != nil || got { + if got, err := loadTUIPreferences(pathA); err != nil || got.ShowCancelled || got.Filter.Text != "" || len(got.Filter.Tags) != 0 { t.Fatalf("cleared preference = %v,%v", got, err) } if err := os.WriteFile(pathA, []byte("{"), 0o600); err != nil { t.Fatal(err) } - if _, err := loadCancelledPreference(pathA); err == nil || !strings.Contains(err.Error(), "decode") { + if _, err := loadTUIPreferences(pathA); err == nil || !strings.Contains(err.Error(), "decode") { t.Fatalf("malformed preference error = %v", err) } } @@ -469,19 +470,19 @@ func TestCancelledPreferenceCommandFailure(t *testing.T) { m := NewModel(stubBoardReader{}, nil, "u") m.boardView.showCancelled = true - m.saveCancelled = func(show bool) error { - if !show { + m.savePreferences = func(preferences tuiPreferences) error { + if !preferences.ShowCancelled { t.Fatal("saved wrong toggle") } return errors.New("disk full") } - message := m.queueCancelledPreference()() + message := m.queuePreferences()() updateTestModel(t, &m, message) if m.preferenceErr == nil || !strings.Contains(m.render(), "disk full") { t.Fatalf("preference failure = %+v", m.preferenceErr) } - m.saveCancelled = nil - if command := m.queueCancelledPreference(); command != nil { + m.savePreferences = nil + if command := m.queuePreferences(); command != nil { t.Fatalf("nil preference saver returned %v", command) } } @@ -523,7 +524,8 @@ func TestCancelledPreferenceAtomicFailuresPreservePriorFile(t *testing.T) { t.Run(stage, func(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "prefs.json") - if err := saveCancelledPreference(path, false); err != nil { + prior := tuiPreferences{Filter: boardFilter{Text: "prior", Tags: []string{"stable"}}} + if err := saveTUIPreferences(path, prior); err != nil { t.Fatal(err) } ops := osPreferenceFileOps @@ -539,13 +541,13 @@ func TestCancelledPreferenceAtomicFailuresPreservePriorFile(t *testing.T) { if stage == "rename" { ops.rename = func(string, string) error { return failure } } - if err := saveCancelledPreferenceWithOps(path, true, ops); err == nil { + if err := saveTUIPreferencesWithOps(path, tuiPreferences{ShowCancelled: true, Filter: boardFilter{Text: "next"}}, ops); err == nil { t.Fatal("injected atomic write succeeded") } if createdIn != dir { t.Fatalf("temporary file directory = %q, want %q", createdIn, dir) } - if got, err := loadCancelledPreference(path); err != nil || got { + if got, err := loadTUIPreferences(path); err != nil || !reflect.DeepEqual(got, prior) { t.Fatalf("prior preference after %s = %v,%v", stage, got, err) } entries, err := os.ReadDir(dir) @@ -560,20 +562,20 @@ func TestCancelledPreferenceAtomicFailuresPreservePriorFile(t *testing.T) { } func TestCancelledPreferenceWritesSerializeLatestToggle(t *testing.T) { - var saved []bool + var saved []tuiPreferences m := NewModel(stubBoardReader{}, nil, "u") - m.saveCancelled = func(show bool) error { - saved = append(saved, show) + m.savePreferences = func(preferences tuiPreferences) error { + saved = append(saved, preferences) return nil } m.boardView.handleKey("c", m.board) - first := m.queueCancelledPreference() + first := m.queuePreferences() m.boardView.handleKey("c", m.board) - if command := m.queueCancelledPreference(); command != nil { + if command := m.queuePreferences(); command != nil { t.Fatal("overlapping toggle started a concurrent preference write") } - if !m.prefSaving || m.prefPending == nil || *m.prefPending { + if !m.prefSaving || m.prefPending == nil || m.prefPending.ShowCancelled { t.Fatalf("queued preference state = %+v", m) } second := updateTestModel(t, &m, first()) @@ -583,7 +585,7 @@ func TestCancelledPreferenceWritesSerializeLatestToggle(t *testing.T) { if next := updateTestModel(t, &m, second()); next != nil || m.prefSaving { t.Fatalf("final preference state = %+v command=%v", m, next) } - if !reflect.DeepEqual(saved, []bool{true, false}) { + if !reflect.DeepEqual(saved, []tuiPreferences{{ShowCancelled: true}, {ShowCancelled: false}}) { t.Fatalf("saved toggles = %v", saved) } } diff --git a/internal/tui/filter.go b/internal/tui/filter.go new file mode 100644 index 0000000..ad6a793 --- /dev/null +++ b/internal/tui/filter.go @@ -0,0 +1,274 @@ +package tui + +import ( + "reflect" + "sort" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/RandomCodeSpace/kb/internal/board" +) + +type boardFilter struct { + Text string `json:"text,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type filterFocus uint8 + +const ( + filterUnfocused filterFocus = iota + filterText + filterLabels +) + +type boardFilterState struct { + input textinput.Model + tags []string + focus filterFocus + labelIndex int +} + +func newBoardFilterState() boardFilterState { + input := textinput.New() + input.Prompt = "" + input.Placeholder = "Filter cards" + input.SetWidth(40) + return boardFilterState{input: input} +} + +func (s boardFilterState) value() boardFilter { + return boardFilter{Text: s.input.Value(), Tags: append([]string(nil), s.tags...)} +} + +func (s *boardFilterState) restore(value boardFilter) { + s.input.SetValue(value.Text) + s.tags = normalizedFilterTags(value.Tags) +} + +func normalizedFilterTags(tags []string) []string { + result := make([]string, 0, len(tags)) + seen := make(map[string]struct{}, len(tags)) + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + if _, ok := seen[tag]; ok { + continue + } + seen[tag] = struct{}{} + result = append(result, tag) + } + return result +} + +func (s boardFilterState) active() bool { + return strings.TrimSpace(s.input.Value()) != "" || len(s.tags) > 0 +} + +func (s *boardFilterState) focusText() tea.Cmd { + s.focus = filterText + return s.input.Focus() +} + +func (s *boardFilterState) blur() { + s.focus = filterUnfocused + s.input.Blur() +} + +func (s *boardFilterState) clear() bool { + if !s.active() { + return false + } + s.input.SetValue("") + s.tags = nil + return true +} + +func (s *boardFilterState) toggleTag(tag string) bool { + if tag == "" { + return false + } + for i, selected := range s.tags { + if selected == tag { + s.tags = append(s.tags[:i], s.tags[i+1:]...) + return true + } + } + s.tags = append(s.tags, tag) + return true +} + +func (s boardFilterState) hasTag(tag string) bool { + for _, selected := range s.tags { + if selected == tag { + return true + } + } + return false +} + +func (s boardFilterState) matches(task board.Task) bool { + for _, selected := range s.tags { + found := false + for _, tag := range task.Tags { + if tag == selected { + found = true + break + } + } + if !found { + return false + } + } + needle := webLower(strings.TrimSpace(s.input.Value())) + if needle == "" { + return true + } + if strings.Contains(webLower(task.Title), needle) || + strings.Contains(webLower(task.Desc), needle) { + return true + } + for _, tag := range task.Tags { + if strings.Contains(webLower(tag), needle) { + return true + } + } + return false +} + +func (s boardFilterState) project(current board.Board) board.Board { + if !s.active() { + return current + } + filtered := current + filtered.Tasks = make([]board.Task, 0, len(current.Tasks)) + for _, task := range current.Tasks { + if s.matches(task) { + filtered.Tasks = append(filtered.Tasks, task) + } + } + return filtered +} + +func boardLabels(current board.Board) []string { + seen := make(map[string]struct{}) + for _, task := range current.Tasks { + for _, tag := range task.Tags { + if tag != "" { + seen[tag] = struct{}{} + } + } + } + labels := make([]string, 0, len(seen)) + for tag := range seen { + labels = append(labels, tag) + } + sort.Strings(labels) + return labels +} + +func (m Model) filteredBoard() board.Board { + return m.filter.project(m.board) +} + +func (m *Model) mutateFilter(change func(*boardFilterState)) tea.Cmd { + previous := m.filteredBoard() + before := m.filter.value() + change(&m.filter) + if reflect.DeepEqual(before, m.filter.value()) { + return nil + } + m.boardView.adoptBoard(previous, m.filteredBoard()) + return m.queuePreferences() +} + +func (m *Model) handleFilterKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { + key := msg.String() + labels := m.filterLabels() + switch m.filter.focus { + case filterText: + switch key { + case "esc", "enter": + m.filter.blur() + return true, nil + case "tab", "shift+tab": + m.filter.input.Blur() + if len(labels) == 0 { + m.filter.focus = filterUnfocused + } else { + m.filter.focus = filterLabels + if key == "shift+tab" { + m.filter.labelIndex = len(labels) - 1 + } + } + return true, nil + } + previous := m.filteredBoard() + before := m.filter.input.Value() + updated, inputCmd := m.filter.input.Update(msg) + m.filter.input = updated + if before == m.filter.input.Value() { + return true, inputCmd + } + m.boardView.adoptBoard(previous, m.filteredBoard()) + return true, batchCommands(inputCmd, m.queuePreferences()) + case filterLabels: + if len(labels) == 0 { + m.filter.blur() + return true, nil + } + m.filter.labelIndex = min(max(m.filter.labelIndex, 0), len(labels)-1) + switch key { + case "esc": + m.filter.blur() + return true, nil + case "/": + return true, m.filter.focusText() + case "left", "h", "shift+tab": + m.filter.labelIndex = (m.filter.labelIndex - 1 + len(labels)) % len(labels) + return true, nil + case "right", "l", "tab": + m.filter.labelIndex = (m.filter.labelIndex + 1) % len(labels) + return true, nil + case "enter", " ": + tag := labels[m.filter.labelIndex] + return true, m.mutateFilter(func(filter *boardFilterState) { filter.toggleTag(tag) }) + } + return true, nil + default: + switch key { + case "/": + return true, m.filter.focusText() + case "f": + if len(labels) > 0 { + m.filter.focus = filterLabels + m.filter.input.Blur() + } + return true, nil + case "x": + if m.filter.active() { + return true, m.mutateFilter(func(filter *boardFilterState) { filter.clear() }) + } + } + } + return false, nil +} + +func (m Model) filterLabels() []string { + labels := boardLabels(m.board) + seen := make(map[string]struct{}, len(labels)) + for _, label := range labels { + seen[label] = struct{}{} + } + for _, selected := range m.filter.tags { + if _, ok := seen[selected]; !ok { + labels = append(labels, selected) + } + } + sort.Strings(labels) + return labels +} diff --git a/internal/tui/filter_test.go b/internal/tui/filter_test.go new file mode 100644 index 0000000..acea2ed --- /dev/null +++ b/internal/tui/filter_test.go @@ -0,0 +1,535 @@ +package tui + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/RandomCodeSpace/kb/internal/board" +) + +func filterFixture() board.Board { + return board.Board{Title: "Filter", Tasks: []board.Task{ + {ID: "login", Title: "Fix login timeout", Desc: "auth token expires", Status: board.StatusTodo, Tags: []string{"bug", "auth"}}, + {ID: "landing", Title: "Design landing page", Status: board.StatusTodo, Tags: []string{"ui"}}, + {ID: "rotate", Title: "Rotate keys", Desc: "quarterly", Status: board.StatusDoing, Tags: []string{"auth", "env::prod"}}, + {ID: "billing", Title: "Fix billing", Status: board.StatusTodo, Tags: []string{"bug"}}, + {ID: "cancelled", Title: "Retired login", Status: board.StatusCancelled, Tags: []string{"bug"}}, + }} +} + +func TestFilterMatchesWebSemantics(t *testing.T) { + current := filterFixture() + for _, test := range []struct { + name string + filter boardFilter + want []string + }{ + {"inactive", boardFilter{}, []string{"login", "landing", "rotate", "billing", "cancelled"}}, + {"title case insensitive", boardFilter{Text: "LOGIN"}, []string{"login", "cancelled"}}, + {"description substring", boardFilter{Text: "TOKEN"}, []string{"login"}}, + {"tag substring", boardFilter{Text: "PROD"}, []string{"rotate"}}, + {"exact tag", boardFilter{Tags: []string{"env::prod"}}, []string{"rotate"}}, + {"not partial tag", boardFilter{Tags: []string{"prod"}}, []string{}}, + {"tag case sensitive", boardFilter{Tags: []string{"BUG"}}, []string{}}, + {"AND tags", boardFilter{Tags: []string{"bug", "auth"}}, []string{"login"}}, + {"text and tags", boardFilter{Text: "quarter", Tags: []string{"auth"}}, []string{"rotate"}}, + } { + t.Run(test.name, func(t *testing.T) { + state := newBoardFilterState() + state.restore(test.filter) + gotBoard := state.project(current) + got := make([]string, len(gotBoard.Tasks)) + for i, task := range gotBoard.Tasks { + got[i] = task.ID + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("filtered ids = %v, want %v", got, test.want) + } + }) + } +} + +func TestWebLowerMatchesFrozenJavaScriptVectors(t *testing.T) { + // Expected values were captured from Node 24 String.prototype.toLowerCase. + for _, test := range []struct { + input string + want string + }{ + {"İ", "i\u0307"}, + {"Iİ", "ii\u0307"}, + {"ΟΣ", "ος"}, + {"Σ", "σ"}, + {"ẞ", "ß"}, + } { + if got := webLower(test.input); got != test.want { + t.Errorf("webLower(%q) = %q, want %q", test.input, got, test.want) + } + } + state := newBoardFilterState() + state.restore(boardFilter{Text: "İ"}) + if state.matches(board.Task{Title: "i"}) { + t.Fatal("U+0130 query matched plain i unlike JavaScript") + } + if !state.matches(board.Task{Title: "i\u0307"}) { + t.Fatal("U+0130 query did not match JavaScript expanded lowercase") + } +} + +func TestFilterKeyboardRoutingPersistenceAndClear(t *testing.T) { + m := NewModel(stubBoardReader{}, nil, "alice") + m.loading = false + m.board = filterFixture() + var saved []tuiPreferences + m.savePreferences = func(preferences tuiPreferences) error { + saved = append(saved, preferences) + return nil + } + + focus := updateTestModel(t, &m, tea.KeyPressMsg{Code: '/'}) + if m.filter.focus != filterText || focus == nil { + t.Fatalf("slash focus = %v command=%v", m.filter.focus, focus) + } + updateTestModel(t, &m, focus()) + save := updateTestModel(t, &m, tea.KeyPressMsg(tea.Key{Code: 'q', Text: "q"})) + if m.stopped || m.filter.input.Value() != "q" || save == nil { + t.Fatalf("typed q = stopped:%v value:%q command:%v", m.stopped, m.filter.input.Value(), save) + } + finishPreferenceCommand(t, &m, save) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.filter.focus != filterUnfocused { + t.Fatalf("escape focus = %v", m.filter.focus) + } + + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'f'}) + if m.filter.focus != filterLabels || m.filterLabels()[m.filter.labelIndex] != "auth" { + t.Fatalf("label focus = %v index=%d labels=%v", m.filter.focus, m.filter.labelIndex, m.filterLabels()) + } + toggle := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if toggle == nil || !reflect.DeepEqual(m.filter.tags, []string{"auth"}) { + t.Fatalf("keyboard tag toggle = %v command=%v", m.filter.tags, toggle) + } + finishPreferenceCommand(t, &m, toggle) + m.boardView.showCancelled = true + clear := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEscape}) + if clear != nil { + t.Fatalf("escape returned %v", clear) + } + clear = updateTestModel(t, &m, tea.KeyPressMsg{Code: 'x'}) + if clear == nil || m.filter.active() || !m.boardView.showCancelled { + t.Fatalf("clear = filter:%+v cancelled:%v command:%v", m.filter.value(), m.boardView.showCancelled, clear) + } + finishPreferenceCommand(t, &m, clear) + if len(saved) != 3 || saved[2].ShowCancelled != true || saved[2].Filter.Text != "" || len(saved[2].Filter.Tags) != 0 { + t.Fatalf("saved snapshots = %+v", saved) + } +} + +func finishPreferenceCommand(t *testing.T, model *Model, command tea.Cmd) { + t.Helper() + message := command() + if batch, ok := message.(tea.BatchMsg); ok { + for _, nested := range batch { + if saved, ok := nested().(preferenceSavedMsg); ok { + updateTestModel(t, model, saved) + return + } + } + t.Fatal("batch did not contain a preference save") + } + if _, ok := message.(preferenceSavedMsg); !ok { + t.Fatalf("preference command returned %T", message) + } + updateTestModel(t, model, message) +} + +func TestFilteredNavigationMouseAndDetailUseVisibleTasks(t *testing.T) { + m := NewModel(stubDetailBoardReader{stubBoardReader{board: filterFixture()}}, nil, "alice") + completeBoardLoad(t, &m, m.Init()) + m.width, m.height = 160, 22 + m.filter.restore(boardFilter{Text: "fix", Tags: []string{"bug"}}) + m.boardView.rows[0] = 1 + if selected, ok := m.selectedTask(); !ok || selected.ID != "billing" { + t.Fatalf("filtered selection = %+v,%v", selected, ok) + } + if command := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}); command == nil || !m.detail.IsOpen() || m.detail.TaskID() != "billing" { + t.Fatalf("filtered detail = open:%v task:%q command:%v", m.detail.IsOpen(), m.detail.TaskID(), command) + } + before := m.filter.value() + for _, message := range []tea.Msg{filterTextClickedMsg{}, filterLabelClickedMsg{tag: "ui"}, filterClearClickedMsg{}} { + if command := updateTestModel(t, &m, message); command != nil || !reflect.DeepEqual(m.filter.value(), before) { + t.Fatalf("%T leaked behind detail: filter=%+v command=%v", message, m.filter.value(), command) + } + } + m.detail.Close() + + m.filter.restore(boardFilter{}) + _, hits := m.renderBoard() + var labelHit boardHit + for _, hit := range hits { + if hit.kind == boardHitFilterLabel && hit.tag == "bug" && hit.y0 > 1 { + labelHit = hit + break + } + } + if labelHit.x1 <= labelHit.x0 { + t.Fatalf("card label hit missing: %+v", hits) + } + command := boardMouseHandler(hits)(tea.MouseClickMsg{X: labelHit.x0, Y: labelHit.y0, Button: tea.MouseLeft}) + if command == nil { + t.Fatal("card label click was not hit") + } + updateTestModel(t, &m, command()) + if m.detail.IsOpen() || !reflect.DeepEqual(m.filter.tags, []string{"bug"}) { + t.Fatalf("label click = detail:%v filter:%v", m.detail.IsOpen(), m.filter.tags) + } +} + +func TestFilterMouseMessagesDoNotLeakBehindSettings(t *testing.T) { + m := NewModel(stubBoardReader{}, nil, "u") + m.board = filterFixture() + m.filter.restore(boardFilter{Text: "fix", Tags: []string{"bug"}}) + m.settings = &settingsModel{} + before := m.filter.value() + for _, message := range []tea.Msg{filterTextClickedMsg{}, filterLabelClickedMsg{tag: "ui"}, filterClearClickedMsg{}} { + if command := updateTestModel(t, &m, message); command != nil || !reflect.DeepEqual(m.filter.value(), before) { + t.Fatalf("%T leaked behind settings: filter=%+v command=%v", message, m.filter.value(), command) + } + } +} + +func TestFilterCountAndNarrowLayout(t *testing.T) { + m := NewModel(stubBoardReader{}, nil, "u") + m.loading = false + m.board = filterFixture() + m.filter.restore(boardFilter{Text: "login"}) + wide := ansi.Strip(m.render()) + if !strings.Contains(wide, "2 of 5 cards") || strings.Contains(wide, "Design landing page") { + t.Fatalf("filtered count/view:\n%s", wide) + } + m.filter.input.SetValue(strings.Repeat("long-query-", 8)) + m.filter.focusText() + m.width = 23 + countLine, _ := m.renderFilterBar(m.width) + countLines := strings.Split(ansi.Strip(countLine), "\n") + if len(countLines) != 2 || !strings.HasPrefix(countLines[1], "0 of 5 cards") { + t.Fatalf("narrow active count was not prioritized: %q", ansi.Strip(countLine)) + } + if !strings.HasPrefix(countLines[0], "> ") { + t.Fatalf("narrow active text focus was not visible: %q", ansi.Strip(countLine)) + } + m.filter.restore(boardFilter{}) + m.filter.focus = filterLabels + m.filter.labelIndex = len(m.filterLabels()) - 1 + m.width = 16 + focusedLine, _ := m.renderFilterBar(m.width) + if !strings.HasPrefix(ansi.Strip(focusedLine), ">[+ ui]<") { + t.Fatalf("focused label is outside the viewport: %q", ansi.Strip(focusedLine)) + } + m.filter.restore(boardFilter{Text: "login"}) + for _, width := range []int{1, 2, 3, 8, 16, 23, 99} { + m.width = width + for lineNumber, line := range strings.Split(m.render(), "\n") { + if got := ansi.StringWidth(line); got > width { + t.Fatalf("width %d line %d rendered %d cells: %q", width, lineNumber+1, got, line) + } + } + } +} + +func TestFilterBarSanitizesTerminalControlsWithoutChangingState(t *testing.T) { + hostileText := "safe\x1b[31m-red\x1b[0m\x1b]2;owned\x07\x00\x9b31m" + hostileTag := "tag\x1bPpayload\x1b\\\x1b]52;c;stolen\x07\x1f" + m := NewModel(stubBoardReader{}, nil, "u") + m.board = board.Board{Tasks: []board.Task{{ID: "x", Status: board.StatusTodo, Tags: []string{hostileTag}}}} + m.filter.restore(boardFilter{Text: hostileText, Tags: []string{hostileTag}}) + storedText := m.filter.input.Value() + m.filter.focus = filterLabels + view, hits := m.renderFilterBar(160) + for _, r := range view { + if r == '\n' { + continue + } + if r <= 0x1f || (r >= 0x7f && r <= 0x9f) { + t.Fatalf("filter bar contains terminal control U+%04X: %q", r, view) + } + } + if strings.Contains(view, "payload") || strings.Contains(view, "stolen") { + t.Fatalf("filter bar retained control-sequence payload: %q", view) + } + if m.filter.input.Value() != storedText || !reflect.DeepEqual(m.filter.tags, []string{hostileTag}) || m.board.Tasks[0].Tags[0] != hostileTag { + t.Fatal("render sanitization mutated filter or board state") + } + foundOriginalHit := false + for _, hit := range hits { + if hit.kind == boardHitFilterLabel && hit.tag == hostileTag { + foundOriginalHit = true + } + } + if !foundOriginalHit { + t.Fatal("sanitized label lost its exact filter identity") + } +} + +func TestBoardMouseFocusLeavesTheFilter(t *testing.T) { + m := NewModel(stubDetailBoardReader{stubBoardReader{board: filterFixture()}}, nil, "u") + completeBoardLoad(t, &m, m.Init()) + m.filter.focusText() + updateTestModel(t, &m, boardColumnClickedMsg{status: board.StatusDoing}) + if m.filter.focus != filterUnfocused || m.filter.input.Focused() { + t.Fatalf("column click retained filter focus: %+v", m.filter) + } + m.filter.focusText() + command := updateTestModel(t, &m, boardCardClickedMsg{taskID: "login"}) + if command == nil || !m.detail.IsOpen() || m.filter.focus != filterUnfocused || m.filter.input.Focused() { + t.Fatalf("card click = detail:%v filter:%+v command:%v", m.detail.IsOpen(), m.filter, command) + } +} + +func TestPreferenceLegacyDecodeAndReadFailures(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "preferences.json") + if err := os.WriteFile(path, []byte("{\"show_cancelled\":true}\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := loadTUIPreferences(path) + if err != nil || !got.ShowCancelled || got.Filter.Text != "" || len(got.Filter.Tags) != 0 { + t.Fatalf("legacy preference = %+v,%v", got, err) + } + if err := os.Chmod(path, 0); err != nil { + t.Fatal(err) + } + if _, err := loadTUIPreferences(path); err == nil && os.Geteuid() != 0 { + t.Fatal("unreadable preference loaded") + } + + root := NewModel(stubBoardReader{}, nil, "u") + root.board = filterFixture() + root.savePreferences = func(tuiPreferences) error { return errors.New("disk full") } + command := root.mutateFilter(func(filter *boardFilterState) { filter.toggleTag("bug") }) + updateTestModel(t, &root, command()) + if !root.filter.matches(root.board.Tasks[0]) || root.preferenceErr == nil { + t.Fatalf("failed persistence changed in-memory filter: filter=%+v err=%v", root.filter.value(), root.preferenceErr) + } +} + +func TestPreferenceRestoreAndSetupFailures(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "preferences.json") + want := tuiPreferences{ + ShowCancelled: true, + Filter: boardFilter{Text: "login", Tags: []string{"bug", "bug", " auth "}}, + } + if err := saveTUIPreferences(path, want); err != nil { + t.Fatal(err) + } + m := NewModel(stubBoardReader{}, nil, "alice") + m.restorePreferences(path) + if m.preferenceErr != nil || !m.boardView.showCancelled || m.filter.input.Value() != "login" || !reflect.DeepEqual(m.filter.tags, []string{"bug", "auth"}) { + t.Fatalf("restored model = cancelled:%v filter:%+v err:%v", m.boardView.showCancelled, m.filter.value(), m.preferenceErr) + } + + failure := errors.New("injected setup failure") + ops := osPreferenceFileOps + ops.mkdirAll = func(string, os.FileMode) error { return failure } + if err := saveTUIPreferencesWithOps(path, tuiPreferences{}, ops); err == nil || !strings.Contains(err.Error(), "create directory") { + t.Fatalf("mkdir failure = %v", err) + } + ops = osPreferenceFileOps + ops.createTmp = func(string, string) (preferenceTempFile, error) { return nil, failure } + if err := saveTUIPreferencesWithOps(path, tuiPreferences{}, ops); err == nil || !strings.Contains(err.Error(), "create temporary") { + t.Fatalf("temporary-file failure = %v", err) + } + + malformed := filepath.Join(dir, "malformed.json") + if err := os.WriteFile(malformed, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + m.restorePreferences(malformed) + if m.preferenceErr == nil || m.boardView.showCancelled != true || m.filter.input.Value() != "login" { + t.Fatalf("failed restore discarded last good state: cancelled:%v filter:%+v err:%v", m.boardView.showCancelled, m.filter.value(), m.preferenceErr) + } +} + +func TestFilterInteractionBranches(t *testing.T) { + m := NewModel(stubBoardReader{}, nil, "u") + if changed := m.filter.clear(); changed { + t.Fatal("empty filter reported a clear") + } + if m.filter.toggleTag("") || m.filter.hasTag("missing") { + t.Fatal("empty/missing tag changed filter") + } + m.filter.toggleTag("stale") + if !m.filter.hasTag("stale") || !m.filter.toggleTag("stale") || m.filter.hasTag("stale") { + t.Fatal("tag removal failed") + } + if command := m.mutateFilter(func(*boardFilterState) {}); command != nil { + t.Fatalf("no-op mutation returned %v", command) + } + + // Text focus on a label-free board can only return to the board. + updateTestModel(t, &m, tea.KeyPressMsg{Code: '/'}) + if handled, command := m.handleFilterKey(tea.KeyPressMsg{Code: tea.KeyTab}); !handled || command != nil || m.filter.focus != filterUnfocused { + t.Fatalf("label-free tab = handled:%v command:%v focus:%v", handled, command, m.filter.focus) + } + if handled, _ := m.handleFilterKey(tea.KeyPressMsg{Code: 'f'}); !handled || m.filter.focus != filterUnfocused { + t.Fatalf("label-free f = handled:%v focus:%v", handled, m.filter.focus) + } + if handled, command := m.handleFilterKey(tea.KeyPressMsg{Code: 'x'}); handled || command != nil { + t.Fatalf("inactive clear = handled:%v command:%v", handled, command) + } + + m.board = filterFixture() + updateTestModel(t, &m, tea.KeyPressMsg{Code: '/'}) + if handled, _ := m.handleFilterKey(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}); !handled || m.filter.focus != filterLabels || m.filter.labelIndex != len(m.filterLabels())-1 { + t.Fatalf("shift-tab label focus = handled:%v focus:%v index:%d", handled, m.filter.focus, m.filter.labelIndex) + } + last := m.filter.labelIndex + for _, key := range []tea.KeyPressMsg{{Code: tea.KeyLeft}, {Code: 'h'}, {Code: tea.KeyTab, Mod: tea.ModShift}} { + if handled, command := m.handleFilterKey(key); !handled || command != nil { + t.Fatalf("previous label key %q = handled:%v command:%v", key.String(), handled, command) + } + } + if m.filter.labelIndex == last { + t.Fatal("previous label keys did not move focus") + } + for _, key := range []tea.KeyPressMsg{{Code: tea.KeyRight}, {Code: 'l'}, {Code: tea.KeyTab}} { + if handled, command := m.handleFilterKey(key); !handled || command != nil { + t.Fatalf("next label key %q = handled:%v command:%v", key.String(), handled, command) + } + } + if handled, command := m.handleFilterKey(tea.KeyPressMsg{Code: '?'}); !handled || command != nil { + t.Fatalf("unknown label key = handled:%v command:%v", handled, command) + } + if handled, command := m.handleFilterKey(tea.KeyPressMsg{Code: '/'}); !handled || command == nil || m.filter.focus != filterText { + t.Fatalf("label-to-text = handled:%v command:%v focus:%v", handled, command, m.filter.focus) + } + if handled, command := m.handleFilterKey(tea.KeyPressMsg{Code: tea.KeyLeft}); !handled || command != nil { + t.Fatalf("text cursor key = handled:%v command:%v", handled, command) + } + if handled, command := m.handleFilterKey(tea.KeyPressMsg{Code: tea.KeyEnter}); !handled || command != nil || m.filter.focus != filterUnfocused { + t.Fatalf("text enter = handled:%v command:%v focus:%v", handled, command, m.filter.focus) + } + + m.filter.restore(boardFilter{Tags: []string{"removed-from-board"}}) + labels := m.filterLabels() + if labels[len(labels)-1] != "ui" || !containsString(labels, "removed-from-board") { + t.Fatalf("labels with stale selection = %v", labels) + } + m.filter.focus = filterLabels + m.filter.labelIndex = 999 + if handled, command := m.handleFilterKey(tea.KeyPressMsg{Code: tea.KeyEscape}); !handled || command != nil || m.filter.focus != filterUnfocused { + t.Fatalf("label escape = handled:%v command:%v focus:%v", handled, command, m.filter.focus) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func TestFilterMouseControlsAndPreferenceEquality(t *testing.T) { + hits := []boardHit{ + {x0: 0, x1: 5, y0: 1, y1: 2, kind: boardHitFilterText}, + {x0: 5, x1: 10, y0: 1, y1: 2, kind: boardHitFilterClear}, + {x0: 0, x1: 10, y0: 2, y1: 3, status: board.StatusDoing}, + } + for _, test := range []struct { + x, y int + want any + }{ + {1, 1, filterTextClickedMsg{}}, + {6, 1, filterClearClickedMsg{}}, + {1, 2, boardColumnClickedMsg{status: board.StatusDoing}}, + } { + command := boardMouseHandler(hits)(tea.MouseClickMsg{X: test.x, Y: test.y, Button: tea.MouseLeft}) + if command == nil || !reflect.DeepEqual(command(), test.want) { + t.Fatalf("mouse %d,%d = %#v, want %#v", test.x, test.y, command, test.want) + } + } + if command := boardMouseHandler(hits)(tea.MouseClickMsg{X: 1, Y: 1, Button: tea.MouseRight}); command != nil { + t.Fatalf("right click returned %v", command) + } + + base := tuiPreferences{ShowCancelled: true, Filter: boardFilter{Text: "x", Tags: []string{"a"}}} + if !preferencesEqual(base, base) || preferencesEqual(base, tuiPreferences{}) || + preferencesEqual(base, tuiPreferences{ShowCancelled: true, Filter: boardFilter{Text: "y", Tags: []string{"a"}}}) || + preferencesEqual(base, tuiPreferences{ShowCancelled: true, Filter: boardFilter{Text: "x", Tags: []string{"b"}}}) { + t.Fatal("preference equality branches changed") + } + + m := NewModel(stubBoardReader{}, nil, "u") + m.savePreferences = func(tuiPreferences) error { return nil } + m.prefSaving = true + pending := base + m.prefPending = &pending + if command := m.finishPreferences(preferenceSavedMsg{preferences: base}); command != nil || m.prefSaving { + t.Fatalf("equal pending snapshot retried: command=%v saving=%v", command, m.prefSaving) + } +} + +func TestMixedPreferenceWritesSerializeLatestSnapshot(t *testing.T) { + var saved []tuiPreferences + m := NewModel(stubBoardReader{}, nil, "u") + m.board = filterFixture() + m.savePreferences = func(preferences tuiPreferences) error { + saved = append(saved, preferences) + return nil + } + m.boardView.handleKey("c", m.filteredBoard()) + first := m.queuePreferences() + if command := m.mutateFilter(func(filter *boardFilterState) { filter.toggleTag("bug") }); command != nil { + t.Fatalf("overlapping filter write returned %v", command) + } + m.boardView.handleKey("c", m.filteredBoard()) + if command := m.queuePreferences(); command != nil { + t.Fatalf("overlapping cancelled write returned %v", command) + } + successor := updateTestModel(t, &m, first()) + if successor == nil || m.prefPending != nil || !m.prefSaving { + t.Fatalf("latest snapshot was not scheduled: model=%+v command=%v", m, successor) + } + if command := updateTestModel(t, &m, successor()); command != nil || m.prefSaving { + t.Fatalf("successor did not finish: command=%v saving=%v", command, m.prefSaving) + } + want := []tuiPreferences{ + {ShowCancelled: true}, + {Filter: boardFilter{Tags: []string{"bug"}}}, + } + if !reflect.DeepEqual(saved, want) { + t.Fatalf("serialized snapshots = %+v, want %+v", saved, want) + } +} + +func TestFailedPreferenceWriteRetriesEqualPendingSnapshot(t *testing.T) { + want := tuiPreferences{ShowCancelled: true, Filter: boardFilter{Text: "same", Tags: []string{"bug"}}} + writes := 0 + m := NewModel(stubBoardReader{}, nil, "u") + m.savePreferences = func(got tuiPreferences) error { + writes++ + if !preferencesEqual(got, want) { + t.Fatalf("retry wrote %+v, want %+v", got, want) + } + return nil + } + m.prefSaving = true + pending := want + m.prefPending = &pending + retry := m.finishPreferences(preferenceSavedMsg{preferences: want, err: errors.New("disk full")}) + if retry == nil || !m.prefSaving || m.prefPending != nil { + t.Fatalf("failed equal snapshot was not retried: model=%+v command=%v", m, retry) + } + if next := updateTestModel(t, &m, retry()); next != nil || m.prefSaving || m.preferenceErr != nil || writes != 1 { + t.Fatalf("retry completion = saving:%v err:%v writes:%d command:%v", m.prefSaving, m.preferenceErr, writes, next) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 128f23c..d5f8aa6 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -49,29 +49,30 @@ type pollTickMsg struct{} // dimensions, and all message routing; commands perform IO and only return // messages, so Update remains deterministic. type Model struct { - store boardReader - watcher dataVersionReader - user string - board board.Board - boardView boardViewState - detail carddetail.Model - width int - height int - loading bool - reloadPending bool - loadErr error - pollErr error - dataVersion int64 - haveVersion bool - stopped bool - readContext context.Context - now func() time.Time - saveCancelled func(bool) error - preferenceErr error - prefSaving bool - prefPending *bool - settings *settingsModel - settingsNew func() *settingsModel + store boardReader + watcher dataVersionReader + user string + board board.Board + boardView boardViewState + filter boardFilterState + detail carddetail.Model + width int + height int + loading bool + reloadPending bool + loadErr error + pollErr error + dataVersion int64 + haveVersion bool + stopped bool + readContext context.Context + now func() time.Time + savePreferences func(tuiPreferences) error + preferenceErr error + prefSaving bool + prefPending *tuiPreferences + settings *settingsModel + settingsNew func() *settingsModel } // NewModel creates the root model for one local board owner. @@ -91,6 +92,7 @@ func newModel( watcher: watcher, user: user, board: board.Board{Title: "Board"}, + filter: newBoardFilterState(), detail: carddetail.New(detailReader, user), width: defaultWidth, height: defaultHeight, @@ -139,7 +141,8 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { default: return m, m.detail.Update(message) } - case boardCardClickedMsg, boardColumnClickedMsg: + case boardCardClickedMsg, boardColumnClickedMsg, + filterTextClickedMsg, filterLabelClickedMsg, filterClearClickedMsg: return m, nil default: detailCmd = m.detail.Update(message) @@ -162,6 +165,9 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } return m, command } + if handled, command := m.handleFilterKey(msg); handled { + return m, command + } switch msg.String() { case "q": m.stopped = true @@ -179,21 +185,38 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } return m, nil default: - if m.boardView.handleKey(msg.String(), m.board) == boardToggledCancelled { - return m, m.queueCancelledPreference() + if m.boardView.handleKey(msg.String(), m.filteredBoard()) == boardToggledCancelled { + return m, m.queuePreferences() } } case boardCardClickedMsg: - if m.boardView.focusTask(m.board, msg.taskID) { + m.filter.blur() + if m.boardView.focusTask(m.filteredBoard(), msg.taskID) { if task, ok := m.selectedTask(); ok { m.detail.Resize(m.width, m.height) return m, m.detail.Open(task) } } case boardColumnClickedMsg: - m.boardView.focusColumn(msg.status, m.board) - case cancelledPreferenceSavedMsg: - return m, m.finishCancelledPreference(msg) + m.filter.blur() + m.boardView.focusColumn(msg.status, m.filteredBoard()) + case filterTextClickedMsg: + if m.settings != nil { + return m, nil + } + return m, m.filter.focusText() + case filterLabelClickedMsg: + if m.settings != nil { + return m, nil + } + return m, m.mutateFilter(func(filter *boardFilterState) { filter.toggleTag(msg.tag) }) + case filterClearClickedMsg: + if m.settings != nil { + return m, nil + } + return m, m.mutateFilter(func(filter *boardFilterState) { filter.clear() }) + case preferenceSavedMsg: + return m, m.finishPreferences(msg) case tea.WindowSizeMsg: if msg.Width > 0 { m.width = msg.Width @@ -252,9 +275,9 @@ func (m *Model) finishBoardLoad(msg boardLoadedMsg) tea.Cmd { m.loadErr = msg.err var detailCmd tea.Cmd if msg.err == nil { - previous := m.board + previous := m.filteredBoard() m.board = msg.board - m.boardView.adoptBoard(previous, m.board) + m.boardView.adoptBoard(previous, m.filteredBoard()) detailCmd = m.reconcileDetail() } if !m.reloadPending { diff --git a/internal/tui/preferences.go b/internal/tui/preferences.go index 60f4003..b4b4ef7 100644 --- a/internal/tui/preferences.go +++ b/internal/tui/preferences.go @@ -14,12 +14,13 @@ import ( ) type tuiPreferences struct { - ShowCancelled bool `json:"show_cancelled"` + ShowCancelled bool `json:"show_cancelled"` + Filter boardFilter `json:"filter,omitempty"` } -type cancelledPreferenceSavedMsg struct { - show bool - err error +type preferenceSavedMsg struct { + preferences tuiPreferences + err error } // tuiPreferencesPath keeps display state beside its SQLite board. Hashing the @@ -37,19 +38,30 @@ func tuiPreferencesPath(databasePath, user string) (string, error) { return filepath.Join(filepath.Dir(databasePath), ".kb-tui", name), nil } -func loadCancelledPreference(path string) (bool, error) { +func loadTUIPreferences(path string) (tuiPreferences, error) { data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { - return false, nil + return tuiPreferences{}, nil } if err != nil { - return false, fmt.Errorf("tui preferences: read: %w", err) + return tuiPreferences{}, fmt.Errorf("tui preferences: read: %w", err) } var preferences tuiPreferences if err := json.Unmarshal(data, &preferences); err != nil { - return false, fmt.Errorf("tui preferences: decode: %w", err) + return tuiPreferences{}, fmt.Errorf("tui preferences: decode: %w", err) } - return preferences.ShowCancelled, nil + preferences.Filter.Tags = normalizedFilterTags(preferences.Filter.Tags) + return preferences, nil +} + +func (m *Model) restorePreferences(path string) { + preferences, err := loadTUIPreferences(path) + m.preferenceErr = err + if err != nil { + return + } + m.boardView.showCancelled = preferences.ShowCancelled + m.filter.restore(preferences.Filter) } type preferenceTempFile interface { @@ -75,16 +87,17 @@ var osPreferenceFileOps = preferenceFileOps{ remove: os.Remove, } -func saveCancelledPreference(path string, show bool) error { - return saveCancelledPreferenceWithOps(path, show, osPreferenceFileOps) +func saveTUIPreferences(path string, preferences tuiPreferences) error { + return saveTUIPreferencesWithOps(path, preferences, osPreferenceFileOps) } -func saveCancelledPreferenceWithOps(path string, show bool, ops preferenceFileOps) error { +func saveTUIPreferencesWithOps(path string, preferences tuiPreferences, ops preferenceFileOps) error { dir := filepath.Dir(path) if err := ops.mkdirAll(dir, 0o700); err != nil { return fmt.Errorf("tui preferences: create directory: %w", err) } - data, err := json.Marshal(tuiPreferences{ShowCancelled: show}) + preferences.Filter.Tags = normalizedFilterTags(preferences.Filter.Tags) + data, err := json.Marshal(preferences) if err != nil { return fmt.Errorf("tui preferences: encode: %w", err) } @@ -125,36 +138,55 @@ func saveCancelledPreferenceWithOps(path string, show bool, ops preferenceFileOp return nil } -func (m *Model) queueCancelledPreference() tea.Cmd { - if m.saveCancelled == nil { +func (m *Model) preferences() tuiPreferences { + return tuiPreferences{ + ShowCancelled: m.boardView.showCancelled, + Filter: m.filter.value(), + } +} + +func (m *Model) queuePreferences() tea.Cmd { + if m.savePreferences == nil { return nil } - show := m.boardView.showCancelled + preferences := m.preferences() if m.prefSaving { - m.prefPending = &show + m.prefPending = &preferences return nil } m.prefSaving = true - return m.writeCancelledPreference(show) + return m.writePreferences(preferences) } -func (m Model) writeCancelledPreference(show bool) tea.Cmd { +func (m Model) writePreferences(preferences tuiPreferences) tea.Cmd { return func() tea.Msg { - return cancelledPreferenceSavedMsg{show: show, err: m.saveCancelled(show)} + return preferenceSavedMsg{preferences: preferences, err: m.savePreferences(preferences)} } } -func (m *Model) finishCancelledPreference(message cancelledPreferenceSavedMsg) tea.Cmd { +func (m *Model) finishPreferences(message preferenceSavedMsg) tea.Cmd { m.prefSaving = false m.preferenceErr = message.err if m.prefPending == nil { return nil } - show := *m.prefPending + preferences := *m.prefPending m.prefPending = nil - if show == message.show { + if message.err == nil && preferencesEqual(preferences, message.preferences) { return nil } m.prefSaving = true - return m.writeCancelledPreference(show) + return m.writePreferences(preferences) +} + +func preferencesEqual(left, right tuiPreferences) bool { + if left.ShowCancelled != right.ShowCancelled || left.Filter.Text != right.Filter.Text || len(left.Filter.Tags) != len(right.Filter.Tags) { + return false + } + for i := range left.Filter.Tags { + if left.Filter.Tags[i] != right.Filter.Tags[i] { + return false + } + } + return true } diff --git a/internal/tui/run.go b/internal/tui/run.go index 91a0141..d37ac41 100644 --- a/internal/tui/run.go +++ b/internal/tui/run.go @@ -68,15 +68,15 @@ func runProgram( model := newModel(st, watcher, user, ctx) preferencePath, preferencePathErr := tuiPreferencesPath(databasePath, user) if preferencePathErr == nil { - model.boardView.showCancelled, model.preferenceErr = loadCancelledPreference(preferencePath) + model.restorePreferences(preferencePath) } else { model.preferenceErr = preferencePathErr } - model.saveCancelled = func(show bool) error { + model.savePreferences = func(preferences tuiPreferences) error { if preferencePathErr != nil { return preferencePathErr } - return saveCancelledPreference(preferencePath, show) + return saveTUIPreferences(preferencePath, preferences) } if settingsNew != nil { model.settingsNew = func() *settingsModel { return settingsNew(ctx) } diff --git a/internal/tui/testdata/TestEmptyBoardGolden.golden b/internal/tui/testdata/TestEmptyBoardGolden.golden index 69e34d6..aa169af 100644 --- a/internal/tui/testdata/TestEmptyBoardGolden.golden +++ b/internal/tui/testdata/TestEmptyBoardGolden.golden @@ -1,4 +1,6 @@ [?2026$p[?2027$p[>4m[=0;1u[?1049h[?25l[?2004h[?1002h[?1006h[>4;2m[=1;1u[?ukb / Board / default +/ Filter cards + ┌[1 TO DO 0]──────────────────────────┐ ┌2 DOING 0───────────────────────────┐ ┌3 DONE 0────────────────────────────┐ │(empty) │ │(empty) │ │(empty) │ │ │ │ │ │ │ @@ -14,7 +16,5 @@ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ -│ │ │ │ │ │ -│ │ │ │ │ │ └──────────────────────────────────────┘ └─────────────────────────────────────┘ └─────────────────────────────────────┘ ready | j/k cards | h/l/tab columns | 1-4 jump | c cancelled:off | q quit \ No newline at end of file diff --git a/internal/tui/testdata/generate_web_lower_fixture.go b/internal/tui/testdata/generate_web_lower_fixture.go new file mode 100644 index 0000000..4807116 --- /dev/null +++ b/internal/tui/testdata/generate_web_lower_fixture.go @@ -0,0 +1,393 @@ +//go:build ignore + +// Command generate_web_lower_fixture freezes Node's Unicode lowercase oracle. +// +// From the repository root: +// +// go run ./internal/tui/testdata/generate_web_lower_fixture.go +// go run ./internal/tui/testdata/generate_web_lower_fixture.go -write +// +// Set NODE_BINARY to another absolute executable path when Node is not at +// /usr/bin/node. Relative names are rejected; PATH is never searched. +// +// The default mode byte-compares generated output with the checked-in fixture. +package main + +import ( + "bufio" + "bytes" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "unicode" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +const ( + minimumNodeVersion = "24.15.0" + oracleNodeVersion = "v24.15.0" + oracleUnicode = "17.0" + baseUnicode = "15.0.0" + defaultFixturePath = "internal/tui/testdata/web_lower_unicode17.json" + defaultNodeBinary = "/usr/bin/node" +) + +const nodeOracle = ` +const [major, minor] = process.versions.node.split(".").map(Number); +if (major < 24 || (major === 24 && minor < 15)) { + throw new Error("Node >= 24.15.0 is required; found " + process.versions.node); +} +if (process.versions.unicode !== "17.0") { + throw new Error("Unicode 17.0 is required; found " + process.versions.unicode); +} +process.stdout.write("META;" + process.versions.node + ";" + process.versions.unicode + "\n"); +for (let cp = 0; cp <= 0x10ffff; cp++) { + if (cp >= 0xd800 && cp <= 0xdfff) continue; + const value = String.fromCodePoint(cp); + const lower = value.toLowerCase(); + const mapping = lower === value + ? "-" + : [...lower].map(char => char.codePointAt(0).toString(16)).join(","); + const cased = /\p{Cased}/u.test(value) ? "1" : "0"; + const ignorable = /\p{Case_Ignorable}/u.test(value) ? "1" : "0"; + process.stdout.write(cp.toString(16) + ";" + mapping + ";" + cased + ";" + ignorable + "\n"); +} +` + +type runeRange struct { + first rune + last rune +} + +type mapping struct { + input rune + output []rune +} + +type mappingRange struct { + first rune + last rune + delta rune +} + +func main() { + fixturePath := flag.String("fixture", defaultFixturePath, "fixture path relative to the repository root") + nodeBinary := flag.String("node", configuredNodeBinary(), "absolute path to Node >= 24.15 with Unicode 17") + write := flag.Bool("write", false, "replace the fixture instead of checking it") + flag.Parse() + + validatedNode, err := resolveNodeBinary(*nodeBinary) + if err != nil { + fatalf("resolve Node binary: %v", err) + } + if cases.UnicodeVersion != baseUnicode || unicode.Version != baseUnicode { + fatalf("base Unicode tables changed: cases=%s stdlib=%s, want %s", cases.UnicodeVersion, unicode.Version, baseUnicode) + } + generated, runtimeNode := generate(validatedNode) + if *write { + if err := os.WriteFile(*fixturePath, generated, 0o644); err != nil { + fatalf("write fixture: %v", err) + } + fmt.Printf("wrote %s using Node %s / Unicode %s\n", *fixturePath, runtimeNode, oracleUnicode) + return + } + checkedIn, err := os.ReadFile(*fixturePath) + if err != nil { + fatalf("read fixture: %v", err) + } + if !bytes.Equal(generated, checkedIn) { + fatalf("%s differs from Node %s / Unicode %s; regenerate with -write", *fixturePath, runtimeNode, oracleUnicode) + } + fmt.Printf("%s matches Node %s / Unicode %s\n", *fixturePath, runtimeNode, oracleUnicode) +} + +func generate(nodeBinary string) ([]byte, string) { + // Path is validated as absolute so os/exec never performs a PATH lookup. + command := &exec.Cmd{ + Path: nodeBinary, + Args: []string{nodeBinary, "-e", nodeOracle}, + } + stdout, err := command.StdoutPipe() + if err != nil { + fatalf("open Node stdout: %v", err) + } + var stderr bytes.Buffer + command.Stderr = &stderr + if err := command.Start(); err != nil { + fatalf("start Node >= %s: %v", minimumNodeVersion, err) + } + + baseLower := cases.Lower(language.Und, cases.HandleFinalSigma(false)) + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + if !scanner.Scan() { + fatalf("read Node metadata: %v %s", scanner.Err(), strings.TrimSpace(stderr.String())) + } + metadata := strings.Split(scanner.Text(), ";") + if len(metadata) != 3 || metadata[0] != "META" || metadata[2] != oracleUnicode { + fatalf("unexpected Node metadata %q", scanner.Text()) + } + runtimeNode := metadata[1] + + var mappings []mapping + var casedAdd, casedRemove []rune + var ignorableAdd, ignorableRemove []rune + seenScalars := 0 + for scanner.Scan() { + fields := strings.Split(scanner.Text(), ";") + if len(fields) != 4 { + fatalf("invalid Node oracle row %q", scanner.Text()) + } + r := parseHexRune(fields[0]) + nodeLower := parseMapping(fields[1], r) + if got := []rune(baseLower.String(string(r))); !runesEqual(got, nodeLower) { + mappings = append(mappings, mapping{input: r, output: nodeLower}) + } + recordPropertyDelta(r, baseCased(r), fields[2], &casedAdd, &casedRemove) + recordPropertyDelta(r, baseCaseIgnorable(r), fields[3], &ignorableAdd, &ignorableRemove) + seenScalars++ + } + if err := scanner.Err(); err != nil { + fatalf("read Node oracle: %v", err) + } + if err := command.Wait(); err != nil { + fatalf("Node oracle failed: %v %s", err, strings.TrimSpace(stderr.String())) + } + const scalarCount = unicode.MaxRune + 1 - (0xdfff - 0xd800 + 1) + if seenScalars != scalarCount { + fatalf("Node emitted %d scalars, want %d", seenScalars, scalarCount) + } + + pairs, mappingRanges := compactMappings(mappings) + return renderFixture( + pairs, + mappingRanges, + compactRanges(casedAdd), + compactRanges(casedRemove), + compactRanges(ignorableAdd), + compactRanges(ignorableRemove), + ), runtimeNode +} + +func configuredNodeBinary() string { + if configured := os.Getenv("NODE_BINARY"); configured != "" { + return configured + } + return defaultNodeBinary +} + +func resolveNodeBinary(value string) (string, error) { + if !filepath.IsAbs(value) { + return "", fmt.Errorf("path must be absolute, got %q", value) + } + value = filepath.Clean(value) + resolved, err := filepath.EvalSymlinks(value) + if err != nil { + return "", fmt.Errorf("evaluate %q: %w", value, err) + } + if !filepath.IsAbs(resolved) { + return "", fmt.Errorf("resolved path must be absolute, got %q", resolved) + } + resolved = filepath.Clean(resolved) + info, err := os.Stat(resolved) + if err != nil { + return "", fmt.Errorf("stat %q: %w", resolved, err) + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("%q is not a regular file", resolved) + } + if info.Mode().Perm()&0o111 == 0 { + return "", fmt.Errorf("%q is not executable", resolved) + } + return resolved, nil +} + +func recordPropertyDelta(r rune, base bool, encoded string, add, remove *[]rune) { + node := encoded == "1" + if encoded != "0" && !node { + fatalf("invalid property value %q for %U", encoded, r) + } + if base == node { + return + } + if node { + *add = append(*add, r) + } else { + *remove = append(*remove, r) + } +} + +func compactMappings(values []mapping) (pairs []mapping, ranges []mappingRange) { + for i := 0; i < len(values); { + if len(values[i].output) == 1 { + delta := values[i].output[0] - values[i].input + last := i + for last+1 < len(values) && len(values[last+1].output) == 1 && + values[last+1].input == values[last].input+1 && + values[last+1].output[0]-values[last+1].input == delta { + last++ + } + if last > i { + ranges = append(ranges, mappingRange{first: values[i].input, last: values[last].input, delta: delta}) + i = last + 1 + continue + } + } + pairs = append(pairs, values[i]) + i++ + } + return pairs, ranges +} + +func compactRanges(values []rune) []runeRange { + ranges := make([]runeRange, 0, len(values)) + for i := 0; i < len(values); { + last := i + for last+1 < len(values) && values[last+1] == values[last]+1 { + last++ + } + ranges = append(ranges, runeRange{first: values[i], last: values[last]}) + i = last + 1 + } + return ranges +} + +func renderFixture( + pairs []mapping, + mappingRanges []mappingRange, + casedAdd, casedRemove, ignorableAdd, ignorableRemove []runeRange, +) []byte { + var output bytes.Buffer + output.WriteString("{\n") + fmt.Fprintf(&output, " \"node_version\": %q,\n", oracleNodeVersion) + fmt.Fprintf(&output, " \"unicode_version\": %q,\n", oracleUnicode) + fmt.Fprintf(&output, " \"base_unicode_version\": %q,\n", baseUnicode) + output.WriteString(" \"source\": \"Generated by generate_web_lower_fixture.go from exhaustive JS toLowerCase, Cased, and Case_Ignorable enumeration\",\n") + output.WriteString(" \"scalar_mapping\": {\n") + output.WriteString(" \"pairs\": [\n") + for i, value := range pairs { + if len(value.output) != 1 { + fatalf("fixture cannot compact multi-rune delta %U -> %U", value.input, value.output) + } + comma := "," + if i == len(pairs)-1 { + comma = "" + } + fmt.Fprintf(&output, " [%q, %q]%s\n", formatRune(value.input), formatRune(value.output[0]), comma) + } + output.WriteString(" ],\n") + output.WriteString(" \"ranges\": [\n") + for i, value := range mappingRanges { + comma := "," + if i == len(mappingRanges)-1 { + comma = "" + } + fmt.Fprintf(&output, " {\"first\": %q, \"last\": %q, \"delta\": %d}%s\n", + formatRune(value.first), formatRune(value.last), value.delta, comma) + } + output.WriteString(" ]\n") + output.WriteString(" },\n") + writeProperty := func(name string, add, remove []runeRange, final bool) { + fmt.Fprintf(&output, " %q: {\n", name) + output.WriteString(" \"add\": [\n") + writeRanges(&output, add, " ") + output.WriteString(" ],\n") + output.WriteString(" \"remove\": [") + writeInlineRanges(&output, remove) + output.WriteString("]\n") + if final { + output.WriteString(" }\n") + } else { + output.WriteString(" },\n") + } + } + writeProperty("cased", casedAdd, casedRemove, false) + writeProperty("case_ignorable", ignorableAdd, ignorableRemove, true) + output.WriteString("}\n") + return output.Bytes() +} + +func writeRanges(output *bytes.Buffer, values []runeRange, indent string) { + for i, value := range values { + comma := "," + if i == len(values)-1 { + comma = "" + } + fmt.Fprintf(output, "%s[%q, %q]%s\n", indent, formatRune(value.first), formatRune(value.last), comma) + } +} + +func writeInlineRanges(output *bytes.Buffer, values []runeRange) { + for i, value := range values { + if i > 0 { + output.WriteString(", ") + } + fmt.Fprintf(output, "[%q, %q]", formatRune(value.first), formatRune(value.last)) + } +} + +func parseMapping(encoded string, identity rune) []rune { + if encoded == "-" { + return []rune{identity} + } + parts := strings.Split(encoded, ",") + result := make([]rune, len(parts)) + for i, value := range parts { + result[i] = parseHexRune(value) + } + return result +} + +func parseHexRune(value string) rune { + parsed, err := strconv.ParseInt(value, 16, 32) + if err != nil || parsed < 0 || parsed > unicode.MaxRune { + fatalf("invalid scalar %q", value) + } + return rune(parsed) +} + +func formatRune(r rune) string { + return fmt.Sprintf("%04X", r) +} + +func runesEqual(left, right []rune) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func baseCased(r rune) bool { + return unicode.IsLower(r) || unicode.Is(unicode.Other_Lowercase, r) || + unicode.IsUpper(r) || unicode.Is(unicode.Other_Uppercase, r) || unicode.IsTitle(r) +} + +func baseCaseIgnorable(r rune) bool { + if unicode.In(r, unicode.Mn, unicode.Me, unicode.Cf, unicode.Lm, unicode.Sk) { + return true + } + switch r { + case 0x0027, 0x002e, 0x003a, 0x00b7, 0x0387, 0x055f, 0x05f4, + 0x2018, 0x2019, 0x2024, 0x2027, 0xfe13, 0xfe52, 0xfe55, + 0xff07, 0xff0e, 0xff1a: + return true + default: + return false + } +} + +func fatalf(format string, values ...any) { + fmt.Fprintf(os.Stderr, "generate web lower fixture: "+format+"\n", values...) + os.Exit(1) +} diff --git a/internal/tui/testdata/generate_web_lower_fixture_test.go b/internal/tui/testdata/generate_web_lower_fixture_test.go new file mode 100644 index 0000000..524a897 --- /dev/null +++ b/internal/tui/testdata/generate_web_lower_fixture_test.go @@ -0,0 +1,68 @@ +//go:build ignore + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveNodeBinaryPinsSymlinkTarget(t *testing.T) { + directory := t.TempDir() + first := filepath.Join(directory, "node-first") + second := filepath.Join(directory, "node-second") + link := filepath.Join(directory, "node") + for path, content := range map[string]string{first: "first", second: "second"} { + if err := os.WriteFile(path, []byte(content), 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(filepath.Base(first), link); err != nil { + t.Fatal(err) + } + + resolved, err := resolveNodeBinary(link) + if err != nil { + t.Fatal(err) + } + if resolved != first { + t.Fatalf("resolved path = %q, want %q", resolved, first) + } + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Base(second), link); err != nil { + t.Fatal(err) + } + if resolved != first { + t.Fatalf("retarget changed pinned path to %q", resolved) + } + content, err := os.ReadFile(resolved) + if err != nil { + t.Fatal(err) + } + if string(content) != "first" { + t.Fatalf("pinned path reads %q after retarget, want first target", content) + } + retargeted, err := resolveNodeBinary(link) + if err != nil { + t.Fatal(err) + } + if retargeted != second { + t.Fatalf("new resolution = %q, want %q", retargeted, second) + } +} + +func TestResolveNodeBinaryRejectsUnsafePaths(t *testing.T) { + if _, err := resolveNodeBinary("node"); err == nil { + t.Fatal("relative Node path was accepted") + } + notExecutable := filepath.Join(t.TempDir(), "node") + if err := os.WriteFile(notExecutable, []byte("fixture"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := resolveNodeBinary(notExecutable); err == nil { + t.Fatal("non-executable Node path was accepted") + } +} diff --git a/internal/tui/testdata/web_lower_unicode17.json b/internal/tui/testdata/web_lower_unicode17.json new file mode 100644 index 0000000..f19a2d9 --- /dev/null +++ b/internal/tui/testdata/web_lower_unicode17.json @@ -0,0 +1,72 @@ +{ + "node_version": "v24.15.0", + "unicode_version": "17.0", + "base_unicode_version": "15.0.0", + "source": "Generated by generate_web_lower_fixture.go from exhaustive JS toLowerCase, Cased, and Case_Ignorable enumeration", + "scalar_mapping": { + "pairs": [ + ["1C89", "1C8A"], + ["A7CB", "0264"], + ["A7CC", "A7CD"], + ["A7CE", "A7CF"], + ["A7D2", "A7D3"], + ["A7D4", "A7D5"], + ["A7DA", "A7DB"], + ["A7DC", "019B"] + ], + "ranges": [ + {"first": "10D50", "last": "10D65", "delta": 32}, + {"first": "16EA0", "last": "16EB8", "delta": 27} + ] + }, + "cased": { + "add": [ + ["1C89", "1C8A"], + ["A7CB", "A7CF"], + ["A7D2", "A7D2"], + ["A7D4", "A7D4"], + ["A7DA", "A7DC"], + ["A7F1", "A7F1"], + ["10D50", "10D65"], + ["10D70", "10D85"], + ["16EA0", "16EB8"], + ["16EBB", "16ED3"] + ], + "remove": [["0295", "0295"]] + }, + "case_ignorable": { + "add": [ + ["0897", "0897"], + ["1ACF", "1ADD"], + ["1AE0", "1AEB"], + ["A7F1", "A7F1"], + ["10D4E", "10D4E"], + ["10D69", "10D6D"], + ["10D6F", "10D6F"], + ["10EC5", "10EC5"], + ["10EFA", "10EFC"], + ["113BB", "113C0"], + ["113CE", "113CE"], + ["113D0", "113D0"], + ["113D2", "113D2"], + ["113E1", "113E2"], + ["11B60", "11B60"], + ["11B62", "11B64"], + ["11B66", "11B66"], + ["11DD9", "11DD9"], + ["11F5A", "11F5A"], + ["1611E", "16129"], + ["1612D", "1612F"], + ["16D40", "16D42"], + ["16D6B", "16D6C"], + ["16FF2", "16FF3"], + ["1E5EE", "1E5EF"], + ["1E6E3", "1E6E3"], + ["1E6E6", "1E6E6"], + ["1E6EE", "1E6EF"], + ["1E6F5", "1E6F5"], + ["1E6FF", "1E6FF"] + ], + "remove": [["1171E", "1171E"]] + } +} diff --git a/internal/tui/web_lower.go b/internal/tui/web_lower.go new file mode 100644 index 0000000..7371b38 --- /dev/null +++ b/internal/tui/web_lower.go @@ -0,0 +1,211 @@ +package tui + +import ( + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// Node 24 uses Unicode 17 while the Go 1.25/1.26 x/text tables use Unicode 15. +// These are the frozen Unicode 16/17 property deltas needed by final sigma. +var webUnicode17CasedAdd = []webRuneRange{ + {0x1c89, 0x1c8a}, + {0xa7cb, 0xa7cf}, + {0xa7d2, 0xa7d2}, + {0xa7d4, 0xa7d4}, + {0xa7da, 0xa7dc}, + {0xa7f1, 0xa7f1}, + {0x10d50, 0x10d65}, + {0x10d70, 0x10d85}, + {0x16ea0, 0x16eb8}, + {0x16ebb, 0x16ed3}, +} + +var webUnicode17CaseIgnorableAdd = []webRuneRange{ + {0x0897, 0x0897}, + {0x1acf, 0x1add}, + {0x1ae0, 0x1aeb}, + {0xa7f1, 0xa7f1}, + {0x10d4e, 0x10d4e}, + {0x10d69, 0x10d6d}, + {0x10d6f, 0x10d6f}, + {0x10ec5, 0x10ec5}, + {0x10efa, 0x10efc}, + {0x113bb, 0x113c0}, + {0x113ce, 0x113ce}, + {0x113d0, 0x113d0}, + {0x113d2, 0x113d2}, + {0x113e1, 0x113e2}, + {0x11b60, 0x11b60}, + {0x11b62, 0x11b64}, + {0x11b66, 0x11b66}, + {0x11dd9, 0x11dd9}, + {0x11f5a, 0x11f5a}, + {0x1611e, 0x16129}, + {0x1612d, 0x1612f}, + {0x16d40, 0x16d42}, + {0x16d6b, 0x16d6c}, + {0x16ff2, 0x16ff3}, + {0x1e5ee, 0x1e5ef}, + {0x1e6e3, 0x1e6e3}, + {0x1e6e6, 0x1e6e6}, + {0x1e6ee, 0x1e6ef}, + {0x1e6f5, 0x1e6f5}, + {0x1e6ff, 0x1e6ff}, +} + +type webRuneRange struct { + first rune + last rune +} + +type webRuneToken struct { + raw string + rune rune + valid bool +} + +// webLower matches Node 24 String.prototype.toLowerCase, frozen at Unicode 17. +// Invalid UTF-8 has no JavaScript equivalent; it is preserved byte-for-byte and +// acts as an uncased, non-ignorable boundary, matching x/text's prior behavior. +func webLower(value string) string { + tokens := webRuneTokens(value) + lower := cases.Lower(language.Und, cases.HandleFinalSigma(false)) + var result strings.Builder + result.Grow(len(value)) + plainStart := 0 + offset := 0 + for i, token := range tokens { + mapped, hasUnicode17Mapping := webUnicode17Lower(token.rune) + if token.valid && token.rune != '\u03a3' && !hasUnicode17Mapping { + offset += len(token.raw) + continue + } + result.WriteString(lower.String(value[plainStart:offset])) + switch { + case !token.valid: + result.WriteString(token.raw) + case token.rune == '\u03a3': + if webFinalSigma(tokens, i) { + result.WriteRune('\u03c2') + } else { + result.WriteRune('\u03c3') + } + case hasUnicode17Mapping: + result.WriteRune(mapped) + } + offset += len(token.raw) + plainStart = offset + } + result.WriteString(lower.String(value[plainStart:])) + return result.String() +} + +func webRuneTokens(value string) []webRuneToken { + tokens := make([]webRuneToken, 0, utf8.RuneCountInString(value)) + for offset := 0; offset < len(value); { + r, size := utf8.DecodeRuneInString(value[offset:]) + valid := r != utf8.RuneError || size != 1 + tokens = append(tokens, webRuneToken{raw: value[offset : offset+size], rune: r, valid: valid}) + offset += size + } + return tokens +} + +func webFinalSigma(tokens []webRuneToken, sigma int) bool { + precededByCased := false + for i := sigma - 1; i >= 0; i-- { + token := tokens[i] + if token.valid && webUnicode17CaseIgnorable(token.rune) { + continue + } + precededByCased = token.valid && webUnicode17Cased(token.rune) + break + } + if !precededByCased { + return false + } + for i := sigma + 1; i < len(tokens); i++ { + token := tokens[i] + if token.valid && webUnicode17CaseIgnorable(token.rune) { + continue + } + return !token.valid || !webUnicode17Cased(token.rune) + } + return true +} + +func webUnicode17Lower(r rune) (rune, bool) { + if 0x10d50 <= r && r <= 0x10d65 { + return r + 0x20, true + } + if 0x16ea0 <= r && r <= 0x16eb8 { + return r + 0x1b, true + } + switch r { + case 0x1c89: + return 0x1c8a, true + case 0xa7cb: + return 0x0264, true + case 0xa7cc: + return 0xa7cd, true + case 0xa7ce: + return 0xa7cf, true + case 0xa7d2: + return 0xa7d3, true + case 0xa7d4: + return 0xa7d5, true + case 0xa7da: + return 0xa7db, true + case 0xa7dc: + return 0x019b, true + default: + return 0, false + } +} + +func webUnicode17Cased(r rune) bool { + if r == 0x0295 { + return false + } + if webInRuneRanges(r, webUnicode17CasedAdd) { + return true + } + return unicode.IsLower(r) || unicode.Is(unicode.Other_Lowercase, r) || + unicode.IsUpper(r) || unicode.Is(unicode.Other_Uppercase, r) || unicode.IsTitle(r) +} + +func webUnicode17CaseIgnorable(r rune) bool { + if r == 0x1171e { + return false + } + if webInRuneRanges(r, webUnicode17CaseIgnorableAdd) { + return true + } + if unicode.In(r, unicode.Mn, unicode.Me, unicode.Cf, unicode.Lm, unicode.Sk) { + return true + } + switch r { + case 0x0027, 0x002e, 0x003a, 0x00b7, 0x0387, 0x055f, 0x05f4, + 0x2018, 0x2019, 0x2024, 0x2027, 0xfe13, 0xfe52, 0xfe55, + 0xff07, 0xff0e, 0xff1a: + return true + default: + return false + } +} + +func webInRuneRanges(r rune, ranges []webRuneRange) bool { + for _, value := range ranges { + if r < value.first { + return false + } + if r <= value.last { + return true + } + } + return false +} diff --git a/internal/tui/web_lower_test.go b/internal/tui/web_lower_test.go new file mode 100644 index 0000000..e2a738d --- /dev/null +++ b/internal/tui/web_lower_test.go @@ -0,0 +1,243 @@ +package tui + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" + "testing" + "unicode" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +type webLowerOracleFixture struct { + NodeVersion string `json:"node_version"` + UnicodeVersion string `json:"unicode_version"` + BaseUnicodeVersion string `json:"base_unicode_version"` + ScalarMapping struct { + Pairs [][2]string `json:"pairs"` + Ranges []struct { + First string `json:"first"` + Last string `json:"last"` + Delta int32 `json:"delta"` + } `json:"ranges"` + } `json:"scalar_mapping"` + Cased webLowerPropertyFixture `json:"cased"` + CaseIgnorable webLowerPropertyFixture `json:"case_ignorable"` +} + +type webLowerPropertyFixture struct { + Add [][2]string `json:"add"` + Remove [][2]string `json:"remove"` +} + +func TestWebLowerMatchesFrozenNode24Unicode17Oracle(t *testing.T) { + fixture := loadWebLowerOracleFixture(t) + if fixture.NodeVersion != "v24.15.0" || fixture.UnicodeVersion != "17.0" { + t.Fatalf("unexpected frozen oracle: Node %s Unicode %s", fixture.NodeVersion, fixture.UnicodeVersion) + } + if cases.UnicodeVersion != fixture.BaseUnicodeVersion { + t.Fatalf("cases Unicode tables drifted: cases=%s fixture=%s", cases.UnicodeVersion, fixture.BaseUnicodeVersion) + } + if unicode.Version != fixture.BaseUnicodeVersion { + t.Fatalf("stdlib Unicode tables drifted: unicode=%s fixture=%s", unicode.Version, fixture.BaseUnicodeVersion) + } + + wantMapping := fixtureScalarMappings(t, fixture) + baseLower := cases.Lower(language.Und, cases.HandleFinalSigma(false)) + for r := rune(0); r <= unicode.MaxRune; r++ { + if 0xd800 <= r && r <= 0xdfff { + continue + } + want := baseLower.String(string(r)) + if mapped, ok := wantMapping[r]; ok { + want = string(mapped) + } + if got := webLower(string(r)); got != want { + t.Fatalf("webLower(%U) = %U, frozen Node oracle wants %U", r, []rune(got), []rune(want)) + } + } + if len(wantMapping) != 55 { + t.Fatalf("frozen scalar delta count = %d, want 55", len(wantMapping)) + } + + assertWebPropertyFixture(t, fixture.Cased, 108, webUnicode17Cased, baseUnicode15Cased) + assertWebPropertyFixture(t, fixture.CaseIgnorable, 89, webUnicode17CaseIgnorable, baseUnicode15CaseIgnorable) +} + +func TestWebLowerFrozenContextAndExpansionVectors(t *testing.T) { + for _, test := range []struct { + name string + input string + want string + }{ + {"expanding lowercase", "İ", "i\u0307"}, + {"Unicode 17 mapping", "\ua7dc", "\u019b"}, + {"new cased before sigma", "\ua7dcΣ", "\u019bς"}, + {"new cased after sigma", "AΣ\ua7dc", "aσ\u019b"}, + {"removed cased before sigma", "\u0295Σ", "\u0295σ"}, + {"new ignorable before sigma", "A\u0897Σ", "a\u0897ς"}, + {"removed ignorable after sigma", "AΣ\U0001171eA", "aς\U0001171ea"}, + {"cased ignorable is ignored", "\ua7f1Σ", "\ua7f1σ"}, + } { + t.Run(test.name, func(t *testing.T) { + if got := webLower(test.input); got != test.want { + t.Fatalf("webLower(%q) = %q, frozen Node oracle wants %q", test.input, got, test.want) + } + }) + } +} + +func TestWebLowerPreservesInvalidUTF8AsContextBoundary(t *testing.T) { + for _, test := range []struct { + name string + input []byte + want []byte + }{ + { + "invalid byte breaks preceding context", + []byte{'A', 0xff, 0xce, 0xa3}, + []byte{'a', 0xff, 0xcf, 0x83}, + }, + { + "invalid byte breaks following context", + []byte{'A', 0xce, 0xa3, 0xff, 'A'}, + []byte{'a', 0xcf, 0x82, 0xff, 'a'}, + }, + { + "invalid sequences remain byte exact", + []byte{0xc0, 0x80, 'I', 0xf5, 0x80, 0x80, 0x80}, + []byte{0xc0, 0x80, 'i', 0xf5, 0x80, 0x80, 0x80}, + }, + } { + t.Run(test.name, func(t *testing.T) { + if got := []byte(webLower(string(test.input))); string(got) != string(test.want) { + t.Fatalf("webLower bytes = % x, want % x", got, test.want) + } + }) + } +} + +func TestWebLowerProductionRangesAreSortedAndNonOverlapping(t *testing.T) { + assertSortedWebRanges(t, "cased additions", webUnicode17CasedAdd) + assertSortedWebRanges(t, "case-ignorable additions", webUnicode17CaseIgnorableAdd) +} + +func loadWebLowerOracleFixture(t *testing.T) webLowerOracleFixture { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", "web_lower_unicode17.json")) + if err != nil { + t.Fatal(err) + } + var fixture webLowerOracleFixture + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + return fixture +} + +func fixtureScalarMappings(t *testing.T, fixture webLowerOracleFixture) map[rune]rune { + t.Helper() + mappings := make(map[rune]rune) + for _, pair := range fixture.ScalarMapping.Pairs { + mappings[fixtureRune(t, pair[0])] = fixtureRune(t, pair[1]) + } + for _, value := range fixture.ScalarMapping.Ranges { + first, last := fixtureRune(t, value.First), fixtureRune(t, value.Last) + for r := first; r <= last; r++ { + mappings[r] = r + rune(value.Delta) + } + } + return mappings +} + +func assertWebPropertyFixture( + t *testing.T, + fixture webLowerPropertyFixture, + wantDeltaCount int, + gotProperty func(rune) bool, + baseProperty func(rune) bool, +) { + t.Helper() + add := fixtureRuneSet(t, fixture.Add) + remove := fixtureRuneSet(t, fixture.Remove) + deltaCount := 0 + for r := rune(0); r <= unicode.MaxRune; r++ { + want := baseProperty(r) + if remove[r] { + want = false + } else if add[r] { + want = true + } + if want != baseProperty(r) { + deltaCount++ + } + if got := gotProperty(r); got != want { + t.Fatalf("property(%U) = %t, frozen Node oracle wants %t", r, got, want) + } + } + if deltaCount != wantDeltaCount { + t.Fatalf("property delta count = %d, want %d", deltaCount, wantDeltaCount) + } +} + +func fixtureRuneSet(t *testing.T, values [][2]string) map[rune]bool { + t.Helper() + result := make(map[rune]bool) + for _, value := range values { + first, last := fixtureRune(t, value[0]), fixtureRune(t, value[1]) + if first > last { + t.Fatalf("fixture range is reversed: %U..%U", first, last) + } + for r := first; r <= last; r++ { + if result[r] { + t.Fatalf("fixture ranges overlap at %U", r) + } + result[r] = true + } + } + return result +} + +func fixtureRune(t *testing.T, value string) rune { + t.Helper() + parsed, err := strconv.ParseInt(value, 16, 32) + if err != nil || parsed < 0 || parsed > unicode.MaxRune { + t.Fatalf("invalid fixture rune %q: %v", value, err) + } + return rune(parsed) +} + +func baseUnicode15Cased(r rune) bool { + return unicode.IsLower(r) || unicode.Is(unicode.Other_Lowercase, r) || + unicode.IsUpper(r) || unicode.Is(unicode.Other_Uppercase, r) || unicode.IsTitle(r) +} + +func baseUnicode15CaseIgnorable(r rune) bool { + if unicode.In(r, unicode.Mn, unicode.Me, unicode.Cf, unicode.Lm, unicode.Sk) { + return true + } + switch r { + case 0x0027, 0x002e, 0x003a, 0x00b7, 0x0387, 0x055f, 0x05f4, + 0x2018, 0x2019, 0x2024, 0x2027, 0xfe13, 0xfe52, 0xfe55, + 0xff07, 0xff0e, 0xff1a: + return true + default: + return false + } +} + +func assertSortedWebRanges(t *testing.T, name string, values []webRuneRange) { + t.Helper() + for i, value := range values { + if value.first > value.last { + t.Fatalf("%s range %d is reversed: %U..%U", name, i, value.first, value.last) + } + if i > 0 && values[i-1].last >= value.first { + t.Fatalf("%s ranges %d and %d overlap or are unsorted: %U..%U then %U..%U", + name, i-1, i, values[i-1].first, values[i-1].last, value.first, value.last) + } + } +} diff --git a/scripts/check-go-checkers.test.sh b/scripts/check-go-checkers.test.sh index 226b7cc..bb85b44 100644 --- a/scripts/check-go-checkers.test.sh +++ b/scripts/check-go-checkers.test.sh @@ -45,6 +45,13 @@ cat >"$fake_go_dir/go" <<'EOF' set -eu case "$1" in test) + if [ "$#" -eq 4 ] && \ + [ "$2" = "./internal/tui/testdata/generate_web_lower_fixture.go" ] && \ + [ "$3" = "./internal/tui/testdata/generate_web_lower_fixture_test.go" ] && \ + [ "$4" = "-count=1" ]; then + printf '%s\n' "${FAKE_HELPER_TEST_OUTPUT:-ok command-line-arguments 0.001s generator helper fixture test ran}" + exit "${FAKE_HELPER_TEST_STATUS:-0}" + fi for argument do case "$argument" in -coverprofile=*) : >"${argument#-coverprofile=}" ;; @@ -89,6 +96,13 @@ if ! PATH="$fake_go_dir:$PATH" GO_COVERAGE_PROFILE="$coverage_profile" \ fi [ -f "$coverage_profile" ] || fail "GO_COVERAGE_PROFILE was not retained" assert_contains 'Go statement coverage: 96.4% (required: 96.4%)' "$coverage_output" "coverage success" +assert_contains 'generator helper fixture test ran' "$coverage_output" "coverage helper invocation" + +status=0 +PATH="$fake_go_dir:$PATH" FAKE_HELPER_TEST_STATUS=19 FAKE_HELPER_TEST_OUTPUT='generator helper fixture test failed' \ + run_capture "$coverage_output" sh "$repo_root/scripts/check-go-coverage.sh" || status=$? +assert_status 19 "$status" "coverage helper failure" +assert_contains 'generator helper fixture test failed' "$coverage_output" "coverage helper failure" status=0 PATH="$fake_go_dir:$PATH" GO_PACKAGE_COVERAGE_THRESHOLD=94.9 \ diff --git a/scripts/check-go-coverage.sh b/scripts/check-go-coverage.sh index 04ca9cb..6e21009 100644 --- a/scripts/check-go-coverage.sh +++ b/scripts/check-go-coverage.sh @@ -37,6 +37,18 @@ validate_threshold GO_TOTAL_COVERAGE_THRESHOLD "$total_threshold" : "${CGO_ENABLED:=0}" export CGO_ENABLED +if helper_test_output="$(go test \ + ./internal/tui/testdata/generate_web_lower_fixture.go \ + ./internal/tui/testdata/generate_web_lower_fixture_test.go \ + -count=1)"; then + : +else + status=$? + printf '%s\n' "$helper_test_output" + exit "$status" +fi +printf '%s\n' "$helper_test_output" + if test_output="$(go test ./... -count=1 -covermode=atomic -coverprofile="$profile")"; then : else diff --git a/sonar-project.properties b/sonar-project.properties index a86906b..8d80bb5 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,8 +1,8 @@ sonar.sources=. sonar.tests=. sonar.exclusions=coverage/**,dist/**,node_modules/**,.omx/**,package-lock.json,tsconfig.tsbuildinfo -sonar.test.inclusions=**/*_test.go,src/**/*.test.ts,src/**/*.test.tsx,src/test/**,scripts/**/*.test.sh,scripts/**/test-*.sh,scripts/**/test_*.py,scripts/**/test_*.cjs -sonar.coverage.exclusions=.github/**,scripts/**,vite.config.ts +sonar.test.inclusions=**/*_test.go,**/testdata/**,src/**/*.test.ts,src/**/*.test.tsx,src/test/**,scripts/**/*.test.sh,scripts/**/test-*.sh,scripts/**/test_*.py,scripts/**/test_*.cjs +sonar.coverage.exclusions=.github/**,**/testdata/**,scripts/**,vite.config.ts sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.go.coverage.reportPaths=coverage/go.out