Skip to content

Commit 22e08ee

Browse files
aksOpsclaude
andcommitted
feat: consolidate DeleteObject, validate window rects, expand tests
- Move DeleteObject P/Invoke onto the main [Native] class; drop the per-call SnipIT.Gdi Add-Type from Convert-BitmapToBitmapSource so bitmap cleanup compiles once per process, not per render. - Reject degenerate RECTs from the GetWindowRect fallback in Show-SmartOverlay so a minimized / query-failed hwnd no longer produces a 0x0 hover overlay. - Trace hotkey-registration failures through Write-SnipDiag so the tray balloon has a matching diagnostic record. - Grow Test-SnipIT.ps1 from 40 to 65 cases: Resolve-SaveImagePath (9), Get-TrimmedRecent (5), Get-LoupePosition flip margins (3), extra format / capture-validity / crop edge cases (8). - Add .github/workflows/test.yml: Linux job runs the headless suite, Windows job parses SnipIT.ps1 and runs the suite as well. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ad09b60 commit 22e08ee

3 files changed

Lines changed: 167 additions & 10 deletions

File tree

.github/workflows/test.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
10+
jobs:
11+
headless:
12+
name: Pure-logic tests (pwsh)
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
- name: Show pwsh version
17+
shell: pwsh
18+
run: $PSVersionTable | Out-String
19+
- name: Run headless tests
20+
shell: pwsh
21+
run: ./Test-SnipIT.ps1
22+
23+
parse:
24+
name: Parse SnipIT.ps1 on Windows (no execution)
25+
runs-on: windows-latest
26+
steps:
27+
- uses: actions/checkout@v4
28+
- name: Parse script
29+
shell: pwsh
30+
run: |
31+
$errors = $null
32+
[System.Management.Automation.Language.Parser]::ParseFile(
33+
(Resolve-Path ./SnipIT.ps1), [ref]$null, [ref]$errors) | Out-Null
34+
if ($errors) {
35+
$errors | ForEach-Object { Write-Host " $($_.Message) at line $($_.Extent.StartLineNumber)" }
36+
exit 1
37+
}
38+
Write-Host "Parsed cleanly."
39+
- name: Run headless tests on Windows
40+
shell: pwsh
41+
run: ./Test-SnipIT.ps1

