From 2fe65eb7cf9429ed3148b8f9bab377bb3d7a3539 Mon Sep 17 00:00:00 2001 From: NiloCK Date: Mon, 1 Jun 2026 00:40:15 -0300 Subject: [PATCH] add SystemTags concept, filescope item type --- main.go | 22 ++++++ main_test.go | 173 ++++++++++++++++---------------------------- tui/focus_test.go | 92 +++++++++++++++++++++++ tui/tui.go | 63 +++++++++++++++- tui/update.go | 14 +++- tui/view.go | 32 ++++++-- tuido/tuido.go | 119 ++++++++++++++++++++++++++++++ tuido/tuido_test.go | 75 +++++++++++++++++++ 8 files changed, 471 insertions(+), 119 deletions(-) create mode 100644 tui/focus_test.go diff --git a/main.go b/main.go index 3a0731f..6c9b7bd 100644 --- a/main.go +++ b/main.go @@ -46,6 +46,19 @@ func main() { showVersionInfo() return + case "focus": + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "Usage: tuido focus ") + os.Exit(1) + } + file := os.Args[2] + if _, err := os.Stat(file); err != nil { + fmt.Fprintf(os.Stderr, "cannot focus %q: %v\n", file, err) + os.Exit(1) + } + tui.RunFocused(file) + return + case "init": tui.RunInitWizard() return @@ -82,6 +95,10 @@ func runList(path string, max int, inclSnoozed bool, inclDone bool) { tui.SortItems(all) + // child rollups are computed against the full, pre-collapse set + fullSet := all + all = tuido.CollapseFileScoped(all) + var filtered []*tuido.Item for _, item := range all { s := item.Satus() @@ -131,6 +148,10 @@ func runList(path string, max int, inclSnoozed bool, inclDone bool) { prevFile = baseName } entries[i].text = item.String() + if item.IsControl() { + rem, tot := tuido.ChildStats(item, fullSet) + entries[i].text += fmt.Sprintf(" [%d of %d]", rem, tot) + } } maxWidth := 0 @@ -177,6 +198,7 @@ Commands: --max N Limit output to N items create Create a new todo item add Alias for create + focus Open the TUI scoped to a single file init Create a local or global config (interactive) version Show version and platform information help Show this help diff --git a/main_test.go b/main_test.go index 2abb5d1..bdc0709 100644 --- a/main_test.go +++ b/main_test.go @@ -1,136 +1,87 @@ package main import ( - "bytes" "os" - "runtime" + "os/exec" + "path/filepath" "strings" "testing" - - "github.com/nilock/tuido/utils" ) -func TestShowVersionInfo(t *testing.T) { - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - showVersionInfo() - - w.Close() - os.Stdout = oldStdout - - var buf bytes.Buffer - buf.ReadFrom(r) - output := buf.String() - - // Test that output contains expected components - expectedVersion := utils.Version() - expectedPlatform := runtime.GOOS + "/" + runtime.GOARCH - expectedAsset := utils.BuildAssetName(expectedVersion, runtime.GOOS, runtime.GOARCH) - - if !strings.Contains(output, expectedVersion) { - t.Errorf("Output should contain version %s, got: %s", expectedVersion, output) - } - - if !strings.Contains(output, expectedPlatform) { - t.Errorf("Output should contain platform %s, got: %s", expectedPlatform, output) - } - - if !strings.Contains(output, expectedAsset) { - t.Errorf("Output should contain asset name %s, got: %s", expectedAsset, output) +// buildBinary compiles tuido into a temp dir and returns the binary path. +func buildBinary(t *testing.T) string { + t.Helper() + bin := filepath.Join(t.TempDir(), "tuido") + out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput() + if err != nil { + t.Fatalf("build failed: %v\n%s", err, out) } + return bin +} - // Test that output has the expected structure - lines := strings.Split(strings.TrimSpace(output), "\n") - if len(lines) < 3 { - t.Errorf("Expected at least 3 lines of output, got %d: %s", len(lines), output) +// TestListCollapsesFileScopedItems asserts that `tuido list` collapses a file +// containing a ##file control item down to that control item (plus a child +// rollup), and does NOT emit the file-scoped siblings individually. +func TestListCollapsesFileScopedItems(t *testing.T) { + bin := buildBinary(t) + + work := t.TempDir() + content := strings.Join([]string{ + "[@] Ship the parser rewrite ##file", + "[x] sketch the grammar", + "[ ] write the lexer", + "[ ] write the parser", + "[ ] wire up errors", + }, "\n") + "\n" + if err := os.WriteFile(filepath.Join(work, "TODO.md"), []byte(content), 0644); err != nil { + t.Fatal(err) } - // Test first line format - if !strings.HasPrefix(lines[0], "tuido ") { - t.Errorf("First line should start with 'tuido ', got: %s", lines[0]) + out, err := exec.Command(bin, "list", work).CombinedOutput() + if err != nil { + t.Fatalf("list failed: %v\n%s", err, out) } + got := string(out) - // Test second line format - if !strings.HasPrefix(lines[1], "Platform: ") { - t.Errorf("Second line should start with 'Platform: ', got: %s", lines[1]) + // the control item's text is shown... + if !strings.Contains(got, "Ship the parser rewrite") { + t.Errorf("expected control item in output, got:\n%s", got) } - - // Test third line format - if !strings.HasPrefix(lines[2], "Asset: ") { - t.Errorf("Third line should start with 'Asset: ', got: %s", lines[2]) + // ...with a child rollup (3 open/ongoing of 4 children) + if !strings.Contains(got, "3 of 4") { + t.Errorf("expected child rollup '3 of 4' in output, got:\n%s", got) } - - // Test fourth line format (Available or error message) - if len(lines) >= 4 { - if !strings.HasPrefix(lines[3], "Available: ") { - t.Errorf("Fourth line should start with 'Available: ', got: %s", lines[3]) + // but the file-scoped children must NOT be listed individually + for _, child := range []string{"write the lexer", "write the parser", "wire up errors"} { + if strings.Contains(got, child) { + t.Errorf("child %q should be collapsed behind the control item, got:\n%s", child, got) } } } -func TestVersionInfoContainsExpectedAssetName(t *testing.T) { - version := utils.Version() - goos := runtime.GOOS - goarch := runtime.GOARCH - - expectedAsset := utils.BuildAssetName(version, goos, goarch) - - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - showVersionInfo() - - w.Close() - os.Stdout = oldStdout - - var buf bytes.Buffer - buf.ReadFrom(r) - output := buf.String() - - if !strings.Contains(output, expectedAsset) { - t.Errorf("Version info should contain asset name %s, but output was: %s", expectedAsset, output) - } -} - -func TestVersionInfoResilience(t *testing.T) { - // This test ensures that even if network calls fail, - // the basic version info is still displayed - - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - showVersionInfo() - - w.Close() - os.Stdout = oldStdout - - var buf bytes.Buffer - buf.ReadFrom(r) - output := buf.String() - - // Even if network fails, we should still get basic info - if !strings.Contains(output, "tuido") { - t.Error("Output should always contain 'tuido' even on network failure") +// TestListUncontrolledFileUnchanged asserts that files WITHOUT a control item +// still list every item, ie collapse is opt-in. +func TestListUncontrolledFileUnchanged(t *testing.T) { + bin := buildBinary(t) + + work := t.TempDir() + content := strings.Join([]string{ + "[ ] buy milk", + "[ ] call the dentist", + }, "\n") + "\n" + if err := os.WriteFile(filepath.Join(work, "list.md"), []byte(content), 0644); err != nil { + t.Fatal(err) } - if !strings.Contains(output, "Platform:") { - t.Error("Output should always contain 'Platform:' even on network failure") + out, err := exec.Command(bin, "list", work).CombinedOutput() + if err != nil { + t.Fatalf("list failed: %v\n%s", err, out) } + got := string(out) - if !strings.Contains(output, "Asset:") { - t.Error("Output should always contain 'Asset:' even on network failure") - } - - // Should either show "Available:" with asset info or error message - hasAvailable := strings.Contains(output, "Available:") - if !hasAvailable { - t.Error("Output should contain 'Available:' line with either asset info or error message") + for _, item := range []string{"buy milk", "call the dentist"} { + if !strings.Contains(got, item) { + t.Errorf("expected %q in output of uncontrolled file, got:\n%s", item, got) + } } -} \ No newline at end of file +} diff --git a/tui/focus_test.go b/tui/focus_test.go new file mode 100644 index 0000000..cbe4e3f --- /dev/null +++ b/tui/focus_test.go @@ -0,0 +1,92 @@ +package tui + +import ( + "testing" + + "github.com/nilock/tuido/tuido" +) + +func focusTestItems() []*tuido.Item { + mk := func(file string, line int, raw string) *tuido.Item { + it := tuido.New(file, line, raw) + return &it + } + return []*tuido.Item{ + mk("TODO.md", 1, "[@] Ship the parser rewrite ##file"), + mk("TODO.md", 2, "[x] sketch the grammar"), + mk("TODO.md", 3, "[ ] write the lexer"), + mk("TODO.md", 4, "[ ] write the parser"), + mk("notes.md", 1, "[ ] standalone item"), + } +} + +// In the aggregate view, a file with a ##file control item collapses to just +// that control item. +func TestRenderSelectionCollapsesControlledFile(t *testing.T) { + tu := newTUI(focusTestItems(), runConfig) + tu.populateRenderSelection() + + controlSeen, childSeen := false, false + for _, it := range tu.renderSelection { + if it.File() == "TODO.md" { + if it.IsControl() { + controlSeen = true + } else { + childSeen = true + } + } + } + + if !controlSeen { + t.Errorf("expected the TODO.md control item in aggregate view") + } + if childSeen { + t.Errorf("expected TODO.md children to be collapsed in aggregate view") + } +} + +// Focus mode scopes the list to a single file and shows every item in it +// (including children and done items), in file order. +func TestFocusShowsAllFileItems(t *testing.T) { + tu := newTUI(focusTestItems(), runConfig) + tu.enterFocus("TODO.md") + + if len(tu.renderSelection) != 4 { + t.Fatalf("expected 4 items in focus on TODO.md, got %d", len(tu.renderSelection)) + } + for i, it := range tu.renderSelection { + if it.File() != "TODO.md" { + t.Errorf("focus leaked a non-TODO.md item: %q", it.File()) + } + if i > 0 && tu.renderSelection[i-1].Line() > it.Line() { + t.Errorf("focus items not in file order at index %d", i) + } + } + + // the checked child must be present (focus bypasses status filtering) + foundDone := false + for _, it := range tu.renderSelection { + if it.Satus() == tuido.Checked { + foundDone = true + } + } + if !foundDone { + t.Errorf("expected the checked child to appear in focus mode") + } +} + +// Exiting focus restores the collapsed aggregate view. +func TestExitFocusRestoresAggregate(t *testing.T) { + tu := newTUI(focusTestItems(), runConfig) + tu.enterFocus("TODO.md") + tu.exitFocus() + + if tu.focused != "" { + t.Errorf("expected focused to be cleared after exitFocus") + } + for _, it := range tu.renderSelection { + if it.File() == "TODO.md" && !it.IsControl() { + t.Errorf("expected children re-collapsed after exiting focus") + } + } +} diff --git a/tui/tui.go b/tui/tui.go index 5f2d1e5..13e5b8b 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -28,7 +28,28 @@ type filterQuery struct { fuzzyTerms []string } -func Run() { +func Run() { run("") } + +// RunFocused launches the TUI scoped to a single file (filezoom). Every item +// in the file is shown in file order; group collapse is disabled. +func RunFocused(file string) { run(file) } + +func run(focus string) { + // In focus mode, load only the focused file's items - this guarantees the + // focused path matches the items' File() exactly, sidestepping any + // relative/absolute path mismatch. + if focus != "" { + items := GetItems(focus) + tui := newTUI(items, runConfig) + tui.focused = focus + + prog := tea.NewProgram(tui, tea.WithAltScreen()) + if err := prog.Start(); err != nil { + panic(err) + } + return + } + wrkdirStr, err := os.Getwd() // [ ] only from cli flag? YES! or... follow .gitignore if err != nil { @@ -179,6 +200,11 @@ type tui struct { pages int currentPage int + // focused, when non-empty, scopes the list to a single file's items + // (file-scoped "focus" / filezoom). In focus mode, that file's control + // item is not collapsed - all of its items are shown in file order. + focused string + mode mode filter textinput.Model @@ -295,6 +321,24 @@ func (t *tui) currentSelection() *tuido.Item { func (t *tui) populateRenderSelection() { t.renderSelection = []*tuido.Item{} + // In focus mode, scope to the focused file and show every item in it + // (in file order), bypassing status filtering and group collapse. + if t.focused != "" { + for _, i := range t.items { + if i.File() == t.focused { + t.renderSelection = append(t.renderSelection, i) + } + } + t.applyFilter() + if len(t.filter.Value()) == 0 { + sort.SliceStable(t.renderSelection, func(a, b int) bool { + return t.renderSelection[a].Line() < t.renderSelection[b].Line() + }) + } + t.setSelection(t.selection) + return + } + if t.itemsFilter == todo { for _, i := range t.items { if (i.Satus() == tuido.Ongoing || i.Satus() == tuido.Open) && @@ -312,6 +356,9 @@ func (t *tui) populateRenderSelection() { } } + // collapse file-scoped groups (##file) to their control item + t.renderSelection = tuido.CollapseFileScoped(t.renderSelection) + t.applyFilter() // Only sort if no filter is active - preserve fuzzy search ranking @@ -323,6 +370,20 @@ func (t *tui) populateRenderSelection() { t.setSelection(t.selection) } +// enterFocus scopes the view to a single file (filezoom). +func (t *tui) enterFocus(file string) { + t.focused = file + t.selection = 0 + t.populateRenderSelection() +} + +// exitFocus returns from filezoom to the aggregate view. +func (t *tui) exitFocus() { + t.focused = "" + t.selection = 0 + t.populateRenderSelection() +} + func (t *tui) applyFilter() { query := t.filter.Value() if len(query) == 0 { diff --git a/tui/update.go b/tui/update.go index 4374151..624cdc3 100644 --- a/tui/update.go +++ b/tui/update.go @@ -240,8 +240,18 @@ func (t tui) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t.tryCreateNewItem() case "z": t.currentSelection().Snooze() - case "enter": - t.setPeekMode() + case "enter", "l", "right": + // On a control item (and not already focused), dive into the + // file's focus view. Otherwise enter peeks; l/right are no-ops. + if t.focused == "" && t.currentSelection() != nil && t.currentSelection().IsControl() { + t.enterFocus(t.currentSelection().File()) + } else if msg.String() == "enter" { + t.setPeekMode() + } + case "esc", "h", "left": + if t.focused != "" { + t.exitFocus() + } case "q": return t, tea.Quit } diff --git a/tui/view.go b/tui/view.go index 051ebeb..49041a7 100644 --- a/tui/view.go +++ b/tui/view.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "path/filepath" "strings" "github.com/charmbracelet/lipgloss" @@ -75,6 +76,15 @@ func (t tui) footer() string { itemLoc := t.currentSelection().Location() itemStr := footStyle.Render(itemLoc) + // recolor the statusbar to signal filezoom (focus mode) + if t.focused != "" { + focusStyle := footStyle.Copy(). + Bold(true). + Foreground(lg.Color("#1a1a1a")). + Background(lg.Color("#a0d0ff")) + itemStr = focusStyle.Render("focus: " + filepath.Base(t.focused)) + } + var right string if t.err != nil { @@ -86,7 +96,11 @@ func (t tui) footer() string { } else { if t.mode == navigation { - right = footStyle.Render(t.pagination()) + if t.focused != "" { + right = footStyle.Copy().Faint(true).Render("[esc] - Exit focus") + } else { + right = footStyle.Render(t.pagination()) + } } else if t.mode == edit { right = footStyle.Copy().Faint(true). Render("[enter] - Save Changes, [esc] - Discard Changes") @@ -155,6 +169,7 @@ func (t tui) View() string { controls += "n: new item\ne: edit item\nz: snooze item\n!: escalate item\n1: relax item\np: begin a pomodoro\n\n" controls += "x: mark done\ns: mark obsolete (strikethrough)\na: mark ongoing (at)\n[space]: mark open\n\n" controls += "[tab]: cycle between todo and done tabs\n/: text search and #tag #filtering\n?: enter help\n\n" + controls += "[enter]: peek item\nl / [right]: focus file (on a control item)\nh / [esc]: exit focus\n\n" if len(t.notifs) > 0 { controls += "u: upgrade to latest version\n" } @@ -169,15 +184,15 @@ func (t tui) View() string { return lg.JoinVertical(lg.Left, notifications, lg.JoinHorizontal(lg.Top, " ", controls, " ", txt)) case configViewer: configText := "\n\nCurrent Configuration:\n\n" + t.config.String() - + instructions := "\n\n[press any key to return to navigation]" - + content := lg.NewStyle().Width(40).Align(lg.Left). Render(configText + instructions) - + notifications := strings.Join(t.notifs, "\n") notifications = lg.NewStyle().Bold(true).Foreground(lg.Color("#ffbbaa")).Render(notifications) - + return lg.JoinVertical(lg.Left, notifications, lg.JoinHorizontal(lg.Top, " ", content)) case upgrade: return t.renderUpgradeView() @@ -281,6 +296,13 @@ func (t tui) renderedItemCollection(width int) []string { // over multiple lines, and returns the text func (t tui) renderTuido(item tuido.Item, width int) string { ret := item.String() + + // In the aggregate view, a collapsed control item shows a child rollup. + if t.focused == "" && item.IsControl() { + rem, tot := tuido.ChildStats(&item, t.items) + ret += lg.NewStyle().Faint(true).Render(fmt.Sprintf(" [%d of %d]", rem, tot)) + } + tags := item.Tags() for _, tag := range tags { diff --git a/tuido/tuido.go b/tuido/tuido.go index e91de85..833b239 100644 --- a/tuido/tuido.go +++ b/tuido/tuido.go @@ -83,6 +83,12 @@ func (i *Item) Location() string { return fmt.Sprintf("%s:%d", i.file, i.line) } +// File returns the path of the source file the item was read from. +func (i Item) File() string { return i.file } + +// Line returns the 1-indexed line number of the item in its source file. +func (i Item) Line() int { return i.line } + // Status returns the status of the item. One of: // - open (ie, noted but not begun) // - ongoing (ie, in progress) @@ -384,6 +390,23 @@ func (i Item) Tags() []Tag { return Tags(i.Text()) } +// SystemTags returns the ## system tags carried by the item. +func (i Item) SystemTags() []SystemTag { + return SystemTags(i.Text()) +} + +// IsControl reports whether the item is a file control item, ie carries the +// ##file system tag. A control item stands in for its whole file in the +// aggregate view: its siblings are collapsed behind it. +func (i Item) IsControl() bool { + for _, t := range i.SystemTags() { + if t.name == SysFile { + return true + } + } + return false +} + // Active returns the "active" status for snoozed items. // Items with `active` tags later than the current date will not // be shown in the regular view. Defaults to true. @@ -563,6 +586,11 @@ func Tags(s string) []Tag { split := strings.Split(s, " ") for _, token := range split { + // ## denotes a system tag; it is deliberately excluded from the + // user tag space (no coloring, no fuzzy filtering). See SystemTags. + if strings.HasPrefix(token, "##") { + continue + } if strings.HasPrefix(token, "#") && len(token) > 1 { tags = append(tags, NewTag(token[1:])) } @@ -571,6 +599,97 @@ func Tags(s string) []Tag { return tags } +// SystemTag is a tuido-reserved, double-hash (##) tag. System tags are parsed +// and acted upon by tuido itself, and are kept separate from the user-facing +// Tag space: they are not colorized, not fuzzy-filterable, and do not appear +// in Item.Tags(). +type SystemTag struct { + name string + value string +} + +func (s SystemTag) Name() string { return s.name } +func (s SystemTag) Value() string { return s.value } +func (s SystemTag) String() string { + if s.value != "" { + return fmt.Sprintf("%s=%s", s.name, s.value) + } + return s.name +} + +// Reserved system tag names. +const ( + // SysFile marks a file's control/summary item. The control item stands + // in for the whole file in the aggregate view. + SysFile = "file" +) + +// SystemTags parses the ##-prefixed system tags out of s. +func SystemTags(s string) []SystemTag { + tags := []SystemTag{} + for _, token := range strings.Split(s, " ") { + if strings.HasPrefix(token, "##") && len(token) > 2 { + tags = append(tags, newSystemTag(token[2:])) + } + } + return tags +} + +// newSystemTag splits a "name=value" (no ## prefix) into a SystemTag. +func newSystemTag(s string) SystemTag { + split := strings.SplitN(s, "=", 2) + if len(split) == 2 { + return SystemTag{name: split[0], value: split[1]} + } + return SystemTag{name: s} +} + +// CollapseFileScoped returns items with file-controlled groups collapsed to +// their control item. For any file that contains a control item (##file), +// only the control item is retained and its siblings are dropped. Files with +// no control item are returned unchanged. Input order is preserved. +func CollapseFileScoped(items []*Item) []*Item { + controlled := map[string]bool{} + for _, it := range items { + if it.IsControl() { + controlled[it.file] = true + } + } + if len(controlled) == 0 { + return items + } + + out := make([]*Item, 0, len(items)) + for _, it := range items { + if controlled[it.file] && !it.IsControl() { + continue // sibling collapsed behind its control item + } + out = append(out, it) + } + return out +} + +// ChildStats returns the (remaining, total) counts of a control item's +// children - the non-control items sharing its file. remaining counts open +// and ongoing children. Because one file carries at most one control item, +// children are identified as same-file items that are not themselves control +// items (rather than by pointer identity, so a copy of the control works). +func ChildStats(control *Item, all []*Item) (remaining, total int) { + if control == nil { + return 0, 0 + } + for _, it := range all { + if it.file != control.file || it.IsControl() { + continue + } + total++ + if s := it.Satus(); s == Open || s == Ongoing { + remaining++ + } + } + return remaining, total +} + type Tag struct { name string value string diff --git a/tuido/tuido_test.go b/tuido/tuido_test.go index add462e..c6835f5 100644 --- a/tuido/tuido_test.go +++ b/tuido/tuido_test.go @@ -57,6 +57,81 @@ func TestNewTag(t *testing.T) { } +func TestSystemTagsExcludedFromUserTags(t *testing.T) { + i := Item{raw: "[ ] do the thing #real ##file ##scope=docs"} + + // ## tags must not leak into the user tag space + for _, tag := range i.Tags() { + if tag.Name() == "file" || tag.Name() == "scope" { + t.Errorf("system tag %q leaked into user Tags()", tag.Name()) + } + } + + // the genuine user tag is still present + foundReal := false + for _, tag := range i.Tags() { + if tag.Name() == "real" { + foundReal = true + } + } + if !foundReal { + t.Errorf("expected user tag 'real' in Tags(), got %v", i.Tags()) + } + + // system tags are parsed, with values + sys := i.SystemTags() + if len(sys) != 2 { + t.Fatalf("expected 2 system tags, got %d (%v)", len(sys), sys) + } + if sys[0].Name() != "file" || sys[0].Value() != "" { + t.Errorf("expected ##file with no value, got %q=%q", sys[0].Name(), sys[0].Value()) + } + if sys[1].Name() != "scope" || sys[1].Value() != "docs" { + t.Errorf("expected ##scope=docs, got %q=%q", sys[1].Name(), sys[1].Value()) + } +} + +func TestIsControl(t *testing.T) { + control := Item{raw: "[@] Ship the parser rewrite ##file"} + plain := Item{raw: "[ ] write the lexer #due=2026-01-01"} + + if !control.IsControl() { + t.Errorf("expected ##file item to be a control item") + } + if plain.IsControl() { + t.Errorf("did not expect plain item to be a control item") + } +} + +func TestCollapseFileScoped(t *testing.T) { + mk := func(file, raw string) *Item { return &Item{file: file, raw: raw} } + items := []*Item{ + mk("TODO.md", "[@] Ship the parser rewrite ##file"), + mk("TODO.md", "[x] sketch the grammar"), + mk("TODO.md", "[ ] write the lexer"), + mk("TODO.md", "[ ] write the parser"), + mk("notes.md", "[ ] standalone item"), + } + + collapsed := CollapseFileScoped(items) + + if len(collapsed) != 2 { + t.Fatalf("expected 2 items after collapse (control + standalone), got %d", len(collapsed)) + } + if !collapsed[0].IsControl() { + t.Errorf("expected the control item to survive collapse") + } + if collapsed[1].Text() != "standalone item" { + t.Errorf("expected uncontrolled file's item to survive, got %q", collapsed[1].Text()) + } + + // rollup: 2 of 3 children open/ongoing (grammar is checked) + rem, tot := ChildStats(items[0], items) + if rem != 2 || tot != 3 { + t.Errorf("expected child stats (2, 3), got (%d, %d)", rem, tot) + } +} + func TestImportance(t *testing.T) { items := []Item{ {