diff --git a/.editorconfig b/.editorconfig index 7867892e..5d51505c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,6 +16,18 @@ trim_trailing_whitespace = true [*.ps1] indent_size = 4 +[*.py] +charset = utf-8 +indent_size = 4 +end_of_line = lf + +[tests/**] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = lf +trim_trailing_whitespace = true + [*.{vcxproj,filters,props}] charset = utf-8 diff --git a/.gitattributes b/.gitattributes index cf59f17e..03145167 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,6 +10,8 @@ *.props text eol=crlf *.md text eol=lf *.ps1 text eol=lf +*.py text eol=lf +*.in text eol=lf *.sh text eol=lf *.yml text eol=lf *.yaml text eol=lf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bd4c6bca..2e4633bf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,14 +32,16 @@ jobs: New-Item -ItemType Directory -Path bin -Force | Out-Null ./build/build.ps1 -Architecture '${{ matrix.architecture }}' -Configuration '${{ matrix.configuration }}' *> bin/build.log if ($LASTEXITCODE -ne 0) { Get-Content bin/build.log -Tail 80; exit 1 } - - name: Test editor themes + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.13' + architecture: ${{ matrix.architecture }} + - name: Install locked test dependencies + run: python -m pip install --require-hashes -r tests/requirements.txt + - name: Test native editor run: | - try { - ./build/test-themes.ps1 -Executable './bin/VS2022/${{ matrix.configuration }}_${{ matrix.architecture }}/Notepad2.exe' *> bin/tests.log - } catch { - Get-Content bin/tests.log -Tail 80 - throw - } + python -m pytest --executable './bin/VS2022/${{ matrix.configuration }}_${{ matrix.architecture }}/Notepad2.exe' --architecture '${{ matrix.architecture }}' --configuration '${{ matrix.configuration }}' --junitxml bin/test-results.xml *> bin/tests.log + if ($LASTEXITCODE -ne 0) { Get-Content bin/tests.log -Tail 100; exit 1 } Get-Content bin/tests.log - name: Package portable release if: matrix.configuration == 'Release' @@ -64,7 +66,9 @@ jobs: if: always() with: name: logs-${{ matrix.configuration }}-${{ matrix.architecture }} - path: bin/*.log + path: | + bin/*.log + bin/test-results.xml retention-days: 14 release: name: Publish release diff --git a/.gitignore b/.gitignore index 289c2296..8201b334 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ *.p12 /.env /.env.* +/.venv/ +/.pytest_cache/ +**/__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index f49a12ea..0ea366ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,12 @@ - Theme scope is the editor only: text, syntax colors, margins, caret, selections, folding and highlights. Application chrome (menus, dialogs, toolbar, status bar, title bar and scrollbars) is out of scope, not a planned follow-up. +- Keep the single-document interface. Multi-tab editing and Notepad++ parity or + compatibility are not product goals. Standalone patches from Notepad3/Notepad4 + may be evaluated surgically; their frameworks and feature stacks remain excluded. +- Use pytest and pywinauto's Win32 backend for native UI regression tests, with + only a small adapter for Scintilla messages. Keep test dependencies separate + from the application. PowerShell remains appropriate for build/release scripts. - Finish each logical change with a granular commit after relevant validation. Do not ask again whether to commit completed, authorized work. Stage explicit paths/hunks and inspect the staged diff. Keep dependency updates, build changes, diff --git a/ROADMAP.md b/ROADMAP.md index ad4f6fc0..74defd56 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -66,14 +66,30 @@ demonstrates a concrete benefit. ## Fork review backlog -GitHub issues hold the review notes, source commit links, acceptance criteria, -and eventual decisions. These are candidates for selective changes, not approved -imports; none was built or runtime-tested during the initial review. - -| Issue | Candidate | Review focus | +Three independent agent reviews and a maintainer cross-check selected four +surgical corrections, now implemented locally in separate commits. The unsaved +buffer defect was reproduced against the pre-fix binary. Native regression tests +cover prompting/cancellation, production MRU functions, source charset and startup +show-state selection; actual standard-user UAC interaction remains a manual check. +The fork review introduced no dependency upgrade or subsystem. A separately +requested improvement adds selection-aware line copy/cut using existing Scintilla +actions and clipboard markers. UI regression tests use pytest and pywinauto. + +| Priority | Accepted correction | Source | | --- | --- | --- | -| [#1](https://github.com/itlezy/notepad2-mod/issues/1) | antiufo | Recovery/autosave; preserve upstream fixes and recovery buffer-fix chain. | -| [#2](https://github.com/itlezy/notepad2-mod/issues/2) | johnwait | Selective editor fixes; exclude wholesale regex/Scintilla migration. | -| [#3](https://github.com/itlezy/notepad2-mod/issues/3) | mercury233 | Small find/replace, reload, and context-menu improvements. | -| [#4](https://github.com/itlezy/notepad2-mod/issues/4) | tnotstar | Initial CMake build; Debug support was explicitly unfinished. | -| [#5](https://github.com/itlezy/notepad2-mod/issues/5) | waskosky | Aggressive reload/append merging; require data-preservation review. | +| First | [#9: save prompting for nonempty untitled documents](https://github.com/itlezy/notepad2-mod/issues/9) | johnwait; remove C-string content sniffing that misclassifies leading NUL and whitespace. | +| Next | [#10: skip empty MRU slots](https://github.com/itlezy/notepad2-mod/issues/10) | antiufo; one null guard before path comparison, without recovery features. | +| Next | [#11: explicit UTF-8 source charset](https://github.com/itlezy/notepad2-mod/issues/11) | mercury233; all four app configurations, unchanged execution charset/resources/vendor code. | +| Lower | [#12: elevated-window show-state flags](https://github.com/itlezy/notepad2-mod/issues/12) | johnwait; default to visible only when no explicit show-state flag was supplied. | + +Closed review decisions remain in [#1](https://github.com/itlezy/notepad2-mod/issues/1), +[#2](https://github.com/itlezy/notepad2-mod/issues/2), and +[#3](https://github.com/itlezy/notepad2-mod/issues/3). Recovery/autosave, regex-engine +replacement, Scintilla migration, broad refactors, notification redesign, and +optional context-menu/focus/shortcut changes are excluded. Several apparent fixes +repair code introduced by those forks and do not apply here. + +[CMake (#4)](https://github.com/itlezy/notepad2-mod/issues/4) and +[aggressive reload/append merging (#5)](https://github.com/itlezy/notepad2-mod/issues/5) +are rejected and closed as not planned. Keep the existing build and file-watching +models; revisit only if a concrete product need changes that decision. diff --git a/Readme.md b/Readme.md index 584993fd..be59e78d 100644 --- a/Readme.md +++ b/Readme.md @@ -12,6 +12,9 @@ minimal, focused patches with a clear benefit. This fork will not move to Notepad3/Notepad4 or adopt their feature stacks; matching newer editors or dependency versions is not a goal. +The interface remains single-document. Multi-tab editing and Notepad++ parity +are out of scope; isolated fixes from other editors may be evaluated surgically. + The upstream repository, [XhmikosR/notepad2-mod](https://github.com/XhmikosR/notepad2-mod), is archived and was last updated in July 2018. Ongoing maintenance for this fork happens here; upstream history, licensing, and contributor credits are preserved. @@ -80,6 +83,12 @@ Compatibility with other Windows versions has not been verified. ## Changed keyboard shortcuts compared to Notepad2: +With no selection, **Ctrl+C / Ctrl+Insert** copy the current line and +**Ctrl+X / Shift+Delete** cut it. These line operations include a line-ending and +use Scintilla's existing line-clipboard marker: **Ctrl+V / Shift+Insert** insert +the line above the caret's current line. With a selection, normal copy/cut/paste +behavior is retained. Cutting is undoable and respects read-only documents. + * Ctrl+Alt+F2 Expand selection to next match. * Ctrl+Alt+Shift+F2 Expand selection to previous match. * Ctrl+Shift+Enter New line with toggled auto indent option. @@ -151,16 +160,24 @@ The packaging command is `./build/package.ps1 -Architecture x64` (or `x86`). It uses Release output and writes into ignored `bin/packages`; supply a fresh `-OutputDirectory` when repackaging an already-created version locally. -To run the native theme integration checks against a built executable: +Tests use Python 3.13, pytest, and pywinauto's Win32 backend. Test dependencies +are locked with hashes and are not application dependencies. PowerShell is used +for building, not UI automation. To test a built executable: ```powershell -.\build\test-themes.ps1 -.\build\test-themes.ps1 -Executable .\bin\VS2022\Release_x86\Notepad2.exe +py -3.13 -m venv .venv +.\.venv\Scripts\python.exe -m pip install --require-hashes -r tests/requirements.txt +.\.venv\Scripts\python.exe -m pytest +.\.venv\Scripts\python.exe -m pytest --executable .\bin\VS2022\Release_x86\Notepad2.exe --architecture x86 ``` The checks launch isolated instances with temporary settings, exercise all 38 schemes and customization Preview/Cancel/Reset, and verify palette/font separation, switching, restart persistence, invalid defaults, and disabled settings saving. -Use `-BaselineExecutable ` for a seven-run small-file startup/private-memory/ -numeric edit/idle comparison. These checks do not replace visual contrast review -or establish large-file performance. +They also exercise save prompts with embedded NUL/whitespace, exact-byte Cancel +preservation, production MRU functions, source charset, and startup show-state +contracts. The latter do not replace an interactive standard-user UAC test. +Use `--configuration Debug` for the corresponding native helper runtime. A +pre-fix binary can be checked with `-m pytest tests/test_save_prompts.py +--legacy-save-behavior --executable ` to reproduce the nine loss paths. +These tests do not replace visual review or establish large-file performance. diff --git a/build/publish-release.ps1 b/build/publish-release.ps1 index 496aaeeb..2fb72123 100644 --- a/build/publish-release.ps1 +++ b/build/publish-release.ps1 @@ -31,13 +31,17 @@ $notes = Join-Path $PackageDirectory 'release-notes.txt' @" Native Original/Dark editor palettes on Scintilla 3.7.6, with a VS 2022 build. +Focused corrections protect nonempty untitled documents during save prompting, skip empty recent-file slots, make application source encoding deterministic, and honor elevated-launch show-state flags. + +With no selection, Ctrl+C / Ctrl+Insert copy the current line and Ctrl+X / Shift+Delete cut it. Ctrl+V / Shift+Insert paste line-marked clipboard content above the current line. Normal selected-text behavior is retained. + Choose View > Editor Theme > Dark. Existing Original colors and font preferences are retained. Application chrome is unchanged. Download the portable ZIP for your architecture and extract it into a writable folder. Preserve your existing Notepad2.ini when upgrading. These binaries are unsigned. SHA256SUMS.txt provides archive integrity checks. Source commit: $commit Executable version: $version -Validation: Release/Debug x86/x64 builds and native theme integration tests, including all 38 schemes and customization/persistence checks. +Validation: Release/Debug x86/x64 builds with pytest and pywinauto, including all 38 schemes, customization/persistence, save-prompt data preservation, line shortcuts, and native helper contracts. Actual standard-user UAC interaction remains a manual check. "@ | Set-Content $notes -Encoding utf8 $existing = & gh release view $Tag --json isDraft 2>$null if ($LASTEXITCODE -eq 0) { diff --git a/build/test-fork-helpers.c b/build/test-fork-helpers.c new file mode 100644 index 00000000..e1caf750 --- /dev/null +++ b/build/test-fork-helpers.c @@ -0,0 +1,65 @@ +// Exercises production MRU/path functions, plus compiler and startup contracts. +#include +#include +#include +#include "Scintilla.h" +#include "Helpers.h" +#include "Version.h" +#include "fork-contracts.h" + +HINSTANCE g_hInstance; +UINT16 g_uWinVer; +HMODULE hModUxTheme; +WCHAR szIniFile[MAX_PATH]; + +#define CHECK(condition) do { if (!(condition)) { printf("Failed at line %d\n",__LINE__); return 1; } } while (0) + +int main(void) +{ + WCHAR path[MAX_PATH],second[MAX_PATH],relative[] = L"test-file.txt"; + LPMRULIST list = MRU_Create(L"Test",MRU_NOCASE,3); + STARTUPINFO si = {0}; + int i; + g_hInstance = GetModuleHandle(NULL); + GetModuleFileName(NULL,path,MAX_PATH); + PathRemoveFileSpec(path); + lstrcpy(second,path); + PathAppend(path,L"test-file.txt"); + PathAppend(second,L"second.txt"); + CHECK(MRU_AddFile(list,path,FALSE,FALSE)); // Initially every slot is NULL. + CHECK(lstrcmpi(list->pszItems[0],path) == 0); + CHECK(MRU_AddFile(list,second,FALSE,FALSE)); // Partial list. + CHECK(MRU_AddFile(list,path,FALSE,FALSE)); // Existing absolute duplicate. + CHECK(lstrcmpi(list->pszItems[0],path) == 0 && lstrcmpi(list->pszItems[1],second) == 0); + MRU_Empty(list); + list->pszItems[1] = StrDup(relative); // Null gap before a relative duplicate. + CHECK(MRU_AddFile(list,path,FALSE,FALSE)); + CHECK(lstrcmpi(list->pszItems[0],path) == 0); + MRU_Empty(list); + SetEnvironmentVariable(L"NP2_MRU_TEST",second); + list->pszItems[1] = StrDup(L"%NP2_MRU_TEST%"); + CHECK(MRU_AddFile(list,second,FALSE,FALSE)); + CHECK(lstrcmpi(list->pszItems[0],second) == 0); + MRU_Empty(list); + for (i = 0; i < 5; ++i) { + WCHAR name[MAX_PATH]; + wsprintf(name,L"C:\\test-%d.txt",i); + CHECK(MRU_AddFile(list,name,FALSE,FALSE)); + } + CHECK(lstrcmp(list->pszItems[0],L"C:\\test-4.txt") == 0); + CHECK(lstrcmp(list->pszItems[2],L"C:\\test-2.txt") == 0); + MRU_Destroy(list); + CHECK(VERSION_LEGALCOPYRIGHT_LONG[0] == 0x00A9); + CHECK(wcschr(titlePunctuation,0x0384) && wcschr(titlePunctuation,0x2019)); + CHECK(StartupShowState(si) == SW_SHOWNORMAL); + si.wShowWindow = SW_SHOWMAXIMIZED; + CHECK(StartupShowState(si) == SW_SHOWNORMAL); // Value irrelevant without flag. + si.dwFlags = STARTF_USESHOWWINDOW; + CHECK(StartupShowState(si) == SW_SHOWMAXIMIZED); + si.wShowWindow = SW_HIDE; + CHECK(StartupShowState(si) == SW_HIDE); + si.wShowWindow = SW_SHOWMINIMIZED; + CHECK(StartupShowState(si) == SW_SHOWMINIMIZED); + puts("MRU, UTF-8 source and startup show-state contracts passed."); + return 0; +} diff --git a/build/test-themes.ps1 b/build/test-themes.ps1 deleted file mode 100644 index 44113876..00000000 --- a/build/test-themes.ps1 +++ /dev/null @@ -1,227 +0,0 @@ -[CmdletBinding()] -param( - [string]$Executable = "$PSScriptRoot/../bin/VS2022/Release_x64/Notepad2.exe", - [string]$BaselineExecutable -) - -$ErrorActionPreference = 'Stop' -Add-Type @' -using System; -using System.Text; -using System.Runtime.InteropServices; -public static class ThemeTestNative { - public delegate bool Callback(IntPtr hwnd, IntPtr state); - [DllImport("user32.dll")] public static extern bool EnumWindows(Callback cb, IntPtr state); - [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr hwnd, Callback cb, IntPtr state); - [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid); - [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hwnd, StringBuilder text, int size); - [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr hwnd, uint msg, IntPtr w, IntPtr l); - [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hwnd, uint msg, IntPtr w, IntPtr l); - [DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr hwnd, int id); - [DllImport("user32.dll", CharSet=CharSet.Unicode, EntryPoint="SendMessageW")] public static extern IntPtr SendText(IntPtr hwnd, uint msg, IntPtr w, string text); -} -'@ - -function Assert($condition, $message) { if (-not $condition) { throw $message } } -function Sci($message, $w = 0, $l = 0) { - [ThemeTestNative]::SendMessage($script:editor, $message, [IntPtr]$w, [IntPtr]$l).ToInt64() -} -function Select-Theme($dark) { - $command = if ($dark) { 40981 } else { 40980 } - $null = [ThemeTestNative]::SendMessage($script:window, 0x111, [IntPtr]$command, [IntPtr]::Zero) -} -function Start-Editor($ini, $document, $scheme = 'cpp') { - $script:app = Start-Process -FilePath $Executable -ArgumentList @('/n', '/f', ('"'+$ini+'"'), '/s', $scheme, ('"'+$document+'"')) -WindowStyle Hidden -PassThru - # Hosted Windows runners can stall process initialization under scanner/load. - # Keep assertions strict, but allow a bounded CI startup window. - Assert ($script:app.WaitForInputIdle(60000)) "Editor startup timed out after 60s: $Executable (PID $($script:app.Id))" - $script:window = [IntPtr]::Zero - $script:editor = [IntPtr]::Zero - $processId = $script:app.Id - $callback = [ThemeTestNative+Callback]{ param($handle, $state) - [uint32]$owner = 0 - $null = [ThemeTestNative]::GetWindowThreadProcessId($handle, [ref]$owner) - $name = New-Object Text.StringBuilder 128 - $null = [ThemeTestNative]::GetClassName($handle, $name, 128) - if ($owner -eq $processId -and $name.ToString() -like 'Notepad2*') { $script:window = $handle } - return $true - } - $null = [ThemeTestNative]::EnumWindows($callback, [IntPtr]::Zero) - Assert ($script:window -ne [IntPtr]::Zero) 'Main window missing' - $child = [ThemeTestNative+Callback]{ param($handle, $state) - $name = New-Object Text.StringBuilder 128 - $null = [ThemeTestNative]::GetClassName($handle, $name, 128) - if ($name.ToString() -eq 'Scintilla') { $script:editor = $handle } - return $true - } - $null = [ThemeTestNative]::EnumChildWindows($script:window, $child, [IntPtr]::Zero) - Assert ($script:editor -ne [IntPtr]::Zero) 'Scintilla window missing' -} -function Stop-Editor { - $null = Sci 2014 - $null = [ThemeTestNative]::PostMessage($script:window, 0x10, [IntPtr]::Zero, [IntPtr]::Zero) - Assert ($script:app.WaitForExit(10000)) 'Editor did not exit normally' -} - -function Open-Customization { - $null = [ThemeTestNative]::PostMessage($script:window, 0x111, [IntPtr]40402, [IntPtr]::Zero) - $script:dialog = [IntPtr]::Zero - $processId = $script:app.Id - $callback = [ThemeTestNative+Callback]{ param($handle, $state) - [uint32]$owner = 0 - $null = [ThemeTestNative]::GetWindowThreadProcessId($handle, [ref]$owner) - $name = New-Object Text.StringBuilder 128 - $null = [ThemeTestNative]::GetClassName($handle, $name, 128) - if ($owner -eq $processId -and $name.ToString() -eq '#32770') { $script:dialog = $handle } - return $true - } - for ($attempt = 0; $attempt -lt 50 -and $script:dialog -eq [IntPtr]::Zero; ++$attempt) { - Start-Sleep -Milliseconds 20 - $null = [ThemeTestNative]::EnumWindows($callback, [IntPtr]::Zero) - } - Assert ($script:dialog -ne [IntPtr]::Zero) 'Customize Schemes dialog missing' - # Window enumeration can see the dialog before WM_INITDIALOG populates it. - $first = [IntPtr]::Zero - for ($attempt = 0; $attempt -lt 500 -and $first -eq [IntPtr]::Zero; ++$attempt) { - $tree = [ThemeTestNative]::GetDlgItem($script:dialog, 100) - $root = [ThemeTestNative]::SendMessage($tree, 0x110A, [IntPtr]::Zero, [IntPtr]::Zero) - if ($root -ne [IntPtr]::Zero) { $first = [ThemeTestNative]::SendMessage($tree, 0x110A, [IntPtr]4, $root) } - if ($first -eq [IntPtr]::Zero) { Start-Sleep -Milliseconds 20 } - } - Assert ($first -ne [IntPtr]::Zero) 'Customize Schemes tree was not populated' - $null = [ThemeTestNative]::SendMessage($tree, 0x110B, [IntPtr]9, $first) -} - -$Executable = (Resolve-Path $Executable).Path -$testDir = Join-Path ([IO.Path]::GetTempPath()) ('np2-themes-' + [Guid]::NewGuid().ToString('N')) -$null = New-Item -ItemType Directory -Path $testDir -$ini = Join-Path $testDir 'settings.ini' -$document = Join-Path $testDir 'sample.cpp' -[IO.File]::WriteAllText($document, "// comment`r`nint main() { return 42; }`r`n") -$settings = "[Settings]`r`nSaveSettings=1`r`n[Default Text]`r`nDefault Style=font:Consolas; size:13; fore:#123456; back:#EEDDCC`r`n" -[IO.File]::WriteAllText($ini, $settings, [Text.Encoding]::Unicode) -try { - Start-Editor $ini $document - $original = Sci 2482 32 - $fontSize = Sci 2485 32 - $lexer = Sci 4002 - $null = Sci 2645 0 1 # Create a real undo record, with numeric-only cross-process messages. - $null = Sci 2160 2 7 - $snapshot = @((Sci 2006), (Sci 2008), (Sci 2009), (Sci 2152), (Sci 2174)) - $timer = [Diagnostics.Stopwatch]::StartNew() - for ($i = 0; $i -lt 20; ++$i) { - Select-Theme $true - Assert ((Sci 2482 32) -eq 0x1E1E1E) 'Dark background not applied' - Assert ((Sci 2485 32) -eq $fontSize) 'Theme changed font size' - Select-Theme $false - Assert ((Sci 2482 32) -eq $original) 'Original custom background lost' - } - $timer.Stop() - Assert ((Sci 4002) -eq $lexer) 'Theme changed lexer' - $after = @((Sci 2006), (Sci 2008), (Sci 2009), (Sci 2152), (Sci 2174)) - Assert (($snapshot -join ',') -eq ($after -join ',')) 'Theme changed document/selection/viewport/undo state' - Select-Theme $true - Stop-Editor - Start-Editor $ini $document - Assert ((Sci 2482 32) -eq 0x1E1E1E) 'Theme selection did not persist' - Select-Theme $false - Assert ((Sci 2482 32) -eq $original) 'Original settings changed after restart' - Stop-Editor - [IO.File]::WriteAllText($ini, $settings.Replace('SaveSettings=1','SaveSettings=0'), [Text.Encoding]::Unicode) - Start-Editor $ini $document - Select-Theme $true - Stop-Editor - Start-Editor $ini $document - Assert ((Sci 2482 32) -eq $original) 'Disabled settings saving was ignored' - Stop-Editor - # Saved Dark colors are independent; typography embedded in a color section is ignored. - $custom = $settings + "[Styles]`r`nEditorTheme=1`r`n[Theme.Dark.Default Text]`r`nDefault Style=font:Ignored; size:99; fore:#ABCDEF; back:#222222`r`n" - [IO.File]::WriteAllText($ini, $custom, [Text.Encoding]::Unicode) - Start-Editor $ini $document - Assert ((Sci 2482 32) -eq 0x222222) 'Custom Dark color not loaded' - Assert ((Sci 2485 32) -eq $fontSize) 'Dark color section changed shared typography' - Select-Theme $false - Assert ((Sci 2482 32) -eq $original) 'Dark customization overwrote Original' - Select-Theme $true - Assert ((Sci 2482 32) -eq 0x222222) 'Dark customization lost on round trip' - Stop-Editor - [IO.File]::WriteAllText($ini, $settings + "[Styles]`r`nEditorTheme=99`r`n", [Text.Encoding]::Unicode) - Start-Editor $ini $document - Assert ((Sci 2482 32) -eq $original) 'Unknown theme did not fall back to Original' - Stop-Editor - [IO.File]::WriteAllText($ini, $settings + "[Styles]`r`nEditorTheme=1`r`n[Theme.Dark.Default Text]`r`nDefault Style=back:invalid`r`n", [Text.Encoding]::Unicode) - Start-Editor $ini $document - Assert ((Sci 2482 32) -eq 0x1E1E1E) 'Invalid saved Dark default did not fall back safely' - Stop-Editor - $source = [IO.File]::ReadAllText((Join-Path $PSScriptRoot '../src/Styles.c')) - $lexers = [regex]::Matches($source, 'EDITLEXER\s+\w+\s*=\s*\{[^\r\n]*?L"([^"]+)",\s*L"([^"]*)"') - Assert ($lexers.Count -eq 38) 'Lexer coverage inventory changed' - foreach ($entry in $lexers) { - [IO.File]::WriteAllText($ini, $settings, [Text.Encoding]::Unicode) - $scheme = $entry.Groups[2].Value.Split(';')[0].Trim() - Start-Editor $ini $document $scheme - Select-Theme $true - Assert ((Sci 2482 32) -eq 0x1E1E1E) "Dark background failed for $scheme" - Select-Theme $false - Stop-Editor - } - [IO.File]::WriteAllText($ini, $settings, [Text.Encoding]::Unicode) - Start-Editor $ini $document - Select-Theme $true - Open-Customization - $field = [ThemeTestNative]::GetDlgItem($script:dialog, 102) - $null = [ThemeTestNative]::SendText($field, 0xC, [IntPtr]::Zero, 'font:Consolas; size:15; fore:#EEEEEE; back:#111111') - $null = [ThemeTestNative]::SendMessage($script:dialog, 0x111, [IntPtr]106, [IntPtr]::Zero) - Assert ((Sci 2482 32) -eq 0x111111) 'Customization preview did not apply' - $null = [ThemeTestNative]::SendMessage($script:dialog, 0x111, [IntPtr]107, [IntPtr]::Zero) - $null = [ThemeTestNative]::SendMessage($script:dialog, 0x111, [IntPtr]106, [IntPtr]::Zero) - Assert ((Sci 2482 32) -eq 0x1E1E1E) 'Reset restored light colors in Dark' - $null = [ThemeTestNative]::SendMessage($script:dialog, 0x111, [IntPtr]2, [IntPtr]::Zero) - Assert ((Sci 2482 32) -eq 0x1E1E1E -and (Sci 2485 32) -eq $fontSize) 'Cancel did not restore preview' - Open-Customization - $field = [ThemeTestNative]::GetDlgItem($script:dialog, 102) - $null = [ThemeTestNative]::SendText($field, 0xC, [IntPtr]::Zero, 'font:Consolas; size:15; fore:#EEEEEE; back:#111111') - $null = [ThemeTestNative]::SendMessage($script:dialog, 0x111, [IntPtr]1, [IntPtr]::Zero) - Select-Theme $false - Assert ((Sci 2485 32) -eq 15 -and (Sci 2482 32) -eq $original) 'Customization did not share typography and isolate colors' - Select-Theme $true - $null = [ThemeTestNative]::SendMessage($script:window, 0x111, [IntPtr]40401, [IntPtr]::Zero) - Assert ((Sci 2482 32) -eq 0x1E1E1E) 'Second default was not themed' - $null = [ThemeTestNative]::SendMessage($script:window, 0x111, [IntPtr]40401, [IntPtr]::Zero) - Stop-Editor - Start-Editor $ini $document - Assert ((Sci 2482 32) -eq 0x111111 -and (Sci 2485 32) -eq 15) 'Customized theme did not persist' - Stop-Editor - Write-Output "Theme integration passed. 40 switches: $($timer.ElapsedMilliseconds) ms. Fixtures: $testDir" - if ($BaselineExecutable) { - $currentExecutable = $Executable - foreach ($mode in @('Baseline', 'Original', 'Dark')) { - $Executable = if ($mode -eq 'Baseline') { (Resolve-Path $BaselineExecutable).Path } else { $currentExecutable } - $samples = for ($run = 0; $run -lt 7; ++$run) { - $theme = if ($mode -eq 'Dark') { 1 } else { 0 } - [IO.File]::WriteAllText($ini, $settings + "[Styles]`r`nEditorTheme=$theme`r`n", [Text.Encoding]::Unicode) - $startup = [Diagnostics.Stopwatch]::StartNew() - Start-Editor $ini $document - $startup.Stop() - $script:app.Refresh() - $privateBytes = $script:app.PrivateMemorySize64 - $editing = [Diagnostics.Stopwatch]::StartNew() - for ($op = 0; $op -lt 100; ++$op) { $null = Sci 2645 0 1; $null = Sci 2176 } - $editing.Stop() - $cpuBefore = $script:app.TotalProcessorTime.TotalMilliseconds - Start-Sleep -Milliseconds 200 - $script:app.Refresh() - $idle = $script:app.TotalProcessorTime.TotalMilliseconds - $cpuBefore - Stop-Editor - [pscustomobject]@{ StartupMs=$startup.Elapsed.TotalMilliseconds; PrivateKiB=$privateBytes/1024; Edit100Ms=$editing.Elapsed.TotalMilliseconds; Idle200Ms=$idle } - } - $median = [ordered]@{ Mode=$mode } - foreach ($metric in @('StartupMs','PrivateKiB','Edit100Ms','Idle200Ms')) { - $median[$metric] = [Math]::Round(($samples.$metric | Sort-Object)[3], 2) - } - [pscustomobject]$median - } - } -} finally { - if ($script:app -and -not $script:app.HasExited) { Stop-Process -Id $script:app.Id } -} diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..d843d637 --- /dev/null +++ b/conftest.py @@ -0,0 +1,11 @@ +"""Register CLI options before pytest resolves test paths.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + + +def pytest_addoption(parser): + parser.addoption("--executable", default=str(ROOT / "bin/VS2022/Release_x64/Notepad2.exe")) + parser.addoption("--architecture", choices=("x86", "x64"), default="x64") + parser.addoption("--configuration", choices=("Release", "Debug"), default="Release") + parser.addoption("--legacy-save-behavior", action="store_true") diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..0bc51309 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -ra diff --git a/src/Helpers.c b/src/Helpers.c index 3870c26e..950b2ef2 100644 --- a/src/Helpers.c +++ b/src/Helpers.c @@ -1656,6 +1656,7 @@ BOOL MRU_AddFile(LPMRULIST pmru,LPCWSTR pszFile,BOOL bRelativePath,BOOL bUnexpan int i; for (i = 0; i < pmru->iSize; i++) { + if (!pmru->pszItems[i]) continue; if (lstrcmpi(pmru->pszItems[i],pszFile) == 0) { LocalFree(pmru->pszItems[i]); break; diff --git a/src/Notepad2.c b/src/Notepad2.c index dfed1cdb..9c6916b4 100644 --- a/src/Notepad2.c +++ b/src/Notepad2.c @@ -2108,8 +2108,8 @@ void MsgInitMenu(HWND hwnd,WPARAM wParam,LPARAM lParam) i = (int)SendMessage(hwndEdit,SCI_GETSELECTIONEND,0,0) - (int)SendMessage(hwndEdit,SCI_GETSELECTIONSTART,0,0); i2 = (int)SendMessage(hwndEdit,SCI_CANPASTE,0,0); - EnableCmd(hmenu,IDM_EDIT_CUT,i /*&& !bReadOnly*/); - EnableCmd(hmenu,IDM_EDIT_COPY,i /*&& !bReadOnly*/); + EnableCmd(hmenu,IDM_EDIT_CUT,SendMessage(hwndEdit,SCI_GETLENGTH,0,0)); + EnableCmd(hmenu,IDM_EDIT_COPY,SendMessage(hwndEdit,SCI_GETLENGTH,0,0)); EnableCmd(hmenu,IDM_EDIT_COPYALL,SendMessage(hwndEdit,SCI_GETLENGTH,0,0) /*&& !bReadOnly*/); EnableCmd(hmenu,IDM_EDIT_COPYADD,i /*&& !bReadOnly*/); EnableCmd(hmenu,IDM_EDIT_PASTE,i2 /*&& !bReadOnly*/); @@ -2845,14 +2845,21 @@ LRESULT MsgCommand(HWND hwnd,WPARAM wParam,LPARAM lParam) case IDM_EDIT_CUT: if (flagPasteBoard) bLastCopyFromMe = TRUE; - SendMessage(hwndEdit,SCI_CUT,0,0); + if (SendMessage(hwndEdit,SCI_GETSELECTIONEMPTY,0,0)) { + if (!SendMessage(hwndEdit,SCI_GETREADONLY,0,0) && SendMessage(hwndEdit,SCI_GETLENGTH,0,0)) { + SendMessage(hwndEdit,SCI_COPYALLOWLINE,0,0); + SendMessage(hwndEdit,SCI_LINEDELETE,0,0); + } + } + else + SendMessage(hwndEdit,SCI_CUT,0,0); break; case IDM_EDIT_COPY: if (flagPasteBoard) bLastCopyFromMe = TRUE; - SendMessage(hwndEdit,SCI_COPY,0,0); + SendMessage(hwndEdit,SCI_COPYALLOWLINE,0,0); UpdateToolbar(); break; @@ -7021,13 +7028,6 @@ BOOL FileSave(BOOL bSaveAlways,BOOL bAsk,BOOL bSaveAs,BOOL bSaveCopy) int cchText = (int)SendMessage(hwndEdit,SCI_GETLENGTH,0,0); if (cchText == 0) bIsEmptyNewFile = TRUE; - else if (cchText < 1023) { - char tchText[2048]; - SendMessage(hwndEdit,SCI_GETTEXT,(WPARAM)2047,(LPARAM)tchText); - StrTrimA(tchText," \t\n\r"); - if (lstrlenA(tchText) == 0) - bIsEmptyNewFile = TRUE; - } } if (!bSaveAlways && (!bModified && iEncoding == iOriginalEncoding || bIsEmptyNewFile) && !bSaveAs) @@ -7618,7 +7618,7 @@ BOOL RelaunchElevated() { sei.lpFile = lpArg1; sei.lpParameters = lpArg2; sei.lpDirectory = g_wchWorkingDirectory; - sei.nShow = si.wShowWindow; + sei.nShow = (si.dwFlags & STARTF_USESHOWWINDOW) ? si.wShowWindow : SW_SHOWNORMAL; ShellExecuteEx(&sei); } diff --git a/src/Notepad2.rc b/src/Notepad2.rc index 73d65ece..999eb5e7 100644 Binary files a/src/Notepad2.rc and b/src/Notepad2.rc differ diff --git a/src/Notepad2.vcxproj b/src/Notepad2.vcxproj index ccd68ebd..0b066a48 100644 --- a/src/Notepad2.vcxproj +++ b/src/Notepad2.vcxproj @@ -102,6 +102,7 @@ WIN32;STATIC_BUILD;SCI_LEXER;BOOKMARK_EDITION;_CRT_SECURE_NO_WARNINGS;%(PreprocessorDefinitions) MultiThreadedDebug Level3 + /source-charset:utf-8 %(AdditionalOptions) comctl32.lib;imm32.lib;shlwapi.lib;msimg32.lib;%(AdditionalDependencies) @@ -128,6 +129,7 @@ _WIN64;STATIC_BUILD;SCI_LEXER;BOOKMARK_EDITION;_CRT_SECURE_NO_WARNINGS;%(PreprocessorDefinitions) MultiThreadedDebug Level3 + /source-charset:utf-8 %(AdditionalOptions) comctl32.lib;imm32.lib;shlwapi.lib;msimg32.lib;%(AdditionalDependencies) @@ -152,6 +154,7 @@ WIN32;STATIC_BUILD;SCI_LEXER;BOOKMARK_EDITION;NDEBUG;_CRT_SECURE_NO_WARNINGS;%(PreprocessorDefinitions) MultiThreaded Level3 + /source-charset:utf-8 %(AdditionalOptions) comctl32.lib;imm32.lib;shlwapi.lib;msimg32.lib;%(AdditionalDependencies) @@ -179,6 +182,7 @@ _WIN64;STATIC_BUILD;SCI_LEXER;BOOKMARK_EDITION;NDEBUG;_CRT_SECURE_NO_WARNINGS;%(PreprocessorDefinitions) MultiThreaded Level3 + /source-charset:utf-8 %(AdditionalOptions) comctl32.lib;imm32.lib;shlwapi.lib;msimg32.lib;%(AdditionalDependencies) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..91cdd231 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,37 @@ +from contextlib import contextmanager +from pathlib import Path +import sys + +# OLE clipboard preservation requires STA; select it before importing pywinauto. +sys.coinit_flags = 2 + +import pytest + +from windows_app import Editor + +@pytest.fixture +def legacy_save_behavior(request): + return request.config.getoption("--legacy-save-behavior") + + +@pytest.fixture +def editor_factory(request, tmp_path): + serial = 0 + + @contextmanager + def factory(named=False, settings=None, scheme="cpp", content=None): + nonlocal serial + serial += 1 + folder = tmp_path / str(serial) + folder.mkdir() + ini = folder / "settings.ini" + ini.write_text(settings or "[Settings]\nSaveSettings=1\n", encoding="utf-16") + document = folder / "sample.cpp" + document.write_bytes(content if content is not None else b"// comment\r\nint main() { return 42; }\r\n") + editor = Editor(Path(request.config.getoption("--executable")).resolve(), ini, document if named else None, scheme) + try: + yield editor + finally: + editor.close() + + return factory diff --git a/tests/requirements.in b/tests/requirements.in new file mode 100644 index 00000000..b79da906 --- /dev/null +++ b/tests/requirements.in @@ -0,0 +1,2 @@ +pytest +pywinauto diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 00000000..b3655a61 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,61 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile tests/requirements.in --generate-hashes -o tests/requirements.txt +colorama==0.4.6 \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via pytest +comtypes==1.4.17 \ + --hash=sha256:3d9c1e92ad8daf7600d371e76ee16161a627a5fb70c3144f6e52e78af6034363 \ + --hash=sha256:4e0a221dde2c589b82977bed802efd71cd5eb67a380347b732e02735e597f586 + # via pywinauto +iniconfig==2.3.1 \ + --hash=sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960 \ + --hash=sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7 + # via pytest +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via pytest +pluggy==1.7.0 \ + --hash=sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec \ + --hash=sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8 + # via pytest +pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ + --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c + # via pytest +pytest==9.2.0 \ + --hash=sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588 \ + --hash=sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b + # via -r tests/requirements.in +pywin32==312 \ + --hash=sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc \ + --hash=sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c \ + --hash=sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd \ + --hash=sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831 \ + --hash=sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed \ + --hash=sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db \ + --hash=sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950 \ + --hash=sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e \ + --hash=sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c \ + --hash=sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa \ + --hash=sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e \ + --hash=sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b \ + --hash=sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9 \ + --hash=sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47 \ + --hash=sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc \ + --hash=sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5 \ + --hash=sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9 \ + --hash=sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a \ + --hash=sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5 \ + --hash=sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b \ + --hash=sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb + # via pywinauto +pywinauto==0.6.9 \ + --hash=sha256:5924b3072864a1d730c5546bbeb17cf4063ba518b618dbc5e43c18276c7c9356 \ + --hash=sha256:94d710bfa796df245250f952ffa65d97233d9807bcd42e052b71b81af469b6de + # via -r tests/requirements.in +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ + --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 + # via pywinauto diff --git a/tests/test_line_shortcuts.py b/tests/test_line_shortcuts.py new file mode 100644 index 00000000..b71a22a4 --- /dev/null +++ b/tests/test_line_shortcuts.py @@ -0,0 +1,127 @@ +"""Current-line shortcuts use existing Scintilla actions, without a new subsystem.""" +import ctypes +import pythoncom +import pytest +import win32clipboard +from pywinauto.timings import wait_until, wait_until_passes + + +def clipboard_text(value=None): + def access(): + win32clipboard.OpenClipboard() + try: + if value is None: + return win32clipboard.GetClipboardData(win32clipboard.CF_UNICODETEXT) + win32clipboard.EmptyClipboard() + win32clipboard.SetClipboardText(value, win32clipboard.CF_UNICODETEXT) + finally: + win32clipboard.CloseClipboard() + return wait_until_passes(5, 0.05, access) + + +@pytest.fixture(autouse=True) +def preserve_clipboard(): + # OLE retains the original data object, including non-text formats. + pythoncom.OleInitialize() + original = wait_until_passes(5, 0.05, pythoncom.OleGetClipboard) + try: + clipboard_text("np2-test-sentinel") + yield + finally: + try: + wait_until_passes(5, 0.05, lambda: pythoncom.OleSetClipboard(original)) + wait_until_passes(5, 0.05, pythoncom.OleFlushClipboard) + finally: + ctypes.windll.ole32.OleUninitialize() + + +@pytest.mark.parametrize("eol", [b"\r\n", b"\n", b"\r"]) +@pytest.mark.parametrize("last", [False, True]) +@pytest.mark.parametrize("keys", ["^c", "^{INSERT}"]) +def test_ctrl_c_copies_line_without_selection(editor_factory, eol, last, keys): + content = b"first" + eol + "βeta".encode() + with editor_factory(named=True, content=content) as e: + e.sci(2031, {b"\r\n": 0, b"\r": 1, b"\n": 2}[eol]) + position = len(b"first" + eol) + 2 if last else 2 + e.sci(2160, position, position) + before, undo = e.read_bytes(), e.sci(2174) + e.editor.send_keystrokes(keys) + expected = ("βeta" if last else "first") + eol.decode() + wait_until(5, 0.05, lambda: clipboard_text() == expected) + assert e.read_bytes() == before + assert e.sci(2008) == e.sci(2009) == position + assert e.sci(2174) == undo + + +@pytest.mark.parametrize("selection", [False, True]) +@pytest.mark.parametrize("keys", ["^x", "+{DELETE}"]) +def test_shift_delete_cuts_line_or_selection_and_undoes(editor_factory, selection, keys): + original = b"first\r\nsecond\r\nthird" + with editor_factory(named=True, content=original) as e: + e.sci(2160, 1 if selection else 2, 4 if selection else 2) + e.editor.send_keystrokes(keys) + expected = b"ft\r\nsecond\r\nthird" if selection else b"second\r\nthird" + wait_until(5, 0.05, lambda: e.read_bytes() == expected) + assert clipboard_text() == ("irs" if selection else "first\r\n") + e.sci(2176) + assert e.read_bytes() == original + + +def test_ctrl_c_keeps_selected_text_behavior(editor_factory): + with editor_factory(named=True, content=b"first\r\nsecond") as e: + e.sci(2160, 1, 4) + e.editor.send_keystrokes("^c") + wait_until(5, 0.05, lambda: clipboard_text() == "irs") + assert e.read_bytes() == b"first\r\nsecond" + + +def test_read_only_cut_preserves_text_and_clipboard(editor_factory): + with editor_factory(named=True, content=b"read only\r\n") as e: + e.sci(2171, 1) + e.command(40302) + assert e.read_bytes() == b"read only\r\n" + assert clipboard_text() == "np2-test-sentinel" + + +@pytest.mark.parametrize("original,position,remaining,copied", [ + (b"last", 2, b"", "last\r\n"), + (b"first\r\nlast", 9, b"first\r\n", "last\r\n"), + (b"first\r\n\r\nthird", 7, b"first\r\nthird", "\r\n"), +]) +def test_cut_line_edges(editor_factory, original, position, remaining, copied): + with editor_factory(named=True, content=original) as e: + e.sci(2160, position, position) + e.editor.send_keystrokes("+{DELETE}") + wait_until(5, 0.05, lambda: e.read_bytes() == remaining) + assert clipboard_text() == copied + e.sci(2176) + assert e.read_bytes() == original + + +def test_empty_cut_does_not_replace_clipboard(editor_factory): + with editor_factory() as e: + e.command(40302) + assert e.read_bytes() == b"" + assert clipboard_text() == "np2-test-sentinel" + + +@pytest.mark.parametrize("copy_keys", ["^c", "^{INSERT}", "^x", "+{DELETE}"]) +@pytest.mark.parametrize("paste_keys", ["^v", "+{INSERT}"]) +def test_line_clipboard_pastes_above_current_line(editor_factory, copy_keys, paste_keys): + original = b"first\r\nsecond\r\nthird" + cutting = copy_keys in ("^x", "+{DELETE}") + with editor_factory(named=True, content=original) as e: + e.sci(2160, 2, 2) + e.editor.send_keystrokes(copy_keys) + wait_until(5, 0.05, lambda: clipboard_text() == "first\r\n") + marker = win32clipboard.RegisterClipboardFormat("MSDEVLineSelect") + assert win32clipboard.IsClipboardFormatAvailable(marker) + # Paste while the caret is inside 'third', not at column zero. + before_paste = b"second\r\nthird" if cutting else original + caret = len(before_paste) - 2 + e.sci(2160, caret, caret) + e.editor.send_keystrokes(paste_keys) + expected = b"second\r\nfirst\r\nthird" if cutting else b"first\r\nsecond\r\nfirst\r\nthird" + wait_until(5, 0.05, lambda: e.read_bytes() == expected) + e.sci(2176) + assert e.read_bytes() == before_paste diff --git a/tests/test_native_contracts.py b/tests/test_native_contracts.py new file mode 100644 index 00000000..2e7e8376 --- /dev/null +++ b/tests/test_native_contracts.py @@ -0,0 +1,54 @@ +"""Compile production MRU functions and source-linked Unicode/startup contracts.""" +import os +from pathlib import Path +import re +import subprocess +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] + + +def test_native_contracts(request, tmp_path): + arch = request.config.getoption("--architecture") + config = request.config.getoption("--configuration") + for source in (ROOT / "src").iterdir(): + if source.suffix in (".c", ".cpp", ".h"): + source.read_bytes().decode("utf-8", errors="strict") + ns = {"m": "http://schemas.microsoft.com/developer/msbuild/2003"} + project = ET.parse(ROOT / "src/Notepad2.vcxproj") + compilers = project.findall("m:ItemDefinitionGroup/m:ClCompile", ns) + assert len(compilers) == 4 + for compiler in compilers: + options = compiler.findtext("m:AdditionalOptions", namespaces=ns) + assert "/source-charset:utf-8" in options + assert "/execution-charset" not in options and "/utf-8" not in options + source = (ROOT / "src/Notepad2.c").read_text(encoding="utf-8") + elevation = source[source.index("BOOL RelaunchElevated() {"):] + show = re.search(r"sei\.nShow\s*=\s*([^;]+);", elevation).group(1) + edit = (ROOT / "src/Edit.c").read_text(encoding="utf-8") + punctuation = re.search(r'StrChr\((L"[^"]+"),pszTextW\[i\]\)', edit).group(1) + (tmp_path / "fork-contracts.h").write_text( + f"static int StartupShowState(STARTUPINFO si) {{ return {show}; }}\n" + f"static const wchar_t titlePunctuation[] = {punctuation};\n", encoding="utf-8" + ) + vswhere = Path(os.environ["ProgramFiles(x86)"]) / "Microsoft Visual Studio/Installer/vswhere.exe" + vs = subprocess.check_output([ + str(vswhere), "-latest", "-version", "[17.0,18.0)", "-products", "*", + "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath" + ], text=True).strip() + assert vs + runtime = "/MTd" if config == "Debug" else "/MT" + batch = tmp_path / "run.bat" + batch.write_text( + f'@echo off\nchcp 65001 >nul\ncall "{vs}\\Common7\\Tools\\VsDevCmd.bat" -no_logo -arch={arch}\n' + 'if errorlevel 1 exit /b 1\n' + f'cd /d "{tmp_path}"\n' + f'cl /nologo /TC {runtime} /Gy /DUNICODE /D_UNICODE /D_CRT_SECURE_NO_WARNINGS /source-charset:utf-8 ' + f'/I"{ROOT / "src"}" /I"{ROOT / "scintilla/include"}" /I"{tmp_path}" ' + f'"{ROOT / "build/test-fork-helpers.c"}" "{ROOT / "src/Helpers.c"}" /Fe:contracts.exe ' + '/link /OPT:REF /INCREMENTAL:NO shlwapi.lib shell32.lib user32.lib comctl32.lib ole32.lib comdlg32.lib gdi32.lib advapi32.lib\n' + 'if errorlevel 1 exit /b 1\ncontracts.exe\nexit /b %errorlevel%\n', + encoding="utf-8", newline="\r\n" + ) + result = subprocess.run(["cmd.exe", "/d", "/c", str(batch)], capture_output=True, timeout=60) + assert result.returncode == 0, (result.stdout + result.stderr).decode("utf-8", errors="replace") diff --git a/tests/test_save_prompts.py b/tests/test_save_prompts.py new file mode 100644 index 00000000..455d80e1 --- /dev/null +++ b/tests/test_save_prompts.py @@ -0,0 +1,69 @@ +"""Exercise save prompting through the native UI, including embedded NUL data.""" + +import pytest +from pywinauto.timings import wait_until + + +CASES = [ + pytest.param(b"\0AB", False, True, id="leading-nul"), + pytest.param(b" \t\r\n", False, True, id="whitespace"), + pytest.param(b"\0A" + bytes(1020), False, True, id="1022-byte-nul"), + pytest.param(b"\0A" + bytes(1021), False, False, id="1023-byte-nul"), + pytest.param(b"", False, False, id="empty"), + pytest.param(b"\0AB", True, False, id="named-nul"), +] + + +@pytest.mark.parametrize("payload,named,legacy_drops", CASES) +@pytest.mark.parametrize("operation", ["new", "open", "close"]) +def test_nonempty_document_prompts_before_discard( + editor_factory, legacy_save_behavior, payload, named, legacy_drops, operation +): + with editor_factory(named=named) as editor: + editor.add_bytes(payload) + before = editor.read_bytes() + if operation == "close": + editor.post_close() + else: + editor.post_command(40000 if operation == "new" else 40001) + + result = {} + + def operation_reached_ui(): + if not editor.is_running(): + result["state"] = "closed" + return True + dialog = editor.dialog() + if dialog is not None and dialog.is_visible(): + buttons = { + child.control_id() + for child in dialog.descendants(class_name="Button") + } + if {6, 7}.issubset(buttons): + result.update(state="save", dialog=dialog) + return True + if {1, 2}.issubset(buttons): + result["state"] = "open-dialog" + return True + if operation == "new" and editor.sci(2006) == 0: + result["state"] = "cleared" + return True + return False + + wait_until(10, 0.05, operation_reached_ui) + should_prompt = bool(before) and not ( + legacy_save_behavior and legacy_drops + ) + assert (result["state"] == "save") is should_prompt, ( + f"{operation}: observed {result['state']}; " + f"save prompt expected={should_prompt}" + ) + if should_prompt: + result["dialog"].child_window(control_id=2).wrapper_object().click() + wait_until(5, 0.05, lambda: editor.dialog() is None) + assert editor.read_bytes() == before, "Cancel changed document bytes" + elif legacy_save_behavior and legacy_drops: + expected_state = { + "new": "cleared", "open": "open-dialog", "close": "closed" + }[operation] + assert result["state"] == expected_state diff --git a/tests/test_themes.py b/tests/test_themes.py new file mode 100644 index 00000000..78445832 --- /dev/null +++ b/tests/test_themes.py @@ -0,0 +1,87 @@ +import re +from pathlib import Path + +import pytest + +SETTINGS = "[Settings]\nSaveSettings=1\n[Default Text]\nDefault Style=font:Consolas; size:13; fore:#123456; back:#EEDDCC\n" +SOURCE = (Path(__file__).resolve().parents[1] / "src/Styles.c").read_text(encoding="utf-8") +SCHEMES = re.findall(r'EDITLEXER\s+\w+\s*=\s*\{[^\r\n]*?L"([^"]+)",\s*L"([^"]*)"', SOURCE) + + +@pytest.mark.parametrize("name,extensions", SCHEMES, ids=[entry[0] for entry in SCHEMES]) +def test_scheme_round_trip(editor_factory, name, extensions): + assert len(SCHEMES) == 38 + with editor_factory(named=True, settings=SETTINGS, scheme=extensions.split(";")[0].strip()) as e: + original = e.sci(2482, 32) + e.theme(True) + assert e.sci(2482, 32) == 0x1E1E1E + e.theme(False) + assert e.sci(2482, 32) == original + + +def test_switching_preserves_document_and_preferences(editor_factory): + with editor_factory(named=True, settings=SETTINGS) as e: + e.sci(2645, 0, 1) + e.sci(2160, 2, 7) + before = e.read_bytes() + state = [e.sci(m) for m in (2008, 2009, 2152, 2174, 4002)] + original, font = e.sci(2482, 32), e.sci(2485, 32) + for _ in range(20): + e.theme(True) + assert e.sci(2482, 32) == 0x1E1E1E + assert e.sci(2485, 32) == font + e.theme(False) + assert e.sci(2482, 32) == original + assert e.read_bytes() == before + assert [e.sci(m) for m in (2008, 2009, 2152, 2174, 4002)] == state + + +@pytest.mark.parametrize("save", [True, False]) +def test_selection_persistence(editor_factory, save): + with editor_factory(named=True, settings=SETTINGS.replace("SaveSettings=1", f"SaveSettings={int(save)}")) as e: + original = e.sci(2482, 32) + e.theme(True) + e.exit() + e.launch() + assert e.sci(2482, 32) == (0x1E1E1E if save else original) + e.theme(False) + assert e.sci(2482, 32) == original + + +@pytest.mark.parametrize("extra,expected", [ + ("[Styles]\nEditorTheme=99\n", 0xCCDDEE), + ("[Styles]\nEditorTheme=1\n[Theme.Dark.Default Text]\nDefault Style=back:invalid\n", 0x1E1E1E), + ("[Styles]\nEditorTheme=1\n[Theme.Dark.Default Text]\nDefault Style=font:Ignored; size:99; fore:#ABCDEF; back:#222222\n", 0x222222), +]) +def test_saved_palette_defaults(editor_factory, extra, expected): + with editor_factory(named=True, settings=SETTINGS + extra) as e: + assert e.sci(2482, 32) == expected + assert e.sci(2485, 32) == 13 + e.theme(False) + assert e.sci(2482, 32) == 0xCCDDEE + + +def test_customize_preview_cancel_reset_and_save(editor_factory): + with editor_factory(named=True, settings=SETTINGS) as e: + e.theme(True) + dialog = e.customize() + dialog.child_window(control_id=102).set_edit_text("font:Consolas; size:15; fore:#EEEEEE; back:#111111") + dialog.child_window(control_id=106).click() + assert e.sci(2482, 32) == 0x111111 + dialog.child_window(control_id=107).click() + dialog.child_window(control_id=106).click() + assert e.sci(2482, 32) == 0x1E1E1E + dialog.child_window(control_id=2).click() + assert e.sci(2482, 32) == 0x1E1E1E and e.sci(2485, 32) == 13 + dialog = e.customize() + dialog.child_window(control_id=102).set_edit_text("font:Consolas; size:15; fore:#EEEEEE; back:#111111") + dialog.child_window(control_id=1).click() + e.theme(False) + assert e.sci(2485, 32) == 15 and e.sci(2482, 32) == 0xCCDDEE + e.theme(True) + e.command(40401) + assert e.sci(2482, 32) == 0x1E1E1E + e.command(40401) + e.exit() + e.launch() + assert e.sci(2482, 32) == 0x111111 and e.sci(2485, 32) == 15 diff --git a/tests/windows_app.py b/tests/windows_app.py new file mode 100644 index 00000000..620ee96f --- /dev/null +++ b/tests/windows_app.py @@ -0,0 +1,97 @@ +"""pywinauto owns window/control automation; this adapter handles Scintilla messages.""" +import ctypes +import subprocess + +from pywinauto import Application +from pywinauto.remote_memory_block import RemoteMemoryBlock +from pywinauto.timings import wait_until + + +class Editor: + def __init__(self, executable, ini, document=None, scheme="cpp"): + self.executable, self.ini, self.document, self.scheme = executable, ini, document, scheme + self.launch() + + def launch(self): + args = [str(self.executable), "/n", "/f", str(self.ini), "/s", self.scheme] + if self.document: + args.append(str(self.document)) + startup = subprocess.STARTUPINFO() + startup.dwFlags = subprocess.STARTF_USESHOWWINDOW + startup.wShowWindow = 0 + self.process = subprocess.Popen(args, startupinfo=startup) + try: + self.app = Application(backend="win32").connect(process=self.process.pid, timeout=60) + self.main = self.app.window(class_name_re="Notepad2.*", visible_only=False) + self.main.wait("exists", timeout=60) + control = self.main.child_window(class_name="Scintilla") + control.wait("exists", timeout=60) + self.editor = control.wrapper_object() + except BaseException: + self.close() + raise + + def sci(self, message, w=0, l=0): + return self.editor.send_message_timeout(message, w, l, timeout=10) + + def add_bytes(self, data): + if data: + buffer = ctypes.create_string_buffer(data) + remote = RemoteMemoryBlock(self.editor, size=len(buffer)) + try: + remote.Write(buffer) + self.sci(2001, len(data), remote.Address()) + finally: + remote.CleanUp() + + def read_bytes(self): + size = self.sci(2006) + buffer = ctypes.create_string_buffer(size + 1) + remote = RemoteMemoryBlock(self.editor, size=size + 1) + try: + self.sci(2182, size + 1, remote.Address()) + remote.Read(buffer) + return buffer.raw[:size] + finally: + remote.CleanUp() + + def command(self, command): + self.main.send_message_timeout(0x111, command, 0, timeout=10) + + def post_command(self, command): + self.main.post_message(0x111, command, 0) + + def post_close(self): + self.main.post_message(0x10) + + def dialog(self): + if not self.is_running(): + return None + windows = self.app.windows(class_name="#32770", visible_only=False) + return self.app.window(handle=windows[0].handle) if windows else None + + def is_running(self): + return self.process.poll() is None + + def theme(self, dark): + self.command(40981 if dark else 40980) + + def customize(self): + self.post_command(40402) + wait_until(10, 0.05, lambda: self.dialog() is not None and self.dialog().is_visible()) + dialog = self.dialog() + tree = dialog.child_window(control_id=100).wrapper_object() + wait_until(10, 0.05, lambda: tree.item_count() > 38) + tree.get_item([0, 0]).select() + return dialog + + def exit(self): + self.sci(2014) # Tests decide what to save; never leave an unsaved prompt. + self.post_close() + self.process.wait(timeout=10) + assert self.process.returncode == 0 + + def close(self): + if self.is_running(): + self.process.terminate() + self.process.wait(timeout=10)