diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..0a04e89 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,153 @@ +name: Build + +# Cross-OS build verification on every PR and every push to main -- a +# lighter-weight, continuous companion to release.yml's tag-triggered +# release builds. Catches "the frozen build doesn't actually launch" the +# same day it's introduced, not the next time someone cuts a release. +on: + pull_request: + push: + branches: + - main + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - uses: actions/setup-node@v7 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install Linux GTK/WebKit dependencies + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 update -y + sudo apt-get install -y \ + python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1 xvfb \ + libgirepository1.0-dev libgirepository-2.0-dev libcairo2-dev \ + pkg-config python3-dev + + - name: Install project + shell: bash + run: | + if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then + pip install -e ".[dev,linux]" + elif [ "${{ matrix.os }}" = "macos-latest" ]; then + pip install -e ".[dev,macos]" + else + pip install -e ".[dev]" + fi + + - name: Frontend typecheck, test, build + working-directory: frontend + run: | + npm ci + npm run typecheck + npm test + npm run build + + - name: Python tests + shell: bash + timeout-minutes: 8 + run: | + if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then + xvfb-run --auto-servernum pytest + else + pytest + fi + + - name: Lint + run: ruff check src/ + + - name: Build frozen app + run: python packaging/build.py --skip-frontend + + - name: Smoke test the binary actually launches (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + # No --version flag; with no args the app opens the GUI and + # blocks, so give it 5s under a virtual display and kill it. + # A clean launch exits 124 (timeout killed it); any other code + # means it crashed on startup -- this is exactly the class of + # bug (Gdk.Display.get_default() returning None) that compiles + # fine and only shows up when the frozen build actually runs. + set +e + xvfb-run --auto-servernum timeout 5 ./dist/NoRefund/NoRefund + code=$? + set -e + if [ "$code" -ne 0 ] && [ "$code" -ne 124 ]; then + echo "::error::NoRefund exited with code $code within 5s instead of staying up — the frozen build is broken." + exit 1 + fi + + - name: Smoke test the binary actually launches (Windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + $proc = Start-Process -FilePath "dist\NoRefund\NoRefund.exe" -PassThru + Start-Sleep -Seconds 5 + if ($proc.HasExited) { + Write-Error "NoRefund.exe exited within 5s (exit code $($proc.ExitCode)) instead of staying up — the frozen build is broken." + exit 1 + } + Stop-Process -Id $proc.Id -Force + + - name: Smoke test the binary actually launches (macOS) + if: matrix.os == 'macos-latest' + run: | + # py2app names the bundle executable after the plist, not + # assumed to match "NoRefund" -- read it back rather than + # guessing, same reasoning as the dynamic lookup below. + EXE=$(plutil -extract CFBundleExecutable raw dist/NoRefund.app/Contents/Info.plist) + "dist/NoRefund.app/Contents/MacOS/$EXE" & + PID=$! + sleep 5 + if ! kill -0 "$PID" 2>/dev/null; then + echo "::error::NoRefund.app exited within 5s instead of staying up — the frozen build is broken." + exit 1 + fi + kill "$PID" + + - name: Package artifact (Linux) + if: matrix.os == 'ubuntu-latest' + run: tar -czf NoRefund-linux-x86_64.tar.gz -C dist NoRefund + + - name: Package artifact (macOS) + if: matrix.os == 'macos-latest' + run: | + codesign --force --deep --sign - dist/NoRefund.app + tar -czf NoRefund-macos.tar.gz -C dist NoRefund.app + + - uses: actions/upload-artifact@v7 + if: matrix.os == 'ubuntu-latest' + with: + name: linux-build + path: NoRefund-linux-x86_64.tar.gz + if-no-files-found: error + + - uses: actions/upload-artifact@v7 + if: matrix.os == 'windows-latest' + with: + name: windows-build + path: dist/NoRefund + if-no-files-found: error + + - uses: actions/upload-artifact@v7 + if: matrix.os == 'macos-latest' + with: + name: macos-build + path: NoRefund-macos.tar.gz + if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd629a1..86e58f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c5d214..ee35283 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: outputs: version: ${{ steps.resolve.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Resolve version id: resolve @@ -48,27 +48,40 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" + - uses: actions/setup-node@v7 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install Linux GTK/WebKit dependencies + run: | + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 update -y + sudo apt-get install -y \ + python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1 xvfb \ + libgirepository1.0-dev libgirepository-2.0-dev libcairo2-dev \ + pkg-config python3-dev + - name: Install project - run: pip install -e .[dev] + run: pip install -e ".[dev,linux]" - - name: Build with PyInstaller - run: pyinstaller packaging/norefund.spec --distpath dist --workpath build --noconfirm + - name: Build + run: python packaging/build.py - name: Smoke test the binary actually launches run: | # There's no --version flag; with no args the app opens the GUI - # and blocks in mainloop(), so give it 5s under a virtual display - # and kill it. A clean launch exits 124 (timeout killed it); any - # other code means it crashed on startup (this is exactly how the - # PIL._tkinter_finder packaging bug was first caught). - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 update -y - sudo apt-get install -y xvfb + # and blocks, so give it 5s under a virtual display and kill it. + # A clean launch exits 124 (timeout killed it); any other code + # means it crashed on startup -- this is exactly the class of bug + # (Gdk.Display.get_default() returning None; see docs/packaging.md) + # that compiles fine and only shows up when the frozen build runs. set +e xvfb-run --auto-servernum timeout 5 ./dist/NoRefund/NoRefund code=$? @@ -83,7 +96,7 @@ jobs: VERSION="${{ needs.version.outputs.version }}" tar -czf "NoRefund-${VERSION}-linux-x86_64.tar.gz" -C dist NoRefund - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: linux-build path: NoRefund-*-linux-x86_64.tar.gz @@ -93,20 +106,26 @@ jobs: needs: version runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" + - uses: actions/setup-node@v7 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + - name: Install project run: pip install -e .[dev] - name: Install Inno Setup run: choco install innosetup --no-progress -y - - name: Build with PyInstaller - run: pyinstaller packaging\norefund.spec --distpath dist --workpath build --noconfirm + - name: Build + run: python packaging\build.py - name: Smoke test the binary actually launches shell: pwsh @@ -125,22 +144,77 @@ jobs: $version = "${{ needs.version.outputs.version }}" iscc "/DMyAppVersion=$version" packaging\windows\installer.iss - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: windows-build path: packaging/windows/dist/NoRefund-Setup-*.exe if-no-files-found: error + build-macos: + needs: version + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - uses: actions/setup-node@v7 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install project + run: pip install -e ".[dev,macos]" + + - name: Build + run: python packaging/build.py + + - name: Smoke test the binary actually launches + run: | + # py2app names the bundle executable after the plist, not + # assumed to match "NoRefund" -- read it back rather than + # guessing (see build.yml's matching macOS smoke test). + EXE=$(plutil -extract CFBundleExecutable raw dist/NoRefund.app/Contents/Info.plist) + "dist/NoRefund.app/Contents/MacOS/$EXE" & + PID=$! + sleep 5 + if ! kill -0 "$PID" 2>/dev/null; then + echo "::error::NoRefund.app exited within 5s instead of staying up — the frozen build is broken." + exit 1 + fi + kill "$PID" + + - name: Ad-hoc sign and package tarball + run: | + # No Apple Developer ID ($99/yr) -- ad-hoc signing is the free + # option. It quiets Gatekeeper's outright refusal to launch but + # not the "is damaged" first-run dialog; see packaging/README.md + # for the xattr -cr workaround end users need until this project + # can afford real notarization. + VERSION="${{ needs.version.outputs.version }}" + codesign --force --deep --sign - dist/NoRefund.app + tar -czf "NoRefund-${VERSION}-macos.tar.gz" -C dist NoRefund.app + + - uses: actions/upload-artifact@v7 + with: + name: macos-build + path: NoRefund-*-macos.tar.gz + if-no-files-found: error + release: - needs: [version, build-linux, build-windows] + needs: [version, build-linux, build-windows, build-macos] if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: path: artifacts @@ -151,5 +225,6 @@ jobs: gh release create "${{ github.ref_name }}" \ artifacts/linux-build/* \ artifacts/windows-build/* \ + artifacts/macos-build/* \ --title "NoRefund ${{ github.ref_name }}" \ --generate-notes diff --git a/.gitignore b/.gitignore index 5a9311e..8704010 100644 --- a/.gitignore +++ b/.gitignore @@ -238,4 +238,5 @@ docs/superpowers/ # The Python packaging "lib/" rule above is unanchored and would otherwise # swallow the frontend's own src/lib/ directory. !/frontend/src/lib/ -!/frontend/src/lib/** \ No newline at end of file +!/frontend/src/lib/** +/graphify-out/ diff --git a/README.md b/README.md index d78cc4e..0d608b6 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,20 @@ used only when you explicitly download a tokenizer, from the app's Resources vie --- +## Download + +Prebuilt Windows, macOS, and Linux builds are on the +[Releases page](https://github.com/Phantom-VK/NoRefund/releases) — no Python install +required. + +**Windows SmartScreen:** NoRefund isn't code-signed (a signing certificate costs money +this free project doesn't have), so Windows shows a "Windows protected your PC" warning +the first time you run it. Click **More info → Run anyway** to continue. This warning +means the publisher isn't verified, not that the app is unsafe — the source is right +here in this repo. + +--- + ## Quick Start ```bash diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ea7b3cd..bb287c7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,7 @@ import { useSettings } from "@/hooks/useSettings"; import { useShortcuts } from "@/hooks/useShortcuts"; import { useJob } from "@/hooks/useJob"; import { useTheme, type ThemeMode } from "@/hooks/useTheme"; -import { bridge, bridgeReady, BridgeError } from "@/lib/bridge"; +import { bridge, BridgeError } from "@/lib/bridge"; import type { ExchangeRates, ModelInfo, ResourceReport } from "@/lib/types"; import Calculator from "@/views/Calculator"; @@ -49,8 +49,7 @@ export default function App() { useEffect(() => { let cancelled = false; - bridgeReady() - .then(() => Promise.all([bridge.getModels(), bridge.getExchangeRates()])) + Promise.all([bridge.getModels(), bridge.getExchangeRates()]) .then(([m, rates]) => { if (cancelled) return; setModels(m); diff --git a/frontend/src/lib/bridge.test.ts b/frontend/src/lib/bridge.test.ts new file mode 100644 index 0000000..d028aac --- /dev/null +++ b/frontend/src/lib/bridge.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { bridge, bridgeReady, BridgeError } from "./bridge"; + +// pywebview injects window.pywebview.api asynchronously, on its own timing +// relative to the page's own scripts. A component that calls a bridge +// method the instant it mounts can win or lose that race depending on the +// machine -- these tests pin down that every call waits it out instead of +// failing the moment the api object isn't there yet (the bug behind PR #43: +// useSettings called bridge.getSettings() directly, lost the race on some +// machines, and the resulting error was silently swallowed into a state +// nothing rendered -- an infinite loading spinner with no error on screen). + +function setPywebview(api: Record Promise> | undefined) { + (globalThis as { window: unknown }).window = api ? { pywebview: { api } } : {}; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + setPywebview(undefined); +}); + +afterEach(() => { + vi.useRealTimers(); + delete (globalThis as { window?: unknown }).window; +}); + +describe("bridgeReady", () => { + it("resolves once window.pywebview.api appears, even if it wasn't there yet", async () => { + const ready = bridgeReady(); + await vi.advanceTimersByTimeAsync(100); + setPywebview({}); + await vi.advanceTimersByTimeAsync(100); + await expect(ready).resolves.toBeUndefined(); + }); + + it("rejects if the bridge never becomes ready within the timeout", async () => { + const ready = bridgeReady(); + ready.catch(() => {}); // fake-timer advancement below is what settles it; avoid an unhandled-rejection warning in between + // A touch past the 10s deadline -- the poll loop checks Date.now() > + // deadline, so landing exactly on it is one tick short of tripping. + await vi.advanceTimersByTimeAsync(10_100); + await expect(ready).rejects.toThrow(/did not become ready/); + }); +}); + +describe("bridge calls", () => { + it("wait for a not-yet-ready bridge instead of rejecting immediately", async () => { + const get_settings = vi.fn().mockResolvedValue({ ok: true, data: { theme: "dark" } }); + const promise = bridge.getSettings(); + + // Still not ready -- must not have rejected synchronously with + // "Python bridge is not ready" the way a naive `if (!api) throw` would. + await vi.advanceTimersByTimeAsync(100); + + setPywebview({ get_settings }); + await vi.advanceTimersByTimeAsync(100); + + await expect(promise).resolves.toEqual({ theme: "dark" }); + }); + + it("still rejects for a method the backend never exposed", async () => { + setPywebview({}); + await expect(bridge.getSettings()).rejects.toThrow(/Unknown bridge method/); + }); + + it("surfaces a backend error via BridgeError", async () => { + setPywebview({ get_settings: vi.fn().mockResolvedValue({ ok: false, error: "boom" }) }); + await expect(bridge.getSettings()).rejects.toThrow(BridgeError); + await expect(bridge.getSettings()).rejects.toThrow("boom"); + }); +}); diff --git a/frontend/src/lib/bridge.ts b/frontend/src/lib/bridge.ts index 625820b..1ba04f8 100644 --- a/frontend/src/lib/bridge.ts +++ b/frontend/src/lib/bridge.ts @@ -25,8 +25,14 @@ export class BridgeError extends Error {} /** The one place the bridge's return type is unchecked — narrowed * immediately below via the `ok`/`error` envelope check. */ async function call(method: string, ...args: unknown[]): Promise { - const api = window.pywebview?.api; - if (!api) throw new BridgeError("Python bridge is not ready"); + // pywebview injects window.pywebview.api asynchronously, on its own + // schedule relative to React mounting -- a call made the instant a + // component mounts can easily lose that race. Waiting here, once, means + // no call site has to remember to do it (a prior version required each + // caller to await bridgeReady() itself; the one that didn't shipped a + // silent bridge failure disguised as an infinite loading spinner). + await bridgeReady(); + const api = window.pywebview!.api; const fn = api[method]; if (!fn) throw new BridgeError(`Unknown bridge method: ${method}`); const raw = (await fn(...args)) as Envelope; diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..8814f7d --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,154 @@ +# Packaging NoRefund + +One command builds a distributable on any platform: + +```bash +python packaging/build.py +``` + +This always rebuilds the frontend first (`npm ci && npm run build` in +`frontend/`) unless you pass `--skip-frontend`, and refuses to proceed if +`src/norefund/web/index.html` is missing or older than the newest file in +`frontend/src/` -- a stale bundled UI is a silent, hard-to-notice bug, not +a convenience worth risking. + +It dispatches to PyInstaller on Windows/Linux and py2app on macOS. + +## Linux + +**Build prerequisites:** + +```bash +# Debian/Ubuntu -- pygobject builds pycairo from source, which needs these +# even though they're not needed at runtime: +sudo apt install libgirepository1.0-dev libgirepository-2.0-dev \ + libcairo2-dev pkg-config python3-dev + +pip install -e ".[dev,linux]" +``` + +`pyinstaller` comes from the `dev` extra; `pygobject` (the Python GTK +bindings) from `linux`. You also need the system GTK/WebKitGTK packages +themselves -- see "End-user requirements" below, since the build machine +needs the same runtime the app does to even launch for testing. + +**Build:** + +```bash +python packaging/build.py +``` + +**Output:** `dist/NoRefund/` (a one-dir build -- an executable plus its +dependencies, not a single-file bundle). Launch with +`./dist/NoRefund/NoRefund`. + +**End-user requirements:** WebKitGTK is a system library with a GObject +introspection layer, not something any Python packager can bundle -- every +Linux user needs it installed already. This is not a workaround; it's how +every webview-based desktop app behaves on Linux, PyInstaller or not. + +```bash +# Debian/Ubuntu +sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1 + +# Fedora +sudo dnf install python3-gobject webkit2gtk4.1 + +# Arch +sudo pacman -S python-gobject webkit2gtk-4.1 +``` + +These are the exact strings `missing_runtime_message()` in +`src/norefund/desktop/app.py` prints when the runtime is missing -- +**keep the two in sync**. If the app is launched without WebKitGTK +installed, it prints this message to stderr and exits with code 2, +rather than an unhandled traceback. + +**Known limitation:** the frozen build was made to work by excluding +PyInstaller's bundled copies of libglib/libgobject/libgio (see the +comment in `packaging/norefund.spec`) so the whole GTK stack resolves +from the system consistently. If a future PyInstaller/PyGObject upgrade +changes what gets auto-bundled, re-verify by actually launching +`dist/NoRefund/NoRefund` -- a build that compiles is not a build that +works, and this exact class of bug (a silent `Gdk.Display.get_default() +== None` instead of an import error) does not show up any other way. + +## Windows + +**Build prerequisites:** + +```powershell +pip install -e ".[dev]" +``` + +**Build:** + +```powershell +python packaging\build.py +``` + +**Output:** `dist\NoRefund\NoRefund.exe`. `packaging\windows\build.ps1` +wraps the same PyInstaller spec and additionally produces a signed-free +Inno Setup installer (`packaging\windows\installer.iss`) if `iscc` is on +`PATH` -- see that script's own header comment. + +**End-user requirements:** Microsoft Edge WebView2 runtime. Present on +Windows 11 and most Windows 10 installs already, but not guaranteed. If +missing, the app prints an actionable message with the download link +(`https://developer.microsoft.com/microsoft-edge/webview2/`) and exits +with code 2, rather than a traceback. + +**Known limitation:** no code-signing certificate. Windows SmartScreen +will warn on first run of an unsigned installer/exe. + +## macOS + +**Build prerequisites:** + +```bash +pip install -e ".[dev,macos]" +``` + +**Build:** + +```bash +python packaging/build.py +``` + +**Output:** `dist/NoRefund.app`. Not code-signed by `build.py` -- ad-hoc +sign it yourself before the first launch: + +```bash +codesign --force --deep --sign - dist/NoRefund.app +open dist/NoRefund.app +``` + +**End-user requirements:** none beyond macOS 12+ -- WebKit is part of the +OS, unlike Linux's WebKitGTK. + +**Known limitation:** notarisation is out of scope for now (it needs a +paid $99/yr Apple Developer account) -- deliberately deferred, not an +oversight; revisit if macOS adoption justifies the cost. The app is only +ad-hoc signed, so Gatekeeper blocks a plain double-click on first launch. +On older macOS, **right-click the app -> Open** and confirm once in the +dialog that appears; every run after that launches normally. On current +macOS this often shows **"NoRefund is damaged and can't be opened"** +instead, with no right-click override -- that message is misleading (the +download isn't actually corrupt), and the fix is to open Terminal and run: + +```bash +xattr -cr /path/to/NoRefund.app +``` + +This strips the quarantine flag the browser/Finder attached on download, +after which the app opens normally on every launch. + +## What is never bundled, on any platform + +Tokenizer caches (tiktoken vocab files, HuggingFace `tokenizer.json` +files) are never shipped in any build. They're large, backend-specific, +and the whole point of the Resources view is that a user downloads only +the tokenizers they actually need, on their own machine, on their own +network connection -- bundling any of them would both bloat every install +and quietly contradict "your documents never leave this machine" by +shipping a pre-populated cache nobody asked for. diff --git a/packaging/build.py b/packaging/build.py new file mode 100644 index 0000000..ea38db6 --- /dev/null +++ b/packaging/build.py @@ -0,0 +1,129 @@ +"""One command to produce a distributable build on any platform. + +Usage: python packaging/build.py [--skip-frontend] + +Always builds the frontend first unless explicitly skipped, because a stale +src/norefund/web/ produces a build that looks fine and ships the wrong UI. +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +FRONTEND_DIR = PROJECT_ROOT / "frontend" +WEB_DIR = PROJECT_ROOT / "src" / "norefund" / "web" +SPEC_PATH = PROJECT_ROOT / "packaging" / "norefund.spec" +MACOS_SETUP = PROJECT_ROOT / "packaging" / "macos_setup.py" + + +def _run(cmd: list[str], *, cwd: Path) -> None: + # Windows' CreateProcess only auto-appends .exe, not .cmd -- a bare + # "npm" (really npm.cmd on Windows) fails with WinError 2 unless + # resolved through shutil.which(), which does the full PATHEXT search. + resolved = [shutil.which(cmd[0]) or cmd[0], *cmd[1:]] + print(f"== {' '.join(cmd)} (in {cwd}) ==") + result = subprocess.run(resolved, cwd=cwd) + if result.returncode != 0: + print(f"FAILED: {' '.join(cmd)}", file=sys.stderr) + raise SystemExit(result.returncode) + + +def build_frontend() -> None: + _run(["npm", "ci"], cwd=FRONTEND_DIR) + _run(["npm", "run", "build"], cwd=FRONTEND_DIR) + + +def verify_frontend_fresh() -> None: + index_html = WEB_DIR / "index.html" + if not index_html.exists(): + raise SystemExit( + f"{index_html} does not exist. Run the frontend build first " + "(omit --skip-frontend)." + ) + newest_src = max( + (p.stat().st_mtime for p in (FRONTEND_DIR / "src").rglob("*") if p.is_file()), + default=0.0, + ) + if index_html.stat().st_mtime < newest_src: + raise SystemExit( + f"{index_html} is older than the newest file in frontend/src/ -- " + "the built UI is stale. Rebuild the frontend (omit --skip-frontend)." + ) + + +def build_pyinstaller() -> Path: + _run( + [ + "pyinstaller", + str(SPEC_PATH), + "--distpath", + "dist", + "--workpath", + "build", + "--noconfirm", + ], + cwd=PROJECT_ROOT, + ) + return PROJECT_ROOT / "dist" / "NoRefund" + + +def build_macos() -> Path: + _run([sys.executable, str(MACOS_SETUP), "py2app"], cwd=PROJECT_ROOT) + return PROJECT_ROOT / "dist" / "NoRefund.app" + + +def dir_size(path: Path) -> int: + if path.is_file(): + return path.stat().st_size + return sum(p.stat().st_size for p in path.rglob("*") if p.is_file()) + + +def fmt_size(num_bytes: int) -> str: + size = float(num_bytes) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} TB" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--skip-frontend", + action="store_true", + help="Skip the npm build and use the frontend already at src/norefund/web/", + ) + args = parser.parse_args() + + if not args.skip_frontend: + build_frontend() + verify_frontend_fresh() + + if sys.platform == "darwin": + output = build_macos() + elif sys.platform in ("win32", "linux"): + if shutil.which("pyinstaller") is None: + raise SystemExit( + "pyinstaller not found. Install the dev extras: pip install -e '.[dev]'" + ) + output = build_pyinstaller() + else: + raise SystemExit(f"Unsupported platform: {sys.platform}") + + if not output.exists(): + raise SystemExit(f"Build reported success but {output} does not exist.") + + size = dir_size(output) + print("== Build complete ==") + print(f"Output: {output}") + print(f"Size: {fmt_size(size)}") + + +if __name__ == "__main__": + main() diff --git a/packaging/macos_setup.py b/packaging/macos_setup.py new file mode 100644 index 0000000..fcbcc57 --- /dev/null +++ b/packaging/macos_setup.py @@ -0,0 +1,79 @@ +"""py2app setup script for the macOS NoRefund.app bundle. + +py2app is the pywebview-recommended packaging path on macOS (pywebview's +macOS backend is a thin PyObjC/WebKit wrapper, not a separate renderer to +choose the way Windows/Linux have one). + +Usage (on macOS, inside the project venv with `py2app` installed): + python packaging/macos_setup.py py2app + +Output: dist/NoRefund.app. Not code-signed by this script -- ad-hoc sign +separately (see packaging/README.md) before the first `open`. + +Requires the frontend already built at src/norefund/web/ -- packaging/ +build.py does this automatically; a direct `py2app` invocation does not. +""" + +from __future__ import annotations + +from pathlib import Path + +from setuptools import setup + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SRC = PROJECT_ROOT / "src" / "norefund" + +APP = [str(SRC / "desktop" / "app.py")] + +DATA_FILES = [ + ( + "norefund/config", + [ + str(SRC / "config" / "default_models.yaml"), + str(SRC / "config" / "model_architectures.yaml"), + str(SRC / "config" / "hardware.yaml"), + ], + ), +] +# The built frontend, as a directory tree -- without it the app launches to +# a blank window. py2app's data_files wants one (dest_dir, [files]) tuple +# per directory level, not a recursive copy, so walk it ourselves. +_web_dir = SRC / "web" +for _dir in [_web_dir, *(_p for _p in _web_dir.rglob("*") if _p.is_dir())]: + _files = [str(f) for f in _dir.iterdir() if f.is_file()] + if _files: + _dest = "norefund/web" / _dir.relative_to(_web_dir) + DATA_FILES.append((str(_dest), _files)) + +OPTIONS = { + "argv_emulation": False, + "iconfile": None, # no .icns yet -- uses the generic app icon + # reportlab's Type 1 font metrics (AFM data) are package data, not + # importable modules -- py2app's static analysis can't see them, + # same reason the PyInstaller spec needs collect_data_files. + "packages": ["reportlab"], + # tiktoken discovers its encoding plugins via pkgutil/namespace-package + # scanning at import time, invisible to static analysis. + "includes": ["tiktoken_ext", "tiktoken_ext.openai_public"], + "plist": { + "CFBundleName": "NoRefund", + "CFBundleDisplayName": "NoRefund", + "CFBundleIdentifier": "com.norefund.app", + "CFBundleVersion": "0.1.0", + "CFBundleShortVersionString": "0.1.0", + "LSMinimumSystemVersion": "12.0", + "NSHighResolutionCapable": True, + # No entitlements requesting network, camera, microphone or + # contacts -- the app needs none of them, and requesting them + # would undermine the "your documents never leave this machine" + # claim. (No entitlements file at all is not the same as ad-hoc + # signing with the default entitlements Xcode adds -- see + # packaging/README.md for the codesign command actually used.) + }, +} + +setup( + app=APP, + data_files=DATA_FILES, + options={"py2app": OPTIONS}, +) diff --git a/packaging/norefund.spec b/packaging/norefund.spec index 469f69e..e4da578 100644 --- a/packaging/norefund.spec +++ b/packaging/norefund.spec @@ -1,17 +1,26 @@ -# PyInstaller spec for NoRefund (Windows one-dir, windowed build). +# PyInstaller spec for NoRefund (Windows/Linux one-dir, windowed build) -- +# the React + pywebview desktop app, not the legacy CustomTkinter GUI. # -# Build (on Windows, inside the project venv with `pyinstaller` installed): +# Build (inside the project venv with `pyinstaller` installed): +# python packaging/build.py +# or directly: # pyinstaller packaging/norefund.spec --distpath dist --workpath build # -# Output lands in dist/NoRefund/ (one-dir build — an .exe plus its -# dependencies, not a single-file bundle). See docs/packaging.md for the -# rationale behind one-dir, the hiddenimports, and why tokenizer caches -# are never bundled. +# Output lands in dist/NoRefund/ (one-dir build -- an executable plus its +# dependencies, not a single-file bundle). The frontend must already be +# built at src/norefund/web/ (packaging/build.py does this automatically; +# a direct pyinstaller invocation does not). See docs/packaging.md for the +# rationale behind one-dir, the hiddenimports, and why tokenizer caches are +# never bundled. +# +# Linux: WebKitGTK cannot be bundled -- it's a system library with a GObject +# introspection layer, not a Python dependency. Every Linux user needs it +# installed; desktop/app.py's missing_runtime_message() gives them the exact +# install command instead of a traceback. -import sys from pathlib import Path -from PyInstaller.utils.hooks import collect_data_files +from PyInstaller.utils.hooks import collect_data_files, collect_submodules block_cipher = None @@ -22,28 +31,15 @@ datas = [ (str(SRC / "config" / "default_models.yaml"), "norefund/config"), (str(SRC / "config" / "model_architectures.yaml"), "norefund/config"), (str(SRC / "config" / "hardware.yaml"), "norefund/config"), + # The built frontend -- without this the app launches to a blank window. + (str(SRC / "web"), "norefund/web"), ] -datas += [ - (str(icon_path), "norefund/assets/icons") - for icon_path in (SRC / "assets" / "icons").iterdir() - if icon_path.is_file() -] -datas += [ - (str(icon_path), "norefund/assets/icons/providers") - for icon_path in (SRC / "assets" / "icons" / "providers").iterdir() -] -datas += collect_data_files("customtkinter") # reportlab ships its Type 1 font metrics/AFM data as package data, not # importable modules -- static analysis can't see it, so PDF export would # fail to find the standard fonts in a frozen build without this. datas += collect_data_files("reportlab") hiddenimports = [ - # PIL.ImageTk resolves this at runtime via a try/except import to find - # the right _tkinter shared library; PyInstaller's static analysis can't - # see that either, so every CTkImage-bearing widget (i.e. every icon) - # fails with "No module named 'PIL._tkinter_finder'" without this. - "PIL._tkinter_finder", # tiktoken discovers its encoding plugins via pkgutil/namespace-package # scanning at import time, which static analysis can't see. Without # this, the frozen build fails the first time it loads any encoding. @@ -51,18 +47,33 @@ hiddenimports = [ "tiktoken_ext.openai_public", ] -# Drag-and-drop is an optional extra ([project.optional-dependencies] dnd); -# bundle it only if it's actually installed in the build environment. -try: - import tkinterdnd2 # noqa: F401 +# PyGObject looks up gi.overrides. by name for every gi.repository +# import (the same "discovered at runtime, invisible to static analysis" +# problem as tiktoken_ext above) -- Gtk.py and Gdk.py in there are what +# actually wire up display/window init. Without them, `import gi.repository +# .Gtk` "succeeds" but nothing is initialized, and Gdk.Display.get_default() +# silently returns None instead of a real display -- the frozen build +# looks like it launched fine and then crashes the instant it touches the +# screen list. +hiddenimports += collect_submodules("gi.overrides") - datas += collect_data_files("tkinterdnd2") - hiddenimports.append("tkinterdnd2") -except ImportError: - pass +# PyInstaller bundles every pywebview backend it can find on the build +# machine -- an unrelated Qt install would silently double the bundle size +# for a backend the app never uses (desktop/app.py pins gtk on Linux and +# edgechromium on Windows; macOS has no choice of backend). +excludes = [ + "tkinter", + "customtkinter", + "PyQt5", + "PyQt6", + "PySide2", + "PySide6", + "matplotlib", + "pytest", +] a = Analysis( - [str(PROJECT_ROOT / "src" / "norefund" / "main.py")], + [str(SRC / "desktop" / "app.py")], pathex=[str(PROJECT_ROOT / "src")], binaries=[], datas=datas, @@ -70,11 +81,31 @@ a = Analysis( hookspath=[], hooksconfig={}, runtime_hooks=[], - excludes=[], + excludes=excludes, noarchive=False, cipher=block_cipher, ) +# PyInstaller's binary dependency scan follows PyGObject's compiled _gi +# extension straight to the system's libglib/libgobject/libgio and copies +# them in -- but it does NOT bundle libgtk/libgdk themselves (those are a +# declared system dependency, same as WebKitGTK). The result is a split- +# brain bundle: the system's libgtk-3.so loads, but finds the *bundled* +# glib/gobject copy first via the bundle's library search path instead of +# the system copy it was actually built against, and silently fails to get +# a display (Gdk.Display.get_default() returns None) instead of erroring. +# Excluding them here makes the whole glib/gtk stack come from the system, +# consistently, on every Linux install that already satisfies the +# WebKitGTK dependency in missing_runtime_message(). +_glib_stack_prefixes = ( + "libglib-2.0", + "libgobject-2.0", + "libgio-2.0", + "libgmodule-2.0", + "libgirepository", +) +a.binaries = [b for b in a.binaries if not b[0].startswith(_glib_stack_prefixes)] + pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE( diff --git a/pyproject.toml b/pyproject.toml index bb417d7..fd4b3c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.12" authors = [ - { name = "Vikramaditya Khupse", email = "" } + { name = "Vikramaditya Khupse" } ] keywords = ["llm", "tokens", "cost", "ai", "desktop"] classifiers = [ @@ -48,6 +48,19 @@ dnd = [ linux = [ "pygobject>=3.50", ] +macos = [ + # py2app 0.28.9+ hard-fails with "install_requires is no longer + # supported" the moment ANY pyproject.toml with a [project.dependencies] + # table exists anywhere setuptools' own auto-discovery can find one -- + # this project's own, not anything macos_setup.py passes to setup(). + # Upstream bug, open as of this pin: + # https://github.com/ronaldoussoren/py2app/issues/560 + "py2app>=0.28,<0.28.9", + # py2app 0.28.8 still imports pkg_resources directly; setuptools removed + # it entirely in v82.0.0 (the setuptools maintainers' own recommendation + # for anyone still needing it: pin setuptools<81). + "setuptools<81", +] [project.scripts] norefund = "norefund.main:main" diff --git a/src/norefund/desktop/app.py b/src/norefund/desktop/app.py index 6c5f7e2..991c9f3 100644 --- a/src/norefund/desktop/app.py +++ b/src/norefund/desktop/app.py @@ -17,6 +17,43 @@ if "TIKTOKEN_CACHE_DIR" not in os.environ: os.environ["TIKTOKEN_CACHE_DIR"] = str(tiktoken_cache_dir()) +# PyInstaller's own gi runtime hook points GI_TYPELIB_PATH at the frozen +# bundle's gi_typelibs/ dir unconditionally, even when its build-time hook +# found nothing to put there (a PyInstaller/PyGObject version mismatch can +# make that introspection silently fail). That then hides the system's +# real typelibs instead of falling back to them, and GTK/WebKitGTK can't +# find a display at all. WebKitGTK is a system dependency by design (see +# missing_runtime_message() below) and was never meant to be bundled, so +# undo the override before `gi` reads it -- but only when it's genuinely +# empty, so a build where the hook did succeed is left alone. +_typelib_dir = os.environ.get("GI_TYPELIB_PATH") +if _typelib_dir and not (os.path.isdir(_typelib_dir) and os.listdir(_typelib_dir)): + os.environ.pop("GI_TYPELIB_PATH") + + +def _unblock_frozen_bundle() -> None: + """Remove Windows' "downloaded from the internet" tag from our files. + + Extracting a build downloaded from GitHub tags every file with a + Zone.Identifier stream. .NET Framework then refuses to load pythonnet's + DLL from a tagged file, crashing before the app can even start (a + locally built .exe never gets tagged, so this only bites downloaded + builds). Clearing the tag here, before pythonnet loads, avoids that. + """ + if not getattr(sys, "frozen", False): + return + bundle_dir = Path(sys.executable).parent + for file_path in bundle_dir.rglob("*"): + if file_path.is_file(): + try: + os.remove(f"{file_path}:Zone.Identifier") + except OSError: + pass # not tagged, or couldn't remove it -- either way, move on + + +if sys.platform == "win32": + _unblock_frozen_bundle() + import webview # noqa: E402 from norefund.core.paths import bundled_resource # noqa: E402 @@ -88,11 +125,19 @@ def missing_runtime_message() -> str | None: if sys.platform == "win32": try: import clr # noqa: F401 - except ImportError: + except Exception: + # Broad on purpose -- missing WebView2 raises ImportError, but + # pythonnet failing to load its .NET host raises other types + # too. Either way the app can't start; show a message instead + # of a raw traceback. return ( "NoRefund needs the Microsoft Edge WebView2 runtime.\n\n" "Download it from:\n" - " https://developer.microsoft.com/microsoft-edge/webview2/" + " https://developer.microsoft.com/microsoft-edge/webview2/\n\n" + "If that's already installed, this build's files may be " + "blocked as downloaded from the internet -- right-click the " + "extracted folder, choose Properties, and click Unblock " + "(or re-download and extract again)." ) return None diff --git a/tests/conftest.py b/tests/conftest.py index b01e586..153eb34 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,6 @@ from __future__ import annotations import os -import time import pytest @@ -34,45 +33,3 @@ def _skip_unless_cached(encoding_name: str) -> None: f"'{encoding_name}' not cached locally — open Resources in the " "app (or run the CLI hint in TikTokenOfflineError) to cache it" ) - - -@pytest.fixture -def root(): - ctk = pytest.importorskip("customtkinter") - try: - r = ctk.CTk() - r.withdraw() - except Exception as exc: # no display available - pytest.skip(f"no Tk display available: {exc}") - yield r - r.destroy() - - -def _pump(root, ms: int) -> None: - """Run a real Tcl mainloop for `ms` milliseconds. - - Views that do work on a background threading.Thread (Resources, - Compare) call back into Tk via self.after(0, ...) from that other - thread. Tcl only allows cross-thread calls while the main thread is - actually inside mainloop() -- polling with plain root.update() calls - leaves it "not in the main loop" and those callbacks raise - RuntimeError. A real mainloop, stopped with quit(), doesn't have that - restriction. - """ - root.after(ms, root.quit) - root.mainloop() - - -def _pump_until(root, predicate, timeout_ms: int = 3000) -> None: - deadline = time.monotonic() + timeout_ms / 1000 - - def _check() -> None: - if predicate() or time.monotonic() >= deadline: - root.quit() - else: - root.after(20, _check) - - root.after(0, _check) - root.mainloop() - if not predicate(): - pytest.fail("condition not met within timeout") diff --git a/tests/test_compare_view.py b/tests/test_compare_view.py deleted file mode 100644 index 4c14e1f..0000000 --- a/tests/test_compare_view.py +++ /dev/null @@ -1,318 +0,0 @@ -"""Smoke tests for CompareView's run/what-if/export/cancel flows. - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -import threading -from dataclasses import dataclass - -import pytest - -ctk = pytest.importorskip("customtkinter") - -import norefund.gui.compare_view as compare_view_module # noqa: E402 -import norefund.gui.native_dialog as native_dialog_module # noqa: E402 -from norefund.core.compare import CompareReport, ModelComparison # noqa: E402 -from norefund.core.export import comparison_to_csv, comparison_to_markdown # noqa: E402 -from norefund.core.models_registry import ModelInfo # noqa: E402 -from norefund.core.settings import Settings # noqa: E402 -from norefund.gui import theme # noqa: E402 -from norefund.gui.compare_view import CompareView # noqa: E402 -from norefund.gui.theme import COLORS # noqa: E402 - -from .conftest import _pump, _pump_until # noqa: E402 - - -@dataclass -class _FakeShell: - models: list - settings: Settings - - -def _model(id_: str) -> ModelInfo: - return ModelInfo( - id=id_, - display_name=id_, - provider="Test", - tokenizer_backend="tiktoken", - tokenizer_name="cl100k_base", - context_window=8000, - input_price_per_million=1.0, - output_price_per_million=1.0, - ) - - -def _comparison( - model: ModelInfo, - *, - token_count=100, - total_cost=0.01, - error=None, - fits_in_context=True, -) -> ModelComparison: - return ModelComparison( - model=model, - token_count=token_count, - context_usage_pct=1.0, - fits_in_context=fits_in_context, - min_chunks_needed=1, - output_tokens=1024, - input_cost=total_cost / 2, - output_cost=total_cost / 2, - total_cost=total_cost, - tokenizer_is_approximate=False, - error=error, - ) - - -def _shell(models: list[ModelInfo]) -> _FakeShell: - return _FakeShell(models=models, settings=Settings()) - - -def _label_texts(widget) -> list[str]: - texts = [] - for child in widget.winfo_children(): - if isinstance(child, ctk.CTkLabel): - texts.append(child.cget("text")) - texts.extend(_label_texts(child)) - return texts - - -def _images(widget) -> list: - images = [] - for child in widget.winfo_children(): - if isinstance(child, ctk.CTkLabel): - img = child.cget("image") - if img is not None: - images.append(img) - images.extend(_images(child)) - return images - - -def test_run_compare_renders_sorted_results_cheapest_highlighted(root, monkeypatch): - cheap = _model("test:cheap") - pricey = _model("test:pricey") - report = CompareReport( - source_label="12 characters", - results=[ - _comparison(pricey, total_cost=0.05), - _comparison(cheap, total_cost=0.01), - ], - ) - monkeypatch.setattr( - compare_view_module, "compare_text", lambda text, models, output_tokens: report - ) - - view = CompareView(root, _shell([cheap, pricey])) - view._text_box.insert("1.0", "hello world") - view._run_compare() - _pump_until(root, lambda: not view._running) - - cards = view._results_scroll.winfo_children() - assert len(cards) == 2 - assert any("cheapest" in t for t in _label_texts(cards[0])) - assert not any("cheapest" in t for t in _label_texts(cards[1])) - - -def test_cheapest_row_keeps_card_background_with_accent_strip(root, monkeypatch): - # The cheapest row used to recolor the whole card COLORS["primary"], - # which collided with primary_fg text/icons also defaulting to a - # primary-ish color -- a left accent strip should signal "cheapest" - # instead, with every card keeping the same neutral surface. - cheap = _model("test:cheap") - pricey = _model("test:pricey") - report = CompareReport( - source_label="x", - results=[ - _comparison(pricey, total_cost=0.05), - _comparison(cheap, total_cost=0.01), - ], - ) - monkeypatch.setattr( - compare_view_module, "compare_text", lambda text, models, output_tokens: report - ) - - view = CompareView(root, _shell([cheap, pricey])) - view._text_box.insert("1.0", "hello world") - view._run_compare() - _pump_until(root, lambda: not view._running) - - cheapest_card, other_card = view._results_scroll.winfo_children() - assert cheapest_card.cget("fg_color") == COLORS["card"] - assert other_card.cget("fg_color") == COLORS["card"] - - accent_strip = cheapest_card.winfo_children()[0] - assert accent_strip.cget("fg_color") == COLORS["primary"] - - -def test_cheapest_row_fits_icon_stays_destructive_when_not_fitting(root, monkeypatch): - cheap = _model("test:cheap") - report = CompareReport( - source_label="x", - results=[_comparison(cheap, total_cost=0.01, fits_in_context=False)], - ) - monkeypatch.setattr( - compare_view_module, "compare_text", lambda text, models, output_tokens: report - ) - - view = CompareView(root, _shell([cheap])) - view._text_box.insert("1.0", "hello world") - view._run_compare() - _pump_until(root, lambda: not view._running) - - card = view._results_scroll.winfo_children()[0] - images = _images(card) - destructive_icon = theme.icon_image( - "x_circle", size=14, color=COLORS["destructive"] - ) - muted_icon = theme.icon_image("x_circle", size=14, color=COLORS["muted_fg"]) - - assert destructive_icon in images - assert muted_icon not in images - - -def test_run_button_disabled_with_no_models_selected(root, monkeypatch): - model = _model("test:only") - view = CompareView(root, _shell([model])) - - assert view._check_list._on_change == view._sync_run_button_state - assert view._run_btn.cget("state") == "normal" - - for var in view._check_list._vars.values(): - var.set(False) - view._check_list._notify_change() - assert view._run_btn.cget("state") == "disabled" - - for var in view._check_list._vars.values(): - var.set(True) - view._check_list._notify_change() - assert view._run_btn.cget("state") == "normal" - - -def test_output_tokens_entry_flags_invalid_input(root): - model = _model("test:only") - view = CompareView(root, _shell([model])) - - assert view._output_entry.cget("border_color") == COLORS["input_bg"] - - view._output_var.set("not a number") - view._on_output_tokens_change() - assert view._output_entry.cget("border_color") == COLORS["destructive"] - - view._output_var.set("2048") - view._on_output_tokens_change() - assert view._output_entry.cget("border_color") == COLORS["input_bg"] - - -def test_what_if_recomputes_without_recalling_compare_text(root, monkeypatch): - model = _model("test:only") - report = CompareReport( - source_label="x", results=[_comparison(model, token_count=200, total_cost=0.02)] - ) - calls: list[int] = [] - - def fake_compare_text(text, models, output_tokens): - calls.append(output_tokens) - return report - - monkeypatch.setattr(compare_view_module, "compare_text", fake_compare_text) - - view = CompareView(root, _shell([model])) - view._text_box.insert("1.0", "hello") - view._run_compare() - _pump_until(root, lambda: not view._running) - assert calls == [1024] - - view._output_var.set("2048") - view._on_output_tokens_change() - _pump(root, 50) - - assert calls == [1024] - assert view._report.results[0].output_tokens == 2048 - - -def test_error_row_renders_message(root, monkeypatch): - model = _model("test:broken") - message = "Tokenizer unavailable — download it in Resources." - report = CompareReport( - source_label="x", results=[_comparison(model, error=message)] - ) - monkeypatch.setattr( - compare_view_module, "compare_text", lambda text, models, output_tokens: report - ) - - view = CompareView(root, _shell([model])) - view._text_box.insert("1.0", "hello") - view._run_compare() - _pump_until(root, lambda: not view._running) - - cards = view._results_scroll.winfo_children() - assert len(cards) == 1 - assert any(message in t for t in _label_texts(cards[0])) - - -def test_export_csv_and_md_write_expected_content(root, monkeypatch, tmp_path): - model = _model("test:only") - report = CompareReport(source_label="x", results=[_comparison(model)]) - monkeypatch.setattr( - compare_view_module, "compare_text", lambda text, models, output_tokens: report - ) - - view = CompareView(root, _shell([model])) - view._text_box.insert("1.0", "hello") - view._run_compare() - _pump_until(root, lambda: not view._running) - - csv_path = tmp_path / "out.csv" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(csv_path) - ) - view._export_csv() - with csv_path.open(encoding="utf-8", newline="") as f: - assert f.read() == comparison_to_csv(view._report) - - md_path = tmp_path / "out.md" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(md_path) - ) - view._export_md() - assert md_path.read_text(encoding="utf-8") == comparison_to_markdown(view._report) - - pdf_path = tmp_path / "out.pdf" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(pdf_path) - ) - view._export_pdf() - assert pdf_path.read_bytes().startswith(b"%PDF") - - html_path = tmp_path / "out.html" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(html_path) - ) - view._export_html() - html = html_path.read_text(encoding="utf-8") - assert html.startswith("") - assert model.display_name in html - - -def test_destroy_mid_run_does_not_crash(root, monkeypatch): - model = _model("test:only") - proceed = threading.Event() - - def slow_compare(text, models, output_tokens): - proceed.wait(2) - return CompareReport(source_label="x", results=[_comparison(model)]) - - monkeypatch.setattr(compare_view_module, "compare_text", slow_compare) - - view = CompareView(root, _shell([model])) - view._text_box.insert("1.0", "hello") - view._run_compare() - assert view._running is True - view.destroy() - proceed.set() - _pump(root, 300) diff --git a/tests/test_desktop_dto.py b/tests/test_desktop_dto.py index b1ea988..913fd59 100644 --- a/tests/test_desktop_dto.py +++ b/tests/test_desktop_dto.py @@ -41,9 +41,14 @@ def test_nested_dataclasses_are_converted_recursively(): def test_tuples_become_lists_and_paths_become_strings(): - out = to_jsonable({"warnings": ("a", "b"), "p": Path("/tmp/x.pdf")}) + original = Path("/tmp/x.pdf") + out = to_jsonable({"warnings": ("a", "b"), "p": original}) assert out["warnings"] == ["a", "b"] - assert out["p"] == "/tmp/x.pdf" + assert isinstance(out["p"], str) + # Native separators are correct here (Windows users should see + # backslash paths, not a posix path forced on them) -- so compare via + # round-trip rather than hardcoding a posix literal. + assert Path(out["p"]) == original def test_datetimes_become_iso_strings(): diff --git a/tests/test_dnd.py b/tests/test_dnd.py deleted file mode 100644 index ae3b344..0000000 --- a/tests/test_dnd.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from norefund.gui.dnd import parse_dropped_paths - - -def test_parse_dropped_paths_simple(): - assert parse_dropped_paths("/tmp/a.pdf /tmp/b.txt") == [ - Path("/tmp/a.pdf"), - Path("/tmp/b.txt"), - ] - - -def test_parse_dropped_paths_with_spaces_in_braces(): - data = "{/tmp/my file.pdf} /tmp/other.txt" - assert parse_dropped_paths(data) == [ - Path("/tmp/my file.pdf"), - Path("/tmp/other.txt"), - ] - - -def test_parse_dropped_paths_single_braced_path(): - assert parse_dropped_paths("{/tmp/dir with space}") == [ - Path("/tmp/dir with space") - ] - - -def test_parse_dropped_paths_empty(): - assert parse_dropped_paths("") == [] diff --git a/tests/test_fit_check_view.py b/tests/test_fit_check_view.py deleted file mode 100644 index 09542fc..0000000 --- a/tests/test_fit_check_view.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Smoke tests for FitCheckView: recalculation, auto-fill, edge cases. - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -from dataclasses import dataclass - -import pytest - -ctk = pytest.importorskip("customtkinter") - -import norefund.gui.native_dialog as native_dialog_module # noqa: E402 -from norefund.gui.fit_check_view import FitCheckView # noqa: E402 - -from .conftest import _pump # noqa: E402 - - -@dataclass -class _FakeShell: - last_analysis_tokens: int | None = None - - -def test_builds_and_fits_by_default(root): - view = FitCheckView(root, _FakeShell()) - view.pack(fill="both", expand=True) - _pump(root, 30) - - assert view._verdict_text.cget("text") == "Fits on this hardware" - - -def test_zero_context_renders_error_state(root): - view = FitCheckView(root, _FakeShell()) - view.pack(fill="both", expand=True) - view._context_var.set("0") - view._recalculate() - _pump(root, 30) - - assert view._verdict_text.cget("text") == "Can't estimate" - assert "greater than zero" in view._error_label.cget("text") - - -def test_undersized_hardware_does_not_fit(root): - view = FitCheckView(root, _FakeShell()) - view.pack(fill="both", expand=True) - - view._model_dropdown.select("meta:llama-3.1-405b") - view._hw_dropdown.select("nvidia:rtx-3090-24gb") - view._recalculate() - _pump(root, 30) - - assert view._verdict_text.cget("text") == "Does not fit" - assert "over" in view._headroom_pill._value_label.cget("text") - - -def test_on_show_autofills_context_when_not_user_edited(root): - shell = _FakeShell(last_analysis_tokens=None) - view = FitCheckView(root, shell) - view.pack(fill="both", expand=True) - assert view._context_var.get() == "8192" - - shell.last_analysis_tokens = 50_000 - view.on_show() - _pump(root, 30) - - assert view._context_var.get() == "50000" - - -def test_on_show_does_not_clobber_manual_context_edit(root): - shell = _FakeShell(last_analysis_tokens=None) - view = FitCheckView(root, shell) - view.pack(fill="both", expand=True) - - view._context_var.set("12345") - view._on_context_edited() - - shell.last_analysis_tokens = 999_999 - view.on_show() - _pump(root, 30) - - assert view._context_var.get() == "12345" - - -def test_export_pdf_and_html_write_expected_content(root, monkeypatch, tmp_path): - view = FitCheckView(root, _FakeShell()) - view.pack(fill="both", expand=True) - _pump(root, 30) - - pdf_path = tmp_path / "out.pdf" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(pdf_path) - ) - view._export_pdf() - assert pdf_path.read_bytes().startswith(b"%PDF") - - html_path = tmp_path / "out.html" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(html_path) - ) - view._export_html() - html = html_path.read_text(encoding="utf-8") - assert html.startswith("") - assert "Fit Check" in html diff --git a/tests/test_formatting.py b/tests/test_formatting.py deleted file mode 100644 index 93ffb69..0000000 --- a/tests/test_formatting.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Tests for pure GUI formatting helpers.""" - -from norefund.core.models_registry import ModelInfo -from norefund.gui.formatting import fmt_bytes, model_label - -_BASE_KWARGS = dict( - id="test:model", - display_name="Test Model", - provider="TestCo", - tokenizer_backend="tiktoken", - tokenizer_name="cl100k_base", - context_window=1000, - input_price_per_million=0.0, - output_price_per_million=0.0, -) - - -def test_model_label_shows_display_name_and_provider(): - model = ModelInfo(**_BASE_KWARGS) - assert model_label(model) == "Test Model · TestCo" - - -def test_model_label_flags_approximate_tokenizer(): - model = ModelInfo(**_BASE_KWARGS, tokenizer_is_approximate=True) - label = model_label(model) - assert "Test Model" in label - assert "approx" in label.lower() - - -def test_model_label_does_not_flag_real_tokenizer(): - model = ModelInfo(**_BASE_KWARGS, tokenizer_is_approximate=False) - assert "approx" not in model_label(model).lower() - - -def test_fmt_bytes_none_is_dash(): - assert fmt_bytes(None) == "—" - - -def test_fmt_bytes_bytes(): - assert fmt_bytes(500) == "500 B" - - -def test_fmt_bytes_kilobytes(): - assert fmt_bytes(2048) == "2.0 KB" - - -def test_fmt_bytes_megabytes(): - assert fmt_bytes(5 * 1024 * 1024) == "5.0 MB" - - -def test_fmt_bytes_gigabytes(): - assert fmt_bytes(3 * 1024 * 1024 * 1024) == "3.0 GB" diff --git a/tests/test_main_view.py b/tests/test_main_view.py deleted file mode 100644 index eae2b83..0000000 --- a/tests/test_main_view.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Smoke tests for MainView: view registration, first-run banner, shortcuts. - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -import threading - -import pytest - -ctk = pytest.importorskip("customtkinter") - -import norefund.core.resources as resources_module # noqa: E402 -import norefund.gui.main_view as main_view_module # noqa: E402 -import norefund.gui.resources_view as resources_view_module # noqa: E402 -from norefund.core.resources import ResourceReport, TokenizerResource # noqa: E402 -from norefund.core.settings import Settings # noqa: E402 -from norefund.gui.main_view import MainView # noqa: E402 - -from .conftest import _pump, _pump_until # noqa: E402 - - -class _FakeSettingsStore: - def __init__(self, initial: Settings) -> None: - self._settings = initial - self.saved: list[Settings] = [] - - def load(self) -> Settings: - return self._settings - - def save(self, settings: Settings) -> None: - self.saved.append(settings) - self._settings = settings - - -def _empty_report() -> ResourceReport: - return ResourceReport(tokenizers=[], dirs=[], total_tokenizer_bytes=0) - - -def _cached_report() -> ResourceReport: - resource = TokenizerResource( - key="tiktoken:o200k_base", - backend="tiktoken", - name="o200k_base", - model_ids=("openai:gpt-4o",), - is_cached=True, - cache_path=None, - size_bytes=1024, - source_url=None, - ) - return ResourceReport(tokenizers=[resource], dirs=[], total_tokenizer_bytes=1024) - - -def _build_main_view( - root, monkeypatch, *, onboarding_dismissed: bool = True, report=None -) -> MainView: - store = _FakeSettingsStore(Settings(onboarding_dismissed=onboarding_dismissed)) - monkeypatch.setattr(main_view_module, "SettingsStore", lambda: store) - report = report or _cached_report() - # Patched in both places: MainView's onboarding check re-imports this - # name from core.resources at call time, but ResourcesView (built when - # show_view navigates there) bound its own copy at module-import time. - def fake_scan(models): - return report - - monkeypatch.setattr(resources_module, "build_resource_report", fake_scan) - monkeypatch.setattr(resources_view_module, "build_resource_report", fake_scan) - view = MainView(root) - view.pack(fill="both", expand=True) - return view - - -def test_show_view_builds_all_six_views_without_raising(root, monkeypatch): - view = _build_main_view(root, monkeypatch) - for view_id in ( - MainView.VIEW_CALCULATOR, - MainView.VIEW_PARSER, - MainView.VIEW_REGISTRY, - MainView.VIEW_RESOURCES, - MainView.VIEW_COMPARE, - MainView.VIEW_FIT_CHECK, - ): - view.show_view(view_id) - _pump(root, 30) - assert view._current_view == view_id - assert view_id in view._view_cache - - -def test_first_run_banner_shows_when_nothing_cached_and_not_dismissed( - root, monkeypatch -): - view = _build_main_view( - root, monkeypatch, onboarding_dismissed=False, report=_empty_report() - ) - _pump_until(root, lambda: view._banner is not None) - assert view._banner.winfo_exists() - - -def test_first_run_banner_suppressed_when_something_cached(root, monkeypatch): - view = _build_main_view( - root, monkeypatch, onboarding_dismissed=False, report=_cached_report() - ) - _pump(root, 300) - assert view._banner is None - - -def test_first_run_banner_suppressed_when_already_dismissed(root, monkeypatch): - view = _build_main_view( - root, monkeypatch, onboarding_dismissed=True, report=_empty_report() - ) - _pump(root, 300) - assert view._banner is None - - -def test_dismissing_banner_persists_onboarding_dismissed(root, monkeypatch): - store = _FakeSettingsStore(Settings(onboarding_dismissed=False)) - monkeypatch.setattr(main_view_module, "SettingsStore", lambda: store) - monkeypatch.setattr( - resources_module, "build_resource_report", lambda models: _empty_report() - ) - monkeypatch.setattr( - resources_view_module, "build_resource_report", lambda models: _empty_report() - ) - view = MainView(root) - view.pack(fill="both", expand=True) - _pump_until(root, lambda: view._banner is not None) - - view._dismiss_onboarding() - - assert store.saved - assert store.saved[-1].onboarding_dismissed is True - - -def test_ctrl_shortcuts_route_to_correct_view(root, monkeypatch): - view = _build_main_view(root, monkeypatch) - # Key events only reach bindings on a window that actually has keyboard - # focus -- the root fixture starts withdrawn, so make it visible first. - root.deiconify() - root.focus_force() - _pump(root, 50) - - expected = [ - ("1", MainView.VIEW_CALCULATOR), - ("2", MainView.VIEW_PARSER), - ("3", MainView.VIEW_COMPARE), - ("4", MainView.VIEW_REGISTRY), - ("5", MainView.VIEW_RESOURCES), - ("6", MainView.VIEW_FIT_CHECK), - ] - for key, view_id in expected: - root.event_generate(f"") - _pump(root, 30) - assert view._current_view == view_id - - -def test_escape_sets_active_view_cancel_event(root, monkeypatch): - view = _build_main_view(root, monkeypatch) - fake_view = type("FakeView", (), {"cancel_event": threading.Event()})() - view._view_cache[MainView.VIEW_CALCULATOR] = fake_view - view._current_view = MainView.VIEW_CALCULATOR - - view._cancel_active_work() - - assert fake_view.cancel_event.is_set() diff --git a/tests/test_native_dialog.py b/tests/test_native_dialog.py deleted file mode 100644 index 3233e80..0000000 --- a/tests/test_native_dialog.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Tests for native_dialog.py's zenity polling and extension-append logic.""" - -from __future__ import annotations - -import norefund.gui.native_dialog as native_dialog - - -class _FakeProcess: - """Minimal Popen stand-in: `poll_sequence` is popped once per poll() - call (None while "running", an exit code once "finished"), matching - real Popen semantics closely enough to drive _run_zenity's loop.""" - - def __init__(self, poll_sequence, stdout="", stderr="", returncode=0): - self._poll_sequence = list(poll_sequence) - self.stdout = stdout - self.stderr = stderr - self.returncode = returncode - - def poll(self): - return self._poll_sequence.pop(0) if self._poll_sequence else self.returncode - - def communicate(self): - return self.stdout, self.stderr - - -def _patch_zenity_process(monkeypatch, process: _FakeProcess) -> None: - monkeypatch.setattr(native_dialog.subprocess, "Popen", lambda *a, **kw: process) - monkeypatch.setattr(native_dialog.tk, "_default_root", None) - monkeypatch.setattr(native_dialog.time, "sleep", lambda _s: None) - - -def test_run_zenity_polls_until_process_exits_and_returns_completed_process( - monkeypatch, -): - process = _FakeProcess(poll_sequence=[None, None, 0], stdout="chosen.txt\n") - _patch_zenity_process(monkeypatch, process) - - result = native_dialog._run_zenity(["zenity", "--file-selection"]) - - assert result.returncode == 0 - assert result.stdout == "chosen.txt\n" - assert process._poll_sequence == [] # polled exactly 3 times, exhausting it - - -def test_ask_save_file_appends_missing_extension_on_zenity_path(monkeypatch): - monkeypatch.setattr(native_dialog, "_zenity_available", lambda: True) - _patch_zenity_process( - monkeypatch, _FakeProcess(poll_sequence=[0], stdout="report\n") - ) - - result = native_dialog.ask_save_file( - defaultextension=".pdf", filetypes=[("PDF", "*.pdf")] - ) - - assert result == "report.pdf" - - -def test_ask_save_file_keeps_existing_extension_on_zenity_path(monkeypatch): - monkeypatch.setattr(native_dialog, "_zenity_available", lambda: True) - _patch_zenity_process( - monkeypatch, _FakeProcess(poll_sequence=[0], stdout="report.pdf\n") - ) - - result = native_dialog.ask_save_file( - defaultextension=".pdf", filetypes=[("PDF", "*.pdf")] - ) - - assert result == "report.pdf" - - -def test_ask_save_file_returns_empty_string_on_cancel(monkeypatch): - monkeypatch.setattr(native_dialog, "_zenity_available", lambda: True) - _patch_zenity_process( - monkeypatch, - _FakeProcess(poll_sequence=[native_dialog._USER_CANCELLED], returncode=1), - ) - - result = native_dialog.ask_save_file( - defaultextension=".pdf", filetypes=[("PDF", "*.pdf")] - ) - - assert result == "" diff --git a/tests/test_parser_view.py b/tests/test_parser_view.py deleted file mode 100644 index 8b1b039..0000000 --- a/tests/test_parser_view.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Smoke tests for ParserView's report export (PDF + HTML). - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -import pytest - -ctk = pytest.importorskip("customtkinter") - -import norefund.gui.native_dialog as native_dialog_module # noqa: E402 -from norefund.core.models_registry import ModelInfo # noqa: E402 -from norefund.core.service import AnalysisResult # noqa: E402 -from norefund.core.settings import Settings # noqa: E402 -from norefund.gui.parser_view import LogsPanel, ParserView, ResultsTable # noqa: E402 -from norefund.gui.theme import COLORS # noqa: E402 - -from .conftest import _pump # noqa: E402 - - -@dataclass -class _FakeShell: - models: list - settings: Settings - - def update_header_count(self, _count: int) -> None: - pass - - def update_last_analysis_tokens(self, _tokens: int) -> None: - pass - - -def _model() -> ModelInfo: - return ModelInfo( - id="test:only", - display_name="Test Model", - provider="Test", - tokenizer_backend="tiktoken", - tokenizer_name="cl100k_base", - context_window=8000, - input_price_per_million=1.0, - output_price_per_million=1.0, - ) - - -def _result(model: ModelInfo) -> AnalysisResult: - return AnalysisResult( - file_path="doc.txt", - model_id=model.id, - char_count=100, - word_count=20, - token_count=30, - context_window=model.context_window, - context_usage_pct=0.4, - fits_in_context=True, - min_chunks_needed=1, - estimated_input_cost=0.00003, - ) - - -def test_export_pdf_and_html_write_expected_content(root, monkeypatch, tmp_path): - model = _model() - view = ParserView(root, _FakeShell(models=[model], settings=Settings())) - view.pack(fill="both", expand=True) - _pump(root, 30) - - view._results = [_result(model)] - - pdf_path = tmp_path / "out.pdf" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(pdf_path) - ) - view._export_pdf() - assert pdf_path.read_bytes().startswith(b"%PDF") - - html_path = tmp_path / "out.html" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(html_path) - ) - view._export_html() - html = html_path.read_text(encoding="utf-8") - assert html.startswith("") - assert "doc.txt" in html - - -def test_export_is_noop_with_no_results(root, monkeypatch, tmp_path): - model = _model() - view = ParserView(root, _FakeShell(models=[model], settings=Settings())) - view.pack(fill="both", expand=True) - _pump(root, 30) - - pdf_path = tmp_path / "out.pdf" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(pdf_path) - ) - view._export_pdf() - assert not pdf_path.exists() - - -def test_row_hover_persists_across_child_widgets(root): - # CTkFrame/CTkLabel redirect .bind() to an internal canvas, which - # event_generate() does NOT do automatically -- synthetic events have - # to target that canvas directly to reach the binding. - table = ResultsTable(root) - table.pack() - table.set_results([_result(_model())]) - _pump(root, 20) - - row = table._row_frames[0] - assert row.cget("fg_color") == "transparent" - - row._canvas.event_generate("") - _pump(root, 20) - assert row.cget("fg_color") == COLORS["muted"] - - # Simulate the pointer moving from the row onto one of its own cell - # widgets: Tk delivers to the parent the instant this happens - # (NotifyInferior) -- the highlight must not clear here, because the - # child was bound too (not just the row frame). - child = row.winfo_children()[1] - child._canvas.event_generate("") - _pump(root, 20) - assert row.cget("fg_color") == COLORS["muted"] - - row._canvas.event_generate("") - _pump(root, 20) - assert row.cget("fg_color") == "transparent" - - -def test_add_paths_dedupes_by_resolved_path(root, tmp_path): - model = _model() - view = ParserView(root, _FakeShell(models=[model], settings=Settings())) - view.pack(fill="both", expand=True) - _pump(root, 30) - - f = tmp_path / "doc.txt" - f.write_text("hello") - - view._add_paths([f]) - view._add_paths([f]) - view._add_paths([Path(str(f))]) # a different Path object, same file - - assert view._paths == [f] - - -def test_clear_shows_cleared_message_in_status_bar(root, tmp_path): - model = _model() - view = ParserView(root, _FakeShell(models=[model], settings=Settings())) - view.pack(fill="both", expand=True) - _pump(root, 30) - - f = tmp_path / "doc.txt" - f.write_text("hello") - view._add_paths([f]) - view._refresh_file_strip() - - view._clear() - _pump(root, 20) - - assert "Cleared" in view._status_left.cget("text") - assert "1" in view._status_left.cget("text") - assert view._status_bar.winfo_manager() == "pack" - - -def test_clear_with_nothing_selected_does_not_show_status_bar(root): - model = _model() - view = ParserView(root, _FakeShell(models=[model], settings=Settings())) - view.pack(fill="both", expand=True) - _pump(root, 30) - - view._clear() - _pump(root, 20) - - assert view._status_bar.winfo_manager() == "" - - -def test_logs_panel_refresh_reapplies_tag_colors_after_theme_toggle(root): - panel = LogsPanel(root) - panel.pack() - _pump(root, 20) - panel.refresh() - _pump(root, 20) - original = panel._textbox.tag_cget("ERROR", "foreground") - was_dark = ctk.get_appearance_mode() == "Dark" - - try: - ctk.set_appearance_mode("Light" if was_dark else "Dark") - panel.refresh() - _pump(root, 20) - assert panel._textbox.tag_cget("ERROR", "foreground") != original - finally: - ctk.set_appearance_mode("Dark" if was_dark else "Light") diff --git a/tests/test_registry_view_loading.py b/tests/test_registry_view_loading.py deleted file mode 100644 index bd98563..0000000 --- a/tests/test_registry_view_loading.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Smoke tests for RegistryView's loading-text / atomic-reveal flow. - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -from dataclasses import dataclass - -import pytest - -ctk = pytest.importorskip("customtkinter") - -from norefund.core.models_registry import list_models # noqa: E402 -from norefund.gui.registry_view import RegistryView # noqa: E402 - -from .conftest import _pump, _pump_until # noqa: E402 - -# LoadingOverlay.hide() fades its text color out over ~150ms (motion.py's -# fade_text_color) before calling place_forget(), so the label is still -# placed for a moment after `_loading` flips False -- pump past the fade -# before asserting it's gone. -_FADE_SETTLE_MS = 250 - - -@dataclass -class _FakeShell: - models: list - - -def test_loading_text_shows_first_then_atomic_reveal(root): - shell = _FakeShell(models=list_models()) - view = RegistryView(root, shell) - - relayout_calls: list[list] = [] - original_relayout = view._relayout - - def spy_relayout(force=False): - relayout_calls.append(list(view._cards)) - return original_relayout(force) - - view._relayout = spy_relayout - - # Right after construction (before any event-loop tick), loading must - # already be showing and no cards built yet -- _start_loading() sets - # this up synchronously in __init__. - assert view._loading is True - assert view._cards == [] - assert view._loading_overlay._label.place_info() != {} - - _pump_until(root, lambda: not view._loading) - _pump(root, _FADE_SETTLE_MS) - - assert view._loading_overlay._label.place_info() == {} - assert len(view._cards) == len(shell.models) - for _model, card in view._cards: - assert card.grid_info() != {} - - # Cards only ever get gridded once all of them are built -- there must - # be no _relayout call while some cards exist and others don't (that - # would mean a partial/progressive reveal, which is the jank we removed). - assert relayout_calls, "expected at least one _relayout call" - for snapshot in relayout_calls: - assert len(snapshot) in (0, len(shell.models)) - - final_cards = [card for _model, card in relayout_calls[-1]] - assert len(final_cards) == len(shell.models) - - -def test_configure_on_a_card_does_not_trigger_relayout(root): - # Tk's bindtags put the scroll frame's pathname in every descendant - # card's bindtags too, so a plain bind() fires on each card's own - # Configure as well as the scroll frame's -- the handler must filter - # to the scroll frame's own Configure only. - shell = _FakeShell(models=list_models()) - view = RegistryView(root, shell) - _pump_until(root, lambda: not view._loading) - _pump(root, _FADE_SETTLE_MS) - assert view._cards, "expected cards to have loaded" - - relayout_calls: list = [] - view._relayout = lambda force=False: relayout_calls.append(force) - - _, first_card = view._cards[0] - first_card.event_generate("") - _pump(root, 20) - assert relayout_calls == [] - - view._scroll.event_generate("") - _pump(root, 20) - assert relayout_calls == [False] - - -def test_navigating_away_mid_load_does_not_crash(root): - shell = _FakeShell(models=list_models()) - view = RegistryView(root, shell) - assert view._loading is True - # Destroy before the event loop has ticked at all, so the next-card - # callback scheduled in _start_loading() is still pending in Tcl's - # queue when the widget goes away. - view.destroy() - # That pending after() callback must not raise once its target widget - # is gone. - _pump(root, 300) - - -def test_zero_models_does_not_crash(root): - shell = _FakeShell(models=[]) - view = RegistryView(root, shell) - _pump_until(root, lambda: not view._loading) - assert view._cards == [] diff --git a/tests/test_resources_view.py b/tests/test_resources_view.py deleted file mode 100644 index 17b2c45..0000000 --- a/tests/test_resources_view.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Smoke tests for ResourcesView's scan/download/cancel/error flows. - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -import threading -from dataclasses import dataclass -from pathlib import Path - -import pytest - -ctk = pytest.importorskip("customtkinter") - -import norefund.gui.resources_view as resources_view_module # noqa: E402 -from norefund.core.resources import ( # noqa: E402 - DownloadCancelled, - ManagedDir, - ResourceDownloadError, - ResourceReport, - TokenizerResource, -) -from norefund.gui import formatting # noqa: E402 -from norefund.gui.resources_view import ResourcesView, _TokenizerRow # noqa: E402 - -from .conftest import _pump, _pump_until # noqa: E402 - - -@dataclass -class _FakeShell: - models: list - - -def _make_resource( - key="tiktoken:o200k_base", *, cached=False, notes=None -) -> TokenizerResource: - return TokenizerResource( - key=key, - backend="tiktoken", - name="o200k_base", - model_ids=("openai:gpt-4o",), - is_cached=cached, - cache_path=Path("/fake/cache/o200k_base") if cached else None, - size_bytes=1024 if cached else None, - source_url="https://example.invalid/o200k_base.tiktoken", - notes=notes, - ) - - -def _make_report(*resources: TokenizerResource) -> ResourceReport: - total = sum(r.size_bytes or 0 for r in resources) - return ResourceReport( - tokenizers=list(resources), - dirs=[ - ManagedDir( - label="Config", - path=Path("/fake/config"), - exists=True, - size_bytes=42, - file_count=1, - ) - ], - total_tokenizer_bytes=total, - ) - - -def _action_buttons(row: _TokenizerRow) -> list[ctk.CTkButton]: - return [ - w for w in row._action_area.winfo_children() if isinstance(w, ctk.CTkButton) - ] - - -def _patch_scan(monkeypatch, result) -> None: - """Point ResourcesView's scan at a fake build_resource_report. - - `result` may be a ResourceReport (returned as-is) or any callable - taking `models` (used directly), so slow/blocking fakes work too. - """ - fn = result if callable(result) else (lambda models: result) - monkeypatch.setattr(resources_view_module, "build_resource_report", fn) - - -def test_scan_renders_rows_and_stat_pills(root, monkeypatch): - report = _make_report( - _make_resource(cached=True), - _make_resource(key="tiktoken:cl100k_base"), - ) - _patch_scan(monkeypatch, report) - - view = ResourcesView(root, _FakeShell(models=[])) - _pump_until(root, lambda: not view._loading) - - assert set(view._rows) == {r.key for r in report.tokenizers} - assert view._downloaded_pill._value_label.cget("text") == "1 of 2" - assert view._size_pill._value_label.cget("text") == formatting.fmt_bytes( - report.total_tokenizer_bytes - ) - - -def test_download_progress_flips_row_to_cached_and_updates_pills(root, monkeypatch): - resource = _make_resource(cached=False) - report = _make_report(resource) - _patch_scan(monkeypatch, report) - - progress_calls: list[tuple[int, int | None]] = [] - - def fake_download(res, *, on_progress=None, cancel_event=None): - if on_progress is not None: - on_progress(512, 1024) - progress_calls.append((512, 1024)) - return _make_resource(key=res.key, cached=True) - - monkeypatch.setattr(resources_view_module, "download_tokenizer", fake_download) - - view = ResourcesView(root, _FakeShell(models=[])) - _pump_until(root, lambda: not view._loading) - - view.start_download(view._report.tokenizers[0]) - _pump_until(root, lambda: not view.is_busy()) - - assert progress_calls - row = view._rows[resource.key] - buttons = _action_buttons(row) - assert any("Open folder" in b.cget("text") for b in buttons) - assert view._downloaded_pill._value_label.cget("text") == "1 of 1" - - -def test_cancel_resets_row_to_download_state(root, monkeypatch): - resource = _make_resource(cached=False) - report = _make_report(resource) - _patch_scan(monkeypatch, report) - - def fake_download(res, *, on_progress=None, cancel_event=None): - raise DownloadCancelled() - - monkeypatch.setattr(resources_view_module, "download_tokenizer", fake_download) - - view = ResourcesView(root, _FakeShell(models=[])) - _pump_until(root, lambda: not view._loading) - - view.start_download(view._report.tokenizers[0]) - _pump_until(root, lambda: not view.is_busy()) - - row = view._rows[resource.key] - labels = [b.cget("text") for b in _action_buttons(row)] - assert any("Download" in text for text in labels) - assert not any("Cancel" in text for text in labels) - - -def test_download_error_renders_inline(root, monkeypatch): - resource = _make_resource(cached=False) - report = _make_report(resource) - _patch_scan(monkeypatch, report) - - def fake_download(res, *, on_progress=None, cancel_event=None): - raise ResourceDownloadError("network unreachable") - - monkeypatch.setattr(resources_view_module, "download_tokenizer", fake_download) - - view = ResourcesView(root, _FakeShell(models=[])) - _pump_until(root, lambda: not view._loading) - - view.start_download(view._report.tokenizers[0]) - _pump_until(root, lambda: not view.is_busy()) - - row = view._rows[resource.key] - assert row._notes_label.cget("text") == "network unreachable" - assert row._notes_label.grid_info() != {} - - -def test_gated_repo_note_shows_open_page_link(root, monkeypatch): - _patch_scan(monkeypatch, _make_report()) - view = ResourcesView(root, _FakeShell(models=[])) - _pump_until(root, lambda: not view._loading) - - gated = TokenizerResource( - key="hf:meta-llama/Meta-Llama-3-8B", - backend="hf", - name="Meta-Llama-3-8B", - model_ids=("meta:llama-3-8b",), - is_cached=False, - cache_path=None, - size_bytes=None, - source_url="https://huggingface.co/meta-llama/Meta-Llama-3-8B", - notes="Gated repo — requires HF account/token", - ) - row = _TokenizerRow( - view._scroll, - gated, - is_downloading=view.is_downloading, - is_busy=view.is_busy, - start_download=view.start_download, - cancel_download=view.cancel_download, - ) - assert row._notes_label.cget("text").endswith("open page") - assert row._notes_label.cget("cursor") == "hand2" - - -def test_destroy_mid_scan_does_not_crash(root, monkeypatch): - proceed = threading.Event() - - def slow_report(models): - proceed.wait(2) - return _make_report() - - _patch_scan(monkeypatch, slow_report) - view = ResourcesView(root, _FakeShell(models=[])) - assert view._loading is True - view.destroy() - proceed.set() - _pump(root, 300) diff --git a/tests/test_tabbar.py b/tests/test_tabbar.py deleted file mode 100644 index 3fd4d79..0000000 --- a/tests/test_tabbar.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Tests for the shared TabBar widget (used by Parser and Compare). - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -import pytest - -ctk = pytest.importorskip("customtkinter") - -from norefund.gui.theme import COLORS # noqa: E402 -from norefund.gui.widgets import TabBar # noqa: E402 - -from .conftest import _pump # noqa: E402 - - -def test_active_tab_starts_highlighted(root): - tab_bar = TabBar( - root, [("a", "Alpha"), ("b", "Bravo")], "a", on_change=lambda _t: None - ) - tab_bar.pack() - _pump(root, 20) - - assert tab_bar._buttons["a"].cget("text_color") == COLORS["primary"] - assert tab_bar._buttons["b"].cget("text_color") == COLORS["muted_fg"] - - -def test_clicking_a_tab_restyles_and_notifies(root): - calls: list[str] = [] - tab_bar = TabBar( - root, [("a", "Alpha"), ("b", "Bravo")], "a", on_change=calls.append - ) - tab_bar.pack() - _pump(root, 20) - - tab_bar._buttons["b"].invoke() - _pump(root, 20) - - assert calls == ["b"] - assert tab_bar._buttons["b"].cget("text_color") == COLORS["primary"] - assert tab_bar._buttons["a"].cget("text_color") == COLORS["muted_fg"] diff --git a/tests/test_theme_contrast.py b/tests/test_theme_contrast.py deleted file mode 100644 index 3ad4444..0000000 --- a/tests/test_theme_contrast.py +++ /dev/null @@ -1,52 +0,0 @@ -"""WCAG AA contrast-ratio checks for color combos GUI_REVIEW.md flagged as -failing (< 4.5:1 for text): the primary-button label, and every provider -badge's text-on-tint. Uses the same blend() math the app itself uses to -build these colors, so a color-token change that breaks contrast again -fails a test instead of only being caught by eye.""" - -from __future__ import annotations - -from norefund.gui.formatting import _hex_to_rgb, blend -from norefund.gui.theme import COLORS, PROVIDER_COLORS - -_AA_TEXT_MIN = 4.5 - - -def _luminance(hex_color: str) -> float: - r, g, b = _hex_to_rgb(hex_color) - - def linear(channel: int) -> float: - c = channel / 255 - return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 - - return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) - - -def _contrast_ratio(hex_a: str, hex_b: str) -> float: - lum_a, lum_b = _luminance(hex_a), _luminance(hex_b) - lighter, darker = max(lum_a, lum_b), min(lum_a, lum_b) - return (lighter + 0.05) / (darker + 0.05) - - -def test_primary_button_label_clears_aa_in_both_modes(): - primary = COLORS["primary"] - primary_fg = COLORS["primary_fg"] - for light_or_dark in (0, 1): - ratio = _contrast_ratio(primary[light_or_dark], primary_fg[light_or_dark]) - assert ratio >= _AA_TEXT_MIN, (light_or_dark, ratio) - - -def test_provider_badges_clear_aa_in_both_modes(): - # Mirrors ProviderBadge's own construction: a light-alpha blend for the - # background, blended toward black (light mode) / white (dark mode) by - # the same fraction for the text. - text_blend = 0.4 - bg_alpha = (0.13, 0.18) - card = COLORS["card"] - - for provider, accent in PROVIDER_COLORS.items(): - for mode in (0, 1): - bg = blend(accent, card[mode], bg_alpha[mode]) - text = blend("#000000" if mode == 0 else "#ffffff", accent, text_blend) - ratio = _contrast_ratio(text, bg) - assert ratio >= _AA_TEXT_MIN, (provider, mode, ratio) diff --git a/tests/test_widgets.py b/tests/test_widgets.py deleted file mode 100644 index 4a1cfd4..0000000 --- a/tests/test_widgets.py +++ /dev/null @@ -1,364 +0,0 @@ -"""Tests for the generic DropdownButton/DropdownPopover widget. - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -import pytest - -ctk = pytest.importorskip("customtkinter") - -from norefund.gui import theme # noqa: E402 -from norefund.gui.widgets import ( # noqa: E402 - DropdownButton, - DropdownItem, - _popover_geometry, -) - -from .conftest import _pump # noqa: E402 - -_ITEMS = [ - DropdownItem(value="a", label="Alpha"), - DropdownItem(value="b", label="Bravo"), - DropdownItem(value="c", label="Charlie"), -] - - -def test_shows_initial_selected_label(root): - button = DropdownButton(root, _ITEMS, "b", on_select=lambda _v: None) - button.pack() - _pump(root, 20) - assert button._text_label.cget("text") == "Bravo" - - -def test_select_updates_display_without_firing_callback(root): - calls: list[str] = [] - button = DropdownButton(root, _ITEMS, "a", on_select=calls.append) - button.pack() - - button.select("c") - - assert button.selected_value() == "c" - assert button._text_label.cget("text") == "Charlie" - assert calls == [] - - -def test_toggle_opens_popover_matching_trigger_width(root): - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.configure(width=300) - button.pack() - _pump(root, 20) - - button._toggle() - _pump(root, 20) - - assert button._popover is not None - assert button._popover.winfo_exists() - popover_width = int(button._popover.geometry().split("x")[0]) - assert popover_width == max(button.winfo_width(), 220) - - button._popover.destroy() - - -def test_picking_a_row_fires_callback_updates_selection_and_closes(root): - calls: list[str] = [] - button = DropdownButton(root, _ITEMS, "a", on_select=calls.append) - button.pack() - _pump(root, 20) - - button._toggle() - _pump(root, 20) - popover = button._popover - popover._pick("c") - _pump(root, 20) - - assert calls == ["c"] - assert button.selected_value() == "c" - assert not popover.winfo_exists() - assert button._popover is None - - -def test_toggle_twice_closes_without_picking(root): - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - _pump(root, 20) - - button._toggle() - _pump(root, 20) - popover = button._popover - button._toggle() - _pump(root, 20) - - assert not popover.winfo_exists() - assert button._popover is None - - -def test_hover_and_selected_row_colors(root): - # Rows are plain tk.Frame (not CTkFrame) for construction speed, so - # bindings land directly on the row -- no internal-canvas indirection. - is_dark = ctk.get_appearance_mode() == "Dark" - resting = theme.resolve("popover", is_dark) - selected_resting = theme.resolve("sidebar_accent", is_dark) - hover = theme.resolve("popover_hover", is_dark) - - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - _pump(root, 20) - button._toggle() - _pump(root, 20) - popover = button._popover - - selected_row = popover.rows["a"] - other_row = popover.rows["b"] - assert str(selected_row.cget("bg")) == selected_resting - assert str(other_row.cget("bg")) == resting - - other_row.event_generate("") - _pump(root, 20) - assert str(other_row.cget("bg")) == hover - - other_row.event_generate("") - _pump(root, 20) - assert str(other_row.cget("bg")) == resting - - # Hovering the selected row itself must not lose its selected tint. - selected_row.event_generate("") - _pump(root, 20) - assert str(selected_row.cget("bg")) == hover - selected_row.event_generate("") - _pump(root, 20) - assert str(selected_row.cget("bg")) == selected_resting - - popover.destroy() - - -def test_popover_follows_window_on_resize(root): - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - root.geometry("500x400+0+0") - _pump(root, 20) - - button._toggle() - _pump(root, 20) - popover = button._popover - before = popover.geometry() - - root.geometry("900x700+50+50") - _pump(root, 20) - after = popover.geometry() - - assert after != before - popover.destroy() - - -def test_popover_matches_trigger_width_at_hidpi_scaling(root): - # Regression: CTkToplevel.geometry() re-multiplies width/height (but not - # x/y) by the window's scaling factor -- at scaling 1.0 that's a no-op, - # which is why the plain width test above didn't catch this. Force a - # HiDPI-like scaling factor and confirm the popover still renders at the - # trigger's real device-pixel width, not scaled again on top of it. - ctk.ScalingTracker.set_widget_scaling(1.5) - ctk.ScalingTracker.set_window_scaling(1.5) - try: - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.configure(width=300) - button.pack() - _pump(root, 20) - - button._toggle() - _pump(root, 30) - - expected = max(button.winfo_width(), 220) - assert abs(button._popover.winfo_width() - expected) <= 1 - button._popover.destroy() - finally: - ctk.ScalingTracker.set_widget_scaling(1.0) - ctk.ScalingTracker.set_window_scaling(1.0) - _pump(root, 20) - - -def test_popover_geometry_flips_upward_near_bottom_of_screen(root, monkeypatch): - # Drives _popover_geometry directly rather than through a real toggle: - # under a WM-less Xvfb (no window manager to honor absolute placement - # requests for a plain, non-override-redirect toplevel), `root`/`button` - # winfo_rooty() stays pinned at 0 regardless of any geometry() call, so - # a real end-to-end version of this test can't reliably force "anchor - # near the bottom of the screen" -- monkeypatching the winfo methods - # this function actually reads exercises the same decision directly. - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - _pump(root, 20) - monkeypatch.setattr(button, "winfo_rooty", lambda: 900) - monkeypatch.setattr(button, "winfo_screenheight", lambda: 1000) - - geometry = _popover_geometry(button, root, row_count=3, row_height=42) - - y = int(geometry.rsplit("+", 1)[-1]) - assert y < 900 # opened above the anchor, not below it off-screen - - -def test_unmap_closes_open_popovers(root): - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - _pump(root, 20) - button._toggle() - _pump(root, 20) - assert button._popover is not None - - root.event_generate("") - _pump(root, 20) - - assert button._popover is None - - -def test_focus_out_closes_open_popovers_when_app_loses_focus(root, monkeypatch): - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - _pump(root, 20) - button._toggle() - _pump(root, 20) - assert button._popover is not None - - monkeypatch.setattr(root, "focus_get", lambda: None) - root.event_generate("") - _pump(root, 20) - - assert button._popover is None - - -def test_close_all_closes_every_open_popover(root): - b1 = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - b2 = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - b1.pack() - b2.pack() - _pump(root, 20) - - b1._toggle() - b2._toggle() - _pump(root, 20) - assert b1._popover is not None - assert b2._popover is not None - - DropdownButton.close_all() - _pump(root, 20) - - assert b1._popover is None - assert b2._popover is None - - -# Keyboard-navigation tests below need a *visible* (not withdrawn) root: -# real KeyPress dispatch follows actual X input focus, and under a -# window-manager-less Xvfb (this test environment), a withdrawn toplevel -# can never hold real focus, so focus_set() silently no-ops and no -# KeyPress ever arrives. A visible root's own focus_force() (bypassing -# WM cooperation entirely, since there's no WM here to cooperate with) -# does work, which is what these use instead of the shared `root` fixture. -@pytest.fixture -def visible_root(): - try: - r = ctk.CTk() - except Exception as exc: # no display available - pytest.skip(f"no Tk display available: {exc}") - yield r - r.destroy() - - -def test_return_and_space_open_the_dropdown(visible_root): - root = visible_root - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - root.update() - - button._canvas.focus_force() - root.update() - button._canvas.event_generate("") - root.update() - - assert button._popover is not None - button._popover.destroy() - root.update() - - button._canvas.event_generate("") - root.update() - assert button._popover is not None - - -def test_focus_in_and_out_toggle_the_border_color(visible_root): - root = visible_root - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - root.update() - rest_color = button.cget("border_color") - - button._canvas.focus_force() - root.update() - assert button.cget("border_color") == theme.COLORS["primary"] - - button._canvas.event_generate("") - root.update() - assert button.cget("border_color") == rest_color - - -def test_arrow_keys_move_highlight_and_return_selects_and_closes(visible_root): - root = visible_root - picks: list[str] = [] - button = DropdownButton(root, _ITEMS, "a", on_select=picks.append) - button.pack() - root.update() - - button._canvas.focus_force() - root.update() - button._canvas.event_generate("") - root.update() - popover = button._popover - assert popover is not None - popover.focus_force() - root.update() - - assert popover._highlighted == "a" - popover.event_generate("") - root.update() - assert popover._highlighted == "b" - popover.event_generate("") - root.update() - assert popover._highlighted == "c" - popover.event_generate("") - root.update() - assert popover._highlighted == "b" - - popover.event_generate("") - root.update() - - assert picks == ["b"] - assert button.selected_value() == "b" - assert button._popover is None - # Focus returns to the trigger so Tab/Return keep working right after. - assert root.focus_get() is button._canvas - - -def test_escape_closes_popover_without_selecting(visible_root): - root = visible_root - picks: list[str] = [] - button = DropdownButton(root, _ITEMS, "a", on_select=picks.append) - button.pack() - root.update() - - button._canvas.focus_force() - root.update() - button._canvas.event_generate("") - root.update() - popover = button._popover - popover.focus_force() - root.update() - - popover.event_generate("") - root.update() - popover.event_generate("") - root.update() - - assert picks == [] - assert button.selected_value() == "a" - assert button._popover is None