Merge pull request #567 from ggozad/chore/coverage-honesty
Measure the CLI and its application layer
This commit is contained in:
commit
d429ac4996
8 changed files with 1183 additions and 67 deletions
8
.github/workflows/build-docs.yml
vendored
8
.github/workflows/build-docs.yml
vendored
|
|
@ -3,12 +3,15 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
concurrency:
|
||||
group: pages
|
||||
group: pages-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -19,11 +22,14 @@ jobs:
|
|||
- run: uv sync --group dev
|
||||
- run: uv run zensical build
|
||||
- uses: actions/configure-pages@v5
|
||||
if: github.event_name == 'push'
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
if: github.event_name == 'push'
|
||||
with:
|
||||
path: ./site
|
||||
deploy:
|
||||
needs: build
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
|
|
|
|||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
|
@ -77,7 +77,7 @@ jobs:
|
|||
env:
|
||||
HF_HUB_OFFLINE: ${{ steps.hf-cache.outputs.cache-hit == 'true' && '1' || '0' }}
|
||||
TRANSFORMERS_OFFLINE: ${{ steps.hf-cache.outputs.cache-hit == 'true' && '1' || '0' }}
|
||||
run: uv run pytest -m "not integration" --cov=haiku --cov-report=xml --cov-report=term-missing:skip-covered
|
||||
run: uv run pytest -m "not integration" --cov --cov-report=xml --cov-report=term-missing:skip-covered
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- `haiku-rag settings` masked only top-level secret-named fields, printing nested ones in full (`lancedb.api_key`, `providers.docling_serve.api_key`, WebDAV source passwords). It redacts the whole dump.
|
||||
- `haiku-rag chat` reports a missing `tui` extra instead of raising `ImportError`, matching `haiku-rag inspect`.
|
||||
- `storage.data_dir: ""` resolves to the platform data directory, as documented; it coerced to `Path("")`, so the database was created in the process's working directory. Set `data_dir: .` to keep the old placement.
|
||||
- Documented `search.limit` default is 5, was stated as 10.
|
||||
- The documented way to disable reranking is omitting `reranking.model` or setting it to `null`; the previous `provider: ""` example raised `Unknown reranking provider`.
|
||||
|
|
|
|||
|
|
@ -24,12 +24,13 @@ from haiku.rag.store.models.document import Document
|
|||
if TYPE_CHECKING:
|
||||
from haiku.rag.store.engine import Store
|
||||
from haiku.rag.store.models import SearchResult
|
||||
from haiku.rag.config import redact_secrets
|
||||
from haiku.rag.utils import format_bytes, format_citations_rich
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HaikuRAGApp: # pragma: no cover
|
||||
class HaikuRAGApp:
|
||||
def __init__(
|
||||
self,
|
||||
db_path: Path,
|
||||
|
|
@ -822,21 +823,11 @@ class HaikuRAGApp: # pragma: no cover
|
|||
self.console.print("[bold]haiku.rag configuration[/bold]")
|
||||
self.console.print()
|
||||
|
||||
# Get all config fields dynamically
|
||||
for field_name, field_value in self.config.model_dump().items():
|
||||
# Format the display value
|
||||
if isinstance(field_value, str) and (
|
||||
"key" in field_name.lower()
|
||||
or "password" in field_name.lower()
|
||||
or "token" in field_name.lower()
|
||||
):
|
||||
# Hide sensitive values but show if they're set
|
||||
display_value = "✓ Set" if field_value else "✗ Not set"
|
||||
else:
|
||||
display_value = field_value
|
||||
|
||||
# redact_secrets walks the whole dump: masking only top-level names left
|
||||
# nested api keys, tokens and source passwords printed in full.
|
||||
for field_name, field_value in redact_secrets(self.config.model_dump()).items():
|
||||
self.console.print(
|
||||
f" [repr.attrib_name]{field_name}[/repr.attrib_name]: {display_value}"
|
||||
f" [repr.attrib_name]{field_name}[/repr.attrib_name]: {field_value}"
|
||||
)
|
||||
|
||||
def _rich_print_document(self, doc: Document, truncate: bool = False):
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ def cli():
|
|||
_read_only: bool = False
|
||||
|
||||
|
||||
def create_app(db: Path | None = None) -> "HaikuRAGApp": # pragma: no cover
|
||||
def create_app(db: Path | None = None) -> "HaikuRAGApp":
|
||||
"""Create HaikuRAGApp with loaded config and resolved database path.
|
||||
|
||||
Args:
|
||||
|
|
@ -66,7 +66,7 @@ def create_app(db: Path | None = None) -> "HaikuRAGApp": # pragma: no cover
|
|||
return HaikuRAGApp(db_path=db_path, config=config, read_only=_read_only)
|
||||
|
||||
|
||||
async def check_version(): # pragma: no cover
|
||||
async def check_version():
|
||||
"""Check if haiku.rag is up to date and show warning if not."""
|
||||
up_to_date, current_version, latest_version = await is_up_to_date()
|
||||
if not up_to_date:
|
||||
|
|
@ -76,7 +76,7 @@ async def check_version(): # pragma: no cover
|
|||
typer.echo("Please update.")
|
||||
|
||||
|
||||
def version_callback(value: bool): # pragma: no cover
|
||||
def version_callback(value: bool):
|
||||
if value:
|
||||
v = version("haiku.rag-slim")
|
||||
typer.echo(f"haiku.rag version {v}")
|
||||
|
|
@ -130,13 +130,13 @@ def main(
|
|||
# Run version check before any command
|
||||
try:
|
||||
asyncio.run(check_version())
|
||||
except Exception: # pragma: no cover
|
||||
except Exception:
|
||||
# Do not block CLI on version check issues
|
||||
pass
|
||||
|
||||
|
||||
@_cli.command("list", help="List all stored documents")
|
||||
def list_documents( # pragma: no cover
|
||||
def list_documents(
|
||||
db: Path | None = typer.Option(
|
||||
None,
|
||||
"--db",
|
||||
|
|
@ -178,7 +178,7 @@ def _parse_meta_options(meta: list[str] | None) -> dict[str, Any]:
|
|||
|
||||
|
||||
@_cli.command("add", help="Add a document from text input")
|
||||
def add_document_text( # pragma: no cover
|
||||
def add_document_text(
|
||||
text: str = typer.Argument(
|
||||
help="The text content of the document to add",
|
||||
),
|
||||
|
|
@ -207,7 +207,7 @@ def add_document_text( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("add-src", help="Add a document from a file path, directory, or URL")
|
||||
def add_document_src( # pragma: no cover
|
||||
def add_document_src(
|
||||
source: str = typer.Argument(
|
||||
help="The file path, directory, or URL of the document(s) to add",
|
||||
),
|
||||
|
|
@ -238,7 +238,7 @@ def add_document_src( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("get", help="Get and display a document by its ID")
|
||||
def get_document( # pragma: no cover
|
||||
def get_document(
|
||||
doc_id: str = typer.Argument(
|
||||
help="The ID of the document to get",
|
||||
),
|
||||
|
|
@ -253,7 +253,7 @@ def get_document( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("delete", help="Delete a document by its ID")
|
||||
def delete_document( # pragma: no cover
|
||||
def delete_document(
|
||||
doc_id: str = typer.Argument(
|
||||
help="The ID of the document to delete",
|
||||
),
|
||||
|
|
@ -274,7 +274,7 @@ _cli.command("rm", help="Alias for delete: remove a document by its ID")(
|
|||
|
||||
|
||||
@_cli.command("search", help="Search for documents by a query")
|
||||
def search( # pragma: no cover
|
||||
def search(
|
||||
query: str | None = typer.Argument(
|
||||
None,
|
||||
help="The search query (omit when using --image)",
|
||||
|
|
@ -321,7 +321,7 @@ def search( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("visualize", help="Show visual grounding for a chunk")
|
||||
def visualize( # pragma: no cover
|
||||
def visualize(
|
||||
chunk_id: str = typer.Argument(
|
||||
help="The ID of the chunk to visualize",
|
||||
),
|
||||
|
|
@ -341,7 +341,7 @@ def visualize( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("ask", help="Ask a question using the QA agent")
|
||||
def ask( # pragma: no cover
|
||||
def ask(
|
||||
question: str = typer.Argument(
|
||||
help="The question to ask",
|
||||
),
|
||||
|
|
@ -373,7 +373,7 @@ def ask( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("analyze", help="Answer questions using the analysis capability")
|
||||
def analyze( # pragma: no cover
|
||||
def analyze(
|
||||
question: str = typer.Argument(
|
||||
help="The question to answer",
|
||||
),
|
||||
|
|
@ -405,7 +405,7 @@ def analyze( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("settings", help="Display current configuration settings")
|
||||
def settings(): # pragma: no cover
|
||||
def settings():
|
||||
from haiku.rag.app import HaikuRAGApp
|
||||
|
||||
config = get_config()
|
||||
|
|
@ -499,25 +499,25 @@ def rebuild(
|
|||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if embed_only: # pragma: no cover
|
||||
if embed_only:
|
||||
mode = RebuildMode.EMBED_ONLY
|
||||
elif rechunk: # pragma: no cover
|
||||
elif rechunk:
|
||||
mode = RebuildMode.RECHUNK
|
||||
elif title_only: # pragma: no cover
|
||||
elif title_only:
|
||||
mode = RebuildMode.TITLE_ONLY
|
||||
elif descriptions: # pragma: no cover
|
||||
elif descriptions:
|
||||
mode = RebuildMode.DESCRIPTIONS
|
||||
elif set_embedder: # pragma: no cover
|
||||
elif set_embedder:
|
||||
mode = RebuildMode.SET_EMBEDDER
|
||||
else: # pragma: no cover
|
||||
else:
|
||||
mode = RebuildMode.FULL
|
||||
|
||||
app = create_app(db) # pragma: no cover
|
||||
asyncio.run(app.rebuild(mode=mode)) # pragma: no cover
|
||||
app = create_app(db)
|
||||
asyncio.run(app.rebuild(mode=mode))
|
||||
|
||||
|
||||
@_cli.command("vacuum", help="Optimize and clean up all tables to reduce disk usage")
|
||||
def vacuum( # pragma: no cover
|
||||
def vacuum(
|
||||
db: Path | None = typer.Option(
|
||||
None,
|
||||
"--db",
|
||||
|
|
@ -529,7 +529,7 @@ def vacuum( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("migrate", help="Run pending database migrations")
|
||||
def migrate( # pragma: no cover
|
||||
def migrate(
|
||||
db: Path | None = typer.Option(
|
||||
None,
|
||||
"--db",
|
||||
|
|
@ -554,7 +554,7 @@ def migrate( # pragma: no cover
|
|||
@_cli.command(
|
||||
"create-index", help="Create vector index for efficient similarity search"
|
||||
)
|
||||
def create_index( # pragma: no cover
|
||||
def create_index(
|
||||
db: Path | None = typer.Option(
|
||||
None,
|
||||
"--db",
|
||||
|
|
@ -566,7 +566,7 @@ def create_index( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("init", help="Initialize a new database")
|
||||
def init_db( # pragma: no cover
|
||||
def init_db(
|
||||
db: Path | None = typer.Option(
|
||||
None,
|
||||
"--db",
|
||||
|
|
@ -578,7 +578,7 @@ def init_db( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("info", help="Show database info")
|
||||
def info( # pragma: no cover
|
||||
def info(
|
||||
db: Path | None = typer.Option(
|
||||
None,
|
||||
"--db",
|
||||
|
|
@ -590,7 +590,7 @@ def info( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("doctor", help="Check database and provider health")
|
||||
def doctor( # pragma: no cover
|
||||
def doctor(
|
||||
db: Path | None = typer.Option(
|
||||
None,
|
||||
"--db",
|
||||
|
|
@ -608,7 +608,7 @@ def doctor( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("history", help="Show version history for database tables")
|
||||
def history( # pragma: no cover
|
||||
def history(
|
||||
db: Path | None = typer.Option(
|
||||
None,
|
||||
"--db",
|
||||
|
|
@ -639,7 +639,7 @@ _cli.add_typer(tag_cli, name="tag")
|
|||
|
||||
|
||||
@tag_cli.command("create", help="Tag the current database state")
|
||||
def tag_create( # pragma: no cover
|
||||
def tag_create(
|
||||
name: str = typer.Argument(help="Name of the tag to create"),
|
||||
db: Path | None = typer.Option(
|
||||
None,
|
||||
|
|
@ -656,7 +656,7 @@ def tag_create( # pragma: no cover
|
|||
|
||||
|
||||
@tag_cli.command("list", help="List database tags")
|
||||
def tag_list( # pragma: no cover
|
||||
def tag_list(
|
||||
db: Path | None = typer.Option(
|
||||
None,
|
||||
"--db",
|
||||
|
|
@ -672,7 +672,7 @@ def tag_list( # pragma: no cover
|
|||
|
||||
|
||||
@tag_cli.command("delete", help="Delete a tag")
|
||||
def tag_delete( # pragma: no cover
|
||||
def tag_delete(
|
||||
name: str = typer.Argument(help="Name of the tag to delete"),
|
||||
db: Path | None = typer.Option(
|
||||
None,
|
||||
|
|
@ -689,7 +689,7 @@ def tag_delete( # pragma: no cover
|
|||
|
||||
|
||||
@tag_cli.command("restore", help="Restore the database to a tagged state")
|
||||
def tag_restore( # pragma: no cover
|
||||
def tag_restore(
|
||||
name: str = typer.Argument(help="Name of the tag to restore"),
|
||||
yes: bool = typer.Option(
|
||||
False,
|
||||
|
|
@ -724,7 +724,7 @@ def tag_restore( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("download-models", help="Download Docling and Ollama models per config")
|
||||
def download_models_cmd(): # pragma: no cover
|
||||
def download_models_cmd():
|
||||
from haiku.rag.app import HaikuRAGApp
|
||||
|
||||
app = HaikuRAGApp(db_path=Path(), config=get_config(), read_only=True)
|
||||
|
|
@ -736,7 +736,7 @@ def download_models_cmd(): # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("inspect", help="Launch interactive TUI to inspect database contents")
|
||||
def inspect( # pragma: no cover
|
||||
def inspect(
|
||||
db: Path | None = typer.Option(
|
||||
None,
|
||||
"--db",
|
||||
|
|
@ -755,7 +755,7 @@ def inspect( # pragma: no cover
|
|||
|
||||
|
||||
@_cli.command("chat", help="Launch interactive chat TUI for conversational RAG")
|
||||
def chat( # pragma: no cover
|
||||
def chat(
|
||||
db: Path | None = typer.Option(
|
||||
None,
|
||||
"--db",
|
||||
|
|
@ -779,12 +779,16 @@ def chat( # pragma: no cover
|
|||
db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb"
|
||||
capabilities = capability if capability else ["rag"]
|
||||
|
||||
run_chat(
|
||||
db_path,
|
||||
read_only=True,
|
||||
model=model,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
try:
|
||||
run_chat(
|
||||
db_path,
|
||||
read_only=True,
|
||||
model=model,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
except ImportError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1) from e
|
||||
|
||||
|
||||
@_cli.command(
|
||||
|
|
@ -814,14 +818,12 @@ def mcp(
|
|||
),
|
||||
) -> None:
|
||||
"""Run the MCP server."""
|
||||
app = create_app(db) # pragma: no cover
|
||||
app = create_app(db)
|
||||
|
||||
transport = "stdio" if stdio else None # pragma: no cover
|
||||
transport = "stdio" if stdio else None
|
||||
|
||||
asyncio.run( # pragma: no cover
|
||||
app.run_mcp(transport=transport, host=host, port=port)
|
||||
)
|
||||
asyncio.run(app.run_mcp(transport=transport, host=host, port=port))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli()
|
||||
if __name__ == "__main__":
|
||||
cli() # pragma: no cover - module-as-script entry, never imported by tests
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@ source = ["haiku_rag_slim"]
|
|||
# flow via a greenlet + worker thread; track both so that code isn't reported
|
||||
# as uncovered.
|
||||
concurrency = ["greenlet", "thread"]
|
||||
# Textual TUIs only: driving a terminal UI is not what this gate is for.
|
||||
# Everything else, the CLI application layer included, is measured.
|
||||
omit = [
|
||||
"haiku_rag_slim/haiku/rag/chat/*",
|
||||
"haiku_rag_slim/haiku/rag/inspector/*",
|
||||
|
|
|
|||
810
tests/test_app.py
Normal file
810
tests/test_app.py
Normal file
|
|
@ -0,0 +1,810 @@
|
|||
"""Tests for the CLI application layer.
|
||||
|
||||
They stub the client and record the console: each test pins what the app asks
|
||||
the client for and what it renders, without a database or a model.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from haiku.rag.app import HaikuRAGApp
|
||||
from haiku.rag.client import RebuildMode
|
||||
from haiku.rag.config.models import AppConfig, LanceDBConfig
|
||||
from haiku.rag.store.models.chunk import Chunk, SearchResult
|
||||
from haiku.rag.store.models.document import Document
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return AsyncMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(tmp_path, client, monkeypatch):
|
||||
class StubHaikuRAG:
|
||||
# run_mcp passes db_path positionally; every other caller uses kwargs.
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return client
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("haiku.rag.app.HaikuRAG", StubHaikuRAG)
|
||||
db = tmp_path / "db.lancedb"
|
||||
db.mkdir()
|
||||
application = HaikuRAGApp(db_path=db, config=AppConfig())
|
||||
application.console = Console(record=True, width=200)
|
||||
return application
|
||||
|
||||
|
||||
def out(app) -> str:
|
||||
# clear=False: export_text() empties the buffer by default, so a second
|
||||
# assertion in the same test would see nothing.
|
||||
return app.console.export_text(clear=False)
|
||||
|
||||
|
||||
def _doc(content: str = "body text", **kwargs) -> Document:
|
||||
doc = Document(content=content, uri=kwargs.pop("uri", "test://doc"), **kwargs)
|
||||
doc.id = kwargs.get("id", "doc-1")
|
||||
return doc
|
||||
|
||||
|
||||
async def test_list_documents_prints_each_document(app, client):
|
||||
client.list_documents.return_value = [_doc("first"), _doc("second")]
|
||||
|
||||
await app.list_documents()
|
||||
|
||||
client.list_documents.assert_awaited_once_with(filter=None)
|
||||
assert "first" in out(app)
|
||||
assert "second" in out(app)
|
||||
|
||||
|
||||
async def test_add_document_from_text_reports_the_new_id(app, client):
|
||||
client.create_document.return_value = _doc("added body")
|
||||
|
||||
await app.add_document_from_text("added body", title="T", metadata={"k": "v"})
|
||||
|
||||
client.create_document.assert_awaited_once_with(
|
||||
"added body", title="T", metadata={"k": "v"}
|
||||
)
|
||||
assert "doc-1 added successfully" in out(app)
|
||||
|
||||
|
||||
async def test_add_document_from_source_reports_one_document(app, client):
|
||||
client.create_document_from_source.return_value = _doc("from file")
|
||||
|
||||
await app.add_document_from_source("/tmp/x.md")
|
||||
|
||||
assert "doc-1 added successfully" in out(app)
|
||||
|
||||
|
||||
async def test_add_document_from_source_reports_a_directory_count(app, client):
|
||||
client.create_document_from_source.return_value = [_doc("a"), _doc("b")]
|
||||
|
||||
await app.add_document_from_source("/tmp/dir")
|
||||
|
||||
assert "2 documents added successfully" in out(app)
|
||||
|
||||
|
||||
async def test_get_document_reports_a_missing_id(app, client):
|
||||
client.get_document_by_id.return_value = None
|
||||
|
||||
await app.get_document("nope")
|
||||
|
||||
assert "not found" in out(app)
|
||||
|
||||
|
||||
async def test_get_document_prints_it_untruncated(app, client):
|
||||
client.get_document_by_id.return_value = _doc("line1\nline2\nline3\nline4")
|
||||
|
||||
await app.get_document("doc-1")
|
||||
|
||||
assert "line4" in out(app)
|
||||
|
||||
|
||||
async def test_delete_document_confirms(app, client):
|
||||
client.delete_document.return_value = True
|
||||
|
||||
await app.delete_document("doc-1")
|
||||
|
||||
assert "deleted successfully" in out(app)
|
||||
|
||||
|
||||
async def test_delete_document_reports_a_missing_id(app, client):
|
||||
client.delete_document.return_value = False
|
||||
|
||||
await app.delete_document("nope")
|
||||
|
||||
assert "not found" in out(app)
|
||||
|
||||
|
||||
async def test_search_requires_a_query_or_an_image(app):
|
||||
await app.search()
|
||||
|
||||
assert "Provide either a query argument or --image" in out(app)
|
||||
|
||||
|
||||
async def test_search_refuses_both_a_query_and_an_image(app, tmp_path):
|
||||
await app.search(query="q", image=tmp_path / "i.png")
|
||||
|
||||
assert "not both" in out(app)
|
||||
|
||||
|
||||
async def test_search_type_needs_a_text_query(app, tmp_path):
|
||||
await app.search(image=tmp_path / "i.png", search_type="vector")
|
||||
|
||||
assert "only for text queries" in out(app)
|
||||
|
||||
|
||||
async def test_search_reports_no_results(app, client):
|
||||
client.search.return_value = []
|
||||
|
||||
await app.search(query="q")
|
||||
|
||||
assert "No results found" in out(app)
|
||||
|
||||
|
||||
async def test_search_prints_results(app, client):
|
||||
client.search.return_value = [
|
||||
SearchResult(content="hit one", score=0.9, chunk_id="c1")
|
||||
]
|
||||
|
||||
await app.search(query="q", limit=3)
|
||||
|
||||
client.search.assert_awaited_once_with("q", limit=3, filter=None, search_type=None)
|
||||
assert "hit one" in out(app)
|
||||
|
||||
|
||||
async def test_search_by_image_reads_the_bytes(app, client, tmp_path):
|
||||
image = tmp_path / "query.png"
|
||||
image.write_bytes(b"pixels")
|
||||
client.search.return_value = []
|
||||
|
||||
await app.search(image=image)
|
||||
|
||||
assert client.search.await_args.args[0] == b"pixels"
|
||||
|
||||
|
||||
async def test_visualize_reports_a_missing_chunk(app, client):
|
||||
client.get_chunk_by_id.return_value = None
|
||||
|
||||
await app.visualize_chunk("nope")
|
||||
|
||||
assert "not found" in out(app)
|
||||
|
||||
|
||||
async def test_visualize_reports_no_grounding(app, client):
|
||||
client.get_chunk_by_id.return_value = Chunk(content="c", order=0)
|
||||
client.visualize_chunk.return_value = []
|
||||
|
||||
await app.visualize_chunk("c1")
|
||||
|
||||
assert "No visual grounding available" in out(app)
|
||||
|
||||
|
||||
async def test_ask_prints_question_answer_and_citations(app, client, monkeypatch):
|
||||
client.ask.return_value = ("The answer.", [])
|
||||
|
||||
async def no_citations(citations, client=None):
|
||||
return ["citation block"]
|
||||
|
||||
monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations)
|
||||
|
||||
await app.ask("why?", filter="uri LIKE 'x%'")
|
||||
|
||||
client.ask.assert_awaited_once_with("why?", filter="uri LIKE 'x%'", images=None)
|
||||
printed = out(app)
|
||||
assert "why?" in printed
|
||||
assert "The answer." in printed
|
||||
assert "citation block" in printed
|
||||
|
||||
|
||||
async def test_ask_attaches_image_bytes(app, client, monkeypatch, tmp_path):
|
||||
image = tmp_path / "a.png"
|
||||
image.write_bytes(b"img")
|
||||
client.ask.return_value = ("answer", [])
|
||||
|
||||
async def no_citations(citations, client=None):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations)
|
||||
|
||||
await app.ask("why?", images=[image])
|
||||
|
||||
assert client.ask.await_args.kwargs["images"] == [b"img"]
|
||||
|
||||
|
||||
async def test_analyze_prints_the_answer(app, client, monkeypatch):
|
||||
result = AsyncMock()
|
||||
result.answer = "computed answer"
|
||||
result.citations = []
|
||||
client.analyze.return_value = result
|
||||
|
||||
async def no_citations(citations, client=None):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("haiku.rag.app.format_citations_rich", no_citations)
|
||||
|
||||
await app.analyze("how many?")
|
||||
|
||||
printed = out(app)
|
||||
assert "how many?" in printed
|
||||
assert "computed answer" in printed
|
||||
|
||||
|
||||
async def test_rebuild_set_embedder_reports_settings_updated(app, client):
|
||||
async def one_document(mode):
|
||||
yield "doc-1"
|
||||
|
||||
client.rebuild_database = one_document
|
||||
|
||||
await app.rebuild(mode=RebuildMode.SET_EMBEDDER)
|
||||
|
||||
assert "Stored embedder settings updated" in out(app)
|
||||
|
||||
|
||||
async def test_rebuild_reports_an_empty_database(app, client):
|
||||
client.list_documents.return_value = []
|
||||
|
||||
await app.rebuild(mode=RebuildMode.FULL)
|
||||
|
||||
assert "No documents found" in out(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode, description",
|
||||
[
|
||||
(RebuildMode.FULL, "full rebuild"),
|
||||
(RebuildMode.RECHUNK, "rechunk"),
|
||||
(RebuildMode.EMBED_ONLY, "embed only"),
|
||||
(RebuildMode.TITLE_ONLY, "title only"),
|
||||
(RebuildMode.DESCRIPTIONS, "picture descriptions"),
|
||||
],
|
||||
)
|
||||
async def test_rebuild_names_the_mode_and_completes(app, client, mode, description):
|
||||
client.list_documents.return_value = [_doc()]
|
||||
|
||||
async def one_document(mode):
|
||||
yield "doc-1"
|
||||
|
||||
client.rebuild_database = one_document
|
||||
|
||||
await app.rebuild(mode=mode)
|
||||
|
||||
printed = out(app)
|
||||
assert description in printed
|
||||
assert "rebuild completed successfully" in printed
|
||||
|
||||
|
||||
async def test_vacuum_confirms(app, client):
|
||||
await app.vacuum()
|
||||
|
||||
client.vacuum.assert_awaited_once()
|
||||
assert "Vacuum completed successfully" in out(app)
|
||||
|
||||
|
||||
async def test_create_index_refuses_a_small_table(app, client):
|
||||
client.store.chunks_table.count_rows = AsyncMock(return_value=10)
|
||||
|
||||
await app.create_index()
|
||||
|
||||
assert "Need at least 256 chunks" in out(app)
|
||||
|
||||
|
||||
async def test_create_index_creates_one(app, client):
|
||||
client.store.chunks_table.count_rows = AsyncMock(return_value=512)
|
||||
client.store.chunks_table.list_indices = AsyncMock(return_value=[])
|
||||
|
||||
await app.create_index()
|
||||
|
||||
client.store._ensure_vector_index.assert_awaited_once()
|
||||
assert "Vector index created successfully" in out(app)
|
||||
|
||||
|
||||
async def test_create_index_rebuilds_an_existing_one(app, client):
|
||||
client.store.chunks_table.count_rows = AsyncMock(return_value=512)
|
||||
client.store.chunks_table.list_indices = AsyncMock(return_value=["vector_idx"])
|
||||
|
||||
await app.create_index()
|
||||
|
||||
assert "Rebuilding existing vector index" in out(app)
|
||||
|
||||
|
||||
def test_show_settings_hides_secrets(tmp_path):
|
||||
config = AppConfig(lancedb=LanceDBConfig(uri="db://x", api_key="secret-value"))
|
||||
app = HaikuRAGApp(db_path=tmp_path / "db", config=config)
|
||||
app.console = Console(record=True, width=200)
|
||||
|
||||
app.show_settings()
|
||||
|
||||
printed = app.console.export_text()
|
||||
assert "haiku.rag configuration" in printed
|
||||
assert "secret-value" not in printed
|
||||
|
||||
|
||||
def test_remote_uri_is_the_display_path(tmp_path):
|
||||
config = AppConfig(lancedb=LanceDBConfig(uri="s3://bucket/path"))
|
||||
app = HaikuRAGApp(db_path=tmp_path / "db", config=config)
|
||||
|
||||
assert app._display_path == "s3://bucket/path"
|
||||
assert app._is_local is False
|
||||
|
||||
|
||||
# --- paths that do not open a client -----------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_stub(monkeypatch):
|
||||
"""Stub the Store for the paths that use it directly (history, migrate, tags)."""
|
||||
store = AsyncMock()
|
||||
|
||||
class StubStore:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return store
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("haiku.rag.store.engine.Store", StubStore)
|
||||
return store
|
||||
|
||||
|
||||
async def test_init_reports_an_existing_database(app):
|
||||
await app.init()
|
||||
|
||||
assert "Database already exists" in out(app)
|
||||
|
||||
|
||||
async def test_info_reports_a_missing_path(tmp_path):
|
||||
application = HaikuRAGApp(db_path=tmp_path / "gone", config=AppConfig())
|
||||
application.console = Console(record=True, width=200)
|
||||
|
||||
await application.info()
|
||||
|
||||
assert "Database path does not exist" in application.console.export_text(
|
||||
clear=False
|
||||
)
|
||||
|
||||
|
||||
async def test_history_reports_a_missing_path(tmp_path):
|
||||
application = HaikuRAGApp(db_path=tmp_path / "gone", config=AppConfig())
|
||||
application.console = Console(record=True, width=200)
|
||||
|
||||
await application.history()
|
||||
|
||||
assert "Database path does not exist" in application.console.export_text(
|
||||
clear=False
|
||||
)
|
||||
|
||||
|
||||
async def test_history_rejects_an_unknown_table(app, store_stub):
|
||||
await app.history(table="nope")
|
||||
|
||||
assert "Unknown table: nope" in out(app)
|
||||
|
||||
|
||||
async def test_migrate_returns_what_the_store_applied(app, store_stub):
|
||||
store_stub.migrate.return_value = ["v0_40_0"]
|
||||
|
||||
assert await app.migrate() == ["v0_40_0"]
|
||||
|
||||
|
||||
async def test_list_tags_reports_none(app, monkeypatch):
|
||||
store = AsyncMock()
|
||||
store.list_tags.return_value = {}
|
||||
monkeypatch.setattr(app, "_tag_read_store", lambda: _as_cm(store))
|
||||
|
||||
await app.list_tags()
|
||||
|
||||
assert "No tags" in out(app)
|
||||
|
||||
|
||||
async def test_list_tags_flags_a_partial_tag(app, monkeypatch):
|
||||
from haiku.rag.store.engine import TagInfo
|
||||
|
||||
store = AsyncMock()
|
||||
store.list_tags.return_value = {
|
||||
"release-1": TagInfo(
|
||||
tables={"documents": 3},
|
||||
missing_tables=["chunks"],
|
||||
)
|
||||
}
|
||||
monkeypatch.setattr(app, "_tag_read_store", lambda: _as_cm(store))
|
||||
|
||||
await app.list_tags()
|
||||
|
||||
printed = out(app)
|
||||
assert "release-1" in printed
|
||||
assert "partial" in printed
|
||||
assert "chunks" in printed
|
||||
|
||||
|
||||
async def test_create_and_delete_tag_confirm(app, monkeypatch):
|
||||
store = AsyncMock()
|
||||
monkeypatch.setattr(app, "_tag_write_store", lambda: _as_cm(store))
|
||||
|
||||
await app.create_tag("release-1")
|
||||
await app.delete_tag("release-1")
|
||||
|
||||
printed = out(app)
|
||||
assert "Created tag 'release-1'" in printed
|
||||
assert "Deleted tag 'release-1'" in printed
|
||||
|
||||
|
||||
async def test_restore_tag_reports_the_safety_tag(app, monkeypatch):
|
||||
store = AsyncMock()
|
||||
store.restore_tag.return_value = "before-restore-20260820T000000Z"
|
||||
monkeypatch.setattr(app, "_tag_write_store", lambda: _as_cm(store))
|
||||
|
||||
await app.restore_tag("release-1")
|
||||
|
||||
assert "Restored database to tag 'release-1'" in out(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method, args",
|
||||
[
|
||||
("create_tag", ("release-1",)),
|
||||
("list_tags", ()),
|
||||
("delete_tag", ("release-1",)),
|
||||
("restore_tag", ("release-1",)),
|
||||
],
|
||||
)
|
||||
async def test_tag_operations_require_the_database(tmp_path, method, args):
|
||||
application = HaikuRAGApp(db_path=tmp_path / "gone", config=AppConfig())
|
||||
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
await getattr(application, method)(*args)
|
||||
|
||||
|
||||
async def test_visualize_prints_a_page_per_image(app, client, monkeypatch):
|
||||
chunk = Chunk(content="c", order=0)
|
||||
chunk.document_uri = "test://doc"
|
||||
client.get_chunk_by_id.return_value = chunk
|
||||
client.visualize_chunk.return_value = [object(), object()]
|
||||
monkeypatch.setattr("textual_image.renderable.Image", lambda img: "<page image>")
|
||||
|
||||
await app.visualize_chunk("c1")
|
||||
|
||||
printed = out(app)
|
||||
assert "Visual grounding for chunk c1" in printed
|
||||
assert "Page 1/2" in printed
|
||||
assert "Page 2/2" in printed
|
||||
|
||||
|
||||
def test_search_result_rendering_includes_provenance(app):
|
||||
result = SearchResult(
|
||||
content="hit",
|
||||
score=0.5,
|
||||
chunk_id="c1",
|
||||
document_id="doc-1",
|
||||
document_uri="test://doc",
|
||||
document_title="The Title",
|
||||
page_numbers=[2, 3],
|
||||
headings=["Chapter", "Section"],
|
||||
)
|
||||
|
||||
app._rich_print_search_result(result)
|
||||
|
||||
printed = out(app)
|
||||
assert "The Title" in printed
|
||||
assert "2, 3" in printed
|
||||
assert "Chapter > Section" in printed
|
||||
|
||||
|
||||
async def test_run_mcp_stdio(app, client, monkeypatch):
|
||||
server = AsyncMock()
|
||||
monkeypatch.setattr("haiku.rag.app.create_mcp_server", lambda *a, **kw: server)
|
||||
|
||||
await app.run_mcp(transport="stdio")
|
||||
|
||||
server.run_stdio_async.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_run_mcp_http(app, client, monkeypatch):
|
||||
server = AsyncMock()
|
||||
monkeypatch.setattr("haiku.rag.app.create_mcp_server", lambda *a, **kw: server)
|
||||
|
||||
await app.run_mcp(host="0.0.0.0", port=9001)
|
||||
|
||||
server.run_http_async.assert_awaited_once_with(
|
||||
transport="streamable-http", host="0.0.0.0", port=9001
|
||||
)
|
||||
|
||||
|
||||
async def test_run_mcp_survives_interruption(app, client, monkeypatch):
|
||||
server = AsyncMock()
|
||||
server.run_stdio_async.side_effect = KeyboardInterrupt
|
||||
monkeypatch.setattr("haiku.rag.app.create_mcp_server", lambda *a, **kw: server)
|
||||
|
||||
await app.run_mcp(transport="stdio")
|
||||
|
||||
|
||||
def _as_cm(store):
|
||||
class _CM:
|
||||
async def __aenter__(self):
|
||||
return store
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
return _CM()
|
||||
|
||||
|
||||
async def test_doctor_renders_the_report(app, monkeypatch):
|
||||
from haiku.rag.doctor import CheckResult, Severity
|
||||
|
||||
class Report:
|
||||
results = [
|
||||
CheckResult(name="tables", severity=Severity.OK, message="tables present"),
|
||||
CheckResult(
|
||||
name="orphans",
|
||||
severity=Severity.WARN,
|
||||
message="2 orphaned chunks",
|
||||
details=["chunk #1", "chunk #2"],
|
||||
remediation="run haiku-rag rebuild",
|
||||
),
|
||||
CheckResult(
|
||||
name="provider:ollama",
|
||||
severity=Severity.FAIL,
|
||||
message="ollama unreachable",
|
||||
),
|
||||
]
|
||||
failed = True
|
||||
|
||||
def count(self, severity):
|
||||
return sum(1 for r in self.results if r.severity is severity)
|
||||
|
||||
async def report(*args, **kwargs):
|
||||
kwargs["on_progress"]("checking tables")
|
||||
return Report()
|
||||
|
||||
monkeypatch.setattr("haiku.rag.doctor.run_doctor", report)
|
||||
|
||||
failed = await app.doctor()
|
||||
|
||||
assert failed is True
|
||||
printed = out(app)
|
||||
assert "tables present" in printed
|
||||
assert "2 orphaned chunks" in printed
|
||||
assert "chunk #1" in printed
|
||||
assert "run haiku-rag rebuild" in printed
|
||||
# providers are reported under their own rule
|
||||
assert "ollama unreachable" in printed
|
||||
assert "1 ok" in printed and "1 warning(s)" in printed and "1 failure(s)" in printed
|
||||
|
||||
|
||||
async def test_doctor_reports_the_duplicates_export(app, monkeypatch, tmp_path):
|
||||
class Report:
|
||||
results = []
|
||||
failed = False
|
||||
|
||||
def count(self, severity):
|
||||
return 0
|
||||
|
||||
async def report(*args, **kwargs):
|
||||
return Report()
|
||||
|
||||
monkeypatch.setattr("haiku.rag.doctor.run_doctor", report)
|
||||
target = tmp_path / "dupes.yaml"
|
||||
|
||||
assert await app.doctor(duplicates_out=target) is False
|
||||
assert f"written to {target}" in out(app)
|
||||
|
||||
|
||||
async def test_doctor_reports_a_missing_database(tmp_path):
|
||||
application = HaikuRAGApp(db_path=tmp_path / "gone", config=AppConfig())
|
||||
application.console = Console(record=True, width=200)
|
||||
|
||||
assert await application.doctor() is True
|
||||
assert "does not exist" in application.console.export_text(clear=False)
|
||||
|
||||
|
||||
async def test_download_models_reports_each_stage(app, monkeypatch):
|
||||
from haiku.rag.client.downloads import DownloadProgress
|
||||
|
||||
events = [
|
||||
DownloadProgress(status="start", model="docling"),
|
||||
DownloadProgress(status="done", model="docling"),
|
||||
DownloadProgress(status="pulling", model="qwen3-embedding"),
|
||||
DownloadProgress(
|
||||
status="downloading",
|
||||
model="qwen3-embedding",
|
||||
digest="sha256:abcdef0123456789",
|
||||
total=100,
|
||||
completed=50,
|
||||
),
|
||||
DownloadProgress(
|
||||
status="verifying sha256 digest",
|
||||
model="qwen3-embedding",
|
||||
),
|
||||
DownloadProgress(status="done", model="qwen3-embedding"),
|
||||
]
|
||||
|
||||
async def stream(config):
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
monkeypatch.setattr("haiku.rag.client.downloads.download_models", stream)
|
||||
|
||||
await app.download_models()
|
||||
|
||||
printed = out(app)
|
||||
assert "Downloading docling" in printed
|
||||
assert "Pulling qwen3-embedding" in printed
|
||||
assert "qwen3-embedding" in printed
|
||||
|
||||
|
||||
async def test_document_content_is_truncated_in_lists(app, client):
|
||||
long_doc = _doc("l1\nl2\nl3\nl4\nl5")
|
||||
client.list_documents.return_value = [long_doc]
|
||||
|
||||
await app.list_documents()
|
||||
|
||||
printed = out(app)
|
||||
assert "l1" in printed
|
||||
assert "l5" not in printed
|
||||
|
||||
|
||||
def _database_info(vector_index, chunk_rows=300):
|
||||
"""A complete DatabaseInfo: info() indexes every required table by name."""
|
||||
from haiku.rag.store.info import (
|
||||
DatabaseInfo,
|
||||
EmbeddingsInfo,
|
||||
TableInfo,
|
||||
VectorIndexInfo,
|
||||
)
|
||||
|
||||
rows = {"chunks": chunk_rows}
|
||||
return DatabaseInfo(
|
||||
path="/tmp/db.lancedb",
|
||||
exists=True,
|
||||
stored_version="0.75.0",
|
||||
embeddings=EmbeddingsInfo(provider="ollama", name="m", vector_dim=3),
|
||||
tables=[
|
||||
TableInfo(
|
||||
name=name,
|
||||
exists=True,
|
||||
num_rows=rows.get(name, 1),
|
||||
num_versions=2 if name == "documents" else 3,
|
||||
)
|
||||
for name in (
|
||||
"documents",
|
||||
"document_meta",
|
||||
"chunks",
|
||||
"document_items",
|
||||
"settings",
|
||||
)
|
||||
],
|
||||
vector_index=VectorIndexInfo(**vector_index),
|
||||
# info() indexes these by name when printing the version block.
|
||||
packages={
|
||||
"haiku_rag": "0.75.0",
|
||||
"lancedb": "0.26.0",
|
||||
"docling": "2.102.2",
|
||||
"pydantic_ai": "2.18.0",
|
||||
"docling_document_schema": "1.7.0",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_info_reports_unindexed_chunks_and_document_meta_versions(
|
||||
app, monkeypatch
|
||||
):
|
||||
"""The index-status branches read from gather_database_info, so drive them
|
||||
through a stubbed report rather than building databases."""
|
||||
info = _database_info({"exists": True, "indexed_rows": 200, "unindexed_rows": 100})
|
||||
|
||||
async def stub_info(config, db_path):
|
||||
return info
|
||||
|
||||
# info() imports it inside the method, so patch where it is looked up.
|
||||
monkeypatch.setattr("haiku.rag.store.info.gather_database_info", stub_info)
|
||||
|
||||
await app.info()
|
||||
|
||||
printed = out(app)
|
||||
assert "unindexed chunks" in printed
|
||||
assert "100" in printed
|
||||
assert "versions (document_meta)" in printed
|
||||
|
||||
|
||||
async def test_info_suggests_creating_an_index_when_there_are_enough_chunks(
|
||||
app, monkeypatch
|
||||
):
|
||||
info = _database_info({"exists": False})
|
||||
|
||||
async def stub_info(config, db_path):
|
||||
return info
|
||||
|
||||
# info() imports it inside the method, so patch where it is looked up.
|
||||
monkeypatch.setattr("haiku.rag.store.info.gather_database_info", stub_info)
|
||||
|
||||
await app.info()
|
||||
|
||||
assert "haiku-rag create-index" in out(app)
|
||||
|
||||
|
||||
async def test_doctor_updates_a_terminal_status(app, monkeypatch):
|
||||
"""On a terminal the checks report progress through console.status."""
|
||||
|
||||
class Report:
|
||||
results = []
|
||||
failed = False
|
||||
|
||||
def count(self, severity):
|
||||
return 0
|
||||
|
||||
labels: list[str] = []
|
||||
|
||||
async def report(*args, **kwargs):
|
||||
kwargs["on_progress"]("scanning vectors")
|
||||
return Report()
|
||||
|
||||
class Status:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def update(self, label):
|
||||
labels.append(label)
|
||||
|
||||
monkeypatch.setattr("haiku.rag.doctor.run_doctor", report)
|
||||
monkeypatch.setattr(type(app.console), "is_terminal", property(lambda self: True))
|
||||
monkeypatch.setattr(app.console, "status", lambda label: Status())
|
||||
|
||||
await app.doctor()
|
||||
|
||||
assert labels == ["scanning vectors..."]
|
||||
|
||||
|
||||
async def test_history_limits_the_versions_shown(app, monkeypatch):
|
||||
store = AsyncMock()
|
||||
store.list_tags.return_value = {}
|
||||
store.list_table_versions.return_value = [
|
||||
{"version": v, "timestamp": None} for v in (1, 2, 3)
|
||||
]
|
||||
|
||||
class StubStore:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return store
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("haiku.rag.store.engine.Store", StubStore)
|
||||
|
||||
await app.history(table="documents", limit=1)
|
||||
|
||||
printed = out(app)
|
||||
assert "v3" in printed
|
||||
assert "v1" not in printed
|
||||
|
||||
|
||||
async def test_analyze_prints_citation_renderables(app, client, monkeypatch):
|
||||
result = AsyncMock()
|
||||
result.answer = "computed"
|
||||
result.citations = ["c1"]
|
||||
client.analyze.return_value = result
|
||||
|
||||
async def one_citation(citations, client=None):
|
||||
return ["citation renderable"]
|
||||
|
||||
monkeypatch.setattr("haiku.rag.app.format_citations_rich", one_citation)
|
||||
|
||||
await app.analyze("how many?")
|
||||
|
||||
assert "citation renderable" in out(app)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from click.exceptions import BadParameter
|
||||
|
|
@ -349,3 +349,306 @@ class TestAskAnalyzeImageOption:
|
|||
await app.ask("q", images=[img_path])
|
||||
|
||||
assert mock_ask.call_args.kwargs["images"] == [buffer.getvalue()]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_stub(monkeypatch, tmp_path):
|
||||
"""Stand in for HaikuRAGApp so a command's wiring can be checked without a
|
||||
database or a model. These tests pin argument parsing and dispatch, not what
|
||||
the application layer renders."""
|
||||
# AsyncMock so every command's `asyncio.run(app.x(...))` gets a coroutine.
|
||||
stub = AsyncMock()
|
||||
monkeypatch.setattr("haiku.rag.cli.create_app", lambda db=None: stub)
|
||||
return stub
|
||||
|
||||
|
||||
DB_ARGS = ["--db", "/tmp/test.lancedb"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv, method, expected",
|
||||
[
|
||||
(["list"], "list_documents", {"filter": None}),
|
||||
(
|
||||
["list", "--filter", "uri LIKE 'x%'"],
|
||||
"list_documents",
|
||||
{"filter": "uri LIKE 'x%'"},
|
||||
),
|
||||
(
|
||||
["add", "some text", "--title", "T"],
|
||||
"add_document_from_text",
|
||||
{"text": "some text", "title": "T", "metadata": None},
|
||||
),
|
||||
(
|
||||
["add-src", "/tmp/doc.md"],
|
||||
"add_document_from_source",
|
||||
{"source": "/tmp/doc.md", "title": None, "metadata": None},
|
||||
),
|
||||
(["get", "doc-1"], "get_document", {"doc_id": "doc-1"}),
|
||||
(["delete", "doc-1"], "delete_document", {"doc_id": "doc-1"}),
|
||||
(
|
||||
["visualize", "chunk-1"],
|
||||
"visualize_chunk",
|
||||
{"chunk_id": "chunk-1", "expand": True},
|
||||
),
|
||||
(
|
||||
["visualize", "chunk-1", "--no-expand"],
|
||||
"visualize_chunk",
|
||||
{"chunk_id": "chunk-1", "expand": False},
|
||||
),
|
||||
(["vacuum"], "vacuum", {}),
|
||||
(["create-index"], "create_index", {}),
|
||||
(["init"], "init", {}),
|
||||
(["info"], "info", {}),
|
||||
# limit/search_type default to None: the app layer resolves the config
|
||||
# default, so the CLI must not invent one.
|
||||
(["history"], "history", {"table": None, "limit": None}),
|
||||
(
|
||||
["history", "--table", "chunks", "--limit", "5"],
|
||||
"history",
|
||||
{"table": "chunks", "limit": 5},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_command_dispatches_to_the_app(app_stub, argv, method, expected):
|
||||
result = runner.invoke(cli, argv + DB_ARGS)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
getattr(app_stub, method).assert_called_once_with(**expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv, expected",
|
||||
[
|
||||
(
|
||||
["search", "q"],
|
||||
{
|
||||
"query": "q",
|
||||
"limit": None,
|
||||
"filter": None,
|
||||
"search_type": None,
|
||||
"image": None,
|
||||
},
|
||||
),
|
||||
(
|
||||
["search", "q", "--limit", "3", "--search-type", "vector"],
|
||||
{
|
||||
"query": "q",
|
||||
"limit": 3,
|
||||
"filter": None,
|
||||
"search_type": "vector",
|
||||
"image": None,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_search_dispatch(app_stub, argv, expected):
|
||||
result = runner.invoke(cli, argv + DB_ARGS)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
app_stub.search.assert_called_once_with(**expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command, method", [("ask", "ask"), ("analyze", "analyze")])
|
||||
def test_question_commands_dispatch(app_stub, command, method):
|
||||
result = runner.invoke(cli, [command, "why?"] + DB_ARGS)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
getattr(app_stub, method).assert_called_once_with(
|
||||
question="why?", filter=None, images=None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flag, mode_name",
|
||||
[
|
||||
(None, "FULL"),
|
||||
("--embed-only", "EMBED_ONLY"),
|
||||
("--rechunk", "RECHUNK"),
|
||||
("--title-only", "TITLE_ONLY"),
|
||||
("--descriptions", "DESCRIPTIONS"),
|
||||
("--set-embedder", "SET_EMBEDDER"),
|
||||
],
|
||||
)
|
||||
def test_rebuild_flag_selects_the_mode(app_stub, flag, mode_name):
|
||||
"""Each flag picks one rebuild mode, and no flag means a full rebuild."""
|
||||
result = runner.invoke(cli, ["rebuild"] + ([flag] if flag else []) + DB_ARGS)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
(_, kwargs) = app_stub.rebuild.call_args
|
||||
assert kwargs["mode"].name == mode_name
|
||||
|
||||
|
||||
def test_migrate_reports_applied_migrations(app_stub):
|
||||
app_stub.migrate.return_value = ["v0_40_0: add document_items"]
|
||||
|
||||
result = runner.invoke(cli, ["migrate"] + DB_ARGS)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Applied 1 migration(s)" in result.output
|
||||
assert "add document_items" in result.output
|
||||
|
||||
|
||||
def test_migrate_reports_an_up_to_date_database(app_stub):
|
||||
app_stub.migrate.return_value = []
|
||||
|
||||
result = runner.invoke(cli, ["migrate"] + DB_ARGS)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No migrations pending" in result.output
|
||||
|
||||
|
||||
def test_migrate_exits_nonzero_on_failure(app_stub):
|
||||
app_stub.migrate.side_effect = RuntimeError("schema is from the future")
|
||||
|
||||
result = runner.invoke(cli, ["migrate"] + DB_ARGS)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Migration failed: schema is from the future" in result.output
|
||||
|
||||
|
||||
def test_mcp_stdio_selects_the_transport(app_stub):
|
||||
result = runner.invoke(cli, ["mcp", "--stdio"] + DB_ARGS)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
app_stub.run_mcp.assert_called_once()
|
||||
kwargs = app_stub.run_mcp.call_args.kwargs
|
||||
assert kwargs["transport"] == "stdio"
|
||||
|
||||
|
||||
def test_mcp_without_stdio_leaves_the_transport_unset(app_stub):
|
||||
result = runner.invoke(cli, ["mcp"] + DB_ARGS)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert app_stub.run_mcp.call_args.kwargs["transport"] is None
|
||||
|
||||
|
||||
def test_version_flag_prints_the_version():
|
||||
result = runner.invoke(cli, ["--version"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "haiku.rag version" in result.output
|
||||
|
||||
|
||||
def test_outdated_install_warns(app_stub, monkeypatch):
|
||||
"""The startup check warns but does not block the command."""
|
||||
|
||||
async def outdated():
|
||||
return False, "0.1.0", "9.9.9"
|
||||
|
||||
monkeypatch.setattr("haiku.rag.cli.is_up_to_date", outdated)
|
||||
|
||||
result = runner.invoke(cli, ["list"] + DB_ARGS)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "haiku.rag is outdated" in result.output
|
||||
assert "Current: 0.1.0, Latest: 9.9.9" in result.output
|
||||
app_stub.list_documents.assert_called_once()
|
||||
|
||||
|
||||
def test_up_to_date_install_says_nothing(app_stub, monkeypatch):
|
||||
async def current():
|
||||
return True, "9.9.9", "9.9.9"
|
||||
|
||||
monkeypatch.setattr("haiku.rag.cli.is_up_to_date", current)
|
||||
|
||||
result = runner.invoke(cli, ["list"] + DB_ARGS)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "outdated" not in result.output
|
||||
|
||||
|
||||
def test_a_failing_version_check_does_not_block_the_cli(app_stub, monkeypatch):
|
||||
"""PyPI being unreachable must not stop a command from running."""
|
||||
|
||||
async def boom():
|
||||
raise RuntimeError("no network")
|
||||
|
||||
monkeypatch.setattr("haiku.rag.cli.is_up_to_date", boom)
|
||||
|
||||
result = runner.invoke(cli, ["list"] + DB_ARGS)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
app_stub.list_documents.assert_called_once()
|
||||
|
||||
|
||||
def test_settings_command_shows_the_configuration(monkeypatch):
|
||||
shown = []
|
||||
|
||||
class StubApp:
|
||||
def __init__(self, **kwargs):
|
||||
shown.append(kwargs)
|
||||
|
||||
def show_settings(self):
|
||||
shown.append("shown")
|
||||
|
||||
monkeypatch.setattr("haiku.rag.app.HaikuRAGApp", StubApp)
|
||||
|
||||
result = runner.invoke(cli, ["settings"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "shown" in shown
|
||||
assert shown[0]["read_only"] is True
|
||||
|
||||
|
||||
def test_download_models_reports_failure(monkeypatch):
|
||||
class StubApp:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
async def download_models(self):
|
||||
raise RuntimeError("hub unreachable")
|
||||
|
||||
monkeypatch.setattr("haiku.rag.app.HaikuRAGApp", StubApp)
|
||||
|
||||
result = runner.invoke(cli, ["download-models"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Error downloading models: hub unreachable" in result.output
|
||||
|
||||
|
||||
def test_download_models_succeeds(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class StubApp:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
async def download_models(self):
|
||||
calls.append("downloaded")
|
||||
|
||||
monkeypatch.setattr("haiku.rag.app.HaikuRAGApp", StubApp)
|
||||
|
||||
result = runner.invoke(cli, ["download-models"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert calls == ["downloaded"]
|
||||
|
||||
|
||||
def test_chat_reports_a_missing_tui_extra(monkeypatch):
|
||||
"""run_chat imports the Textual app itself, so a missing tui extra surfaces
|
||||
from the call, not from importing haiku.rag.chat. The CLI must report it and
|
||||
exit nonzero rather than traceback."""
|
||||
import sys
|
||||
|
||||
monkeypatch.setitem(sys.modules, "haiku.rag.chat.app", None)
|
||||
|
||||
result = runner.invoke(cli, ["chat"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "textual is not installed" in result.output
|
||||
assert "haiku.rag-slim[tui]" in result.output
|
||||
|
||||
|
||||
def test_inspect_reports_a_missing_tui_extra(monkeypatch):
|
||||
"""run_inspector raises at import instead, so the guard sits on the import."""
|
||||
import sys
|
||||
|
||||
monkeypatch.delitem(sys.modules, "haiku.rag.inspector", raising=False)
|
||||
monkeypatch.setitem(sys.modules, "haiku.rag.inspector.app", None)
|
||||
|
||||
result = runner.invoke(cli, ["inspect"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "textual is not installed" in result.output
|
||||
assert "haiku.rag-slim[tui]" in result.output
|
||||
|
|
|
|||
Loading…
Reference in a new issue