1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 13:16:47 +00:00

Merge branch 'main' into dev

This commit is contained in:
tw93
2026-01-31 17:37:46 +08:00
22 changed files with 1642 additions and 414 deletions

5
.gitignore vendored
View File

@@ -69,5 +69,10 @@ tests/tmp-*/
tests/*.tmp
tests/*.log
# Go test coverage files
*.out
coverage.out
coverage.html
session.json
run_tests.ps1

View File

@@ -274,13 +274,9 @@ Real feedback from users who shared Mole on X.
- If Mole helped you, star the repo or [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Mole&text=Mole%20-%20Deep%20clean%20and%20optimize%20your%20Mac.) with friends.
- Got ideas or found bugs? Check the [Contributing Guide](CONTRIBUTING.md) and open an issue or PR.
- Like Mole? <a href="https://miaoyan.app/cats.html?name=Mole" target="_blank">Buy Tw93 a Coke</a> to support the project! 🥤
- Like Mole? <a href="https://miaoyan.app/cats.html?name=Mole" target="_blank">Buy Tw93 a Coke</a> to support the project! 🥤 Supporters below.
<details>
<summary><strong>Friends who bought me Coke</strong></summary>
<br/>
<a href="https://miaoyan.app/cats.html?name=Mole"><img src="https://miaoyan.app/assets/sponsors.svg" width="1000" /></a>
</details>
<a href="https://miaoyan.app/cats.html?name=Mole"><img src="https://miaoyan.app/assets/sponsors.svg" width="1000" loading="lazy" /></a>
## License

View File

@@ -142,10 +142,6 @@ cleanup() {
stop_inline_spinner 2> /dev/null || true
if [[ -t 1 ]]; then
printf "\r\033[K" >&2 || true
fi
cleanup_temp_files
stop_sudo_session
@@ -601,7 +597,7 @@ safe_clean() {
fi
if [[ "$show_spinner" == "true" || "$cleaning_spinner_started" == "true" ]]; then
stop_section_spinner
stop_inline_spinner
fi
local permission_end=${MOLE_PERMISSION_DENIED_COUNT:-0}

View File

@@ -119,14 +119,44 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
size := getActualFileSize(fullPath, info)
atomic.AddInt64(&total, size)
entryChan <- dirEntry{
// Reuse timer to reduce GC pressure
timer := time.NewTimer(0)
// Ensure timer is drained immediately since we start with 0
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
select {
case entryChan <- dirEntry{
Name: child.Name() + " →",
Path: fullPath,
Size: size,
IsDir: isDir,
LastAccess: getLastAccessTimeFromInfo(info),
}:
default:
// If channel is full, use timer to wait with timeout
timer.Reset(100 * time.Millisecond)
select {
case entryChan <- dirEntry{
Name: child.Name() + " →",
Path: fullPath,
Size: size,
IsDir: isDir,
LastAccess: getLastAccessTimeFromInfo(info),
}:
if !timer.Stop() {
<-timer.C
}
case <-timer.C:
// Skip if channel is blocked
}
}
continue
}
if child.IsDir() {
@@ -158,12 +188,19 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1)
entryChan <- dirEntry{
timer := time.NewTimer(100 * time.Millisecond)
select {
case entryChan <- dirEntry{
Name: name,
Path: path,
Size: size,
IsDir: true,
LastAccess: time.Time{},
}:
if !timer.Stop() {
<-timer.C
}
case <-timer.C:
}
}(child.Name(), fullPath)
continue
@@ -188,12 +225,19 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1)
entryChan <- dirEntry{
timer := time.NewTimer(100 * time.Millisecond)
select {
case entryChan <- dirEntry{
Name: name,
Path: path,
Size: size,
IsDir: true,
LastAccess: time.Time{},
}:
if !timer.Stop() {
<-timer.C
}
case <-timer.C:
}
}(child.Name(), fullPath)
continue
@@ -209,12 +253,19 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1)
entryChan <- dirEntry{
timer := time.NewTimer(100 * time.Millisecond)
select {
case entryChan <- dirEntry{
Name: name,
Path: path,
Size: size,
IsDir: true,
LastAccess: time.Time{},
}:
if !timer.Stop() {
<-timer.C
}
case <-timer.C:
}
}(child.Name(), fullPath)
continue
@@ -230,18 +281,35 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
atomic.AddInt64(filesScanned, 1)
atomic.AddInt64(bytesScanned, size)
entryChan <- dirEntry{
// Single-use timer for main loop (less pressure than tight loop above)
// But let's be consistent and optimized
timer := time.NewTimer(100 * time.Millisecond)
select {
case entryChan <- dirEntry{
Name: child.Name(),
Path: fullPath,
Size: size,
IsDir: false,
LastAccess: getLastAccessTimeFromInfo(info),
}:
if !timer.Stop() {
<-timer.C
}
case <-timer.C:
}
// Track large files only.
if !shouldSkipFileForLargeTracking(fullPath) {
minSize := atomic.LoadInt64(&largeFileMinSize)
if size >= minSize {
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
timer.Reset(100 * time.Millisecond)
select {
case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}:
if !timer.Stop() {
<-timer.C
}
case <-timer.C:
}
}
}
}
@@ -451,6 +519,15 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar
maxConcurrent := min(runtime.NumCPU()*2, maxDirWorkers)
sem := make(chan struct{}, maxConcurrent)
// Reuse timer for large file sends
timer := time.NewTimer(0)
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
for _, child := range children {
fullPath := filepath.Join(root, child.Name())
@@ -516,7 +593,14 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, lar
if !shouldSkipFileForLargeTracking(fullPath) && largeFileMinSize != nil {
minSize := atomic.LoadInt64(largeFileMinSize)
if size >= minSize {
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
timer.Reset(100 * time.Millisecond)
select {
case largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}:
if !timer.Stop() {
<-timer.C
}
case <-timer.C:
}
}
}
@@ -584,7 +668,7 @@ func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64,
ctx, cancel := context.WithTimeout(context.Background(), duTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "du", "-sk", target)
cmd := exec.CommandContext(ctx, "du", "-skP", target)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

View File

@@ -86,20 +86,14 @@ func TestColorizeTempThresholds(t *testing.T) {
}
func TestColorizeTempStyleRanges(t *testing.T) {
// Test that different temperature ranges use different styles
// We can't easily test the exact style applied, but we can verify
// the function returns consistent results for each range
normalTemp := colorizeTemp(40.0)
warningTemp := colorizeTemp(65.0)
dangerTemp := colorizeTemp(85.0)
// All should be non-empty and contain the formatted value
if normalTemp == "" || warningTemp == "" || dangerTemp == "" {
t.Fatal("colorizeTemp should not return empty strings")
}
// Verify formatting precision (one decimal place)
if !strings.Contains(normalTemp, "40.0") {
t.Errorf("normal temp should contain '40.0', got: %s", normalTemp)
}
@@ -110,3 +104,93 @@ func TestColorizeTempStyleRanges(t *testing.T) {
t.Errorf("danger temp should contain '85.0', got: %s", dangerTemp)
}
}
func TestCalculateHealthScoreEdgeCases(t *testing.T) {
tests := []struct {
name string
cpu CPUStatus
mem MemoryStatus
disks []DiskStatus
diskIO DiskIOStatus
thermal ThermalStatus
wantMin int
wantMax int
}{
{
name: "all metrics at normal threshold",
cpu: CPUStatus{Usage: 30.0},
mem: MemoryStatus{UsedPercent: 50.0},
disks: []DiskStatus{{UsedPercent: 70.0}},
diskIO: DiskIOStatus{ReadRate: 25.0, WriteRate: 25.0},
thermal: ThermalStatus{CPUTemp: 60.0},
wantMin: 95,
wantMax: 100,
},
{
name: "memory pressure warning only",
cpu: CPUStatus{Usage: 10.0},
mem: MemoryStatus{UsedPercent: 40.0, Pressure: "warn"},
disks: []DiskStatus{{UsedPercent: 40.0}},
diskIO: DiskIOStatus{ReadRate: 5.0, WriteRate: 5.0},
thermal: ThermalStatus{CPUTemp: 40.0},
wantMin: 90,
wantMax: 100,
},
{
name: "empty disks array",
cpu: CPUStatus{Usage: 10.0},
mem: MemoryStatus{UsedPercent: 30.0},
disks: []DiskStatus{},
diskIO: DiskIOStatus{ReadRate: 5.0, WriteRate: 5.0},
thermal: ThermalStatus{CPUTemp: 40.0},
wantMin: 95,
wantMax: 100,
},
{
name: "zero thermal data",
cpu: CPUStatus{Usage: 10.0},
mem: MemoryStatus{UsedPercent: 30.0},
disks: []DiskStatus{{UsedPercent: 40.0}},
diskIO: DiskIOStatus{ReadRate: 5.0, WriteRate: 5.0},
thermal: ThermalStatus{CPUTemp: 0},
wantMin: 95,
wantMax: 100,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score, _ := calculateHealthScore(tt.cpu, tt.mem, tt.disks, tt.diskIO, tt.thermal)
if score < tt.wantMin || score > tt.wantMax {
t.Errorf("calculateHealthScore() = %d, want range [%d, %d]", score, tt.wantMin, tt.wantMax)
}
})
}
}
func TestFormatUptimeEdgeCases(t *testing.T) {
tests := []struct {
name string
secs uint64
want string
}{
{"zero seconds", 0, "0m"},
{"59 seconds", 59, "0m"},
{"one minute exact", 60, "1m"},
{"59 minutes 59 seconds", 3599, "59m"},
{"one hour exact", 3600, "1h 0m"},
{"one day exact", 86400, "1d 0h"},
{"one day one hour", 90000, "1d 1h"},
{"multiple days no hours", 172800, "2d 0h"},
{"large uptime", 31536000, "365d 0h"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatUptime(tt.secs)
if got != tt.want {
t.Errorf("formatUptime(%d) = %q, want %q", tt.secs, got, tt.want)
}
})
}
}

View File

@@ -1,6 +1,9 @@
package main
import "testing"
import (
"strings"
"testing"
)
func TestFormatRate(t *testing.T) {
tests := []struct {
@@ -39,6 +42,95 @@ func TestFormatRate(t *testing.T) {
}
}
func TestColorizePercent(t *testing.T) {
tests := []struct {
name string
percent float64
input string
expectDanger bool
expectWarn bool
expectOk bool
}{
{"low usage", 30.0, "30%", false, false, true},
{"just below warn", 59.9, "59.9%", false, false, true},
{"at warn threshold", 60.0, "60%", false, true, false},
{"mid range", 70.0, "70%", false, true, false},
{"just below danger", 84.9, "84.9%", false, true, false},
{"at danger threshold", 85.0, "85%", true, false, false},
{"high usage", 95.0, "95%", true, false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := colorizePercent(tt.percent, tt.input)
if got == "" {
t.Errorf("colorizePercent(%v, %q) returned empty string", tt.percent, tt.input)
return
}
expected := ""
if tt.expectDanger {
expected = dangerStyle.Render(tt.input)
} else if tt.expectWarn {
expected = warnStyle.Render(tt.input)
} else if tt.expectOk {
expected = okStyle.Render(tt.input)
}
if got != expected {
t.Errorf("colorizePercent(%v, %q) = %q, want %q (danger=%v warn=%v ok=%v)",
tt.percent, tt.input, got, expected, tt.expectDanger, tt.expectWarn, tt.expectOk)
}
})
}
}
func TestColorizeBattery(t *testing.T) {
tests := []struct {
name string
percent float64
input string
expectDanger bool
expectWarn bool
expectOk bool
}{
{"critical low", 10.0, "10%", true, false, false},
{"just below low", 19.9, "19.9%", true, false, false},
{"at low threshold", 20.0, "20%", false, true, false},
{"mid range", 35.0, "35%", false, true, false},
{"just below ok", 49.9, "49.9%", false, true, false},
{"at ok threshold", 50.0, "50%", false, false, true},
{"healthy", 80.0, "80%", false, false, true},
{"full", 100.0, "100%", false, false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := colorizeBattery(tt.percent, tt.input)
if got == "" {
t.Errorf("colorizeBattery(%v, %q) returned empty string", tt.percent, tt.input)
return
}
expected := ""
if tt.expectDanger {
expected = dangerStyle.Render(tt.input)
} else if tt.expectWarn {
expected = warnStyle.Render(tt.input)
} else if tt.expectOk {
expected = okStyle.Render(tt.input)
}
if got != expected {
t.Errorf("colorizeBattery(%v, %q) = %q, want %q (danger=%v warn=%v ok=%v)",
tt.percent, tt.input, got, expected, tt.expectDanger, tt.expectWarn, tt.expectOk)
}
})
}
}
func TestShorten(t *testing.T) {
tests := []struct {
name string
@@ -113,6 +205,173 @@ func TestHumanBytesShort(t *testing.T) {
}
}
func TestHumanBytes(t *testing.T) {
tests := []struct {
name string
input uint64
want string
}{
// Zero and small values.
{"zero", 0, "0 B"},
{"one byte", 1, "1 B"},
{"1023 bytes", 1023, "1023 B"},
// Kilobyte boundaries (uses > not >=).
{"exactly 1KB", 1 << 10, "1024 B"},
{"just over 1KB", (1 << 10) + 1, "1.0 KB"},
{"1.5KB", 1536, "1.5 KB"},
// Megabyte boundaries (uses > not >=).
{"exactly 1MB", 1 << 20, "1024.0 KB"},
{"just over 1MB", (1 << 20) + 1, "1.0 MB"},
{"500MB", 500 << 20, "500.0 MB"},
// Gigabyte boundaries (uses > not >=).
{"exactly 1GB", 1 << 30, "1024.0 MB"},
{"just over 1GB", (1 << 30) + 1, "1.0 GB"},
{"100GB", 100 << 30, "100.0 GB"},
// Terabyte boundaries (uses > not >=).
{"exactly 1TB", 1 << 40, "1024.0 GB"},
{"just over 1TB", (1 << 40) + 1, "1.0 TB"},
{"2TB", 2 << 40, "2.0 TB"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := humanBytes(tt.input)
if got != tt.want {
t.Errorf("humanBytes(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestHumanBytesCompact(t *testing.T) {
tests := []struct {
name string
input uint64
want string
}{
// Zero and small values.
{"zero", 0, "0"},
{"one byte", 1, "1"},
{"1023 bytes", 1023, "1023"},
// Kilobyte boundaries (uses >= not >).
{"exactly 1KB", 1 << 10, "1.0K"},
{"1.5KB", 1536, "1.5K"},
// Megabyte boundaries.
{"exactly 1MB", 1 << 20, "1.0M"},
{"500MB", 500 << 20, "500.0M"},
// Gigabyte boundaries.
{"exactly 1GB", 1 << 30, "1.0G"},
{"100GB", 100 << 30, "100.0G"},
// Terabyte boundaries.
{"exactly 1TB", 1 << 40, "1.0T"},
{"2TB", 2 << 40, "2.0T"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := humanBytesCompact(tt.input)
if got != tt.want {
t.Errorf("humanBytesCompact(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestSplitDisks(t *testing.T) {
tests := []struct {
name string
disks []DiskStatus
wantInternal int
wantExternal int
}{
{
name: "empty slice",
disks: []DiskStatus{},
wantInternal: 0,
wantExternal: 0,
},
{
name: "all internal",
disks: []DiskStatus{
{Mount: "/", External: false},
{Mount: "/System", External: false},
},
wantInternal: 2,
wantExternal: 0,
},
{
name: "all external",
disks: []DiskStatus{
{Mount: "/Volumes/USB", External: true},
{Mount: "/Volumes/Backup", External: true},
},
wantInternal: 0,
wantExternal: 2,
},
{
name: "mixed",
disks: []DiskStatus{
{Mount: "/", External: false},
{Mount: "/Volumes/USB", External: true},
{Mount: "/System", External: false},
},
wantInternal: 2,
wantExternal: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
internal, external := splitDisks(tt.disks)
if len(internal) != tt.wantInternal {
t.Errorf("splitDisks() internal count = %d, want %d", len(internal), tt.wantInternal)
}
if len(external) != tt.wantExternal {
t.Errorf("splitDisks() external count = %d, want %d", len(external), tt.wantExternal)
}
})
}
}
func TestDiskLabel(t *testing.T) {
tests := []struct {
name string
prefix string
index int
total int
want string
}{
// Single disk — no numbering.
{"single disk", "INTR", 0, 1, "INTR"},
{"single external", "EXTR", 0, 1, "EXTR"},
// Multiple disks — numbered (1-indexed).
{"first of two", "INTR", 0, 2, "INTR1"},
{"second of two", "INTR", 1, 2, "INTR2"},
{"third of three", "EXTR", 2, 3, "EXTR3"},
// Edge case: total 0 treated as single.
{"total zero", "DISK", 0, 0, "DISK"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := diskLabel(tt.prefix, tt.index, tt.total)
if got != tt.want {
t.Errorf("diskLabel(%q, %d, %d) = %q, want %q", tt.prefix, tt.index, tt.total, got, tt.want)
}
})
}
}
func TestParseInt(t *testing.T) {
tests := []struct {
name string
@@ -333,3 +592,389 @@ func TestParsePMSet(t *testing.T) {
})
}
}
func TestProgressBar(t *testing.T) {
tests := []struct {
name string
percent float64
wantRune int
}{
{"zero percent", 0, 16},
{"negative clamped", -10, 16},
{"low percent", 25, 16},
{"half", 50, 16},
{"high percent", 75, 16},
{"full", 100, 16},
{"over 100 clamped", 150, 16},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := progressBar(tt.percent)
if len(got) == 0 {
t.Errorf("progressBar(%v) returned empty string", tt.percent)
return
}
gotClean := stripANSI(got)
gotRuneCount := len([]rune(gotClean))
if gotRuneCount != tt.wantRune {
t.Errorf("progressBar(%v) rune count = %d, want %d", tt.percent, gotRuneCount, tt.wantRune)
}
})
}
}
func TestBatteryProgressBar(t *testing.T) {
tests := []struct {
name string
percent float64
wantRune int
}{
{"zero percent", 0, 16},
{"negative clamped", -10, 16},
{"critical low", 15, 16},
{"low", 25, 16},
{"medium", 50, 16},
{"high", 75, 16},
{"full", 100, 16},
{"over 100 clamped", 120, 16},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := batteryProgressBar(tt.percent)
if len(got) == 0 {
t.Errorf("batteryProgressBar(%v) returned empty string", tt.percent)
return
}
gotClean := stripANSI(got)
gotRuneCount := len([]rune(gotClean))
if gotRuneCount != tt.wantRune {
t.Errorf("batteryProgressBar(%v) rune count = %d, want %d", tt.percent, gotRuneCount, tt.wantRune)
}
})
}
}
func TestColorizeTemp(t *testing.T) {
tests := []struct {
name string
temp float64
}{
{"very low", 20.0},
{"low", 40.0},
{"normal threshold", 55.9},
{"at warn threshold", 56.0},
{"warn range", 65.0},
{"just below danger", 75.9},
{"at danger threshold", 76.0},
{"high", 85.0},
{"very high", 95.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := colorizeTemp(tt.temp)
if got == "" {
t.Errorf("colorizeTemp(%v) returned empty string", tt.temp)
}
})
}
}
func TestIoBar(t *testing.T) {
tests := []struct {
name string
rate float64
}{
{"zero", 0},
{"very low", 5},
{"low normal", 20},
{"at warn threshold", 30},
{"warn range", 50},
{"just below danger", 79},
{"at danger threshold", 80},
{"high", 100},
{"very high", 200},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ioBar(tt.rate)
if got == "" {
t.Errorf("ioBar(%v) returned empty string", tt.rate)
return
}
gotClean := stripANSI(got)
gotRuneCount := len([]rune(gotClean))
if gotRuneCount != 5 {
t.Errorf("ioBar(%v) rune count = %d, want 5", tt.rate, gotRuneCount)
}
})
}
}
func TestMiniBar(t *testing.T) {
tests := []struct {
name string
percent float64
}{
{"zero", 0},
{"negative", -5},
{"low", 15},
{"at first step", 20},
{"mid", 50},
{"high", 75},
{"full", 100},
{"over 100", 120},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := miniBar(tt.percent)
if got == "" {
t.Errorf("miniBar(%v) returned empty string", tt.percent)
return
}
gotClean := stripANSI(got)
gotRuneCount := len([]rune(gotClean))
if gotRuneCount != 5 {
t.Errorf("miniBar(%v) rune count = %d, want 5", tt.percent, gotRuneCount)
}
})
}
}
func TestFormatDiskLine(t *testing.T) {
tests := []struct {
name string
label string
disk DiskStatus
}{
{
name: "empty label defaults to DISK",
label: "",
disk: DiskStatus{UsedPercent: 50.5, Used: 100 << 30, Total: 200 << 30},
},
{
name: "internal disk",
label: "INTR",
disk: DiskStatus{UsedPercent: 67.2, Used: 336 << 30, Total: 500 << 30},
},
{
name: "external disk",
label: "EXTR1",
disk: DiskStatus{UsedPercent: 85.0, Used: 850 << 30, Total: 1000 << 30},
},
{
name: "low usage",
label: "INTR",
disk: DiskStatus{UsedPercent: 15.3, Used: 15 << 30, Total: 100 << 30},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatDiskLine(tt.label, tt.disk)
if got == "" {
t.Errorf("formatDiskLine(%q, ...) returned empty string", tt.label)
return
}
expectedLabel := tt.label
if expectedLabel == "" {
expectedLabel = "DISK"
}
if !contains(got, expectedLabel) {
t.Errorf("formatDiskLine(%q, ...) = %q, should contain label %q", tt.label, got, expectedLabel)
}
})
}
}
func TestGetScoreStyle(t *testing.T) {
tests := []struct {
name string
score int
}{
{"critical low", 10},
{"poor low", 25},
{"just below fair", 39},
{"at fair threshold", 40},
{"fair range", 50},
{"just below good", 59},
{"at good threshold", 60},
{"good range", 70},
{"just below excellent", 74},
{"at excellent threshold", 75},
{"excellent range", 85},
{"just below perfect", 89},
{"perfect", 90},
{"max", 100},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
style := getScoreStyle(tt.score)
if style.GetForeground() == nil {
t.Errorf("getScoreStyle(%d) returned style with no foreground color", tt.score)
}
})
}
}
func TestMaxInt(t *testing.T) {
tests := []struct {
name string
a int
b int
want int
}{
{"a greater", 10, 5, 10},
{"b greater", 3, 8, 8},
{"equal", 7, 7, 7},
{"negative a greater", -5, -10, -5},
{"negative b greater", -10, -5, -5},
{"zero vs positive", 0, 5, 5},
{"zero vs negative", 0, -5, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := maxInt(tt.a, tt.b)
if got != tt.want {
t.Errorf("maxInt(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestSparkline(t *testing.T) {
tests := []struct {
name string
history []float64
current float64
width int
wantLen int
}{
{
name: "empty history",
history: []float64{},
current: 1.5,
width: 10,
wantLen: 10,
},
{
name: "short history padded",
history: []float64{1.0, 2.0, 3.0},
current: 3.0,
width: 10,
wantLen: 10,
},
{
name: "exact width",
history: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
current: 5.0,
width: 5,
wantLen: 5,
},
{
name: "history longer than width",
history: []float64{1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0},
current: 10.0,
width: 5,
wantLen: 5,
},
{
name: "low current value ok style",
history: []float64{1.0, 1.5, 2.0},
current: 2.0,
width: 5,
wantLen: 5,
},
{
name: "medium current value warn style",
history: []float64{3.0, 4.0, 5.0},
current: 5.0,
width: 5,
wantLen: 5,
},
{
name: "high current value danger style",
history: []float64{8.0, 9.0, 10.0},
current: 10.0,
width: 5,
wantLen: 5,
},
{
name: "all identical values flatline",
history: []float64{5.0, 5.0, 5.0, 5.0, 5.0},
current: 5.0,
width: 5,
wantLen: 5,
},
{
name: "zero width edge case",
history: []float64{1.0, 2.0, 3.0},
current: 2.0,
width: 0,
wantLen: 0,
},
{
name: "width of 1",
history: []float64{1.0, 2.0, 3.0},
current: 2.0,
width: 1,
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sparkline(tt.history, tt.current, tt.width)
if tt.width == 0 {
return
}
if got == "" {
t.Errorf("sparkline() returned empty string")
return
}
gotClean := stripANSI(got)
if len([]rune(gotClean)) != tt.wantLen {
t.Errorf("sparkline() rune length = %d, want %d", len([]rune(gotClean)), tt.wantLen)
}
})
}
}
func stripANSI(s string) string {
var result strings.Builder
i := 0
for i < len(s) {
if i < len(s)-1 && s[i] == '\x1b' && s[i+1] == '[' {
i += 2
for i < len(s) && (s[i] < 'A' || s[i] > 'Z') && (s[i] < 'a' || s[i] > 'z') {
i++
}
if i < len(s) {
i++
}
} else {
result.WriteByte(s[i])
i++
}
}
return result.String()
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr)))
}
func containsMiddle(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@@ -506,6 +506,7 @@ download_binary() {
if curl -fsSL --connect-timeout 10 --max-time 60 -o "$target_path" "$url"; then
if [[ -t 1 ]]; then stop_line_spinner; fi
chmod +x "$target_path"
xattr -cr "$target_path" 2> /dev/null || true
log_success "Downloaded ${binary_name} binary"
else
if [[ -t 1 ]]; then stop_line_spinner; fi

View File

@@ -422,8 +422,11 @@ get_macos_update_labels() {
# ============================================================================
check_disk_space() {
local free_gb=$(command df -H / | awk 'NR==2 {print $4}' | sed 's/G//')
local free_num=$(echo "$free_gb" | tr -d 'G' | cut -d'.' -f1)
# Use df -k to get KB values (always numeric), then calculate GB via math
# This avoids unit suffix parsing issues (df -H can return MB or GB)
local free_kb=$(command df -k / | awk 'NR==2 {print $4}')
local free_gb=$(awk "BEGIN {printf \"%.1f\", $free_kb / 1048576}")
local free_num=$(awk "BEGIN {printf \"%d\", $free_kb / 1048576}")
export DISK_FREE_GB=$free_num

View File

@@ -114,6 +114,11 @@ clean_media_players() {
fi
safe_clean ~/Library/Caches/com.apple.Music "Apple Music cache"
safe_clean ~/Library/Caches/com.apple.podcasts "Apple Podcasts cache"
# Apple Podcasts sandbox container: zombie sparse files and stale artwork cache (#387)
safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/StreamedMedia "Podcasts streamed media"
safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/*.heic "Podcasts artwork cache"
safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/*.img "Podcasts image cache"
safe_clean ~/Library/Containers/com.apple.podcasts/Data/tmp/*CFNetworkDownload*.tmp "Podcasts download temp"
safe_clean ~/Library/Caches/com.apple.TV/* "Apple TV cache"
safe_clean ~/Library/Caches/tv.plex.player.desktop "Plex cache"
safe_clean ~/Library/Caches/com.netease.163music "NetEase Music cache"

View File

@@ -413,7 +413,7 @@ clean_orphaned_system_services() {
fi
orphaned_files+=("$plist")
local size_kb
size_kb=$(sudo du -sk "$plist" 2> /dev/null | awk '{print $1}' || echo "0")
size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0")
((total_orphaned_kb += size_kb))
((orphaned_count++))
break
@@ -444,7 +444,7 @@ clean_orphaned_system_services() {
fi
orphaned_files+=("$plist")
local size_kb
size_kb=$(sudo du -sk "$plist" 2> /dev/null | awk '{print $1}' || echo "0")
size_kb=$(sudo du -skP "$plist" 2> /dev/null | awk '{print $1}' || echo "0")
((total_orphaned_kb += size_kb))
((orphaned_count++))
break
@@ -474,7 +474,7 @@ clean_orphaned_system_services() {
fi
orphaned_files+=("$helper")
local size_kb
size_kb=$(sudo du -sk "$helper" 2> /dev/null | awk '{print $1}' || echo "0")
size_kb=$(sudo du -skP "$helper" 2> /dev/null | awk '{print $1}' || echo "0")
((total_orphaned_kb += size_kb))
((orphaned_count++))
break

View File

@@ -29,7 +29,7 @@ clean_homebrew() {
local skip_cleanup=false
local brew_cache_size=0
if [[ -d ~/Library/Caches/Homebrew ]]; then
brew_cache_size=$(run_with_timeout 3 du -sk ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}')
brew_cache_size=$(run_with_timeout 3 du -skP ~/Library/Caches/Homebrew 2> /dev/null | awk '{print $1}')
local du_exit=$?
if [[ $du_exit -eq 0 && -n "$brew_cache_size" && "$brew_cache_size" -lt 51200 ]]; then
skip_cleanup=true

View File

@@ -97,7 +97,7 @@ check_multiple_versions() {
if [[ -n "$list_cmd" ]]; then
hint=" · ${GRAY}${list_cmd}${NC}"
fi
echo -e " ${GRAY}${ICON_WARNING}${NC} ${tool_name}: ${count} found${hint}"
echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${tool_name}: ${count} found${hint}"
fi
}

View File

@@ -489,7 +489,7 @@ is_recently_modified() {
get_dir_size_kb() {
local path="$1"
if [[ -d "$path" ]]; then
du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0"
du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0"
else
echo "0"
fi

View File

@@ -14,7 +14,7 @@ clean_user_essentials() {
[[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0"
if [[ "$DRY_RUN" == "true" ]]; then
[[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty, $trash_count items" || echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty"
[[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty, $trash_count items" || echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty"
elif [[ $trash_count -gt 0 ]]; then
if osascript -e 'tell application "Finder" to empty trash' > /dev/null 2>&1; then
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $trash_count items"
@@ -25,7 +25,7 @@ clean_user_essentials() {
done < <(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true)
fi
else
echo -e " ${GRAY}${ICON_EMPTY}${NC} Trash · already empty"
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty"
fi
fi
}
@@ -628,7 +628,7 @@ check_ios_device_backups() {
if [[ -d "$backup_dir" ]]; then
local backup_kb=$(get_path_size_kb "$backup_dir")
if [[ -n "${backup_kb:-}" && "$backup_kb" -gt 102400 ]]; then
local backup_human=$(command du -sh "$backup_dir" 2> /dev/null | awk '{print $1}')
local backup_human=$(command du -shP "$backup_dir" 2> /dev/null | awk '{print $1}')
if [[ -n "$backup_human" ]]; then
note_activity
echo -e " ${YELLOW}${ICON_WARNING}${NC} iOS backups: ${GREEN}${backup_human}${NC}${GRAY}, Path: $backup_dir${NC}"

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@ readonly MOLE_BASE_LOADED=1
# ============================================================================
readonly ESC=$'\033'
readonly GREEN="${ESC}[0;32m"
readonly BLUE="${ESC}[0;34m"
readonly BLUE="${ESC}[1;34m"
readonly CYAN="${ESC}[0;36m"
readonly YELLOW="${ESC}[0;33m"
readonly PURPLE="${ESC}[0;35m"
@@ -626,9 +626,12 @@ start_section_spinner() {
# Stop spinner and clear the line
# Usage: stop_section_spinner
stop_section_spinner() {
stop_inline_spinner 2> /dev/null || true
if [[ -t 1 ]]; then
echo -ne "\r\033[K" >&2 || true
# Only clear line if spinner was actually running
if [[ -n "${INLINE_SPINNER_PID:-}" ]]; then
stop_inline_spinner 2> /dev/null || true
if [[ -t 1 ]]; then
echo -ne "\r\033[2K" >&2 || true
fi
fi
}
@@ -646,7 +649,7 @@ safe_clear_lines() {
# Clear lines one by one (more reliable than multi-line sequences)
local i
for ((i = 0; i < lines; i++)); do
printf "\033[1A\r\033[K" > "$tty_device" 2> /dev/null || return 1
printf "\033[1A\r\033[2K" > "$tty_device" 2> /dev/null || return 1
done
return 0
@@ -660,7 +663,7 @@ safe_clear_line() {
# Use centralized ANSI support check
is_ansi_supported 2> /dev/null || return 1
printf "\r\033[K" > "$tty_device" 2> /dev/null || return 1
printf "\r\033[2K" > "$tty_device" 2> /dev/null || return 1
return 0
}

View File

@@ -267,7 +267,7 @@ safe_sudo_remove() {
if sudo test -e "$path" 2> /dev/null; then
local size_kb
size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0")
size_kb=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0")
if [[ "$size_kb" -gt 0 ]]; then
file_size=$(bytes_to_human "$((size_kb * 1024))")
fi
@@ -297,7 +297,7 @@ safe_sudo_remove() {
local size_human=""
if oplog_enabled; then
if sudo test -e "$path" 2> /dev/null; then
size_kb=$(sudo du -sk "$path" 2> /dev/null | awk '{print $1}' || echo "0")
size_kb=$(sudo du -skP "$path" 2> /dev/null | awk '{print $1}' || echo "0")
if [[ "$size_kb" =~ ^[0-9]+$ ]] && [[ "$size_kb" -gt 0 ]]; then
size_human=$(bytes_to_human "$((size_kb * 1024))" 2> /dev/null || echo "${size_kb}KB")
fi
@@ -418,7 +418,7 @@ get_path_size_kb() {
# Use || echo 0 to ensure failure in du (e.g. permission error) doesn't exit script under set -e
# Pipefail would normally cause the pipeline to fail if du fails, but || handle catches it.
local size
size=$(command du -sk "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true)
size=$(command du -skP "$path" 2> /dev/null | awk 'NR==1 {print $1; exit}' || true)
# Ensure size is a valid number (fix for non-numeric du output)
if [[ "$size" =~ ^[0-9]+$ ]]; then

View File

@@ -44,17 +44,25 @@ rotate_log_once() {
export MOLE_LOG_ROTATED=1
local max_size="$LOG_MAX_SIZE_DEFAULT"
if [[ -f "$LOG_FILE" ]] && [[ $(get_file_size "$LOG_FILE") -gt "$max_size" ]]; then
mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true
ensure_user_file "$LOG_FILE"
if [[ -f "$LOG_FILE" ]]; then
local size
size=$(get_file_size "$LOG_FILE")
if [[ "$size" -gt "$max_size" ]]; then
mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true
ensure_user_file "$LOG_FILE"
fi
fi
# Rotate operations log (5MB limit)
if [[ "${MO_NO_OPLOG:-}" != "1" ]]; then
local oplog_max_size="$OPLOG_MAX_SIZE_DEFAULT"
if [[ -f "$OPERATIONS_LOG_FILE" ]] && [[ $(get_file_size "$OPERATIONS_LOG_FILE") -gt "$oplog_max_size" ]]; then
mv "$OPERATIONS_LOG_FILE" "${OPERATIONS_LOG_FILE}.old" 2> /dev/null || true
ensure_user_file "$OPERATIONS_LOG_FILE"
if [[ -f "$OPERATIONS_LOG_FILE" ]]; then
local size
size=$(get_file_size "$OPERATIONS_LOG_FILE")
if [[ "$size" -gt "$oplog_max_size" ]]; then
mv "$OPERATIONS_LOG_FILE" "${OPERATIONS_LOG_FILE}.old" 2> /dev/null || true
ensure_user_file "$OPERATIONS_LOG_FILE"
fi
fi
fi
}
@@ -63,10 +71,16 @@ rotate_log_once() {
# Logging Functions
# ============================================================================
# Get current timestamp (centralized for consistency)
get_timestamp() {
date '+%Y-%m-%d %H:%M:%S'
}
# Log informational message
log_info() {
echo -e "${BLUE}$1${NC}"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
local timestamp
timestamp=$(get_timestamp)
echo "[$timestamp] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true
if [[ "${MO_DEBUG:-}" == "1" ]]; then
echo "[$timestamp] INFO: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
@@ -76,38 +90,43 @@ log_info() {
# Log success message
log_success() {
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
local timestamp
timestamp=$(get_timestamp)
echo "[$timestamp] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true
if [[ "${MO_DEBUG:-}" == "1" ]]; then
echo "[$timestamp] SUCCESS: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
fi
}
# Log warning message
# shellcheck disable=SC2329
log_warning() {
echo -e "${YELLOW}$1${NC}"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
local timestamp
timestamp=$(get_timestamp)
echo "[$timestamp] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true
if [[ "${MO_DEBUG:-}" == "1" ]]; then
echo "[$timestamp] WARNING: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
fi
}
# Log error message
# shellcheck disable=SC2329
log_error() {
echo -e "${YELLOW}${ICON_ERROR}${NC} $1" >&2
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
local timestamp
timestamp=$(get_timestamp)
echo "[$timestamp] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true
if [[ "${MO_DEBUG:-}" == "1" ]]; then
echo "[$timestamp] ERROR: $1" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
fi
}
# Debug logging (active when MO_DEBUG=1)
# shellcheck disable=SC2329
debug_log() {
if [[ "${MO_DEBUG:-}" == "1" ]]; then
echo -e "${GRAY}[DEBUG]${NC} $*" >&2
echo "[$(date '+%Y-%m-%d %H:%M:%S')] DEBUG: $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
local timestamp
timestamp=$(get_timestamp)
echo "[$timestamp] DEBUG: $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true
fi
}
@@ -139,7 +158,7 @@ log_operation() {
[[ -z "$path" ]] && return 0
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
timestamp=$(get_timestamp)
local log_line="[$timestamp] [$command] $action $path"
[[ -n "$detail" ]] && log_line+=" ($detail)"
@@ -154,7 +173,7 @@ log_operation_session_start() {
local command="${1:-mole}"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
timestamp=$(get_timestamp)
{
echo ""
@@ -162,8 +181,7 @@ log_operation_session_start() {
} >> "$OPERATIONS_LOG_FILE" 2> /dev/null || true
}
# Log session end with summary
# Usage: log_operation_session_end <command> <items_count> <total_size>
# shellcheck disable=SC2329
log_operation_session_end() {
oplog_enabled || return 0
@@ -171,7 +189,7 @@ log_operation_session_end() {
local items="${2:-0}"
local size="${3:-0}"
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
timestamp=$(get_timestamp)
local size_human=""
if [[ "$size" =~ ^[0-9]+$ ]] && [[ "$size" -gt 0 ]]; then

View File

@@ -301,6 +301,9 @@ start_inline_spinner() {
[[ -z "$chars" ]] && chars="|/-\\"
local i=0
# Clear line on first output to prevent text remnants from previous messages
printf "\r\033[2K" >&2 || true
# Cooperative exit: check for stop file instead of relying on signals
while [[ ! -f "$stop_file" ]]; do
local c="${chars:$((i % ${#chars})):1}"

View File

@@ -160,6 +160,32 @@ EOF
[ "$result" = "protected" ]
}
@test "Apple apps from App Store can be uninstalled (Issue #386)" {
# Xcode should NOT be protected from uninstall
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.dt.Xcode' && echo 'protected' || echo 'not-protected'")
[ "$result" = "not-protected" ]
# Final Cut Pro should NOT be protected from uninstall
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.FinalCutPro' && echo 'protected' || echo 'not-protected'")
[ "$result" = "not-protected" ]
# GarageBand should NOT be protected from uninstall
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.GarageBand' && echo 'protected' || echo 'not-protected'")
[ "$result" = "not-protected" ]
# iWork apps should NOT be protected from uninstall
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.iWork.Pages' && echo 'protected' || echo 'not-protected'")
[ "$result" = "not-protected" ]
# But Safari (system app) should still be protected
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.Safari' && echo 'protected' || echo 'not-protected'")
[ "$result" = "protected" ]
# And Finder should still be protected
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; should_protect_from_uninstall 'com.apple.finder' && echo 'protected' || echo 'not-protected'")
[ "$result" = "protected" ]
}
@test "print_summary_block formats output correctly" {
result=$(HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/core/common.sh'; print_summary_block 'success' 'Test Summary' 'Detail 1' 'Detail 2'")
[[ "$result" == *"Test Summary"* ]]

View File

@@ -123,28 +123,28 @@ EOF
}
@test "check_android_ndk reports multiple NDK versions" {
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk"/{21.0.1,22.0.0,20.0.0} && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh"
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk"/{21.0.1,22.0.0,20.0.0} && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"Android NDK versions: 3 found"* ]]
}
@test "check_android_ndk silent when only one NDK" {
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk/22.0.0" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh"
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/Library/Android/sdk/ndk/22.0.0" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="" && check_android_ndk' "$PROJECT_ROOT/lib/clean/dev.sh"
[ "$status" -eq 0 ]
[[ "$output" != *"NDK versions"* ]]
}
@test "check_rust_toolchains reports multiple toolchains" {
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains"/{stable,nightly,1.75.0}-aarch64-apple-darwin && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh"
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains"/{stable,nightly,1.75.0}-aarch64-apple-darwin && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"Rust toolchains: 3 found"* ]]
}
@test "check_rust_toolchains silent when only one toolchain" {
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains/stable-aarch64-apple-darwin" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_WARNING="" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh"
run bash -c 'HOME=$(mktemp -d) && mkdir -p "$HOME/.rustup/toolchains/stable-aarch64-apple-darwin" && source "$0" && note_activity() { :; } && NC="" && GREEN="" && GRAY="" && YELLOW="" && ICON_SUCCESS="" && rustup() { :; } && export -f rustup && check_rust_toolchains' "$PROJECT_ROOT/lib/clean/dev.sh"
[ "$status" -eq 0 ]
[[ "$output" != *"Rust toolchains"* ]]

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bats
# Test naming variant detection for find_app_files (Issue #377)
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-naming.XXXXXX")"
export HOME
source "$PROJECT_ROOT/lib/core/base.sh"
source "$PROJECT_ROOT/lib/core/log.sh"
source "$PROJECT_ROOT/lib/core/app_protection.sh"
}
teardown_file() {
if [[ -d "$HOME" && "$HOME" =~ tmp-naming ]]; then
rm -rf "$HOME"
fi
export HOME="$ORIGINAL_HOME"
}
setup() {
find "$HOME" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
source "$PROJECT_ROOT/lib/core/base.sh"
source "$PROJECT_ROOT/lib/core/log.sh"
source "$PROJECT_ROOT/lib/core/app_protection.sh"
}
@test "find_app_files detects lowercase-hyphen variant (maestro-studio)" {
mkdir -p "$HOME/.config/maestro-studio"
echo "test" > "$HOME/.config/maestro-studio/config.json"
result=$(find_app_files "com.maestro.studio" "Maestro Studio")
[[ "$result" =~ .config/maestro-studio ]]
}
@test "find_app_files detects no-space variant (MaestroStudio)" {
mkdir -p "$HOME/Library/Application Support/MaestroStudio"
echo "test" > "$HOME/Library/Application Support/MaestroStudio/data.db"
result=$(find_app_files "com.maestro.studio" "Maestro Studio")
[[ "$result" =~ "Library/Application Support/MaestroStudio" ]]
}
@test "find_app_files extracts base name from version suffix (Zed Nightly -> zed)" {
mkdir -p "$HOME/.config/zed"
mkdir -p "$HOME/Library/Application Support/Zed"
echo "test" > "$HOME/.config/zed/settings.json"
echo "test" > "$HOME/Library/Application Support/Zed/cache.db"
result=$(find_app_files "dev.zed.Zed-Nightly" "Zed Nightly")
[[ "$result" =~ .config/zed ]]
[[ "$result" =~ "Library/Application Support/Zed" ]]
}
@test "find_app_files detects multiple naming variants simultaneously" {
mkdir -p "$HOME/.config/maestro-studio"
mkdir -p "$HOME/Library/Application Support/MaestroStudio"
mkdir -p "$HOME/Library/Application Support/Maestro-Studio"
mkdir -p "$HOME/.local/share/maestrostudio"
echo "test" > "$HOME/.config/maestro-studio/config.json"
echo "test" > "$HOME/Library/Application Support/MaestroStudio/data.db"
echo "test" > "$HOME/Library/Application Support/Maestro-Studio/prefs.json"
echo "test" > "$HOME/.local/share/maestrostudio/cache.db"
result=$(find_app_files "com.maestro.studio" "Maestro Studio")
[[ "$result" =~ .config/maestro-studio ]]
[[ "$result" =~ "Library/Application Support/MaestroStudio" ]]
[[ "$result" =~ "Library/Application Support/Maestro-Studio" ]]
[[ "$result" =~ .local/share/maestrostudio ]]
}
@test "find_app_files handles multi-word version suffix (Firefox Developer Edition)" {
mkdir -p "$HOME/.local/share/firefox"
echo "test" > "$HOME/.local/share/firefox/profiles.ini"
result=$(find_app_files "org.mozilla.firefoxdeveloperedition" "Firefox Developer Edition")
[[ "$result" =~ .local/share/firefox ]]
}
@test "find_app_files does not match empty app name" {
mkdir -p "$HOME/Library/Application Support/test"
result=$(find_app_files "com.test" "" 2>/dev/null || true)
[[ ! "$result" =~ "Library/Application Support"$ ]]
}