commit local changes.

This commit is contained in:
2026-10-02 15:29:35 +02:00
parent 92e5b6e363
commit 95af3a84af
24 changed files with 3187 additions and 78 deletions
+103
View File
@@ -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)
+277
View File
@@ -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"
+189
View File
@@ -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)
+113
View File
@@ -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"
+77
View File
@@ -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"
+176
View File
@@ -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
+201
View File
@@ -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") == []
+236
View File
@@ -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())