diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-13 02:31:32 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-09-13 02:31:32 +0200 |
| commit | 26c94eb3db62ec6eebbf8d22c11afe691d9520c4 (patch) | |
| tree | 165e5bf69234b4f96c9b74deb4898d7143ddf120 /internal/journal/read_test.go | |
| parent | a6e442a645902011b2081c216daaec052cdc6ce6 (diff) | |
| download | krino-0.0.1.tar.gz krino-0.0.1.zip | |
krino: release 0.0.1 — man pages, install, examples, cross and release, README, changelogv0.0.1
Also: undo removes the directories its run created; a hardlink is never a
duplicate of its own other name; a flag written before "undo" is honoured;
--version prints no leading v. Duplicate conditions with different scopes
not sharing an original is documented as a known limitation.
Diffstat (limited to 'internal/journal/read_test.go')
| -rw-r--r-- | internal/journal/read_test.go | 214 |
1 files changed, 214 insertions, 0 deletions
diff --git a/internal/journal/read_test.go b/internal/journal/read_test.go index 3ffa14d..2f0f40f 100644 --- a/internal/journal/read_test.go +++ b/internal/journal/read_test.go @@ -196,6 +196,220 @@ func TestRunsDoesNotMarkUndoneWhenOnlyOkEntryIsMkdir(t *testing.T) { } } +// TestEntriesCrashedRunReturnsNilError is item 1: a run-start present, +// run-end absent, and otherwise clean is exactly the crashed-run shape +// Entries' own doc comment says it must accept - "to end of file when there +// is no run-end (a crashed run, which is precisely when corruption is +// likely)". Pinning it as its own test, rather than leaving it implicit in +// tests about something else, is the point of the item. +func TestEntriesCrashedRunReturnsNilError(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + at := time.Date(2026, 9, 12, 8, 0, 0, 0, time.UTC) + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil { + t.Fatal(err) + } + // No run-end: the process crashed right here. + if err := w.Close(); err != nil { + t.Fatal(err) + } + + got, err := Entries(path, "A") + if err != nil { + t.Fatalf("a crashed but otherwise clean run returned an error: %v", err) + } + if len(got) != 2 || got[0].Action != "run-start" || got[1].Action != "move" { + t.Errorf("entries = %+v, want [run-start, move]", got) + } +} + +// TestEntriesIntactRunReturnsNilError is item 2: a complete, clean run - +// run-start, a step, run-end, nothing corrupt - must read back with a nil +// error. Every other test in this file needs this to be true along the way, +// but none of them state it as their own point; this one does. +func TestEntriesIntactRunReturnsNilError(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + at := time.Date(2026, 9, 12, 8, 0, 0, 0, time.UTC) + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + got, err := Entries(path, "A") + if err != nil { + t.Fatalf("a fully intact run returned an error: %v", err) + } + if len(got) != 3 || got[0].Action != "run-start" || got[1].Action != "move" || got[2].Action != "run-end" { + t.Errorf("entries = %+v, want [run-start, move, run-end]", got) + } +} + +// TestEntriesBothFailureModesReportsBadLineFirst is item 3: a run that both +// has an unparsable line inside its window AND lacks a readable run-start +// must surface as the unparsable-line error, not the missing-run-start one - +// Entries checks badLine before sawRunStart. The run-start line here is +// destroyed unattributably (as in TestEntriesFailsClosedOnMissingRunStart), +// and a second, still-attributable line is separately corrupted so badLine +// is set via the runFieldOf fallback rather than the window check. +func TestEntriesBothFailureModesReportsBadLineFirst(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + at := time.Date(2026, 9, 12, 8, 0, 0, 0, time.UTC) + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("fixture has %d lines, want 3", len(lines)) + } + // Line 1 (run-start): destroyed unattributably - no tabs at all, so + // runFieldOf cannot even recover its Run column. + lines[0] = "totally-mangled-no-tabs-here" + // Line 2 (move): corrupt its Step column only - it keeps its tabs and + // its Run column ("A") stays readable via runFieldOf's fallback. + fields := strings.Split(lines[1], "\t") + fields[4] = "not-a-number" + lines[1] = strings.Join(fields, "\t") + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := Entries(path, "A") + if err == nil { + t.Fatal("Entries returned no error with both a bad line and a missing run-start present") + } + if !strings.Contains(err.Error(), "unparsable line 2") { + t.Errorf("error = %q, want it to report the unparsable line (line 2), not the missing run-start", err) + } + if len(got) != 1 || got[0].Action != "run-end" { + t.Errorf("entries = %+v, want just the surviving run-end", got) + } +} + +// TestEntriesAdjacentRunStartsOneCorrupted is item 4. Ruling R2: this pins +// what journal.Entries does TODAY for two runs whose run-start lines are +// adjacent, one of them corrupted - it does not assert an invented "correct" +// result, and internal/journal is not touched by this task. The log here is +// exactly: +// +// 1 run-start A (good) +// 2 run-start B (corrupted: no tabs, unattributable) +// 3 move A (good) +// 4 run-end A (good) +// 5 move B (good) +// 6 run-end B (good) +// +// Observed behaviour, traced by hand against Entries and confirmed by this +// test: Entries(path, "A") fails closed with the unparsable-line error, +// because line 2 falls inside A's own window (opened by line 1, not yet +// closed by a run-end) even though the corrupted line was actually B's +// run-start, not A's - this is exactly the "residual risk... a false +// refusal, not a false success" the function's own doc comment already +// names. Entries(path, "B"), in contrast, never sees line 2 as inside its +// window (B's window has not opened - its own run-start is the corrupted +// line), so it reaches the end of the file with no badLine, and instead +// fails on B's missing run-start. +// +// Concern (not fixed here, per R2 - flagged for judgement, not code +// change): the SAME corrupted line produces two different error shapes +// depending only on which run asks, which is a surprising inconsistency in +// the message a caller sees, even though both directions correctly fail +// closed. +func TestEntriesAdjacentRunStartsOneCorrupted(t *testing.T) { + path := filepath.Join(t.TempDir(), "krino.log") + w, _ := Open(path) + at := time.Date(2026, 9, 12, 8, 0, 0, 0, time.UTC) + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "B", Action: "run-start", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "B", Dir: "dl", File: "y.pdf", Step: 1, + Action: "move", Status: "ok", Src: "/a/y.pdf", Dst: "/b/y.pdf"}); err != nil { + t.Fatal(err) + } + if err := w.Append(Entry{Time: at, Run: "B", Action: "run-end", Status: "ok"}); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + if len(lines) != 6 { + t.Fatalf("fixture has %d lines, want 6", len(lines)) + } + // Line 2, B's run-start, adjacent to A's on line 1: destroyed + // unattributably. + lines[1] = "totally-mangled-no-tabs-here" + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + gotA, errA := Entries(path, "A") + if errA == nil { + t.Fatal("Entries(A) returned no error; pinned behaviour expects one (the corrupted adjacent line falls inside A's window)") + } + if !strings.Contains(errA.Error(), "unparsable line 2") { + t.Errorf("Entries(A) error = %q, want it to name the unparsable line 2", errA) + } + if len(gotA) != 3 || gotA[0].Action != "run-start" || gotA[1].Action != "move" || gotA[2].Action != "run-end" { + t.Errorf("Entries(A) = %+v, want A's own [run-start, move, run-end]", gotA) + } + + gotB, errB := Entries(path, "B") + if errB == nil { + t.Fatal("Entries(B) returned no error; pinned behaviour expects one (B's own run-start is the corrupted line)") + } + if !strings.Contains(errB.Error(), "no readable run-start") { + t.Errorf("Entries(B) error = %q, want it to name the missing run-start", errB) + } + if len(gotB) != 2 || gotB[0].Action != "move" || gotB[1].Action != "run-end" { + t.Errorf("Entries(B) = %+v, want B's surviving [move, run-end]", gotB) + } +} + // TestEntriesReportsAMangledLine: a corrupt line that is not the log's // final line must not be silently dropped by Entries the way Runs drops it // - PlanUndo needs to know a step went missing so it can refuse the whole |
