From 9f8d8d6fcd38e9057d18e854fdf8dbcea44c4a7e Mon Sep 17 00:00:00 2001 From: Adam Ladachowski Date: Tue, 3 Feb 2026 23:10:30 +0100 Subject: [PATCH] =?UTF-8?q?Add=20comprehensive=20tests,=20coverage=2021%?= =?UTF-8?q?=20=E2=86=92=2074%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coverage | Bin 69632 -> 86016 bytes pyproject.toml | 1 + tests/test_tensors.py | 462 +++++++++++++++++++++++++++++++++++++++++- uv.lock | 14 ++ 4 files changed, 476 insertions(+), 1 deletion(-) diff --git a/.coverage b/.coverage index 54a3d726c5adc5ce0043edc2839bb46139dbe213..cea8ccffa4a6a08c9a0bdb2826cb1169c0f2fbe8 100644 GIT binary patch literal 86016 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 L*tUpcP2+z8MUuM0 diff --git a/pyproject.toml b/pyproject.toml index e831b43..531f4af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dev = [ "pytest>=8.0", "pytest-cov>=4.1", "pre-commit>=3.6", + "respx>=0.22.0", ] [tool.ruff] diff --git a/tests/test_tensors.py b/tests/test_tensors.py index 134350d..95f7892 100644 --- a/tests/test_tensors.py +++ b/tests/test_tensors.py @@ -4,13 +4,45 @@ from __future__ import annotations import struct from pathlib import Path +from typing import Any +import httpx import pytest +import respx +from rich.console import Console +from typer.testing import CliRunner from tensors import config -from tensors.config import get_default_output_path, load_api_key +from tensors.api import ( + download_model, + fetch_civitai_by_hash, + fetch_civitai_model, + fetch_civitai_model_version, + search_civitai, +) +from tensors.cli import app +from tensors.config import ( + BaseModel, + ModelType, + SortOrder, + get_default_output_path, + load_api_key, + load_config, + save_config, +) +from tensors.display import ( + _format_count, + _format_size, + display_civitai_data, + display_file_info, + display_local_metadata, + display_model_info, + display_search_results, +) from tensors.safetensor import get_base_name, read_safetensor_metadata +runner = CliRunner() + class TestReadSafetensorMetadata: """Tests for read_safetensor_metadata function.""" @@ -128,3 +160,431 @@ class TestLoadApiKey: monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "nonexistent" / "config.toml") monkeypatch.setattr(config, "LEGACY_RC_FILE", legacy_file) assert load_api_key() == "legacy-key" + + +class TestSaveConfig: + """Tests for save_config function.""" + + def test_saves_simple_config(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Test saving a simple config.""" + config_dir = tmp_path / "config" + config_file = config_dir / "config.toml" + monkeypatch.setattr(config, "CONFIG_DIR", config_dir) + monkeypatch.setattr(config, "CONFIG_FILE", config_file) + + save_config({"key": "value"}) + + assert config_file.exists() + content = config_file.read_text() + assert 'key = "value"' in content + + def test_saves_nested_config(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Test saving a nested config with sections.""" + config_dir = tmp_path / "config" + config_file = config_dir / "config.toml" + monkeypatch.setattr(config, "CONFIG_DIR", config_dir) + monkeypatch.setattr(config, "CONFIG_FILE", config_file) + + save_config({"api": {"civitai_key": "test-key"}}) + + content = config_file.read_text() + assert "[api]" in content + assert 'civitai_key = "test-key"' in content + + def test_saves_numeric_values(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Test saving numeric values without quotes.""" + config_dir = tmp_path / "config" + config_file = config_dir / "config.toml" + monkeypatch.setattr(config, "CONFIG_DIR", config_dir) + monkeypatch.setattr(config, "CONFIG_FILE", config_file) + + save_config({"timeout": 30}) + + content = config_file.read_text() + assert "timeout = 30" in content + + +class TestLoadConfig: + """Tests for load_config function.""" + + def test_returns_empty_dict_if_no_config(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Test that empty dict is returned when config file doesn't exist.""" + monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "nonexistent.toml") + assert load_config() == {} + + +class TestEnums: + """Tests for enum to_api methods.""" + + def test_model_type_to_api(self) -> None: + """Test ModelType enum to_api conversion.""" + assert ModelType.checkpoint.to_api() == "Checkpoint" + assert ModelType.lora.to_api() == "LORA" + assert ModelType.embedding.to_api() == "TextualInversion" + assert ModelType.vae.to_api() == "VAE" + assert ModelType.controlnet.to_api() == "Controlnet" + assert ModelType.locon.to_api() == "LoCon" + + def test_base_model_to_api(self) -> None: + """Test BaseModel enum to_api conversion.""" + assert BaseModel.sd15.to_api() == "SD 1.5" + assert BaseModel.sdxl.to_api() == "SDXL 1.0" + assert BaseModel.pony.to_api() == "Pony" + assert BaseModel.flux.to_api() == "Flux.1 D" + assert BaseModel.illustrious.to_api() == "Illustrious" + + def test_sort_order_to_api(self) -> None: + """Test SortOrder enum to_api conversion.""" + assert SortOrder.downloads.to_api() == "Most Downloaded" + assert SortOrder.rating.to_api() == "Highest Rated" + assert SortOrder.newest.to_api() == "Newest" + + +class TestDisplayFormatters: + """Tests for display formatting functions.""" + + def test_format_size_kb(self) -> None: + """Test formatting sizes in KB.""" + assert _format_size(500) == "500 KB" + assert _format_size(1023) == "1023 KB" + + def test_format_size_mb(self) -> None: + """Test formatting sizes in MB.""" + assert _format_size(1024) == "1.0 MB" + assert _format_size(2048) == "2.0 MB" + assert _format_size(1024 * 500) == "500.0 MB" + + def test_format_size_gb(self) -> None: + """Test formatting sizes in GB.""" + assert _format_size(1024 * 1024) == "1.00 GB" + assert _format_size(1024 * 1024 * 2.5) == "2.50 GB" + + def test_format_count_small(self) -> None: + """Test formatting small counts.""" + assert _format_count(0) == "0" + assert _format_count(999) == "999" + + def test_format_count_thousands(self) -> None: + """Test formatting counts in thousands.""" + assert _format_count(1000) == "1.0K" + assert _format_count(5500) == "5.5K" + assert _format_count(999999) == "1000.0K" + + def test_format_count_millions(self) -> None: + """Test formatting counts in millions.""" + assert _format_count(1_000_000) == "1.0M" + assert _format_count(2_500_000) == "2.5M" + + +class TestDisplayFunctions: + """Tests for display functions with console output.""" + + def test_display_file_info(self, temp_safetensor: Path) -> None: + """Test display_file_info renders without error.""" + console = Console(force_terminal=True, width=80) + metadata = read_safetensor_metadata(temp_safetensor) + # Should not raise + display_file_info(temp_safetensor, metadata, "ABC123", console) + + def test_display_local_metadata_with_data(self) -> None: + """Test display_local_metadata with metadata.""" + console = Console(force_terminal=True, width=80) + metadata = {"metadata": {"key1": "value1", "key2": "value2"}, "tensor_count": 0, "header_size": 100} + # Should not raise + display_local_metadata(metadata, console) + + def test_display_local_metadata_empty(self) -> None: + """Test display_local_metadata with no metadata.""" + console = Console(force_terminal=True, width=80) + metadata: dict[str, Any] = {"metadata": {}, "tensor_count": 0, "header_size": 100} + # Should not raise + display_local_metadata(metadata, console) + + def test_display_local_metadata_with_filter(self) -> None: + """Test display_local_metadata with key filter.""" + console = Console(force_terminal=True, width=80) + metadata = {"metadata": {"key1": "value1", "key2": "value2"}, "tensor_count": 0, "header_size": 100} + # Should not raise + display_local_metadata(metadata, console, keys_filter=["key1"]) + + def test_display_civitai_data_none(self) -> None: + """Test display_civitai_data with None.""" + console = Console(force_terminal=True, width=80) + # Should not raise + display_civitai_data(None, console) + + def test_display_civitai_data_with_data(self) -> None: + """Test display_civitai_data with model data.""" + console = Console(force_terminal=True, width=80) + data = { + "modelId": 123, + "id": 456, + "name": "Test Model v1", + "baseModel": "SDXL 1.0", + "createdAt": "2024-01-01", + "trainedWords": ["word1", "word2"], + "downloadUrl": "https://example.com/download", + "files": [ + { + "primary": True, + "name": "model.safetensors", + "sizeKB": 5000, + "metadata": {"format": "SafeTensor", "fp": "fp16", "size": "full"}, + } + ], + } + # Should not raise + display_civitai_data(data, console) + + def test_display_model_info(self) -> None: + """Test display_model_info with model data.""" + console = Console(force_terminal=True, width=80) + data = { + "id": 123, + "name": "Test Model", + "type": "LORA", + "nsfw": False, + "creator": {"username": "testuser"}, + "tags": ["tag1", "tag2"], + "stats": {"downloadCount": 1000, "thumbsUpCount": 100}, + "modelVersions": [ + { + "id": 456, + "name": "v1.0", + "baseModel": "SDXL 1.0", + "createdAt": "2024-01-01", + "files": [{"primary": True, "name": "model.safetensors", "sizeKB": 5000}], + } + ], + } + # Should not raise + display_model_info(data, console) + + def test_display_search_results_empty(self) -> None: + """Test display_search_results with no results.""" + console = Console(force_terminal=True, width=80) + # Should not raise + display_search_results({"items": []}, console) + + def test_display_search_results_with_data(self) -> None: + """Test display_search_results with results.""" + console = Console(force_terminal=True, width=80) + results = { + "items": [ + { + "id": 123, + "name": "Test Model", + "type": "LORA", + "modelVersions": [{"baseModel": "SDXL 1.0", "files": [{"primary": True, "sizeKB": 5000}]}], + "stats": {"downloadCount": 1000, "thumbsUpCount": 100}, + } + ], + "metadata": {"totalItems": 1}, + } + # Should not raise + display_search_results(results, console) + + +class TestAPIFunctions: + """Tests for API functions with mocked HTTP.""" + + @respx.mock + def test_fetch_model_version_success(self) -> None: + """Test successful model version fetch.""" + console = Console(force_terminal=True, width=80) + respx.get("https://civitai.com/api/v1/model-versions/123").mock( + return_value=httpx.Response(200, json={"id": 123, "name": "Test"}) + ) + + result = fetch_civitai_model_version(123, None, console) + assert result == {"id": 123, "name": "Test"} + + @respx.mock + def test_fetch_model_version_not_found(self) -> None: + """Test model version not found.""" + console = Console(force_terminal=True, width=80) + respx.get("https://civitai.com/api/v1/model-versions/999").mock(return_value=httpx.Response(404)) + + result = fetch_civitai_model_version(999, None, console) + assert result is None + + @respx.mock + def test_fetch_model_success(self) -> None: + """Test successful model fetch.""" + console = Console(force_terminal=True, width=80) + respx.get("https://civitai.com/api/v1/models/123").mock( + return_value=httpx.Response(200, json={"id": 123, "name": "Test Model"}) + ) + + result = fetch_civitai_model(123, None, console) + assert result == {"id": 123, "name": "Test Model"} + + @respx.mock + def test_fetch_model_not_found(self) -> None: + """Test model not found.""" + console = Console(force_terminal=True, width=80) + respx.get("https://civitai.com/api/v1/models/999").mock(return_value=httpx.Response(404)) + + result = fetch_civitai_model(999, None, console) + assert result is None + + @respx.mock + def test_fetch_by_hash_success(self) -> None: + """Test successful hash lookup.""" + console = Console(force_terminal=True, width=80) + respx.get("https://civitai.com/api/v1/model-versions/by-hash/ABC123").mock( + return_value=httpx.Response(200, json={"id": 456, "name": "Found"}) + ) + + result = fetch_civitai_by_hash("ABC123", None, console) + assert result == {"id": 456, "name": "Found"} + + @respx.mock + def test_fetch_by_hash_not_found(self) -> None: + """Test hash not found.""" + console = Console(force_terminal=True, width=80) + respx.get("https://civitai.com/api/v1/model-versions/by-hash/NOTFOUND").mock(return_value=httpx.Response(404)) + + result = fetch_civitai_by_hash("NOTFOUND", None, console) + assert result is None + + @respx.mock + def test_search_civitai_success(self) -> None: + """Test successful search.""" + console = Console(force_terminal=True, width=80) + respx.get("https://civitai.com/api/v1/models").mock( + return_value=httpx.Response(200, json={"items": [{"id": 1}], "metadata": {}}) + ) + + result = search_civitai("test", None, None, SortOrder.downloads, 20, None, console) + assert result is not None + assert len(result["items"]) == 1 + + @respx.mock + def test_search_civitai_with_filters(self) -> None: + """Test search with type and base model filters.""" + console = Console(force_terminal=True, width=80) + respx.get("https://civitai.com/api/v1/models").mock( + return_value=httpx.Response(200, json={"items": [{"id": 1, "name": "Test LORA"}], "metadata": {}}) + ) + + result = search_civitai("test", ModelType.lora, BaseModel.sdxl, SortOrder.downloads, 20, None, console) + assert result is not None + + @respx.mock + def test_download_model_success(self, tmp_path: Path) -> None: + """Test successful model download.""" + console = Console(force_terminal=True, width=80) + dest = tmp_path / "model.safetensors" + respx.get("https://civitai.com/api/download/models/123").mock( + return_value=httpx.Response(200, content=b"fake model data") + ) + + result = download_model(123, dest, None, console, resume=False) + assert result is True + assert dest.exists() + assert dest.read_bytes() == b"fake model data" + + @respx.mock + def test_download_model_unauthorized(self, tmp_path: Path) -> None: + """Test download with 401 unauthorized.""" + console = Console(force_terminal=True, width=80) + dest = tmp_path / "model.safetensors" + respx.get("https://civitai.com/api/download/models/123").mock(return_value=httpx.Response(401)) + + result = download_model(123, dest, None, console, resume=False) + assert result is False + + +class TestCLI: + """Tests for CLI commands.""" + + def test_help(self) -> None: + """Test --help works.""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "safetensor" in result.stdout.lower() + + def test_info_file_not_found(self, tmp_path: Path) -> None: + """Test info command with non-existent file.""" + result = runner.invoke(app, ["info", str(tmp_path / "nonexistent.safetensors")]) + assert result.exit_code == 1 + assert "not found" in result.stdout.lower() + + def test_info_with_safetensor(self, temp_safetensor: Path) -> None: + """Test info command with valid safetensor file.""" + result = runner.invoke(app, ["info", str(temp_safetensor), "--skip-civitai"]) + assert result.exit_code == 0 + + def test_info_json_output(self, temp_safetensor: Path) -> None: + """Test info command with JSON output.""" + result = runner.invoke(app, ["info", str(temp_safetensor), "--skip-civitai", "--json"]) + assert result.exit_code == 0 + assert "sha256" in result.stdout + + def test_info_meta_filter(self, temp_safetensor: Path) -> None: + """Test info command with metadata filter.""" + result = runner.invoke(app, ["info", str(temp_safetensor), "--meta", "test_key"]) + assert result.exit_code == 0 + assert "test_value" in result.stdout + + @respx.mock + def test_search_command(self) -> None: + """Test search command.""" + respx.get("https://civitai.com/api/v1/models").mock( + return_value=httpx.Response( + 200, + json={ + "items": [{"id": 1, "name": "Test", "type": "LORA", "modelVersions": [], "stats": {}}], + "metadata": {"totalItems": 1}, + }, + ) + ) + + result = runner.invoke(app, ["search", "test"]) + assert result.exit_code == 0 + + @respx.mock + def test_get_command(self) -> None: + """Test get command.""" + respx.get("https://civitai.com/api/v1/models/123").mock( + return_value=httpx.Response( + 200, + json={ + "id": 123, + "name": "Test Model", + "type": "LORA", + "nsfw": False, + "stats": {}, + "modelVersions": [], + }, + ) + ) + + result = runner.invoke(app, ["get", "123"]) + assert result.exit_code == 0 + + @respx.mock + def test_get_command_not_found(self) -> None: + """Test get command with non-existent model.""" + respx.get("https://civitai.com/api/v1/models/999").mock(return_value=httpx.Response(404)) + + result = runner.invoke(app, ["get", "999"]) + assert result.exit_code == 1 + assert "not found" in result.stdout.lower() + + def test_config_show(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Test config --show command.""" + monkeypatch.delenv("CIVITAI_API_KEY", raising=False) + monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "config.toml") + monkeypatch.setattr(config, "LEGACY_RC_FILE", tmp_path / "nonexistent") + + result = runner.invoke(app, ["config", "--show"]) + assert result.exit_code == 0 + assert "config file" in result.stdout.lower() + + def test_download_no_args(self) -> None: + """Test dl command with no arguments.""" + result = runner.invoke(app, ["dl"]) + assert result.exit_code == 1 + assert "must specify" in result.stdout.lower() diff --git a/uv.lock b/uv.lock index b9fc952..a17a45d 100644 --- a/uv.lock +++ b/uv.lock @@ -490,6 +490,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + [[package]] name = "rich" version = "14.3.0" @@ -578,6 +590,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "respx" }, { name = "ruff" }, ] @@ -596,6 +609,7 @@ dev = [ { name = "pre-commit", specifier = ">=3.6" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=4.1" }, + { name = "respx", specifier = ">=0.22.0" }, { name = "ruff", specifier = ">=0.9.0" }, ]