SnipIT.ps1

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,9 @@ public static class Native {
308308
309309
// Cursor pos in screen pixels
310310
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);
311+
312+
// GDI cleanup for handles returned by Bitmap.GetHbitmap()
313+
[DllImport("gdi32.dll")] public static extern bool DeleteObject(IntPtr hObject);
311314
}
312315
'@
313316
if (-not ('Native' -as [type])) {
@@ -477,12 +480,7 @@ function New-ScreenBitmap {
477480

478481
function Convert-BitmapToBitmapSource {
479482
param([System.Drawing.Bitmap]$Bitmap)
480-
# Cached P/Invoke; compile-once so cleanup is guaranteed (no JIT per call, no silent failure).
481-
if (-not ('SnipIT.Gdi' -as [type])) {
482-
Add-Type -Namespace SnipIT -Name Gdi -MemberDefinition @'
483-
[System.Runtime.InteropServices.DllImport("gdi32.dll")] public static extern bool DeleteObject(System.IntPtr hObject);
484-
'@
485-
}
483+
# DeleteObject lives on the main [Native] class defined at startup — no per-call JIT.
486484
$hbmp = [IntPtr]::Zero
487485
try {
488486
$hbmp = $Bitmap.GetHbitmap()
@@ -493,7 +491,7 @@ function Convert-BitmapToBitmapSource {
493491
$src.Freeze()
494492
return $src
495493
} finally {
496-
if ($hbmp -ne [IntPtr]::Zero) { [SnipIT.Gdi]::DeleteObject($hbmp) | Out-Null }
494+
if ($hbmp -ne [IntPtr]::Zero) { [Native]::DeleteObject($hbmp) | Out-Null }
497495
}
498496
}
499497

@@ -713,9 +711,14 @@ function Show-SmartOverlay {
713711
$r = New-Object Native+RECT
714712
$ok = ([Native]::DwmGetWindowAttribute($top, [Native]::DWMWA_EXTENDED_FRAME_BOUNDS, [ref]$r, 16) -eq 0)
715713
if (-not $ok) { [Native]::GetWindowRect($top, [ref]$r) | Out-Null }
716-
$state.HoverRect = [pscustomobject]@{
717-
X = $r.Left; Y = $r.Top
718-
W = $r.Right - $r.Left; H = $r.Bottom - $r.Top
714+
# Reject degenerate rects (minimized windows, failed fallback) — avoids a 0x0 hover overlay.
715+
if ($r.Right -gt $r.Left -and $r.Bottom -gt $r.Top) {
716+
$state.HoverRect = [pscustomobject]@{
717+
X = $r.Left; Y = $r.Top
718+
W = $r.Right - $r.Left; H = $r.Bottom - $r.Top
719+
}
720+
} else {
721+
$state.HoverRect = $null
719722
}
720723
}
721724
if ($state.HoverRect) {
@@ -2131,6 +2134,7 @@ $tray.ContextMenuStrip = $menu
21312134
$tray.Add_DoubleClick({ Invoke-SmartCapture })
21322135

21332136
if ($hotkeyErrors.Count -gt 0) {
2137+
Write-SnipDiag "Hotkey registration failed: $($hotkeyErrors -join ', ')"
21342138
$tray.BalloonTipTitle = 'SnipIT — hotkey conflict'
21352139
$tray.BalloonTipText = "Could not register: $($hotkeyErrors -join ', '). Use the tray menu instead."
21362140
$tray.ShowBalloonTip(5000)

Test-SnipIT.ps1

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,118 @@ It 'builds the launcher arg string with the script path quoted' {
173173
ShouldBeTrue ($a -match '-File "C:\\Users\\x\\AppData\\Local\\SnipIT\\SnipIT.ps1"')
174174
}
175175

176+
Describe 'Resolve-SaveImagePath'
177+
It 'keeps a valid PNG path unchanged' {
178+
ShouldBe (Resolve-SaveImagePath -Path '/tmp/a.png' -FilterFormat 'Png') '/tmp/a.png'
179+
}
180+
It 'keeps a valid JPG path unchanged even when filter is PNG' {
181+
# User explicitly typed .jpg — respect their extension.
182+
ShouldBe (Resolve-SaveImagePath -Path '/tmp/a.jpg' -FilterFormat 'Png') '/tmp/a.jpg'
183+
}
184+
It 'keeps a .jpeg extension (both supported jpeg forms)' {
185+
ShouldBe (Resolve-SaveImagePath -Path '/tmp/a.jpeg' -FilterFormat 'Jpeg') '/tmp/a.jpeg'
186+
}
187+
It 'forces a non-image extension to match the PNG filter' {
188+
$p = Resolve-SaveImagePath -Path '/tmp/a.txt' -FilterFormat 'Png'
189+
ShouldBeTrue ($p.EndsWith('a.png'))
190+
}
191+
It 'forces a non-image extension to match the JPEG filter' {
192+
$p = Resolve-SaveImagePath -Path '/tmp/a.txt' -FilterFormat 'Jpeg'
193+
ShouldBeTrue ($p.EndsWith('a.jpg'))
194+
}
195+
It 'forces a non-image extension to match the BMP filter' {
196+
$p = Resolve-SaveImagePath -Path '/tmp/a.txt' -FilterFormat 'Bmp'
197+
ShouldBeTrue ($p.EndsWith('a.bmp'))
198+
}
199+
It 'appends the filter extension when path has no extension' {
200+
$p = Resolve-SaveImagePath -Path '/tmp/a' -FilterFormat 'Png'
201+
ShouldBeTrue ($p.EndsWith('a.png'))
202+
}
203+
It 'preserves the directory component when correcting extension' {
204+
$p = Resolve-SaveImagePath -Path '/tmp/sub/foo.txt' -FilterFormat 'Bmp'
205+
ShouldBeTrue ($p.EndsWith('foo.bmp'))
206+
ShouldBeTrue ($p -like '*sub*foo.bmp')
207+
}
208+
It 'is case-insensitive for extension recognition' {
209+
ShouldBe (Resolve-SaveImagePath -Path '/tmp/a.PNG' -FilterFormat 'Jpeg') '/tmp/a.PNG'
210+
}
211+
212+
Describe 'Get-TrimmedRecent'
213+
It 'returns the input unchanged when under the cap' {
214+
$r = Get-TrimmedRecent -Items @('c','b','a') -MaxDepth 10
215+
ShouldBe $r.Count 3
216+
ShouldBe $r[0] 'c'
217+
}
218+
It 'trims to the top N most recent when over cap' {
219+
$items = 1..20 # 20 is top (most recent) by Stack.ToArray() convention
220+
$r = Get-TrimmedRecent -Items $items -MaxDepth 5
221+
ShouldBe $r.Count 5
222+
ShouldBe $r[0] 1 # top-first ordering preserved
223+
ShouldBe $r[4] 5
224+
}
225+
It 'returns empty array for null input' {
226+
$r = Get-TrimmedRecent -Items $null
227+
ShouldBe $r.Count 0
228+
}
229+
It 'handles empty array' {
230+
$r = Get-TrimmedRecent -Items @() -MaxDepth 5
231+
ShouldBe $r.Count 0
232+
}
233+
It 'exactly-at-cap returns the whole set' {
234+
$r = Get-TrimmedRecent -Items (1..100) -MaxDepth 100
235+
ShouldBe $r.Count 100
236+
}
237+
238+
Describe 'Get-LoupePosition flip margins'
239+
It 'uses custom FlipMarginX when near right edge' {
240+
$p = Get-LoupePosition -MouseX 1900 -MouseY 100 `
241+
-VsX 0 -VsY 0 -VsWidth 1920 -VsHeight 1080 `
242+
-LoupeWidth 170 -LoupeHeight 190 -FlipMarginX 20
243+
# After flip: X = MouseX - LoupeWidth - FlipMarginX = 1900 - 170 - 20 = 1710
244+
ShouldBe $p.X 1710
245+
}
246+
It 'uses custom FlipMarginY when near bottom edge' {
247+
$p = Get-LoupePosition -MouseX 100 -MouseY 1070 `
248+
-VsX 0 -VsY 0 -VsWidth 1920 -VsHeight 1080 `
249+
-LoupeWidth 170 -LoupeHeight 190 -FlipMarginY 25
250+
# After flip: Y = MouseY - LoupeHeight - FlipMarginY = 1070 - 190 - 25 = 855
251+
ShouldBe $p.Y 855
252+
}
253+
It 'does not flip when loupe fits comfortably' {
254+
$p = Get-LoupePosition -MouseX 500 -MouseY 500 `
255+
-VsX 0 -VsY 0 -VsWidth 1920 -VsHeight 1080
256+
ShouldBe $p.X 524
257+
ShouldBe $p.Y 524
258+
}
259+
260+
Describe 'Get-ImageFormatNameFromPath extra'
261+
It 'recognises uppercase .BMP' { ShouldBe (Get-ImageFormatNameFromPath 'x.BMP') 'Bmp' }
262+
It 'defaults .tiff to Png (unsupported)' { ShouldBe (Get-ImageFormatNameFromPath 'x.tiff') 'Png' }
263+
It 'handles dot-prefixed hidden filenames' { ShouldBe (Get-ImageFormatNameFromPath '.hidden.jpg') 'Jpeg' }
264+
265+
Describe 'Test-CaptureRectValid edge'
266+
It 'accepts the exact MinSize boundary' {
267+
ShouldBeTrue (Test-CaptureRectValid -Width 2 -Height 2 -MinSize 2)
268+
}
269+
It 'rejects width just below MinSize' {
270+
ShouldBeFalse (Test-CaptureRectValid -Width 1 -Height 2 -MinSize 2)
271+
}
272+
It 'rejects negative dimensions' {
273+
ShouldBeFalse (Test-CaptureRectValid -Width -5 -Height 10)
274+
}
275+
276+
Describe 'Get-CropBounds DPI scenarios'
277+
It 'maps a non-zero-origin viewport (laptop + 4K right monitor)' {
278+
# Virtual screen: X=0, Y=0 across both; user clicks on right monitor at (2500, 800)
279+
$b = Get-CropBounds -RectX 2500 -RectY 800 -RectW 400 -RectH 300 -VsX 0 -VsY 0
280+
ShouldBe $b.X 2500; ShouldBe $b.Y 800
281+
ShouldBe $b.Width 400; ShouldBe $b.Height 300
282+
}
283+
It 'maps when virtual screen starts below zero (top monitor above primary)' {
284+
$b = Get-CropBounds -RectX 100 -RectY -200 -RectW 50 -RectH 50 -VsX 0 -VsY -1080
285+
ShouldBe $b.X 100; ShouldBe $b.Y 880
286+
}
287+
176288
Write-Host ""
177289
$total = $script:Pass + $script:Fail
178290
$color = if ($script:Fail -eq 0) { 'Green' } else { 'Red' }

0 commit comments

Comments
 (0)