diff --git a/internal/mire/diff.go b/internal/mire/diff.go new file mode 100644 index 0000000..ab997fa --- /dev/null +++ b/internal/mire/diff.go @@ -0,0 +1,96 @@ +package mire + +import ( + "fmt" + "io" + "strings" + + "mire/internal/output" +) + +const missingMismatchLine = "" + +type mismatchLine struct { + text string + present bool +} + +type mismatchDetails struct { + lineNumber int + expected mismatchLine + actual mismatchLine +} + +func writeScenarioMismatch(w io.Writer, mismatch *testMismatchError) { + details := firstMismatchingLine(mismatch.expected, mismatch.actual) + + fmt.Fprintf(w, "%s\n%s\n", italicLabel("Expected", output.Pass), formatMismatchLine(details.expected)) + fmt.Fprintf(w, "%s\n%s\n", italicLabel("Actual", output.Fail), formatMismatchLine(details.actual)) +} + +func firstMismatchingLine(expected, actual []byte) mismatchDetails { + expectedLines := splitMismatchLines(expected) + actualLines := splitMismatchLines(actual) + + maxLines := len(expectedLines) + if len(actualLines) > maxLines { + maxLines = len(actualLines) + } + + for i := 0; i < maxLines; i++ { + expectedLine := mismatchLineAt(expectedLines, i) + actualLine := mismatchLineAt(actualLines, i) + if expectedLine != actualLine { + return mismatchDetails{ + lineNumber: i + 1, + expected: expectedLine, + actual: actualLine, + } + } + } + + return mismatchDetails{ + expected: mismatchLineAt(expectedLines, len(expectedLines)-1), + actual: mismatchLineAt(actualLines, len(actualLines)-1), + } +} + +func splitMismatchLines(data []byte) []string { + if len(data) == 0 { + return nil + } + + lines := strings.Split(string(data), "\n") + if data[len(data)-1] == '\n' { + lines = lines[:len(lines)-1] + } + + for i := range lines { + lines[i] = strings.TrimSuffix(lines[i], "\r") + } + + return lines +} + +func mismatchLineAt(lines []string, index int) mismatchLine { + if index < 0 || index >= len(lines) { + return mismatchLine{} + } + + return mismatchLine{ + text: lines[index], + present: true, + } +} + +func formatMismatchLine(line mismatchLine) string { + if !line.present { + return missingMismatchLine + } + + return line.text +} + +func italicLabel(text string, color output.Color) string { + return output.NewStyle().Italic().Apply(output.Label(text, color)) +} diff --git a/internal/mire/diff_test.go b/internal/mire/diff_test.go new file mode 100644 index 0000000..c1251a5 --- /dev/null +++ b/internal/mire/diff_test.go @@ -0,0 +1,112 @@ +package mire + +import ( + "bytes" + "strings" + "testing" +) + +func TestFirstMismatchingLineReturnsFirstChangedLine(t *testing.T) { + got := firstMismatchingLine( + []byte("$ echo x\nx\n$ \nexit\n"), + []byte("$ echo a\na\n$ \nexit\n"), + ) + + if got.lineNumber != 1 { + t.Fatalf("lineNumber = %d, want 1", got.lineNumber) + } + if got.expected != (mismatchLine{text: "$ echo x", present: true}) { + t.Fatalf("expected line = %#v, want first changed expected line", got.expected) + } + if got.actual != (mismatchLine{text: "$ echo a", present: true}) { + t.Fatalf("actual line = %#v, want first changed actual line", got.actual) + } +} + +func TestFirstMismatchingLineMarksMissingLine(t *testing.T) { + got := firstMismatchingLine( + []byte("alpha\nbeta\n"), + []byte("alpha\n"), + ) + + if got.lineNumber != 2 { + t.Fatalf("lineNumber = %d, want 2", got.lineNumber) + } + if got.expected != (mismatchLine{text: "beta", present: true}) { + t.Fatalf("expected line = %#v, want missing expected line content", got.expected) + } + if got.actual != (mismatchLine{}) { + t.Fatalf("actual line = %#v, want missing line", got.actual) + } +} + +func TestWriteScenarioMismatchPrintsOnlyFirstMismatch(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + var buf bytes.Buffer + writeScenarioMismatch(&buf, &testMismatchError{ + expected: []byte("$ echo x\nx\n$ \nexit\n"), + actual: []byte("$ echo a\na\n$ \nexit\n"), + }) + + got := buf.String() + for _, want := range []string{ + "Expected\n", + "$ echo x\n", + "Actual\n", + "$ echo a\n", + } { + if !strings.Contains(got, want) { + t.Fatalf("writeScenarioMismatch() output = %q, want substring %q", got, want) + } + } + if strings.Contains(got, "mire › Expected:\n") || strings.Contains(got, "mire › Actual:\n") { + t.Fatalf("writeScenarioMismatch() output = %q, want mismatch lines without mire prefix", got) + } + + for _, unwanted := range []string{"\nx\n", "\na\n", "\nexit\n"} { + if strings.Contains(got, unwanted) { + t.Fatalf("writeScenarioMismatch() output = %q, want only first mismatching line", got) + } + } +} + +func TestWriteScenarioMismatchFormatsMissingLine(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + var buf bytes.Buffer + writeScenarioMismatch(&buf, &testMismatchError{ + expected: []byte("alpha\nbeta\n"), + actual: []byte("alpha\n"), + }) + + got := buf.String() + if !strings.Contains(got, "beta\n") { + t.Fatalf("writeScenarioMismatch() output = %q, want expected mismatching line", got) + } + if !strings.Contains(got, missingMismatchLine+"\n") { + t.Fatalf("writeScenarioMismatch() output = %q, want missing-line placeholder", got) + } +} + +func TestWriteScenarioMismatchItalicizesLabels(t *testing.T) { + t.Setenv("NO_COLOR", "") + + var buf bytes.Buffer + writeScenarioMismatch(&buf, &testMismatchError{ + expected: []byte("alpha\n"), + actual: []byte("beta\n"), + }) + + got := buf.String() + if !strings.Contains(got, "\x1b[3m") { + t.Fatalf("writeScenarioMismatch() output = %q, want italic styling", got) + } +} + +func TestTestMismatchErrorIncludesLineNumber(t *testing.T) { + err := &testMismatchError{lineNumber: 7} + if got := err.Error(); got != "output differed at line 7" { + t.Fatalf("Error() = %q, want %q", got, "output differed at line 7") + } +} diff --git a/internal/mire/test.go b/internal/mire/test.go index 7bf6a02..cf73f96 100644 --- a/internal/mire/test.go +++ b/internal/mire/test.go @@ -42,8 +42,9 @@ type testFixtureFiles struct { } type testMismatchError struct { - expected []byte - actual []byte + expected []byte + actual []byte + lineNumber int } const ( @@ -52,6 +53,10 @@ const ( ) func (e *testMismatchError) Error() string { + if e.lineNumber > 0 { + return fmt.Sprintf("output differed at line %d", e.lineNumber) + } + return "output differed" } @@ -302,26 +307,17 @@ func replayScenario(scenario testScenario, shellPath string, _ testIO, sandboxCo } if !bytes.Equal(got, want) { + details := firstMismatchingLine(want, got) return &testMismatchError{ - expected: want, - actual: got, + expected: want, + actual: got, + lineNumber: details.lineNumber, } } return nil } -func writeScenarioMismatch(w io.Writer, mismatch *testMismatchError) { - output.Fprintf(w, "Expected:\n%s", string(mismatch.expected)) - if len(mismatch.expected) == 0 || mismatch.expected[len(mismatch.expected)-1] != '\n' { - output.Fprintf(w, "\n") - } - output.Fprintf(w, "Actual:\n%s", string(mismatch.actual)) - if len(mismatch.actual) == 0 || mismatch.actual[len(mismatch.actual)-1] != '\n' { - output.Fprintf(w, "\n") - } -} - func trimReplayOutputToMarker(data []byte, shellPath string) ([]byte, error) { idx := bytes.Index(data, []byte(compareOutputMarker)) if idx == -1 {