diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1e1944d..094804d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -87,7 +87,7 @@ jobs: - name: Unit + integration tests with coverage shell: bash run: | - go test ./test/unit/... ./test/integration/... -race \ + go test ./internal/app/ ./test/unit/... ./test/integration/... -race \ -coverpkg=./internal/... -coverprofile=cover.out # e2e builds a real binary and drives it under Xvfb -- Linux-only by @@ -105,9 +105,7 @@ jobs: if: runner.os == 'Linux' shell: bash env: - # TODO: ratchet this to 80 (the target in doc/ci-cd-setup.md) once - # the suite gets there. Measured at 77.8% when this gate was added. - MIN_COVERAGE: '75' + MIN_COVERAGE: '80' run: | pct=$(go tool cover -func=cover.out | tail -1 | grep -oE '[0-9]+\.[0-9]+' | head -1) echo "Total line coverage: ${pct}% (minimum: ${MIN_COVERAGE}%)" diff --git a/doc/developer.md b/doc/developer.md index 3831471..f8453b6 100644 --- a/doc/developer.md +++ b/doc/developer.md @@ -282,7 +282,7 @@ rationale (and the not-yet-implemented publishing channels) is in ### Coverage gate `pr.yml` fails if total line coverage over `internal/...` drops below -**75%** (`MIN_COVERAGE` in the workflow). Coverage comes from the unit + +**80%** (`MIN_COVERAGE` in the workflow). Coverage comes from the unit + integration tiers only — e2e drives a separately-compiled binary, so its execution isn't visible to `-coverpkg` (see §6). diff --git a/src/internal/app/icon_test.go b/src/internal/app/icon_test.go new file mode 100644 index 0000000..fd509ef --- /dev/null +++ b/src/internal/app/icon_test.go @@ -0,0 +1,74 @@ +package editorapp + +import ( + "image" + "testing" + + "gioui.org/io/key" + "gioui.org/io/pointer" + + "github.com/kgfly/SimpleNvimEditor/internal/config" +) + +func TestAppIcon(t *testing.T) { + img := appIcon() + if img == nil { + t.Fatal("appIcon returned nil") + } + b := img.Bounds() + if b.Dx() == 0 || b.Dy() == 0 { + t.Fatalf("appIcon returned empty image: %v", b) + } +} + +func TestNewApp(t *testing.T) { + cfg := config.Default() + a := New(cfg, []string{"file1.go", "file2.go"}) + if a == nil { + t.Fatal("New returned nil") + } + if a.state == nil { + t.Fatal("New should initialize state") + } + if len(a.files) != 2 { + t.Fatalf("files = %v, want 2 entries", a.files) + } +} + +func TestQuitWithNilProc(t *testing.T) { + a := New(config.Default(), nil) + // Should not panic. + a.quit() +} + +func TestOnKeyWithNilProc(t *testing.T) { + a := New(config.Default(), nil) + a.onKey(key.Event{Name: "A", State: key.Press}) +} + +func TestOnEditWithNilProc(t *testing.T) { + a := New(config.Default(), nil) + a.onEdit(key.EditEvent{Text: "hello"}) +} + +func TestOnPointerWithNilProc(t *testing.T) { + a := New(config.Default(), nil) + a.onPointer(pointer.Event{Kind: pointer.Press, Buttons: pointer.ButtonPrimary}) +} + +func TestCaretWithZeroMetrics(t *testing.T) { + a := New(config.Default(), nil) + c := a.caret() + if c.Ascent != 0 || c.Descent != 0 { + t.Fatalf("caret with zero metrics should be zero, got %+v", c) + } +} + +func TestSyncSizeWithZeroCellDims(t *testing.T) { + a := New(config.Default(), nil) + // CellWidth and CellHeight are 0, should return early. + a.syncSize(image.Pt(800, 600)) + if a.proc != nil { + t.Fatal("syncSize should not start nvim with zero cell dims") + } +} diff --git a/src/internal/app/ime_test.go b/src/internal/app/ime_test.go new file mode 100644 index 0000000..bf04b1f --- /dev/null +++ b/src/internal/app/ime_test.go @@ -0,0 +1,173 @@ +package editorapp + +import ( + "testing" + + "gioui.org/io/key" +) + +func TestNewIMEShadow(t *testing.T) { + s := newIMEShadow() + if s.composing() { + t.Fatal("new shadow should not be composing") + } + if got := s.snippet(); got.Text != "" { + t.Fatalf("new shadow snippet = %q, want empty", got.Text) + } + if got := s.composingText(); got != "" { + t.Fatalf("new shadow composingText = %q, want empty", got) + } +} + +func TestIMEReset(t *testing.T) { + s := newIMEShadow() + s.replace(key.Range{}, "hello") + s.setComposing(key.Range{Start: 0, End: 5}) + s.reset() + if s.composing() { + t.Fatal("composing should be false after reset") + } + if len(s.text) != 0 { + t.Fatalf("text should be empty after reset, got %d runes", len(s.text)) + } +} + +func TestIMEReplaceInsert(t *testing.T) { + s := newIMEShadow() + displaced := s.replace(key.Range{}, "hello") + if displaced != 0 { + t.Fatalf("insert displaced %d, want 0", displaced) + } + if got := s.snippet().Text; got != "hello" { + t.Fatalf("text = %q, want %q", got, "hello") + } + if s.sel.Start != 5 || s.sel.End != 5 { + t.Fatalf("sel = %+v, want {5,5}", s.sel) + } +} + +func TestIMEReplaceOverwrite(t *testing.T) { + s := newIMEShadow() + s.replace(key.Range{}, "hello world") + displaced := s.replace(key.Range{Start: 0, End: 5}, "hi") + if displaced != 5 { + t.Fatalf("displaced = %d, want 5", displaced) + } + if got := s.snippet().Text; got != "hi world" { + t.Fatalf("text = %q, want %q", got, "hi world") + } +} + +func TestIMESetComposingAndComposingText(t *testing.T) { + s := newIMEShadow() + s.replace(key.Range{}, "hello") + s.setComposing(key.Range{Start: 0, End: 5}) + if !s.composing() { + t.Fatal("should be composing") + } + if got := s.composingText(); got != "hello" { + t.Fatalf("composingText = %q, want %q", got, "hello") + } +} + +func TestIMESetComposingNegativeClears(t *testing.T) { + s := newIMEShadow() + s.replace(key.Range{}, "hello") + s.setComposing(key.Range{Start: 0, End: 5}) + s.setComposing(key.Range{Start: -1, End: -1}) + if s.composing() { + t.Fatal("negative start should clear composing") + } +} + +func TestIMESetSelection(t *testing.T) { + s := newIMEShadow() + s.replace(key.Range{}, "hello") + s.setSelection(key.Range{Start: 2, End: 4}) + if s.sel.Start != 2 || s.sel.End != 4 { + t.Fatalf("sel = %+v, want {2,4}", s.sel) + } +} + +func TestIMEClampReversedRange(t *testing.T) { + s := newIMEShadow() + s.replace(key.Range{}, "ab") + r := s.clamp(key.Range{Start: 2, End: 0}) + if r.Start != 0 || r.End != 2 { + t.Fatalf("clamp reversed = %+v, want {0,2}", r) + } +} + +func TestIMEClampOutOfBounds(t *testing.T) { + s := newIMEShadow() + s.replace(key.Range{}, "ab") + r := s.clamp(key.Range{Start: -5, End: 100}) + if r.Start != 0 || r.End != 2 { + t.Fatalf("clamp oob = %+v, want {0,2}", r) + } +} + +func TestIMESnippet(t *testing.T) { + s := newIMEShadow() + s.replace(key.Range{}, "test") + snip := s.snippet() + if snip.Range.Start != 0 || snip.Range.End != 4 { + t.Fatalf("snippet range = %+v, want {0,4}", snip.Range) + } + if snip.Text != "test" { + t.Fatalf("snippet text = %q, want %q", snip.Text, "test") + } +} + +func TestIMETrimIfIdle(t *testing.T) { + s := newIMEShadow() + // Fill buffer beyond maxShadowRunes. + big := make([]rune, maxShadowRunes+1) + for i := range big { + big[i] = 'x' + } + s.text = big + s.trimIfIdle() + if len(s.text) != 0 { + t.Fatalf("trimIfIdle should empty oversized idle buffer, got %d", len(s.text)) + } +} + +func TestIMETrimIfIdleSkipsComposing(t *testing.T) { + s := newIMEShadow() + big := make([]rune, maxShadowRunes+1) + for i := range big { + big[i] = 'x' + } + s.text = big + s.setComposing(key.Range{Start: 0, End: 10}) + s.trimIfIdle() + if len(s.text) == 0 { + t.Fatal("trimIfIdle should not empty buffer while composing") + } +} + +func TestIMETrimIfIdleSmallBuffer(t *testing.T) { + s := newIMEShadow() + s.replace(key.Range{}, "small") + s.trimIfIdle() + if s.snippet().Text != "small" { + t.Fatal("trimIfIdle should not touch small buffers") + } +} + +func TestClampInt(t *testing.T) { + cases := []struct { + v, lo, hi, want int + }{ + {5, 0, 10, 5}, + {-1, 0, 10, 0}, + {15, 0, 10, 10}, + {0, 0, 0, 0}, + } + for _, c := range cases { + if got := clampInt(c.v, c.lo, c.hi); got != c.want { + t.Errorf("clampInt(%d, %d, %d) = %d, want %d", c.v, c.lo, c.hi, got, c.want) + } + } +} diff --git a/src/test/unit/config_test.go b/src/test/unit/config_test.go index 704c3f4..93f8509 100644 --- a/src/test/unit/config_test.go +++ b/src/test/unit/config_test.go @@ -189,3 +189,113 @@ func TestDefaultFontFamilyForOSNeverEmpty(t *testing.T) { t.Fatalf("DefaultFontFamilyForOS() returned empty string") } } + +func TestLoadExplicitZeroFontSizeAndEmptyCommand(t *testing.T) { + withIsolatedConfigDir(t) + + path, err := config.FilePath() + if err != nil { + t.Fatalf("FilePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + toml := ` +[editor] +font_size = 0 + +[nvim] +command = "" +` + if err := os.WriteFile(path, []byte(toml), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Editor.FontSize != config.Default().Editor.FontSize { + t.Errorf("zero FontSize should fall back to default, got %v", cfg.Editor.FontSize) + } + if cfg.Nvim.Command != config.Default().Nvim.Command { + t.Errorf("empty Command should fall back to default, got %q", cfg.Nvim.Command) + } +} + +func TestLoadNegativeFontSize(t *testing.T) { + withIsolatedConfigDir(t) + + path, err := config.FilePath() + if err != nil { + t.Fatalf("FilePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte("[editor]\nfont_size = -5\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Editor.FontSize != config.Default().Editor.FontSize { + t.Errorf("negative FontSize should fall back to default, got %v", cfg.Editor.FontSize) + } +} + +func TestLoadUnreadableFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod does not restrict reads on Windows") + } + withIsolatedConfigDir(t) + + path, err := config.FilePath() + if err != nil { + t.Fatalf("FilePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte("[editor]\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := os.Chmod(path, 0o000); err != nil { + t.Skipf("cannot chmod on this platform: %v", err) + } + t.Cleanup(func() { os.Chmod(path, 0o644) }) + + _, err = config.Load() + if err == nil { + t.Fatal("Load() with unreadable file should return an error") + } +} + +func TestLoadSameFontFamilyAsDefault(t *testing.T) { + withIsolatedConfigDir(t) + + path, err := config.FilePath() + if err != nil { + t.Fatalf("FilePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + // Set font_family to the same as default — UseSystemFonts should NOT be set. + toml := `[editor] +font_family = "monospace" +` + if err := os.WriteFile(path, []byte(toml), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Editor.UseSystemFonts { + t.Error("UseSystemFonts should not be set when font_family == default") + } +} diff --git a/src/test/unit/coverage_test.go b/src/test/unit/coverage_test.go new file mode 100644 index 0000000..ed86d7f --- /dev/null +++ b/src/test/unit/coverage_test.go @@ -0,0 +1,602 @@ +package unit_test + +import ( + "image/color" + "testing" + + "github.com/kgfly/SimpleNvimEditor/internal/config" + "github.com/kgfly/SimpleNvimEditor/internal/uistate" +) + +func nrgba(r, g, b uint8) color.NRGBA { + return color.NRGBA{R: r, G: g, B: b, A: 0xff} +} + +// Exercise the len(t)