Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ func main() {
showVersionInfo()
return

case "focus":
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: tuido focus <file>")
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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -177,6 +198,7 @@ Commands:
--max N Limit output to N items
create <text> Create a new todo item
add <text> Alias for create
focus <file> 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
Expand Down
173 changes: 62 additions & 111 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
92 changes: 92 additions & 0 deletions tui/focus_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Loading
Loading