feat: implement knowledge management system (spec complete, all 53 tasks done)
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
"""Unit-Tests: CLI-Argument-Parsing für Knowledge Management.
|
||||
|
||||
Testet alle Subcommands mit verschiedenen Flag-Kombinationen,
|
||||
Kontext-Ableitung aus CWD und Hilfe-Ausgabe.
|
||||
|
||||
Requirements: 15.2, 15.3, 15.7
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from monorepo.knowledge.cli import (
|
||||
VALID_CONTEXTS,
|
||||
_derive_context_from_cwd,
|
||||
build_knowledge_parser,
|
||||
knowledge_main,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
"""Build a standalone knowledge parser for testing."""
|
||||
return build_knowledge_parser()
|
||||
|
||||
|
||||
def _parse(parser: argparse.ArgumentParser, args: list[str]) -> argparse.Namespace:
|
||||
"""Parse args without triggering SystemExit on error."""
|
||||
return parser.parse_args(args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: capture subcommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCaptureCommand:
|
||||
"""Tests for 'knowledge capture' argument parsing."""
|
||||
|
||||
def test_capture_plain_text(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Capture with plain text input."""
|
||||
ns = _parse(parser, ["capture", "Eine schnelle Notiz"])
|
||||
assert ns.knowledge_command == "capture"
|
||||
assert ns.input == "Eine schnelle Notiz"
|
||||
assert ns.context is None
|
||||
assert ns.tags is None
|
||||
|
||||
def test_capture_url_input(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Capture with URL input."""
|
||||
ns = _parse(parser, ["capture", "https://example.com/docs"])
|
||||
assert ns.knowledge_command == "capture"
|
||||
assert ns.input == "https://example.com/docs"
|
||||
|
||||
def test_capture_file_path_input(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Capture with file path input."""
|
||||
ns = _parse(parser, ["capture", "/tmp/notes/meeting.md"])
|
||||
assert ns.input == "/tmp/notes/meeting.md"
|
||||
|
||||
def test_capture_with_context(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Capture with explicit --context flag."""
|
||||
ns = _parse(parser, ["capture", "Notiz", "--context", "bahn"])
|
||||
assert ns.context == "bahn"
|
||||
|
||||
def test_capture_with_context_short(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Capture with -c short flag."""
|
||||
ns = _parse(parser, ["capture", "Notiz", "-c", "dhive"])
|
||||
assert ns.context == "dhive"
|
||||
|
||||
def test_capture_with_tags(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Capture with --tags flag."""
|
||||
ns = _parse(parser, ["capture", "Notiz", "--tags", "tag1,tag2"])
|
||||
assert ns.tags == "tag1,tag2"
|
||||
|
||||
def test_capture_with_tags_short(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Capture with -t short flag."""
|
||||
ns = _parse(parser, ["capture", "Notiz", "-t", "meeting,acv2"])
|
||||
assert ns.tags == "meeting,acv2"
|
||||
|
||||
def test_capture_with_context_and_tags(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Capture with both --context and --tags."""
|
||||
ns = _parse(parser, [
|
||||
"capture", "Sprint Retro Notiz",
|
||||
"--context", "bahn",
|
||||
"--tags", "tag1,tag2",
|
||||
])
|
||||
assert ns.context == "bahn"
|
||||
assert ns.tags == "tag1,tag2"
|
||||
assert ns.input == "Sprint Retro Notiz"
|
||||
|
||||
def test_capture_invalid_context_rejected(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Capture with invalid context raises error."""
|
||||
with pytest.raises(SystemExit):
|
||||
_parse(parser, ["capture", "Notiz", "--context", "invalid"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: ingest subcommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIngestCommand:
|
||||
"""Tests for 'knowledge ingest' argument parsing."""
|
||||
|
||||
def test_ingest_defaults(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Ingest with no flags uses defaults."""
|
||||
ns = _parse(parser, ["ingest"])
|
||||
assert ns.knowledge_command == "ingest"
|
||||
assert ns.context is None
|
||||
assert ns.dry_run is False
|
||||
assert ns.verbose is False
|
||||
assert ns.no_commit is False
|
||||
|
||||
def test_ingest_with_context(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Ingest with --context flag."""
|
||||
ns = _parse(parser, ["ingest", "--context", "privat"])
|
||||
assert ns.context == "privat"
|
||||
|
||||
def test_ingest_with_dry_run(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Ingest with --dry-run flag."""
|
||||
ns = _parse(parser, ["ingest", "--dry-run"])
|
||||
assert ns.dry_run is True
|
||||
|
||||
def test_ingest_with_verbose(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Ingest with --verbose flag."""
|
||||
ns = _parse(parser, ["ingest", "--verbose"])
|
||||
assert ns.verbose is True
|
||||
|
||||
def test_ingest_with_verbose_short(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Ingest with -v short flag."""
|
||||
ns = _parse(parser, ["ingest", "-v"])
|
||||
assert ns.verbose is True
|
||||
|
||||
def test_ingest_with_no_commit(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Ingest with --no-commit flag."""
|
||||
ns = _parse(parser, ["ingest", "--no-commit"])
|
||||
assert ns.no_commit is True
|
||||
|
||||
def test_ingest_all_flags(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Ingest with all flags combined."""
|
||||
ns = _parse(parser, [
|
||||
"ingest",
|
||||
"--context", "privat",
|
||||
"--dry-run",
|
||||
"--verbose",
|
||||
"--no-commit",
|
||||
])
|
||||
assert ns.context == "privat"
|
||||
assert ns.dry_run is True
|
||||
assert ns.verbose is True
|
||||
assert ns.no_commit is True
|
||||
|
||||
def test_ingest_invalid_context_rejected(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Ingest with invalid context raises error."""
|
||||
with pytest.raises(SystemExit):
|
||||
_parse(parser, ["ingest", "--context", "ungueltig"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: search subcommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSearchCommand:
|
||||
"""Tests for 'knowledge search' argument parsing."""
|
||||
|
||||
def test_search_basic(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Search with query string."""
|
||||
ns = _parse(parser, ["search", "API Design"])
|
||||
assert ns.knowledge_command == "search"
|
||||
assert ns.query == "API Design"
|
||||
assert ns.context is None
|
||||
assert ns.limit is None
|
||||
|
||||
def test_search_with_context(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Search with --context flag."""
|
||||
ns = _parse(parser, ["search", "query", "--context", "dhive"])
|
||||
assert ns.context == "dhive"
|
||||
|
||||
def test_search_with_limit(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Search with --limit flag."""
|
||||
ns = _parse(parser, ["search", "query", "--limit", "5"])
|
||||
assert ns.limit == 5
|
||||
|
||||
def test_search_with_limit_short(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Search with -l short flag."""
|
||||
ns = _parse(parser, ["search", "query", "-l", "10"])
|
||||
assert ns.limit == 10
|
||||
|
||||
def test_search_all_flags(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Search with --context and --limit combined."""
|
||||
ns = _parse(parser, [
|
||||
"search", "Confluence Migration",
|
||||
"--context", "bahn",
|
||||
"--limit", "5",
|
||||
])
|
||||
assert ns.query == "Confluence Migration"
|
||||
assert ns.context == "bahn"
|
||||
assert ns.limit == 5
|
||||
|
||||
def test_search_missing_query_rejected(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Search without query raises error."""
|
||||
with pytest.raises(SystemExit):
|
||||
_parse(parser, ["search"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: status subcommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStatusCommand:
|
||||
"""Tests for 'knowledge status' argument parsing."""
|
||||
|
||||
def test_status_defaults(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Status with no flags."""
|
||||
ns = _parse(parser, ["status"])
|
||||
assert ns.knowledge_command == "status"
|
||||
assert ns.context is None
|
||||
|
||||
def test_status_with_context(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Status with --context flag."""
|
||||
ns = _parse(parser, ["status", "--context", "bahn"])
|
||||
assert ns.context == "bahn"
|
||||
|
||||
def test_status_with_context_short(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Status with -c short flag."""
|
||||
ns = _parse(parser, ["status", "-c", "privat"])
|
||||
assert ns.context == "privat"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: link subcommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLinkCommand:
|
||||
"""Tests for 'knowledge link' argument parsing."""
|
||||
|
||||
def test_link_basic(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Link with URL only."""
|
||||
ns = _parse(parser, ["link", "https://example.com"])
|
||||
assert ns.knowledge_command == "link"
|
||||
assert ns.url == "https://example.com"
|
||||
assert ns.title is None
|
||||
assert ns.tags is None
|
||||
assert ns.context is None
|
||||
|
||||
def test_link_with_title(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Link with --title flag."""
|
||||
ns = _parse(parser, ["link", "https://example.com", "--title", "Example Page"])
|
||||
assert ns.title == "Example Page"
|
||||
|
||||
def test_link_with_title_short(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Link with -T short flag."""
|
||||
ns = _parse(parser, ["link", "https://example.com", "-T", "Example"])
|
||||
assert ns.title == "Example"
|
||||
|
||||
def test_link_with_tags(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Link with --tags flag."""
|
||||
ns = _parse(parser, ["link", "https://example.com", "--tags", "ref"])
|
||||
assert ns.tags == "ref"
|
||||
|
||||
def test_link_with_context(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Link with --context flag."""
|
||||
ns = _parse(parser, ["link", "https://example.com", "--context", "bahn"])
|
||||
assert ns.context == "bahn"
|
||||
|
||||
def test_link_all_flags(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Link with all flags combined."""
|
||||
ns = _parse(parser, [
|
||||
"link", "https://example.com/docs",
|
||||
"--title", "Documentation",
|
||||
"--tags", "ref,docs",
|
||||
"--context", "dhive",
|
||||
])
|
||||
assert ns.url == "https://example.com/docs"
|
||||
assert ns.title == "Documentation"
|
||||
assert ns.tags == "ref,docs"
|
||||
assert ns.context == "dhive"
|
||||
|
||||
def test_link_missing_url_rejected(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Link without URL raises error."""
|
||||
with pytest.raises(SystemExit):
|
||||
_parse(parser, ["link"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Context derivation from CWD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestContextDerivation:
|
||||
"""Tests for _derive_context_from_cwd."""
|
||||
|
||||
def test_derive_bahn_context(self) -> None:
|
||||
"""CWD in bahn/ subtree returns 'bahn'."""
|
||||
with patch("monorepo.knowledge.cli.Path") as mock_path:
|
||||
mock_path.cwd.return_value = Path("/repo/bahn/aisupport/src")
|
||||
result = _derive_context_from_cwd()
|
||||
assert result == "bahn"
|
||||
|
||||
def test_derive_dhive_context(self) -> None:
|
||||
"""CWD in dhive/ subtree returns 'dhive'."""
|
||||
with patch("monorepo.knowledge.cli.Path") as mock_path:
|
||||
mock_path.cwd.return_value = Path("/repo/dhive/project/src")
|
||||
result = _derive_context_from_cwd()
|
||||
assert result == "dhive"
|
||||
|
||||
def test_derive_privat_context(self) -> None:
|
||||
"""CWD in privat/ subtree returns 'privat'."""
|
||||
with patch("monorepo.knowledge.cli.Path") as mock_path:
|
||||
mock_path.cwd.return_value = Path("/repo/privat/notes")
|
||||
result = _derive_context_from_cwd()
|
||||
assert result == "privat"
|
||||
|
||||
def test_derive_no_context(self) -> None:
|
||||
"""CWD outside context folders returns None."""
|
||||
with patch("monorepo.knowledge.cli.Path") as mock_path:
|
||||
mock_path.cwd.return_value = Path("/repo/shared/tools")
|
||||
result = _derive_context_from_cwd()
|
||||
assert result is None
|
||||
|
||||
def test_derive_bahn_at_end(self) -> None:
|
||||
"""CWD ending with /bahn returns 'bahn'."""
|
||||
with patch("monorepo.knowledge.cli.Path") as mock_path:
|
||||
mock_path.cwd.return_value = Path("/repo/bahn")
|
||||
result = _derive_context_from_cwd()
|
||||
assert result == "bahn"
|
||||
|
||||
def test_derive_windows_path(self) -> None:
|
||||
"""CWD as Windows path with backslashes works."""
|
||||
with patch("monorepo.knowledge.cli.Path") as mock_path:
|
||||
mock_path.cwd.return_value = Path("C:\\Users\\user\\repo\\bahn\\project")
|
||||
result = _derive_context_from_cwd()
|
||||
assert result == "bahn"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Help output (no arguments)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHelpOutput:
|
||||
"""Tests for help behavior when called without arguments."""
|
||||
|
||||
def test_no_command_sets_none(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""No subcommand results in knowledge_command=None."""
|
||||
ns = _parse(parser, [])
|
||||
assert ns.knowledge_command is None
|
||||
|
||||
def test_knowledge_main_no_command_shows_help(self) -> None:
|
||||
"""knowledge_main with no command triggers help (SystemExit)."""
|
||||
parser = build_knowledge_parser()
|
||||
ns = parser.parse_args([])
|
||||
# knowledge_main should call sys.exit when no command given
|
||||
with pytest.raises(SystemExit):
|
||||
knowledge_main(ns)
|
||||
|
||||
def test_parser_has_all_subcommands(self, parser: argparse.ArgumentParser) -> None:
|
||||
"""Parser registers all expected subcommands."""
|
||||
# Verify by parsing each command without error
|
||||
commands = {
|
||||
"capture": ["capture", "test input"],
|
||||
"ingest": ["ingest"],
|
||||
"search": ["search", "query"],
|
||||
"status": ["status"],
|
||||
"link": ["link", "https://example.com"],
|
||||
}
|
||||
for cmd_name, args in commands.items():
|
||||
ns = _parse(parser, args)
|
||||
assert ns.knowledge_command == cmd_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Handler dispatch with mocks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandlerDispatch:
|
||||
"""Tests that handlers are correctly dispatched and call underlying components."""
|
||||
|
||||
def test_capture_handler_calls_quick_capture(self, tmp_path: Path) -> None:
|
||||
"""capture handler invokes QuickCapture.capture."""
|
||||
parser = build_knowledge_parser()
|
||||
ns = parser.parse_args(["capture", "Test note", "--context", "bahn"])
|
||||
|
||||
with patch("monorepo.knowledge.cli._find_monorepo_root", return_value=tmp_path), \
|
||||
patch("monorepo.knowledge.ingestion.capture.QuickCapture") as mock_qc_cls:
|
||||
mock_qc = MagicMock()
|
||||
mock_qc.capture.return_value = tmp_path / "bahn/knowledge/inbox/note.md"
|
||||
mock_qc_cls.return_value = mock_qc
|
||||
|
||||
from monorepo.knowledge.cli import _cmd_capture
|
||||
result = _cmd_capture(ns)
|
||||
|
||||
assert result == 0
|
||||
mock_qc.capture.assert_called_once_with(
|
||||
input_text="Test note",
|
||||
context="bahn",
|
||||
tags=[],
|
||||
)
|
||||
|
||||
def test_capture_handler_splits_tags(self, tmp_path: Path) -> None:
|
||||
"""capture handler correctly splits comma-separated tags."""
|
||||
parser = build_knowledge_parser()
|
||||
ns = parser.parse_args(["capture", "Notiz", "-c", "dhive", "-t", "a,b,c"])
|
||||
|
||||
with patch("monorepo.knowledge.cli._find_monorepo_root", return_value=tmp_path), \
|
||||
patch("monorepo.knowledge.ingestion.capture.QuickCapture") as mock_qc_cls:
|
||||
mock_qc = MagicMock()
|
||||
mock_qc.capture.return_value = tmp_path / "dhive/knowledge/inbox/note.md"
|
||||
mock_qc_cls.return_value = mock_qc
|
||||
|
||||
from monorepo.knowledge.cli import _cmd_capture
|
||||
_cmd_capture(ns)
|
||||
|
||||
mock_qc.capture.assert_called_once_with(
|
||||
input_text="Notiz",
|
||||
context="dhive",
|
||||
tags=["a", "b", "c"],
|
||||
)
|
||||
|
||||
def test_ingest_handler_calls_pipeline(self, tmp_path: Path) -> None:
|
||||
"""ingest handler invokes IngestionPipeline.run."""
|
||||
parser = build_knowledge_parser()
|
||||
ns = parser.parse_args(["ingest", "--context", "privat", "--dry-run", "--no-commit"])
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.context = "privat"
|
||||
mock_result.processed = 5
|
||||
mock_result.updated = 0
|
||||
mock_result.skipped = 2
|
||||
mock_result.errors = []
|
||||
|
||||
with patch("monorepo.knowledge.cli._find_monorepo_root", return_value=tmp_path), \
|
||||
patch("monorepo.knowledge.ingestion.pipeline.IngestionPipeline") as mock_pipeline_cls:
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.run.return_value = mock_result
|
||||
mock_pipeline_cls.return_value = mock_pipeline
|
||||
|
||||
from monorepo.knowledge.cli import _cmd_ingest
|
||||
result = _cmd_ingest(ns)
|
||||
|
||||
assert result == 0
|
||||
mock_pipeline_cls.assert_called_once_with(
|
||||
monorepo_root=tmp_path,
|
||||
context="privat",
|
||||
dry_run=True,
|
||||
no_commit=True,
|
||||
)
|
||||
mock_pipeline.run.assert_called_once()
|
||||
|
||||
def test_status_handler_counts_inbox(self, tmp_path: Path) -> None:
|
||||
"""status handler counts .md files in inbox (excluding index.md)."""
|
||||
# Create inbox with some files
|
||||
inbox = tmp_path / "bahn" / "knowledge" / "inbox"
|
||||
inbox.mkdir(parents=True)
|
||||
(inbox / "index.md").write_text("# Inbox")
|
||||
(inbox / "note1.md").write_text("note 1")
|
||||
(inbox / "note2.md").write_text("note 2")
|
||||
|
||||
parser = build_knowledge_parser()
|
||||
ns = parser.parse_args(["status", "--context", "bahn"])
|
||||
|
||||
with patch("monorepo.knowledge.cli._find_monorepo_root", return_value=tmp_path):
|
||||
from monorepo.knowledge.cli import _cmd_status
|
||||
result = _cmd_status(ns)
|
||||
|
||||
assert result == 0
|
||||
|
||||
def test_link_handler_calls_registry(self, tmp_path: Path) -> None:
|
||||
"""link handler invokes LinkRegistry.save_link."""
|
||||
parser = build_knowledge_parser()
|
||||
ns = parser.parse_args([
|
||||
"link", "https://example.com",
|
||||
"--title", "Example",
|
||||
"--tags", "ref",
|
||||
"--context", "bahn",
|
||||
])
|
||||
|
||||
with patch("monorepo.knowledge.cli._find_monorepo_root", return_value=tmp_path), \
|
||||
patch("monorepo.knowledge.sources.link.LinkRegistry") as mock_reg_cls:
|
||||
mock_reg = MagicMock()
|
||||
mock_reg.save_link.return_value = tmp_path / "bahn/knowledge/links/example.md"
|
||||
mock_reg_cls.return_value = mock_reg
|
||||
|
||||
from monorepo.knowledge.cli import _cmd_link
|
||||
result = _cmd_link(ns)
|
||||
|
||||
assert result == 0
|
||||
mock_reg_cls.assert_called_once_with(tmp_path / "bahn" / "knowledge")
|
||||
mock_reg.save_link.assert_called_once_with(
|
||||
url="https://example.com",
|
||||
title="Example",
|
||||
tags=["ref"],
|
||||
)
|
||||
Reference in New Issue
Block a user