Skip to content
Merged
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
6 changes: 2 additions & 4 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}%)"
Expand Down
2 changes: 1 addition & 1 deletion doc/developer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
74 changes: 74 additions & 0 deletions src/internal/app/icon_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
173 changes: 173 additions & 0 deletions src/internal/app/ime_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
110 changes: 110 additions & 0 deletions src/test/unit/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading
Loading