From 46fd0362bce11d709e5efe6d540358533985d363 Mon Sep 17 00:00:00 2001 From: EuAndreh Date: Fri, 12 Jun 2026 09:19:28 -0300 Subject: Rewrite remembering in Go The shell pipeline (sed | sort | tee | awk | sort | cut | "$@" plus the cut | uniq | awk profile rewrite) becomes a single static binary with the same observable behaviour, pinned by the original ranking.sh, signals.sh and cli-opts.sh suites, now aimed at remembering.bin: - the profile keeps the exact on-disk format, COUNT profile TEXT, byte-sorted with new picks appended at 1 and offered-but-never- picked entries persisted at 0; - the menu stays count-descending with byte-order ties, stdin alone defines what is offered, and duplicate profile lines sum for ranking but collapse to the highest count on rewrite, as sort | uniq -f1 did; - the wrapped command's exit status is forwarded as-is (128+sig for signal deaths), its stderr passes through, and an empty pick learns nothing; - the profile rewrite stays atomic via .tmp plus rename. Per the house CLI conventions, -h/-V/--help/--version are gone (the manpage is the documentation; bad options print the usage on stderr and exit 2), and getopts-style attached option arguments (-pNAME) are not accepted any more --- no script in the wild used them. The project layout follows rot: raw go tool compile/link Makefile, mkdeps.sh-generated deps.mk, white-box unit suite, fuzz target over the profile parse/serialize roundtrip, functional pick roundtrip, a 1M-line ranking benchmark, and a single English asciidoc manpage absorbing the old .5 page; the po4a/aux release machinery goes away. Co-Authored-By: Claude Fable 5 --- tests/benchmarks/ranking-throughput/main.go | 7 + tests/benchmarks/ranking-throughput/remembering.go | 76 +++ tests/cli-opts.sh | 106 +++-- tests/functional/pick-roundtrip/main.go | 7 + tests/functional/pick-roundtrip/remembering.go | 129 +++++ tests/fuzz/profile/main.go | 7 + tests/fuzz/profile/remembering.go | 76 +++ tests/main.go | 7 + tests/ranking.sh | 12 +- tests/remembering.go | 523 +++++++++++++++++++++ tests/signals.sh | 10 +- 11 files changed, 902 insertions(+), 58 deletions(-) create mode 100644 tests/benchmarks/ranking-throughput/main.go create mode 100644 tests/benchmarks/ranking-throughput/remembering.go create mode 100644 tests/functional/pick-roundtrip/main.go create mode 100644 tests/functional/pick-roundtrip/remembering.go create mode 100644 tests/fuzz/profile/main.go create mode 100644 tests/fuzz/profile/remembering.go create mode 100644 tests/main.go create mode 100644 tests/remembering.go (limited to 'tests') diff --git a/tests/benchmarks/ranking-throughput/main.go b/tests/benchmarks/ranking-throughput/main.go new file mode 100644 index 0000000..0b02dc7 --- /dev/null +++ b/tests/benchmarks/ranking-throughput/main.go @@ -0,0 +1,7 @@ +package main + +import "remembering" + +func main() { + remembering.MainTest() +} diff --git a/tests/benchmarks/ranking-throughput/remembering.go b/tests/benchmarks/ranking-throughput/remembering.go new file mode 100644 index 0000000..041eaeb --- /dev/null +++ b/tests/benchmarks/ranking-throughput/remembering.go @@ -0,0 +1,76 @@ +package remembering + +import ( + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + + + +var linesFlag = flag.Int( + "lines", + 1000000, + "The number of menu lines to rank", +) + +func MainTest() { + flag.Parse() + n := *linesFlag + + tmp, err := os.MkdirTemp("", "remembering-bench-") + if err != nil { + panic(err) + } + defer os.RemoveAll(tmp) + err = os.Setenv("XDG_DATA_HOME", tmp) + if err != nil { + panic(err) + } + + menu := strings.Builder{} + for i := 0; i < n; i++ { + fmt.Fprintf(&menu, "item-%07d\n", i) + } + + // half the items are already known, with spread counts + profile := strings.Builder{} + for i := 0; i < n; i += 2 { + fmt.Fprintf( + &profile, "%d profile item-%07d\n", + i%97, i, + ) + } + path, err := profilePath("bench") + if err != nil { + panic(err) + } + err = os.MkdirAll(filepath.Dir(path), 0755) + if err != nil { + panic(err) + } + err = os.WriteFile( + path, []byte(profile.String()), 0644, + ) + if err != nil { + panic(err) + } + + rc := run(envT{ + allArgs: []string{ + "remembering", "-p", "bench", + "--", "tail", "-n1", + }, + in: strings.NewReader(menu.String()), + out: io.Discard, + err: os.Stderr, + }) + if rc != 0 { + panic("ranking failed") + } + + fmt.Printf("ranked %d lines\n", n) +} diff --git a/tests/cli-opts.sh b/tests/cli-opts.sh index 83a5bfc..0ce4eef 100755 --- a/tests/cli-opts.sh +++ b/tests/cli-opts.sh @@ -3,6 +3,8 @@ set -u . tests/lib.sh +REMEMBERING="$PWD/remembering.bin" + export XDG_DATA_HOME="$PWD/tests/test-profiles" test_unsupported_long_flags() { @@ -10,32 +12,40 @@ test_unsupported_long_flags() { N="$LINENO" OUT="$(mkstemp)" ERR="$(mkstemp)" - ./src/remembering --unknown-long-flag 1>"$OUT" 2>"$ERR" + "$REMEMBERING" --unknown-long-flag 1>"$OUT" 2>"$ERR" STATUS=$? assert_status 2 assert_usage "$ERR" test_ok } -test_missing_required_flags() { - testing 'missing required flags' +test_unknown_flags() { + testing 'unknown flags' N="$LINENO" OUT="$(mkstemp)" ERR="$(mkstemp)" - ./src/remembering -a something -b else 1>"$OUT" 2>"$ERR" + "$REMEMBERING" -a something -b else 1>"$OUT" 2>"$ERR" STATUS=$? assert_status 2 assert_usage "$ERR" test_ok } -test_single_required_flag() { - testing 'single required flag' +test_missing_command() { + testing 'missing command' + + N="$LINENO" + OUT="$(mkstemp)" + ERR="$(mkstemp)" + "$REMEMBERING" 1>"$OUT" 2>"$ERR" + STATUS=$? + assert_status 2 + assert_usage "$ERR" N="$LINENO" OUT="$(mkstemp)" ERR="$(mkstemp)" - ./src/remembering -p valid-test-profile 1>"$OUT" 2>"$ERR" + "$REMEMBERING" -p valid-test-profile 1>"$OUT" 2>"$ERR" STATUS=$? assert_status 2 assert_usage "$ERR" @@ -43,7 +53,7 @@ test_single_required_flag() { N="$LINENO" OUT="$(mkstemp)" ERR="$(mkstemp)" - ./src/remembering -c 'head -n' 1>"$OUT" 2>"$ERR" + "$REMEMBERING" -c 'head -n' 1>"$OUT" 2>"$ERR" STATUS=$? assert_status 2 assert_usage "$ERR" @@ -57,7 +67,7 @@ test_flags_without_required_argument() { N="$LINENO" OUT="$(mkstemp)" ERR="$(mkstemp)" - ./src/remembering -pc 1>"$OUT" 2>"$ERR" + "$REMEMBERING" -pc 1>"$OUT" 2>"$ERR" STATUS=$? assert_status 2 assert_usage "$ERR" @@ -65,7 +75,7 @@ test_flags_without_required_argument() { N="$LINENO" OUT="$(mkstemp)" ERR="$(mkstemp)" - ./src/remembering -cp 1>"$OUT" 2>"$ERR" + "$REMEMBERING" -cp 1>"$OUT" 2>"$ERR" STATUS=$? assert_status 2 assert_usage "$ERR" @@ -73,7 +83,7 @@ test_flags_without_required_argument() { N="$LINENO" OUT="$(mkstemp)" ERR="$(mkstemp)" - ./src/remembering -p -c 1>"$OUT" 2>"$ERR" + "$REMEMBERING" -p -c 1>"$OUT" 2>"$ERR" STATUS=$? assert_status 2 assert_usage "$ERR" @@ -87,7 +97,7 @@ test_valid_options() { OUT="$(mkstemp)" ERR="$(mkstemp)" printf 'a\nb\nc\n' | \ - ./src/remembering \ + "$REMEMBERING" \ -p "always-unique-$(uuid)" \ -- head -n1 \ 1>"$OUT" 2>"$ERR" @@ -98,69 +108,68 @@ test_valid_options() { test_ok } -test_help_flags() { - testing 'help flags' +test_rejected_help_flags() { + testing 'help flags are rejected' N="$LINENO" OUT="$(mkstemp)" ERR="$(mkstemp)" - ./src/remembering -h 1>"$OUT" 2>"$ERR" + "$REMEMBERING" -h 1>"$OUT" 2>"$ERR" STATUS=$? - assert_status 0 - assert_empty_stderr - assert_usage "$OUT" + assert_status 2 + assert_empty_stdout + assert_usage "$ERR" N="$LINENO" OUT="$(mkstemp)" ERR="$(mkstemp)" - ./src/remembering --help 1>"$OUT" 2>"$ERR" + "$REMEMBERING" --help 1>"$OUT" 2>"$ERR" STATUS=$? - assert_status 0 - assert_empty_stderr - assert_usage "$OUT" + assert_status 2 + assert_empty_stdout + assert_usage "$ERR" N="$LINENO" OUT="$(mkstemp)" ERR="$(mkstemp)" - ./src/remembering -p profile -c command --help 1>"$OUT" 2>"$ERR" + "$REMEMBERING" -p profile --help -- head -n1 1>"$OUT" 2>"$ERR" STATUS=$? - assert_status 0 - assert_empty_stderr - assert_usage "$OUT" + assert_status 2 + assert_empty_stdout + assert_usage "$ERR" N="$LINENO" OUT="$(mkstemp)" ERR="$(mkstemp)" - ./src/remembering --help -p profile -c command 1>"$OUT" 2>"$ERR" + "$REMEMBERING" --help -p profile -- head -n1 1>"$OUT" 2>"$ERR" STATUS=$? - assert_status 0 - assert_empty_stderr - assert_usage "$OUT" + assert_status 2 + assert_empty_stdout + assert_usage "$ERR" test_ok } -test_version_flags() { - testing 'version flags' - REGEX='^remembering [0-9\.]+ [0-9-]+$' +test_rejected_version_flags() { + testing 'version flags are rejected' N="$LINENO" OUT="$(mkstemp)" ERR="$(mkstemp)" - ./src/remembering -V 1>"$OUT" 2>"$ERR" + "$REMEMBERING" -V 1>"$OUT" 2>"$ERR" STATUS=$? - assert_status 0 - assert_empty_stderr - assert_grep_stdout "$REGEX" + assert_status 2 + assert_empty_stdout + assert_usage "$ERR" N="$LINENO" OUT="$(mkstemp)" ERR="$(mkstemp)" - ./src/remembering --version 1>"$OUT" 2>"$ERR" + "$REMEMBERING" --version 1>"$OUT" 2>"$ERR" STATUS=$? - assert_status 0 - assert_empty_stderr - assert_grep_stdout "$REGEX" + assert_status 2 + assert_empty_stdout + assert_usage "$ERR" test_ok } @@ -174,7 +183,7 @@ test_environment_variables_and_precedence() { PROFILE='environment-variables-xdg' XDG="$PWD/tests/test-profiles/xdg-test-$(uuid)" printf 'a\n' | \ - XDG_DATA_HOME="$XDG" ./src/remembering \ + XDG_DATA_HOME="$XDG" "$REMEMBERING" \ -p "$PROFILE" \ -- head -n1 \ 1>"$OUT" 2>"$ERR" @@ -190,7 +199,7 @@ test_environment_variables_and_precedence() { PROFILE='environment-variables-home' HHOME="$PWD/tests/test-profiles/home-test-$(uuid)" printf 'b\n' | \ - HOME="$HHOME" XDG_DATA_HOME='' ./src/remembering \ + HOME="$HHOME" XDG_DATA_HOME='' "$REMEMBERING" \ -p "$PROFILE" \ -- head -n1 \ 1>"$OUT" 2>"$ERR" @@ -207,7 +216,7 @@ test_environment_variables_and_precedence() { HHOME="$PWD/tests/test-profiles/home-wins-over-xdg-test-$(uuid)" XDG="$PWD/tests/test-profiles/xdg-wins-over-home-test-$(uuid)" printf 'c\n' | \ - HOME="$HHOME" XDG_DATA_HOME="$XDG" ./src/remembering \ + HOME="$HHOME" XDG_DATA_HOME="$XDG" "$REMEMBERING" \ -p "$PROFILE" \ -- head -n1 \ 1>"$OUT" 2>"$ERR" @@ -227,11 +236,10 @@ test_environment_variables_and_precedence() { } test_unsupported_long_flags -test_missing_required_flags -test_missing_required_flags -test_single_required_flag +test_unknown_flags +test_missing_command test_flags_without_required_argument test_valid_options -test_help_flags -test_version_flags +test_rejected_help_flags +test_rejected_version_flags test_environment_variables_and_precedence diff --git a/tests/functional/pick-roundtrip/main.go b/tests/functional/pick-roundtrip/main.go new file mode 100644 index 0000000..0b02dc7 --- /dev/null +++ b/tests/functional/pick-roundtrip/main.go @@ -0,0 +1,7 @@ +package main + +import "remembering" + +func main() { + remembering.MainTest() +} diff --git a/tests/functional/pick-roundtrip/remembering.go b/tests/functional/pick-roundtrip/remembering.go new file mode 100644 index 0000000..e2d54da --- /dev/null +++ b/tests/functional/pick-roundtrip/remembering.go @@ -0,0 +1,129 @@ +package remembering + +import ( + "fmt" + "os" + "reflect" + "strings" +) + + + +func showColour() bool { + return os.Getenv("NO_COLOUR") == "" +} + +func testing(message string, body func()) { + if showColour() { + fmt.Fprintf( + os.Stderr, + "\033[0;33mtesting\033[0m: %s... ", + message, + ) + body() + fmt.Fprintf(os.Stderr, "\033[0;32mOK\033[0m.\n") + } else { + fmt.Fprintf(os.Stderr, "testing: %s... ", message) + body() + fmt.Fprintf(os.Stderr, "OK.\n") + } +} + +func assertEq(given any, expected any) { + if !reflect.DeepEqual(given, expected) { + if showColour() { + fmt.Fprintf(os.Stderr, "\033[0;31mERR\033[0m.\n") + } else { + fmt.Fprintf(os.Stderr, "ERR.\n") + } + fmt.Fprintf(os.Stderr, "given != expected\n") + fmt.Fprintf(os.Stderr, "given: %#v\n", given) + fmt.Fprintf(os.Stderr, "expected: %#v\n", expected) + os.Exit(1) + } +} + + + +func pick(menu string, command ...string) (int, string, string) { + out := strings.Builder{} + errW := strings.Builder{} + rc := run(envT{ + allArgs: append( + []string{"remembering", "-p", "func", "--"}, + command..., + ), + in: strings.NewReader(menu), + out: &out, + err: &errW, + }) + return rc, out.String(), errW.String() +} + +func profileBytes() string { + path, err := profilePath("func") + if err != nil { + panic(err) + } + data, err := os.ReadFile(path) + if err != nil { + panic(err) + } + return string(data) +} + + + +func MainTest() { + testing("a session of picks shapes the ranking", func() { + tmp, err := os.MkdirTemp( + "", "remembering-functional-", + ) + assertEq(err, nil) + defer os.RemoveAll(tmp) + saved := os.Getenv("XDG_DATA_HOME") + os.Setenv("XDG_DATA_HOME", tmp) + defer os.Setenv("XDG_DATA_HOME", saved) + + menu := "a\nb\nc\nd\ne\n" + + rc, out, errW := pick(menu, "grep", "-F", "c") + assertEq(rc, 0) + assertEq(out, "c\n") + assertEq(errW, "") + assertEq( + profileBytes(), + "0 profile a\n"+ + "0 profile b\n"+ + "1 profile c\n"+ + "0 profile d\n"+ + "0 profile e\n", + ) + + // the learnt pick now ranks first + rc, out, errW = pick(menu, "head", "-n1") + assertEq(rc, 0) + assertEq(out, "c\n") + assertEq(errW, "") + assertEq( + profileBytes(), + "0 profile a\n"+ + "0 profile b\n"+ + "2 profile c\n"+ + "0 profile d\n"+ + "0 profile e\n", + ) + + // a cancelled menu forwards the status and + // learns nothing + rc, out, _ = pick(menu, "sh", "-c", "exit 3") + assertEq(rc, 3) + assertEq(out, "") + assertEq( + strings.Contains( + profileBytes(), "2 profile c", + ), + true, + ) + }) +} diff --git a/tests/fuzz/profile/main.go b/tests/fuzz/profile/main.go new file mode 100644 index 0000000..0b02dc7 --- /dev/null +++ b/tests/fuzz/profile/main.go @@ -0,0 +1,7 @@ +package main + +import "remembering" + +func main() { + remembering.MainTest() +} diff --git a/tests/fuzz/profile/remembering.go b/tests/fuzz/profile/remembering.go new file mode 100644 index 0000000..aab4ac4 --- /dev/null +++ b/tests/fuzz/profile/remembering.go @@ -0,0 +1,76 @@ +package remembering + +import ( + "os" + "reflect" + "strings" + "testing" + "testing/internal/testdeps" +) + + + +func fn(f *testing.F) { + f.Add("0 profile a\n1 profile b\n", "c\nd", "a") + f.Add("", "x", "x") + f.Fuzz(func( + t *testing.T, + content string, + input string, + choice string, + ) { + profile := parseProfile(content) + again := parseProfile(serializeProfile(profile)) + if !reflect.DeepEqual(again, profile) { + t.Fatalf( + "roundtrip: %#v != %#v", + again, profile, + ) + } + + if choice == "" || + strings.Contains(choice, "\n") { + return + } + lines := splitLines(input) + next := nextProfile(profile, lines, choice) + + found := false + for _, entry := range next { + if entry.text == choice { + found = true + } + } + if !found { + t.Fatalf( + "pick %q not learnt in %#v", + choice, next, + ) + } + + reNext := parseProfile(serializeProfile(next)) + if !reflect.DeepEqual(reNext, next) { + t.Fatalf( + "next roundtrip: %#v != %#v", + reNext, next, + ) + } + }) +} + + + +func MainTest() { + fuzzTargets := []testing.InternalFuzzTarget{ + {"fn", fn}, + } + + deps := testdeps.TestDeps{} + tests := []testing.InternalTest {} + benchmarks := []testing.InternalBenchmark{} + examples := []testing.InternalExample {} + m := testing.MainStart( + deps, tests, benchmarks, fuzzTargets, examples, + ) + os.Exit(m.Run()) +} diff --git a/tests/main.go b/tests/main.go new file mode 100644 index 0000000..0b02dc7 --- /dev/null +++ b/tests/main.go @@ -0,0 +1,7 @@ +package main + +import "remembering" + +func main() { + remembering.MainTest() +} diff --git a/tests/ranking.sh b/tests/ranking.sh index af26c94..45cb0ec 100755 --- a/tests/ranking.sh +++ b/tests/ranking.sh @@ -3,6 +3,8 @@ set -eu . tests/lib.sh +REMEMBERING="$PWD/remembering.bin" + export XDG_DATA_HOME="$PWD/tests/test-profiles" export LANG=C.UTF-8 @@ -38,7 +40,7 @@ pick_x() { PICK="$1" echo "${2:-$INPUT}" | \ - ./src/remembering \ + "$REMEMBERING" \ -p "$PROFILE" \ -- sh -c "tee -a /dev/stderr | grep -F \"$PICK\"" \ 1>"$OUT" 2>"$ERR" @@ -83,7 +85,7 @@ test_picking_first_makes_it_be_always_first() { PROFILE="always-picks-first-$(uuid)" for _ in $(seq 10); do printf 'always-picked\nnever-picked\n' | \ - ./src/remembering \ + "$REMEMBERING" \ -p "$PROFILE" \ -- head -n1 \ 1>"$OUT" 2>"$ERR" @@ -203,7 +205,7 @@ d e' echo "$INPUT" | \ - ./src/remembering \ + "$REMEMBERING" \ -p "$PROFILE" \ -- echo f \ 1>"$OUT" 2>"$ERR" @@ -277,7 +279,7 @@ test_stdin_is_empty() { ERR="$(mkstemp)" printf '' | \ - ./src/remembering \ + "$REMEMBERING" \ -p "$PROFILE" \ -- sh -c 'tee -a /dev/stderr | head -n1' \ 1>"$OUT" 2>"$ERR" @@ -340,7 +342,7 @@ test_really_long_list() { PROFILE="really-long-list-$(uuid)" N=999999 seq "$N" | \ - ./src/remembering \ + "$REMEMBERING" \ -p "$PROFILE" \ -- tail -n1 \ 1>"$OUT" 2>"$ERR" diff --git a/tests/remembering.go b/tests/remembering.go new file mode 100644 index 0000000..3f8e446 --- /dev/null +++ b/tests/remembering.go @@ -0,0 +1,523 @@ +package remembering + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" +) + + + +func showColour() bool { + return os.Getenv("NO_COLOUR") == "" +} + +func testStart(name string) { + fmt.Fprintf(os.Stderr, "%s:\n", name) +} + +func testing(message string, body func()) { + if showColour() { + fmt.Fprintf( + os.Stderr, + "\033[0;33mtesting\033[0m: %s... ", + message, + ) + body() + fmt.Fprintf(os.Stderr, "\033[0;32mOK\033[0m.\n") + } else { + fmt.Fprintf(os.Stderr, "testing: %s... ", message) + body() + fmt.Fprintf(os.Stderr, "OK.\n") + } +} + +func assertEq(given any, expected any) { + if !reflect.DeepEqual(given, expected) { + if showColour() { + fmt.Fprintf(os.Stderr, "\033[0;31mERR\033[0m.\n") + } else { + fmt.Fprintf(os.Stderr, "ERR.\n") + } + fmt.Fprintf(os.Stderr, "given != expected\n") + fmt.Fprintf(os.Stderr, "given: %#v\n", given) + fmt.Fprintf(os.Stderr, "expected: %#v\n", expected) + os.Exit(1) + } +} + + + +func mktmp() string { + dir, err := os.MkdirTemp("", "remembering-tests-") + if err != nil { + panic(err) + } + return dir +} + +func withEnv(key string, value string, body func()) { + saved, had := os.LookupEnv(key) + os.Setenv(key, value) + defer func() { + if had { + os.Setenv(key, saved) + } else { + os.Unsetenv(key) + } + }() + body() +} + +func runWith(stdin string, args ...string) (int, string, string) { + out := strings.Builder{} + errW := strings.Builder{} + rc := run(envT{ + allArgs: append([]string{"remembering"}, args...), + in: strings.NewReader(stdin), + out: &out, + err: &errW, + }) + return rc, out.String(), errW.String() +} + +func seedProfile(name string, content string) { + path, err := profilePath(name) + if err != nil { + panic(err) + } + err = os.MkdirAll(filepath.Dir(path), 0755) + if err != nil { + panic(err) + } + err = os.WriteFile(path, []byte(content), 0644) + if err != nil { + panic(err) + } +} + +func profileContent(name string) string { + path, err := profilePath(name) + if err != nil { + panic(err) + } + data, err := os.ReadFile(path) + if err != nil { + panic(err) + } + return string(data) +} + + + +func test_splitLines() { + testStart("splitLines()") + + testing("empty input has no lines", func() { + assertEq(splitLines(""), []string{}) + }) + + testing("the final newline opens no empty line", func() { + assertEq(splitLines("a\nb\n"), []string{"a", "b"}) + }) + + testing("a missing final newline still ends a line", func() { + assertEq(splitLines("a\nb"), []string{"a", "b"}) + }) + + testing("blank lines in the middle are kept", func() { + assertEq( + splitLines("a\n\nb\n"), + []string{"a", "", "b"}, + ) + }) + + testing("a lone newline is one empty line", func() { + assertEq(splitLines("\n"), []string{""}) + }) +} + +func test_parseProfile() { + testStart("parseProfile()") + + testing("canonical lines", func() { + assertEq( + parseProfile("0 profile a\n12 profile b c\n"), + []entryT{{0, "a"}, {12, "b c"}}, + ) + }) + + testing("text keeps its leading blanks", func() { + assertEq( + parseProfile("1 profile spaced\n"), + []entryT{{1, " spaced"}}, + ) + }) + + testing("untagged and blank lines are dropped", func() { + given := parseProfile( + "1 stdin a\njunk\n\n2 profile b\n", + ) + assertEq(given, []entryT{{2, "b"}}) + }) + + testing("a non-numeric count reads as zero", func() { + assertEq( + parseProfile("x profile a\n"), + []entryT{{0, "a"}}, + ) + }) + + testing("duplicate texts are kept apart", func() { + assertEq( + parseProfile("3 profile a\n2 profile a\n"), + []entryT{{3, "a"}, {2, "a"}}, + ) + }) +} + +func test_serializeProfile() { + testStart("serializeProfile()") + + testing("one line per entry, count first", func() { + given := serializeProfile([]entryT{ + {1, "a b"}, + {0, "c"}, + }) + assertEq(given, "1 profile a b\n0 profile c\n") + }) + + testing("no entries make an empty file", func() { + assertEq(serializeProfile([]entryT{}), "") + }) + + testing("parse undoes serialize", func() { + entries := []entryT{{4, " x"}, {0, "y z"}} + assertEq( + parseProfile(serializeProfile(entries)), + entries, + ) + }) +} + +func test_menuFor() { + testStart("menuFor()") + + testing("most picked first, ties in byte order", func() { + profile := []entryT{ + {4, "l"}, {3, "f"}, {2, "a"}, {2, "g"}, + {2, "r"}, {1, "b"}, {1, "d"}, {1, "m"}, + {1, "s"}, + } + lines := []string{ + "a", "b", "c", "d", "f", + "g", "l", "m", "r", "s", + } + assertEq(menuFor(profile, lines), []entryT{ + {4, "l"}, {3, "f"}, {2, "a"}, {2, "g"}, + {2, "r"}, {1, "b"}, {1, "d"}, {1, "m"}, + {1, "s"}, {0, "c"}, + }) + }) + + testing("duplicate profile counts are summed", func() { + profile := []entryT{{2, "x"}, {3, "x"}} + given := menuFor(profile, []string{"x", "y"}) + assertEq(given, []entryT{{5, "x"}, {0, "y"}}) + }) + + testing("duplicate stdin lines all appear", func() { + given := menuFor([]entryT{}, []string{"x", "x"}) + assertEq(given, []entryT{{0, "x"}, {0, "x"}}) + }) + + testing("byte order, not collation order", func() { + given := menuFor([]entryT{}, []string{"é", "e"}) + assertEq(given, []entryT{{0, "e"}, {0, "é"}}) + }) + + testing("stdin defines the menu", func() { + profile := []entryT{{9, "ghost"}} + given := menuFor(profile, []string{"a"}) + assertEq(given, []entryT{{0, "a"}}) + }) +} + +func test_menuText() { + testStart("menuText()") + + testing("texts, one per line", func() { + menu := []entryT{{1, "a"}, {0, "b c"}} + assertEq(menuText(menu), "a\nb c\n") + }) + + testing("an empty menu is empty input", func() { + assertEq(menuText([]entryT{}), "") + }) +} + +func test_nextProfile() { + testStart("nextProfile()") + + zeroes := []entryT{ + {0, "a"}, {0, "b"}, {0, "c"}, {0, "d"}, {0, "e"}, + } + lines := []string{"a", "b", "c", "d", "e"} + + testing("the pick's count is bumped", func() { + assertEq(nextProfile(zeroes, lines, "a"), []entryT{ + {1, "a"}, {0, "b"}, {0, "c"}, + {0, "d"}, {0, "e"}, + }) + }) + + testing("an unknown pick is appended at the end", func() { + assertEq(nextProfile(zeroes, lines, "f"), []entryT{ + {0, "a"}, {0, "b"}, {0, "c"}, + {0, "d"}, {0, "e"}, {1, "f"}, + }) + }) + + testing("offered but never picked enters at zero", func() { + given := nextProfile( + []entryT{{0, "a"}}, lines, "a", + ) + assertEq(given, []entryT{ + {1, "a"}, {0, "b"}, {0, "c"}, + {0, "d"}, {0, "e"}, + }) + }) + + testing("entries absent from stdin are retained", func() { + profile := []entryT{{0, "a"}, {7, "z"}} + given := nextProfile( + profile, []string{"a", "b"}, "a", + ) + assertEq(given, []entryT{ + {1, "a"}, {0, "b"}, {7, "z"}, + }) + }) + + testing("duplicate texts collapse to the highest", func() { + profile := []entryT{{2, "x"}, {5, "x"}} + given := nextProfile(profile, []string{}, "x") + assertEq(given, []entryT{{6, "x"}}) + }) + + testing("picking twice accumulates", func() { + once := nextProfile([]entryT{}, lines, "c") + twice := nextProfile(once, lines, "c") + assertEq(twice, []entryT{ + {0, "a"}, {0, "b"}, {2, "c"}, + {0, "d"}, {0, "e"}, + }) + }) +} + +func test_profilePath() { + testStart("profilePath()") + + testing("$XDG_DATA_HOME wins", func() { + withEnv("XDG_DATA_HOME", "/x", func() { + withEnv("HOME", "/h", func() { + given, err := profilePath("name") + assertEq(err, nil) + assertEq( + given, + "/x/remembering/name", + ) + }) + }) + }) + + testing("an empty $XDG_DATA_HOME falls back", func() { + withEnv("XDG_DATA_HOME", "", func() { + withEnv("HOME", "/h", func() { + given, err := profilePath("name") + assertEq(err, nil) + assertEq( + given, + "/h/.local/share/"+ + "remembering/name", + ) + }) + }) + }) + + testing("the default name is the cwd, bashed", func() { + withEnv("XDG_DATA_HOME", "/x", func() { + cwd, err := os.Getwd() + assertEq(err, nil) + expected := filepath.Join( + "/x", Name, + strings.ReplaceAll(cwd, "/", "!"), + ) + given, err := profilePath("") + assertEq(err, nil) + assertEq(given, expected) + }) + }) +} + +func test_run_usage() { + testStart("run() usage") + + testing("a missing COMMAND is a usage error", func() { + rc, out, errW := runWith("") + assertEq(rc, 2) + assertEq(out, "") + assertEq( + strings.Contains( + errW, "Missing \"-- COMMAND\"", + ), + true, + ) + assertEq(strings.Contains(errW, "Usage:"), true) + }) + + testing("an unknown flag is a usage error", func() { + rc, _, errW := runWith("", "-x", "--", "head") + assertEq(rc, 2) + assertEq(strings.Contains(errW, "Usage:"), true) + }) + + testing("long options are rejected", func() { + rc, _, errW := runWith("", "--help") + assertEq(rc, 2) + assertEq(strings.Contains(errW, "Usage:"), true) + }) + + testing("-p alone still misses COMMAND", func() { + rc, _, errW := runWith("", "-p", "x") + assertEq(rc, 2) + assertEq(strings.Contains(errW, "Usage:"), true) + }) +} + +func test_run_flow() { + testStart("run() flow") + + tmp := mktmp() + defer os.RemoveAll(tmp) + + withEnv("XDG_DATA_HOME", tmp, func() { + testing("a pick is learnt, printed", func() { + rc, out, errW := runWith( + "b\na\n", + "-p", "t1", "--", "head", "-n1", + ) + assertEq(rc, 0) + assertEq(out, "a\n") + assertEq(errW, "") + assertEq( + profileContent("t1"), + "1 profile a\n0 profile b\n", + ) + }) + + testing("a second pick ranks above", func() { + rc, out, _ := runWith( + "b\na\n", + "-p", "t1", "--", "head", "-n1", + ) + assertEq(rc, 0) + assertEq(out, "a\n") + assertEq( + profileContent("t1"), + "2 profile a\n0 profile b\n", + ) + }) + + testing("no pick leaves the profile alone", func() { + rc, out, errW := runWith( + "", + "-p", "t2", "--", "head", "-n1", + ) + assertEq(rc, 0) + assertEq(out, "") + assertEq(errW, "") + assertEq(profileContent("t2"), "") + }) + + testing("an unknown pick is appended", func() { + seedProfile( + "t3", + "0 profile a\n0 profile b\n", + ) + rc, out, _ := runWith( + "a\nb\n", + "-p", "t3", "--", + "sh", "-c", "echo q", + ) + assertEq(rc, 0) + assertEq(out, "q\n") + assertEq( + profileContent("t3"), + "0 profile a\n0 profile b\n"+ + "1 profile q\n", + ) + }) + + testing("the command status is forwarded", func() { + rc, out, _ := runWith( + "", + "-p", "t4", "--", + "sh", "-c", "exit 7", + ) + assertEq(rc, 7) + assertEq(out, "") + }) + + testing("a signal death is 128 plus it", func() { + rc, _, _ := runWith( + "", + "-p", "t5", "--", + "sh", "-c", "kill -9 $$", + ) + assertEq(rc, 137) + }) + + testing("a missing command is 127", func() { + rc, _, errW := runWith( + "", + "-p", "t6", "--", + "/nonexistent-remembering-x", + ) + assertEq(rc, 127) + assertEq( + strings.Contains( + errW, "remembering:", + ), + true, + ) + }) + + testing("the command stderr passes through", func() { + rc, out, errW := runWith( + "x\n", + "-p", "t7", "--", + "sh", "-c", + "echo warn >&2; head -n1", + ) + assertEq(rc, 0) + assertEq(out, "x\n") + assertEq(errW, "warn\n") + }) + }) +} + + + +func MainTest() { + test_splitLines() + test_parseProfile() + test_serializeProfile() + test_menuFor() + test_menuText() + test_nextProfile() + test_profilePath() + test_run_usage() + test_run_flow() +} diff --git a/tests/signals.sh b/tests/signals.sh index 36491cf..e1cb063 100755 --- a/tests/signals.sh +++ b/tests/signals.sh @@ -3,6 +3,8 @@ set -u . tests/lib.sh +REMEMBERING="$PWD/remembering.bin" + XDG_DATA_HOME="$PWD/tests/test-profiles/signals-$(uuid)" export XDG_DATA_HOME @@ -10,15 +12,15 @@ test_status_is_zero_when_command_is_successful() { testing 'status is 0 when command is successful' N="$LINENO" - printf 'a\n' | ./src/remembering -pp1 -- head -n1 1>/dev/null 2>/dev/null + printf 'a\n' | "$REMEMBERING" -p p1 -- head -n1 1>/dev/null 2>/dev/null STATUS=$? assert_status 0 - printf '' | ./src/remembering -pp2 -- true 1>/dev/null 2>/dev/null + printf '' | "$REMEMBERING" -p p2 -- true 1>/dev/null 2>/dev/null STATUS=$? assert_status 0 - seq 9 | ./src/remembering -pp3 -- grep 7 1>/dev/null 2>/dev/null + seq 9 | "$REMEMBERING" -p p3 -- grep 7 1>/dev/null 2>/dev/null STATUS=$? assert_status 0 @@ -29,7 +31,7 @@ test_status_is_forwarded_from_command() { testing 'status is forwarded from command' N="$LINENO" for status in $(seq 1 125); do - printf '' | ./src/remembering -pp4 -- sh -c "exit $status" 1>/dev/null 2>/dev/null + printf '' | "$REMEMBERING" -p p4 -- sh -c "exit $status" 1>/dev/null 2>/dev/null STATUS=$? assert_status "$status" done -- cgit v1.3