commit local changes.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"""Shared fixtures for the sjaur test suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from sjaur.metadata import Package
|
||||
from sjaur.search import SearchEngine
|
||||
|
||||
|
||||
def make_package(name: str, **extra) -> Package:
|
||||
"""Build a Package with sensible defaults; override anything via kwargs."""
|
||||
return Package(
|
||||
name=name,
|
||||
version=extra.get("version", "1.0.0-1"),
|
||||
description=extra.get("description", ""),
|
||||
maintainer=extra.get("maintainer", ""),
|
||||
votes=extra.get("votes", 0),
|
||||
popularity=extra.get("popularity", 0.0),
|
||||
out_of_date=extra.get("out_of_date"),
|
||||
last_modified=extra.get("last_modified", 0),
|
||||
first_submitted=extra.get("first_submitted", 0),
|
||||
url=extra.get("url", ""),
|
||||
url_path=extra.get("url_path", ""),
|
||||
package_base=extra.get("package_base", name),
|
||||
keywords=extra.get("keywords", ()),
|
||||
depends=extra.get("depends", ()),
|
||||
make_depends=extra.get("make_depends", ()),
|
||||
check_depends=extra.get("check_depends", ()),
|
||||
opt_depends=extra.get("opt_depends", ()),
|
||||
provides=extra.get("provides", ()),
|
||||
conflicts=extra.get("conflicts", ()),
|
||||
replaces=extra.get("replaces", ()),
|
||||
groups=extra.get("groups", ()),
|
||||
licenses=extra.get("licenses", ()),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine() -> SearchEngine:
|
||||
"""A small, representative snapshot used by most search tests."""
|
||||
packages = [
|
||||
make_package(
|
||||
"firefox",
|
||||
version="130.0-1",
|
||||
description="Standalone web browser from mozilla.org",
|
||||
maintainer="jonas",
|
||||
votes=3500,
|
||||
keywords=("browser", "web"),
|
||||
),
|
||||
make_package(
|
||||
"firefox-nightly",
|
||||
description="Firefox Web Browser — nightly build",
|
||||
maintainer="john_doe",
|
||||
votes=120,
|
||||
),
|
||||
make_package(
|
||||
"firefox-wayland",
|
||||
description="Firefox with Wayland patches",
|
||||
maintainer="max_mustermann",
|
||||
votes=90,
|
||||
),
|
||||
make_package(
|
||||
"firefox-extension-uorigin",
|
||||
description="Firefox extension for managing redirect rules",
|
||||
maintainer="jane",
|
||||
votes=15,
|
||||
),
|
||||
make_package(
|
||||
"chromium",
|
||||
description="A web browser built from Chromium source",
|
||||
maintainer="jonas",
|
||||
votes=2800,
|
||||
provides=("chromium-browser",),
|
||||
),
|
||||
make_package(
|
||||
"python-pytest",
|
||||
description="Python testing tool with full-featured assertions",
|
||||
maintainer="max_musterman123",
|
||||
votes=950,
|
||||
depends=("python",),
|
||||
keywords=("testing", "python"),
|
||||
),
|
||||
make_package(
|
||||
"python-editor",
|
||||
description="Programmatically open an editor for python code",
|
||||
maintainer="orphan",
|
||||
votes=40,
|
||||
),
|
||||
make_package(
|
||||
"fzf",
|
||||
description="Command-line fuzzy finder",
|
||||
maintainer="alice",
|
||||
votes=2100,
|
||||
),
|
||||
make_package(
|
||||
"libreoffice-fresh",
|
||||
description="Office productivity suite",
|
||||
maintainer="",
|
||||
votes=1500,
|
||||
),
|
||||
]
|
||||
return SearchEngine(packages)
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Tests for the cache manager.
|
||||
|
||||
The downloader is monkeypatched, so no test touches the network. Atomic
|
||||
replacement, freshness handling, corruption recovery, and stale fallback are
|
||||
all covered.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from sjaur.cache import CacheManager, download_file, humanize_delta
|
||||
from sjaur.errors import CacheCorruptError, DownloadError, SjaurError
|
||||
|
||||
DATA_V1 = [{"Name": "firefox"}, {"Name": "python"}]
|
||||
DATA_V2 = [{"Name": "firefox"}, {"Name": "python"}, {"Name": "neovim"}]
|
||||
|
||||
|
||||
def gzipped(data) -> bytes:
|
||||
return gzip.compress(json.dumps(data).encode())
|
||||
|
||||
|
||||
def install_downloader(monkeypatch, data) -> None:
|
||||
"""Make ``download_file`` produce a valid dump instead of using network."""
|
||||
|
||||
def fake_download(url, dest, progress=None, timeout=None):
|
||||
dest.write_bytes(gzipped(data))
|
||||
|
||||
monkeypatch.setattr("sjaur.cache.download_file", fake_download)
|
||||
|
||||
|
||||
def install_failing_downloader(monkeypatch, message="Connection timed out.") -> None:
|
||||
def fake_download(url, dest, progress=None, timeout=None):
|
||||
raise DownloadError(message)
|
||||
|
||||
monkeypatch.setattr("sjaur.cache.download_file", fake_download)
|
||||
|
||||
|
||||
def write_cache_file(cache: CacheManager, content: bytes) -> None:
|
||||
cache.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache.metadata_path.write_bytes(content)
|
||||
|
||||
|
||||
def make_cache_stale(cache: CacheManager, hours: float = 25.0) -> None:
|
||||
old = time.time() - hours * 3600
|
||||
os.utime(cache.metadata_path, (old, old))
|
||||
|
||||
|
||||
def no_tmp_files(cache: CacheManager) -> bool:
|
||||
return list(cache.cache_dir.glob("*.tmp")) == []
|
||||
|
||||
|
||||
# -- fresh/missing/expired ----------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_cache_is_downloaded(tmp_path, monkeypatch):
|
||||
cache = CacheManager(tmp_path / "cache")
|
||||
install_downloader(monkeypatch, DATA_V1)
|
||||
|
||||
bundle = cache.ensure_fresh()
|
||||
|
||||
assert bundle.source == "downloaded"
|
||||
assert bundle.data == DATA_V1
|
||||
assert bundle.warning is None
|
||||
assert cache.metadata_path.is_file()
|
||||
assert no_tmp_files(cache)
|
||||
|
||||
|
||||
def test_fresh_cache_is_used_without_download(tmp_path, monkeypatch):
|
||||
cache = CacheManager(tmp_path / "cache")
|
||||
write_cache_file(cache, json.dumps(DATA_V1).encode())
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise AssertionError("download must not run for a fresh cache")
|
||||
|
||||
monkeypatch.setattr("sjaur.cache.download_file", boom)
|
||||
bundle = cache.ensure_fresh()
|
||||
|
||||
assert bundle.source == "cache"
|
||||
assert bundle.data == DATA_V1
|
||||
assert cache.cache_age() is not None and cache.cache_age().total_seconds() < 60
|
||||
|
||||
|
||||
def test_expired_cache_is_replaced(tmp_path, monkeypatch):
|
||||
cache = CacheManager(tmp_path / "cache")
|
||||
write_cache_file(cache, json.dumps(DATA_V1).encode())
|
||||
make_cache_stale(cache)
|
||||
install_downloader(monkeypatch, DATA_V2)
|
||||
|
||||
bundle = cache.ensure_fresh()
|
||||
|
||||
assert bundle.source == "downloaded"
|
||||
assert bundle.data == DATA_V2
|
||||
assert json.loads(cache.metadata_path.read_text()) == DATA_V2
|
||||
assert cache.cache_age().total_seconds() < 60
|
||||
assert no_tmp_files(cache)
|
||||
|
||||
|
||||
def test_force_bypasses_freshness(tmp_path, monkeypatch):
|
||||
cache = CacheManager(tmp_path / "cache")
|
||||
write_cache_file(cache, json.dumps(DATA_V1).encode())
|
||||
install_downloader(monkeypatch, DATA_V2)
|
||||
|
||||
bundle = cache.ensure_fresh(force=True)
|
||||
|
||||
assert bundle.source == "downloaded"
|
||||
assert bundle.data == DATA_V2
|
||||
|
||||
|
||||
# -- download failures ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_failed_download_keeps_stale_cache(tmp_path, monkeypatch):
|
||||
cache = CacheManager(tmp_path / "cache")
|
||||
write_cache_file(cache, json.dumps(DATA_V1).encode())
|
||||
make_cache_stale(cache)
|
||||
install_failing_downloader(monkeypatch, "Connection timed out.")
|
||||
|
||||
bundle = cache.ensure_fresh()
|
||||
|
||||
assert bundle.source == "stale-cache"
|
||||
assert bundle.data == DATA_V1
|
||||
assert bundle.warning is not None
|
||||
assert "Connection timed out." in bundle.warning
|
||||
assert "Using existing cached metadata" in bundle.warning
|
||||
# Old cache untouched, no leftovers.
|
||||
assert json.loads(cache.metadata_path.read_text()) == DATA_V1
|
||||
assert no_tmp_files(cache)
|
||||
|
||||
|
||||
def test_failed_download_without_cache_raises(tmp_path, monkeypatch):
|
||||
cache = CacheManager(tmp_path / "cache")
|
||||
install_failing_downloader(monkeypatch)
|
||||
|
||||
with pytest.raises(SjaurError) as excinfo:
|
||||
cache.ensure_fresh()
|
||||
assert "timed out" in str(excinfo.value)
|
||||
assert not cache.metadata_path.exists()
|
||||
|
||||
|
||||
def test_forced_failed_download_falls_back_to_fresh_cache(tmp_path, monkeypatch):
|
||||
cache = CacheManager(tmp_path / "cache")
|
||||
write_cache_file(cache, json.dumps(DATA_V1).encode())
|
||||
install_failing_downloader(monkeypatch)
|
||||
|
||||
bundle = cache.ensure_fresh(force=True)
|
||||
|
||||
assert bundle.source == "stale-cache"
|
||||
assert bundle.data == DATA_V1
|
||||
|
||||
|
||||
# -- corruption handling --------------------------------------------------------
|
||||
|
||||
|
||||
def test_corrupt_gzip_download_is_rejected_without_touching_cache(tmp_path, monkeypatch):
|
||||
cache = CacheManager(tmp_path / "cache")
|
||||
write_cache_file(cache, json.dumps(DATA_V1).encode())
|
||||
make_cache_stale(cache)
|
||||
|
||||
def corrupt_download(url, dest, progress=None, timeout=None):
|
||||
dest.write_bytes(b"this is not gzip data")
|
||||
|
||||
monkeypatch.setattr("sjaur.cache.download_file", corrupt_download)
|
||||
|
||||
bundle = cache.ensure_fresh()
|
||||
|
||||
assert bundle.source == "stale-cache"
|
||||
assert bundle.data == DATA_V1
|
||||
assert json.loads(cache.metadata_path.read_text()) == DATA_V1
|
||||
assert no_tmp_files(cache)
|
||||
|
||||
|
||||
def test_fresh_but_corrupt_cache_is_replaced(tmp_path, monkeypatch):
|
||||
cache = CacheManager(tmp_path / "cache")
|
||||
write_cache_file(cache, b"{not valid json")
|
||||
install_downloader(monkeypatch, DATA_V2)
|
||||
|
||||
bundle = cache.ensure_fresh()
|
||||
|
||||
assert bundle.source == "downloaded"
|
||||
assert bundle.data == DATA_V2
|
||||
|
||||
|
||||
def test_fresh_but_corrupt_cache_and_failed_download_raises(tmp_path, monkeypatch):
|
||||
cache = CacheManager(tmp_path / "cache")
|
||||
write_cache_file(cache, b"{not valid json")
|
||||
install_failing_downloader(monkeypatch)
|
||||
|
||||
with pytest.raises(SjaurError):
|
||||
cache.ensure_fresh()
|
||||
|
||||
|
||||
def test_structurally_wrong_cache_is_rejected(tmp_path):
|
||||
cache = CacheManager(tmp_path / "cache")
|
||||
write_cache_file(cache, json.dumps({"not": "a list"}).encode())
|
||||
|
||||
with pytest.raises(CacheCorruptError):
|
||||
cache._load()
|
||||
|
||||
|
||||
def test_empty_dump_is_valid_but_yields_no_packages(tmp_path, monkeypatch):
|
||||
cache = CacheManager(tmp_path / "cache")
|
||||
install_downloader(monkeypatch, [])
|
||||
|
||||
bundle = cache.ensure_fresh()
|
||||
|
||||
assert bundle.data == []
|
||||
|
||||
|
||||
def test_unwritable_cache_dir_raises_friendly_error(tmp_path):
|
||||
# A *file* where the cache directory should be triggers a mkdir failure.
|
||||
blocker = tmp_path / "blocker"
|
||||
blocker.write_text("I am a file, not a directory")
|
||||
cache = CacheManager(blocker)
|
||||
|
||||
with pytest.raises(SjaurError) as excinfo:
|
||||
cache.ensure_fresh()
|
||||
assert "cache directory" in str(excinfo.value)
|
||||
|
||||
|
||||
# -- download_file unit tests (local file:// URL, no network) --------------------
|
||||
|
||||
|
||||
def test_download_file_copies_content(tmp_path):
|
||||
source = tmp_path / "source.gz"
|
||||
source.write_bytes(gzipped(DATA_V1))
|
||||
dest = tmp_path / "dest.gz"
|
||||
|
||||
download_file(source.as_uri(), dest)
|
||||
|
||||
assert dest.read_bytes() == source.read_bytes()
|
||||
|
||||
|
||||
def test_download_file_missing_source_raises(tmp_path):
|
||||
dest = tmp_path / "dest.gz"
|
||||
with pytest.raises(DownloadError):
|
||||
download_file((tmp_path / "missing.gz").as_uri(), dest)
|
||||
|
||||
|
||||
def test_progress_callback_reports_bytes(tmp_path):
|
||||
source = tmp_path / "source.gz"
|
||||
source.write_bytes(gzipped(DATA_V1))
|
||||
dest = tmp_path / "dest.gz"
|
||||
seen = []
|
||||
|
||||
download_file(source.as_uri(), dest, progress=lambda done, total: seen.append((done, total)))
|
||||
|
||||
assert seen and seen[-1][0] == source.stat().st_size
|
||||
|
||||
|
||||
# -- helpers ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_humanize_delta():
|
||||
from datetime import timedelta
|
||||
|
||||
assert humanize_delta(timedelta(seconds=10)) == "10 seconds"
|
||||
assert humanize_delta(timedelta(minutes=5)) == "5 minutes"
|
||||
assert "hours" in humanize_delta(timedelta(hours=3))
|
||||
assert "days" in humanize_delta(timedelta(days=3))
|
||||
|
||||
|
||||
def test_cache_age_missing_file(tmp_path):
|
||||
cache = CacheManager(tmp_path / "cache")
|
||||
assert cache.cache_age() is None
|
||||
assert not cache.is_fresh()
|
||||
|
||||
|
||||
def test_metadata_filename(tmp_path):
|
||||
cache = CacheManager(tmp_path)
|
||||
assert cache.metadata_path == Path(tmp_path) / "packages-meta-ext-v1.json"
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Tests for the installer: pure planning logic and the run_step executor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from sjaur.installer import (
|
||||
InstallStep,
|
||||
check_environment,
|
||||
plan_install,
|
||||
run_step,
|
||||
)
|
||||
from sjaur.errors import SjaurError
|
||||
|
||||
|
||||
# -- plan_install (pure logic) ---------------------------------------------------
|
||||
|
||||
|
||||
def test_plan_install_without_existing_clone(tmp_path):
|
||||
steps = plan_install("yay", tmp_path)
|
||||
assert steps == [
|
||||
InstallStep("clone", "yay"),
|
||||
InstallStep("makepkg", "yay"),
|
||||
]
|
||||
|
||||
|
||||
def test_plan_install_reuses_existing_clone(tmp_path):
|
||||
(tmp_path / "yay").mkdir()
|
||||
steps = plan_install("yay", tmp_path)
|
||||
assert steps == [
|
||||
InstallStep("reuse", "yay"),
|
||||
InstallStep("makepkg", "yay"),
|
||||
]
|
||||
|
||||
|
||||
def test_plan_install_refresh_updates_existing_clone(tmp_path):
|
||||
(tmp_path / "yay").mkdir()
|
||||
steps = plan_install("yay", tmp_path, refresh=True)
|
||||
assert steps == [
|
||||
InstallStep("update", "yay"),
|
||||
InstallStep("makepkg", "yay"),
|
||||
]
|
||||
|
||||
|
||||
def test_plan_install_refresh_clones_when_missing(tmp_path):
|
||||
steps = plan_install("yay", tmp_path, refresh=True)
|
||||
assert [step.kind for step in steps] == ["clone", "makepkg"]
|
||||
|
||||
|
||||
def test_plan_install_repo_package_uses_pacman(tmp_path):
|
||||
for repository in ("core", "extra", "multilib"):
|
||||
steps = plan_install("firefox", tmp_path, repository=repository)
|
||||
assert steps == [InstallStep("pacman", "firefox")]
|
||||
|
||||
|
||||
def test_run_step_pacman_uses_sudo_for_non_root(tmp_path, monkeypatch):
|
||||
import sjaur.installer as installer
|
||||
|
||||
recorded = {}
|
||||
|
||||
def fake_run(cmd, cwd=None, check=False):
|
||||
recorded["cmd"] = cmd
|
||||
|
||||
class Result:
|
||||
returncode = 0
|
||||
|
||||
return Result()
|
||||
|
||||
monkeypatch.setattr(installer.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(installer.os, "geteuid", lambda: 1000)
|
||||
|
||||
run_step(InstallStep("pacman", "firefox"), tmp_path)
|
||||
|
||||
assert recorded["cmd"] == ["sudo", "pacman", "-S", "--needed", "firefox"]
|
||||
|
||||
|
||||
def test_run_step_pacman_as_root_needs_no_sudo(tmp_path, monkeypatch):
|
||||
import sjaur.installer as installer
|
||||
|
||||
recorded = {}
|
||||
|
||||
def fake_run(cmd, cwd=None, check=False):
|
||||
recorded["cmd"] = cmd
|
||||
|
||||
class Result:
|
||||
returncode = 0
|
||||
|
||||
return Result()
|
||||
|
||||
monkeypatch.setattr(installer.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(installer.os, "geteuid", lambda: 0)
|
||||
|
||||
run_step(InstallStep("pacman", "firefox"), tmp_path)
|
||||
|
||||
assert recorded["cmd"] == ["pacman", "-S", "--needed", "firefox"]
|
||||
|
||||
|
||||
def test_check_environment_repo_packages_only_need_pacman(monkeypatch):
|
||||
import sjaur.installer as installer
|
||||
|
||||
monkeypatch.setattr(
|
||||
installer.shutil,
|
||||
"which",
|
||||
lambda name: None if name in ("git", "makepkg") else "/usr/bin/pacman",
|
||||
)
|
||||
assert check_environment(include_build_tools=False) == []
|
||||
assert len(check_environment(include_build_tools=True)) == 2
|
||||
|
||||
|
||||
# -- run_step (subprocess execution) ----------------------------------------------
|
||||
|
||||
|
||||
def test_run_step_clone_creates_directory(tmp_path, monkeypatch):
|
||||
import subprocess
|
||||
|
||||
import sjaur.installer as installer
|
||||
|
||||
# Point the AUR URL template at a local bare repository: no network.
|
||||
source = tmp_path / "aur-mock"
|
||||
source.mkdir()
|
||||
subprocess.run(["git", "init", "-q", "--bare", str(source / "yay.git")], check=True)
|
||||
monkeypatch.setattr(installer, "AUR_GIT_URL", str(source / "{name}.git"))
|
||||
|
||||
target_root = tmp_path / "builds"
|
||||
target_root.mkdir()
|
||||
run_step(InstallStep("clone", "yay"), target_root)
|
||||
|
||||
assert (target_root / "yay" / ".git").is_dir()
|
||||
|
||||
|
||||
def test_run_step_reuse_is_a_noop(tmp_path):
|
||||
(tmp_path / "whatever").mkdir()
|
||||
run_step(InstallStep("reuse", "whatever"), tmp_path) # must not raise
|
||||
|
||||
|
||||
def test_run_step_makepkg_without_pkgbuild_raises(tmp_path):
|
||||
(tmp_path / "pkg").mkdir()
|
||||
with pytest.raises(SjaurError) as excinfo:
|
||||
run_step(InstallStep("makepkg", "pkg"), tmp_path)
|
||||
assert "PKGBUILD" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_run_step_makepkg_failure_raises(tmp_path):
|
||||
pkg = tmp_path / "pkg"
|
||||
pkg.mkdir()
|
||||
(pkg / "PKGBUILD").write_text("pkgname=pkg\n")
|
||||
with pytest.raises(SjaurError) as excinfo:
|
||||
run_step(InstallStep("makepkg", "pkg"), tmp_path)
|
||||
assert "makepkg" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_run_step_unknown_kind_raises(tmp_path):
|
||||
with pytest.raises(SjaurError):
|
||||
run_step(InstallStep("explode", "pkg"), tmp_path)
|
||||
|
||||
|
||||
# -- check_environment -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_environment_finds_basic_tools():
|
||||
# git/makepkg/pacman exist in CI-like Linux environments; if any is
|
||||
# missing the message names the tool.
|
||||
problems = check_environment()
|
||||
assert isinstance(problems, list)
|
||||
for problem in problems:
|
||||
assert "not found in PATH" in problem
|
||||
|
||||
|
||||
def test_check_environment_reports_missing_tool(monkeypatch):
|
||||
import sjaur.installer as installer
|
||||
|
||||
monkeypatch.setattr(installer.shutil, "which", lambda name: None)
|
||||
problems = check_environment()
|
||||
assert len(problems) == 3
|
||||
assert any("git" in problem for problem in problems)
|
||||
|
||||
|
||||
def test_missing_binary_in_run_step_raises(tmp_path, monkeypatch):
|
||||
import sjaur.installer as installer
|
||||
|
||||
def raise_missing(*args, **kwargs):
|
||||
raise FileNotFoundError("git")
|
||||
|
||||
monkeypatch.setattr(installer.subprocess, "run", raise_missing)
|
||||
with pytest.raises(SjaurError) as excinfo:
|
||||
run_step(InstallStep("clone", "x"), tmp_path)
|
||||
assert "git" in str(excinfo.value)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for metadata parsing (tolerant of real-world dump quirks)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from sjaur.errors import CacheCorruptError
|
||||
from sjaur.metadata import format_timestamp, parse_packages
|
||||
|
||||
|
||||
def test_parse_minimal_entry():
|
||||
packages, skipped = parse_packages([{"Name": "firefox"}])
|
||||
assert skipped == 0
|
||||
(package,) = packages
|
||||
assert package.name == "firefox"
|
||||
assert package.version == ""
|
||||
assert package.description == ""
|
||||
assert package.maintainer == ""
|
||||
assert package.votes == 0
|
||||
assert package.popularity == 0.0
|
||||
assert package.out_of_date is None
|
||||
assert package.is_orphan
|
||||
assert package.aur_url == "https://aur.archlinux.org/packages/firefox"
|
||||
|
||||
|
||||
def test_parse_full_entry():
|
||||
entry = {
|
||||
"Name": "cower",
|
||||
"PackageBase": "cower",
|
||||
"Version": "14-2",
|
||||
"Description": "A simple AUR agent",
|
||||
"URL": "http://example.com",
|
||||
"NumVotes": 590,
|
||||
"Popularity": 24.59,
|
||||
"OutOfDate": None,
|
||||
"Maintainer": "falconindy",
|
||||
"FirstSubmitted": 1293676237,
|
||||
"LastModified": 1441804093,
|
||||
"Depends": ["curl", "openssl"],
|
||||
"MakeDepends": ["perl"],
|
||||
"Keywords": ["aur"],
|
||||
"License": ["MIT"],
|
||||
}
|
||||
packages, skipped = parse_packages([entry])
|
||||
assert skipped == 0
|
||||
(package,) = packages
|
||||
assert package.name == "cower"
|
||||
assert package.version == "14-2"
|
||||
assert package.votes == 590
|
||||
assert package.popularity == 24.59
|
||||
assert package.out_of_date is None
|
||||
assert package.maintainer == "falconindy"
|
||||
assert package.depends == ("curl", "openssl")
|
||||
assert package.make_depends == ("perl",)
|
||||
assert package.keywords == ("aur",)
|
||||
assert package.licenses == ("MIT",)
|
||||
assert not package.is_orphan
|
||||
|
||||
|
||||
def test_out_of_date_timestamp_is_kept():
|
||||
packages, _ = parse_packages([{"Name": "x", "OutOfDate": 1720000000}])
|
||||
assert packages[0].out_of_date == 1720000000
|
||||
|
||||
|
||||
def test_missing_fields_are_omitted_by_server():
|
||||
# The dump omits fields a package does not have; lists must be empty, not None.
|
||||
packages, _ = parse_packages([{"Name": "x", "Depends": None, "Keywords": []}])
|
||||
assert packages[0].depends == ()
|
||||
assert packages[0].keywords == ()
|
||||
|
||||
|
||||
def test_malformed_entries_are_skipped_and_counted():
|
||||
packages, skipped = parse_packages(
|
||||
[
|
||||
{"Name": "ok"},
|
||||
"not a dict",
|
||||
42,
|
||||
{"Version": "1.0"}, # no Name
|
||||
{"Name": " "}, # blank Name
|
||||
None,
|
||||
]
|
||||
)
|
||||
assert [p.name for p in packages] == ["ok"]
|
||||
assert skipped == 5
|
||||
|
||||
|
||||
def test_non_list_payload_is_rejected():
|
||||
with pytest.raises(CacheCorruptError):
|
||||
parse_packages({"Name": "firefox"})
|
||||
|
||||
|
||||
def test_type_mismatches_do_not_crash():
|
||||
entry = {
|
||||
"Name": "weird",
|
||||
"Version": 123,
|
||||
"NumVotes": "many",
|
||||
"Popularity": None,
|
||||
"Depends": "python", # wrong container type
|
||||
"Keywords": [1, None, "ok"],
|
||||
}
|
||||
packages, _ = parse_packages([entry])
|
||||
package = packages[0]
|
||||
assert package.version == ""
|
||||
assert package.votes == 0
|
||||
assert package.popularity == 0.0
|
||||
assert package.depends == ()
|
||||
assert package.keywords == ("ok",)
|
||||
|
||||
|
||||
def test_format_timestamp():
|
||||
assert format_timestamp(None) == "unknown"
|
||||
assert format_timestamp(0) == "unknown"
|
||||
assert format_timestamp(1441804093) == "2015-09-09 13:08 UTC"
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tests for the query parser, including the required @maintainer syntax."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sjaur.query import OPERATORS, SearchQuery, parse_query
|
||||
|
||||
|
||||
def test_plain_query():
|
||||
query = parse_query("firefox")
|
||||
assert query.text == "firefox"
|
||||
assert query.maintainer is None
|
||||
assert query.filters == ()
|
||||
|
||||
|
||||
def test_maintainer_only():
|
||||
query = parse_query("@max_musterman")
|
||||
assert query.maintainer == "max_musterman"
|
||||
assert query.text == ""
|
||||
|
||||
|
||||
def test_maintainer_with_package_query():
|
||||
query = parse_query("@max_musterman firefox")
|
||||
assert query.maintainer == "max_musterman"
|
||||
assert query.text == "firefox"
|
||||
|
||||
|
||||
def test_only_first_word_is_maintainer_query():
|
||||
query = parse_query("@john python editor")
|
||||
assert query.maintainer == "john"
|
||||
assert query.text == "python editor"
|
||||
|
||||
|
||||
def test_separated_operator_token():
|
||||
query = parse_query("@ john firefox")
|
||||
assert query.maintainer == "john"
|
||||
assert query.text == "firefox"
|
||||
|
||||
|
||||
def test_multiple_at_tokens_uses_first():
|
||||
query = parse_query("@alice @bob firefox")
|
||||
assert query.maintainer == "alice"
|
||||
assert query.text == "firefox"
|
||||
|
||||
|
||||
def test_separated_operator_alone_takes_next_word():
|
||||
# "@ word" behaves like "@word": the next token is the maintainer query.
|
||||
query = parse_query("@ firefox")
|
||||
assert query.maintainer == "firefox"
|
||||
assert query.text == ""
|
||||
|
||||
|
||||
def test_trailing_operator_without_value_is_ignored():
|
||||
query = parse_query("firefox @")
|
||||
assert query.maintainer is None
|
||||
assert query.text == "firefox"
|
||||
|
||||
|
||||
def test_at_inside_word_is_not_an_operator():
|
||||
query = parse_query("user@example.com")
|
||||
assert query.maintainer is None
|
||||
assert query.text == "user@example.com"
|
||||
|
||||
|
||||
def test_empty_query():
|
||||
query = parse_query("")
|
||||
assert query == SearchQuery()
|
||||
|
||||
|
||||
def test_whitespace_is_normalized():
|
||||
query = parse_query(" firefox nightly ")
|
||||
assert query.text == "firefox nightly"
|
||||
|
||||
|
||||
def test_operator_registry_is_extensible():
|
||||
# Future operators must only need a table entry plus a SearchQuery field.
|
||||
assert "@" in OPERATORS
|
||||
assert OPERATORS["@"] == "maintainer"
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Tests for the pacman sync database parser (built from a synthetic archive)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
from sjaur.repodb import load_repo_packages, sync_db_paths
|
||||
|
||||
DESC = """%FILENAME%
|
||||
linux-6.16.8-1-x86_64.pkg.tar.zst
|
||||
|
||||
%NAME%
|
||||
linux
|
||||
|
||||
%BASE%
|
||||
linux
|
||||
|
||||
%VERSION%
|
||||
6.16.8-1
|
||||
|
||||
%DESC%
|
||||
The Linux kernel and modules
|
||||
|
||||
%URL%
|
||||
https://github.com/torvalds/linux
|
||||
|
||||
%ARCH%
|
||||
x86_64
|
||||
|
||||
%PACKAGER%
|
||||
Jan Alexander Steffens (heftig) <heftig@archlinux.org>
|
||||
|
||||
%BUILDDATE%
|
||||
1757429406
|
||||
"""
|
||||
|
||||
DEPENDS = """%DEPENDS%
|
||||
coreutils
|
||||
initramfs
|
||||
kmod
|
||||
|
||||
%OPTDEPENDS%
|
||||
linux-firmware: kernel firmware for hw drivers
|
||||
|
||||
%MAKEDEPENDS%
|
||||
bc
|
||||
cpio
|
||||
|
||||
%PROVIDES%
|
||||
VIRTUALBOX-GUEST-MODULES
|
||||
WIREGUARD-MODULE
|
||||
|
||||
%CONFLICTS%
|
||||
linux-libre
|
||||
|
||||
%REPLACES%
|
||||
virtualbox-guest-modules-arch
|
||||
|
||||
%GROUPS%
|
||||
base
|
||||
|
||||
%LICENSE%
|
||||
GPL2
|
||||
"""
|
||||
|
||||
|
||||
def build_db(tmp_path, repository: str, entries: dict[str, tuple[str, str]]) -> None:
|
||||
"""Write a gzip-compressed tar archive mimicking a pacman sync DB."""
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w") as archive:
|
||||
for directory, (desc, depends) in entries.items():
|
||||
for filename, content in (
|
||||
("desc", desc),
|
||||
("depends", depends),
|
||||
):
|
||||
data = content.encode()
|
||||
info = tarfile.TarInfo(name=f"{directory}/{filename}")
|
||||
info.size = len(data)
|
||||
archive.addfile(info, io.BytesIO(data))
|
||||
(tmp_path / f"{repository}.db").write_bytes(gzip.compress(buffer.getvalue()))
|
||||
|
||||
|
||||
def test_sync_db_paths_sorted_and_filtered(tmp_path):
|
||||
build_db(tmp_path, "extra", {})
|
||||
(tmp_path / "local").mkdir()
|
||||
(tmp_path / "core.db.bak").write_text("not a db")
|
||||
|
||||
paths = sync_db_paths(tmp_path)
|
||||
assert [path.name for path in paths] == ["extra.db"]
|
||||
|
||||
|
||||
def test_sync_db_paths_missing_dir(tmp_path):
|
||||
assert sync_db_paths(tmp_path / "does-not-exist") == []
|
||||
|
||||
|
||||
def test_load_repo_packages_parses_all_fields(tmp_path):
|
||||
build_db(tmp_path, "core", {"linux-6.16.8-1": (DESC, DEPENDS)})
|
||||
|
||||
packages, loaded = load_repo_packages(tmp_path)
|
||||
|
||||
assert loaded == ["core"]
|
||||
(package,) = packages
|
||||
assert package.name == "linux"
|
||||
assert package.version == "6.16.8-1"
|
||||
assert package.description == "The Linux kernel and modules"
|
||||
assert package.repository == "core"
|
||||
assert package.is_repo_package
|
||||
assert not package.is_orphan
|
||||
# Packager email is reduced to the username for @maintainer searches.
|
||||
assert package.maintainer == "heftig"
|
||||
assert package.url == "https://github.com/torvalds/linux"
|
||||
assert package.last_modified == 1757429406
|
||||
assert package.depends == ("coreutils", "initramfs", "kmod")
|
||||
assert package.opt_depends == ("linux-firmware: kernel firmware for hw drivers",)
|
||||
assert package.make_depends == ("bc", "cpio")
|
||||
assert package.provides == ("VIRTUALBOX-GUEST-MODULES", "WIREGUARD-MODULE")
|
||||
assert package.conflicts == ("linux-libre",)
|
||||
assert package.replaces == ("virtualbox-guest-modules-arch",)
|
||||
assert package.groups == ("base",)
|
||||
assert package.licenses == ("GPL2",)
|
||||
assert package.votes == 0 # AUR-only concept
|
||||
assert package.aur_url == "https://aur.archlinux.org/packages/linux"
|
||||
|
||||
|
||||
def test_load_repo_packages_multiple_repositories(tmp_path):
|
||||
bash_desc = DESC.replace("linux", "bash").replace("6.16.8-1", "5.3-1")
|
||||
build_db(tmp_path, "core", {"bash-5.3-1": (bash_desc, "")})
|
||||
build_db(tmp_path, "extra", {"linux-6.16.8-1": (DESC, DEPENDS)})
|
||||
|
||||
packages, loaded = load_repo_packages(tmp_path)
|
||||
|
||||
assert loaded == ["core", "extra"]
|
||||
assert len(packages) == 2
|
||||
repos = {package.name: package.repository for package in packages}
|
||||
assert repos == {"bash": "core", "linux": "extra"}
|
||||
|
||||
|
||||
def test_corrupt_database_is_skipped(tmp_path):
|
||||
build_db(tmp_path, "core", {"linux": (DESC, DEPENDS)})
|
||||
(tmp_path / "broken.db").write_bytes(b"this is not a tar archive")
|
||||
|
||||
packages, loaded = load_repo_packages(tmp_path)
|
||||
|
||||
assert loaded == ["core"]
|
||||
assert [package.name for package in packages] == ["linux"]
|
||||
|
||||
|
||||
def test_entries_without_name_are_skipped(tmp_path):
|
||||
build_db(tmp_path, "extra", {"broken-1.0-1": ("%VERSION%\n1.0-1\n", "")})
|
||||
|
||||
packages, loaded = load_repo_packages(tmp_path)
|
||||
|
||||
assert loaded == ["extra"]
|
||||
assert packages == []
|
||||
|
||||
|
||||
def test_packager_without_email_is_kept_verbatim(tmp_path):
|
||||
desc = DESC.replace(
|
||||
"Jan Alexander Steffens (heftig) <heftig@archlinux.org>", "Arch Linux"
|
||||
)
|
||||
build_db(tmp_path, "core", {"linux": (desc, "")})
|
||||
|
||||
packages, _ = load_repo_packages(tmp_path)
|
||||
|
||||
assert packages[0].maintainer == "Arch Linux"
|
||||
|
||||
|
||||
def test_builddate_garbage_becomes_zero(tmp_path):
|
||||
desc = DESC.replace("1757429406", "not-a-date")
|
||||
build_db(tmp_path, "core", {"linux": (desc, "")})
|
||||
|
||||
packages, _ = load_repo_packages(tmp_path)
|
||||
|
||||
assert packages[0].last_modified == 0
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Tests for the search engine: ranking, fuzzy matching, and @maintainer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sjaur.query import parse_query
|
||||
from sjaur.search import MAINTAINER_MATCH_THRESHOLD, SearchEngine
|
||||
|
||||
|
||||
def names(results):
|
||||
return [result.package.name for result in results]
|
||||
|
||||
|
||||
def test_exact_name_match_first(engine):
|
||||
results = engine.search(parse_query("firefox"))
|
||||
assert names(results)[0] == "firefox"
|
||||
# All firefox variants are found.
|
||||
assert {"firefox", "firefox-nightly", "firefox-wayland"} <= set(names(results))
|
||||
|
||||
|
||||
def test_name_prefix_ranks_above_substring(engine):
|
||||
results = engine.search(parse_query("fire"))
|
||||
top = names(results)
|
||||
assert "firefox" in top[:3] # prefix match
|
||||
# The substring-only "firefox-extension-uorigin" must not outrank the
|
||||
# prefix matches.
|
||||
assert top.index("firefox") < top.index("firefox-extension-uorigin")
|
||||
|
||||
|
||||
def test_typo_still_finds_package(engine):
|
||||
results = engine.search(parse_query("firfox"))
|
||||
assert "firefox" in names(results)
|
||||
|
||||
|
||||
def test_description_match(engine):
|
||||
results = engine.search(parse_query("office productivity"))
|
||||
assert "libreoffice-fresh" in names(results)
|
||||
|
||||
|
||||
def test_description_tokens_all_must_appear(engine):
|
||||
results = engine.search(parse_query("productivity suite"))
|
||||
assert names(results)[0] == "libreoffice-fresh"
|
||||
|
||||
|
||||
def test_maintainer_field_contributes_to_normal_search(engine):
|
||||
results = engine.search(parse_query("alice"))
|
||||
assert "fzf" in names(results)
|
||||
|
||||
|
||||
def test_maintainer_operator_fuzzy(engine):
|
||||
results = engine.search(parse_query("@max_musterman"))
|
||||
found = names(results)
|
||||
# Fuzzy: similar maintainers match despite the typo.
|
||||
assert "python-pytest" in found # max_musterman123
|
||||
assert "firefox-wayland" in found # max_mustermann
|
||||
# Unrelated maintainers must not appear.
|
||||
assert "firefox" not in found
|
||||
assert "chromium" not in found
|
||||
|
||||
|
||||
def test_maintainer_operator_with_package_query(engine):
|
||||
results = engine.search(parse_query("@jonas browser"))
|
||||
found = names(results)
|
||||
assert {"firefox", "chromium"} <= set(found)
|
||||
# Maintained by others.
|
||||
assert "python-pytest" not in found
|
||||
|
||||
|
||||
def test_maintainer_query_boosts_maintainer_matches(engine):
|
||||
# "jonas" maintains firefox and chromium; both rank top for the plain
|
||||
# text search, and the @-combined search scores at least as high overall.
|
||||
plain = engine.search(parse_query("jonas"))
|
||||
combined = engine.search(parse_query("@jonas jonas"))
|
||||
assert names(plain)[0] == "firefox" # shorter name wins the prefix tie
|
||||
assert names(combined)[0] in ("firefox", "chromium")
|
||||
assert combined[0].score >= plain[0].score
|
||||
|
||||
|
||||
def test_maintainer_below_threshold_is_excluded(engine):
|
||||
results = engine.search(parse_query("@zzzzzzzzzz"))
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_orphan_packages_have_no_maintainer_matches(engine):
|
||||
# An empty (orphaned) maintainer cannot fuzzy-match an @query, but the
|
||||
# package itself is still reachable through a normal search.
|
||||
results = engine.search(parse_query("@orph"))
|
||||
found = names(results)
|
||||
assert "python-editor" in found # maintainer "orphan"
|
||||
assert "libreoffice-fresh" not in found
|
||||
assert "libreoffice-fresh" in names(engine.search(parse_query("office")))
|
||||
|
||||
|
||||
def test_combined_scoring_formula(engine):
|
||||
# With a maintainer query, maintainer similarity carries most of the
|
||||
# weight but the package score still orders results.
|
||||
results = engine.search(parse_query("@jonas standalone"))
|
||||
firefox = next(r for r in results if r.package.name == "firefox")
|
||||
chromium = next(r for r in results if r.package.name == "chromium")
|
||||
assert firefox.score > 0 and chromium.score > 0
|
||||
# firefox's description contains "standalone", chromium's does not.
|
||||
assert firefox.score > chromium.score
|
||||
|
||||
|
||||
def test_provides_field_is_searched(engine):
|
||||
results = engine.search(parse_query("chromium-browser"))
|
||||
assert names(results)[0] == "chromium"
|
||||
|
||||
|
||||
def test_dependencies_are_searched_with_lower_priority(engine):
|
||||
results = engine.search(parse_query("pytest"))
|
||||
# python-pytest matches by name; anything matching only via depends must
|
||||
# not outrank it.
|
||||
assert names(results)[0] == "python-pytest"
|
||||
|
||||
|
||||
def test_empty_results(engine):
|
||||
assert engine.search(parse_query("zzzznotapackagezzzz")) == []
|
||||
|
||||
|
||||
def test_empty_query_returns_popular_packages(engine):
|
||||
results = engine.search(parse_query(""))
|
||||
top = names(results)
|
||||
assert len(top) > 0
|
||||
# Most-voted package first.
|
||||
votes = [r.package.votes for r in results]
|
||||
assert votes == sorted(votes, reverse=True)
|
||||
|
||||
|
||||
def test_results_are_deterministic(engine):
|
||||
first = engine.search(parse_query("web browser"))
|
||||
second = engine.search(parse_query("web browser"))
|
||||
assert names(first) == names(second)
|
||||
assert [r.score for r in first] == [r.score for r in second]
|
||||
|
||||
|
||||
def test_limit(engine):
|
||||
results = engine.search(parse_query("firefox"), limit=2)
|
||||
assert len(results) == 2
|
||||
|
||||
|
||||
def test_whitespace_only_query_behaves_like_empty(engine):
|
||||
results = engine.search(parse_query(" "))
|
||||
assert results == engine.search(parse_query(""))
|
||||
|
||||
|
||||
def test_maintainer_threshold_is_fuzzy_not_exact():
|
||||
# The threshold must be low enough that small typos still match.
|
||||
assert MAINTAINER_MATCH_THRESHOLD < 60.0
|
||||
|
||||
|
||||
def test_index_is_built_once_and_reused(engine):
|
||||
# Repeated searches must not rebuild the index (same engine object).
|
||||
first = engine.search(parse_query("firefox"))
|
||||
assert engine._names is not None
|
||||
second = engine.search(parse_query("firefox"))
|
||||
assert names(first) == names(second)
|
||||
|
||||
|
||||
def test_repo_and_aur_packages_of_same_name_are_both_kept():
|
||||
from conftest import make_package
|
||||
|
||||
aur = make_package("python-editor", votes=42)
|
||||
repo = make_package("python-editor", votes=0)
|
||||
object.__setattr__(repo, "repository", "extra")
|
||||
|
||||
combined = SearchEngine([aur, repo])
|
||||
assert len(combined.packages) == 2
|
||||
# Both survive a search and rank identically (votes tie-break first).
|
||||
results = combined.search(parse_query("python-editor"))
|
||||
assert [r.package.repository for r in results if r.package.name == "python-editor"] == [
|
||||
"aur",
|
||||
"extra",
|
||||
]
|
||||
|
||||
|
||||
def test_quick_filter_returns_matching_subset(engine):
|
||||
from conftest import make_package
|
||||
from sjaur.search import SearchResult
|
||||
|
||||
base = engine.search(parse_query("firefox"))
|
||||
narrowed = engine.quick_filter(base, "firefo")
|
||||
assert {r.package.name for r in narrowed} <= {r.package.name for r in base}
|
||||
assert "firefox" in {r.package.name for r in narrowed}
|
||||
|
||||
# Fuzzy-only hits survive filtering by the same text (no flicker),
|
||||
# and correcting the typo keeps real name matches while dropping
|
||||
# unrelated fuzzy noise.
|
||||
typo = engine.search(parse_query("firfox"))
|
||||
assert engine.quick_filter(typo, "firfox") != []
|
||||
corrected = engine.quick_filter(typo, "firefox")
|
||||
assert "firefox" in {r.package.name for r in corrected}
|
||||
assert "ffbox" not in {r.package.name for r in corrected}
|
||||
|
||||
# Multi-word queries require every token to be present.
|
||||
web = engine.search(parse_query("web"))
|
||||
assert engine.quick_filter(web, "web browser") != []
|
||||
|
||||
# Empty text keeps everything; unknown packages are dropped.
|
||||
assert engine.quick_filter(base, "") == base
|
||||
stranger = SearchResult(package=make_package("not-in-engine"), score=1.0)
|
||||
assert engine.quick_filter([stranger], "firefox") == []
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Headless smoke tests for the TUI (uses Textual's test driver, no terminal)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from rich.table import Table
|
||||
from textual.widgets import ListView, Static
|
||||
|
||||
from sjaur.tui.app import InstallerConfirmScreen, SJaurApp
|
||||
|
||||
|
||||
def pytest_fail(message: str):
|
||||
pytest.fail(message)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_tui_shows_popular_results_and_live_search(engine):
|
||||
async def scenario():
|
||||
app = SJaurApp(engine=engine, cache=None, cache_note="test")
|
||||
async with app.run_test(size=(120, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
list_view = app.query_one("#results", ListView)
|
||||
initial_count = len(list_view.children)
|
||||
assert initial_count > 0 # empty input shows the most-voted packages
|
||||
|
||||
# Type a query; results update live.
|
||||
await pilot.click("#search")
|
||||
for char in "firefox":
|
||||
await pilot.press(char)
|
||||
await pilot.pause(1.0) # debounce + background search worker
|
||||
|
||||
current = [r.package.name for r in app._current_results]
|
||||
assert "firefox" in current
|
||||
assert len(current) < initial_count
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_tui_details_pane_follows_selection(engine):
|
||||
async def scenario():
|
||||
app = SJaurApp(engine=engine, cache=None, cache_note="test")
|
||||
async with app.run_test(size=(120, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
details = app.query_one("#details", Static)
|
||||
renderable = details.content
|
||||
# The first popular package's details are rendered on startup.
|
||||
assert isinstance(renderable, Table)
|
||||
|
||||
# Highlight another row; the detail pane follows.
|
||||
list_view = app.query_one("#results", ListView)
|
||||
list_view.index = 2
|
||||
await pilot.pause()
|
||||
assert app.query_one("#details", Static).content is not renderable
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_tui_updates_results_on_every_keystroke(engine):
|
||||
async def scenario():
|
||||
app = SJaurApp(engine=engine, cache=None, cache_note="test")
|
||||
async with app.run_test(size=(120, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
popular = [r.package.name for r in app._current_results]
|
||||
|
||||
await pilot.click("#search")
|
||||
await pilot.press("f", "z", "f")
|
||||
await pilot.pause(0.05) # less than the debounce: still instant feedback
|
||||
live = [r.package.name for r in app._current_results]
|
||||
assert set(live) < set(popular) # narrowed immediately, not after enter
|
||||
|
||||
await pilot.pause(1.0) # background search refines further
|
||||
assert "fzf" in [r.package.name for r in app._current_results]
|
||||
assert "fzf" in app._results_cache # finished search is cached
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_tui_cached_results_are_restored_instantly(engine):
|
||||
async def scenario():
|
||||
app = SJaurApp(engine=engine, cache=None, cache_note="test")
|
||||
async with app.run_test(size=(120, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.click("#search")
|
||||
for _ in range(3):
|
||||
await pilot.press("f", "z", "f")
|
||||
await pilot.pause(1.0)
|
||||
fzf_results = [r.package.name for r in app._current_results]
|
||||
|
||||
# Clear and retype: results come back from the cache.
|
||||
await pilot.press("backspace", "backspace", "backspace")
|
||||
await pilot.pause(1.0)
|
||||
assert app._current_results # popular list restored
|
||||
await pilot.press("f", "z", "f")
|
||||
await pilot.pause(0.05)
|
||||
assert [r.package.name for r in app._current_results] == fzf_results
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_tui_install_flow_is_requested_for_selected_package(engine):
|
||||
async def scenario():
|
||||
app = SJaurApp(engine=engine, cache=None, cache_note="test")
|
||||
async with app.run_test(size=(120, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
calls = []
|
||||
app._install_flow = lambda package, refresh: calls.append(
|
||||
(package.name, refresh)
|
||||
)
|
||||
|
||||
# Highlight a package and open the install dialog.
|
||||
app.action_focus_results()
|
||||
await pilot.pause()
|
||||
await pilot.press("ctrl+i")
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, InstallerConfirmScreen)
|
||||
|
||||
# 'n' cancels: nothing is installed.
|
||||
await pilot.press("n")
|
||||
await pilot.pause()
|
||||
assert calls == []
|
||||
|
||||
# Re-open and confirm with 'y'.
|
||||
await pilot.press("ctrl+i")
|
||||
await pilot.pause()
|
||||
await pilot.press("y")
|
||||
await pilot.pause()
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1] is False
|
||||
|
||||
# 'u' confirms with a refresh (update the clone first).
|
||||
await pilot.press("ctrl+i")
|
||||
await pilot.pause()
|
||||
await pilot.press("u")
|
||||
await pilot.pause()
|
||||
assert calls[-1][1] is True
|
||||
|
||||
# The plain 'i' key (results focused) opens the dialog as well.
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
app.action_focus_results()
|
||||
await pilot.pause()
|
||||
await pilot.press("i")
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, InstallerConfirmScreen)
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_tui_install_reports_environment_problems(engine, monkeypatch):
|
||||
async def scenario():
|
||||
import sjaur.tui.app as app_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
app_module,
|
||||
"check_environment",
|
||||
lambda **kwargs: ["'git' not found in PATH — required"],
|
||||
)
|
||||
app = SJaurApp(engine=engine, cache=None, cache_note="test")
|
||||
async with app.run_test(size=(120, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
app._install_flow = lambda package, refresh: pytest_fail("flow must not run")
|
||||
|
||||
app.action_focus_results()
|
||||
await pilot.pause()
|
||||
await pilot.press("ctrl+i")
|
||||
await pilot.pause()
|
||||
await pilot.press("y")
|
||||
await pilot.pause()
|
||||
assert "git" in str(app.query_one("#status", Static).content)
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_tui_install_flow_error_is_reported(engine, monkeypatch):
|
||||
async def scenario():
|
||||
app = SJaurApp(engine=engine, cache=None, cache_note="test")
|
||||
async with app.run_test(size=(120, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
def failing_flow(package, refresh):
|
||||
from sjaur.errors import SjaurError
|
||||
|
||||
raise SjaurError("makepkg failed with exit code 4")
|
||||
|
||||
app._install_flow = failing_flow
|
||||
app._install_package(engine.packages[0], refresh=False)
|
||||
await pilot.pause()
|
||||
# The report goes to the suspended terminal, so no status error:
|
||||
# the app must simply survive and stay usable.
|
||||
assert app._search_input.has_focus
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_tui_escape_clears_search(engine):
|
||||
async def scenario():
|
||||
app = SJaurApp(engine=engine, cache=None, cache_note="test")
|
||||
async with app.run_test(size=(120, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.click("#search")
|
||||
for char in "fzf":
|
||||
await pilot.press(char)
|
||||
await pilot.pause(1.0)
|
||||
assert app._current_results
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause(1.0)
|
||||
assert app._search_input.value == ""
|
||||
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_tui_enter_expands_details_and_escape_returns(engine):
|
||||
async def scenario():
|
||||
app = SJaurApp(engine=engine, cache=None, cache_note="test")
|
||||
async with app.run_test(size=(120, 36)) as pilot:
|
||||
await pilot.pause()
|
||||
content = app.query_one("#content")
|
||||
|
||||
# Focus the results and open the selected package.
|
||||
app.action_focus_results()
|
||||
await pilot.pause()
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
assert content.has_class("expanded")
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert not content.has_class("expanded")
|
||||
|
||||
run(scenario())
|
||||
Reference in New Issue
Block a user