mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
c6cdf147c2
_display_build_context was reading top-level item.get("title")/item.get("type")
on each results[i], but the real GraphContext.model_dump() shape wraps every item
as a ContextResult with primary_result + related_results nested inside. This
made every related note render as an empty tree node.
Fix:
- Rewrite _display_build_context to iterate context_items, build a primary-result
node from item["primary_result"], then add each item["related_results"] entry as
a child with relation_type/type/title rendered.
- Update _display_search_results to use the real SearchResponse key "current_page"
(not "page"), pass query from the CLI argument, add Score and Snippet columns
(score + matched_chunk/content truncated to 200 chars) as the issue requested.
- Update test fixtures in test_cli_tool_rich_output.py to match the real payload
shapes (nested ContextResult, current_page, score/matched_chunk fields, no
updated_at in recent-activity).
- Fix test_build_context_json_flag_overrides_tty assertion to navigate the nested
primary_result/related_results shape.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
401 lines
13 KiB
Python
401 lines
13 KiB
Python
"""Tests for Rich (human-readable) output mode for bm tool commands.
|
||
|
||
Commands default to Rich output when stdout is a TTY and fall back to raw JSON
|
||
when stdout is piped or --json is supplied. These tests verify both modes.
|
||
"""
|
||
|
||
import json
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
import pytest
|
||
from typer.testing import CliRunner
|
||
|
||
from basic_memory.cli.main import app as cli_app
|
||
|
||
runner = CliRunner()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Shared mock payloads (mirrors test_cli_tool_json_output.py for symmetry)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
READ_NOTE_RESULT = {
|
||
"title": "Test Note",
|
||
"permalink": "notes/test-note",
|
||
"file_path": "notes/Test Note.md",
|
||
"content": "# Test Note\n\nhello world",
|
||
"frontmatter": {"title": "Test Note", "tags": ["test"]},
|
||
}
|
||
|
||
SEARCH_RESULT = {
|
||
# Real SearchResponse.model_dump() uses "current_page", not "page".
|
||
# No "query" key in the response -- the query comes from the CLI argument.
|
||
"total": 2,
|
||
"current_page": 1,
|
||
"page_size": 10,
|
||
"has_more": False,
|
||
"results": [
|
||
{
|
||
"type": "entity",
|
||
"title": "Test Note",
|
||
"permalink": "notes/test-note",
|
||
"file_path": "notes/Test Note.md",
|
||
"score": 0.95,
|
||
"matched_chunk": "A snippet about test notes",
|
||
"content": None,
|
||
},
|
||
{
|
||
"type": "observation",
|
||
"title": "Another Note",
|
||
"permalink": "notes/another-note",
|
||
"file_path": "notes/Another Note.md",
|
||
"score": 0.72,
|
||
"matched_chunk": None,
|
||
"content": "Full content here",
|
||
},
|
||
],
|
||
}
|
||
|
||
SEARCH_RESULT_EMPTY = {
|
||
"total": 0,
|
||
"current_page": 1,
|
||
"page_size": 10,
|
||
"has_more": False,
|
||
"results": [],
|
||
}
|
||
|
||
BUILD_CONTEXT_RESULT = {
|
||
# Real GraphContext.model_dump() shape: results is a list of ContextResult dicts.
|
||
# Each ContextResult has primary_result + observations + related_results.
|
||
"results": [
|
||
{
|
||
"primary_result": {
|
||
"type": "entity",
|
||
"external_id": "abc123",
|
||
"title": "Test Note",
|
||
"permalink": "notes/test-note",
|
||
"file_path": "notes/Test Note.md",
|
||
"created_at": "2025-01-01T00:00:00",
|
||
},
|
||
"observations": [],
|
||
"related_results": [
|
||
{
|
||
"type": "relation",
|
||
"title": "Related Note",
|
||
"permalink": "notes/related",
|
||
"file_path": "notes/Related Note.md",
|
||
"relation_type": "references",
|
||
"created_at": "2025-01-01T00:00:00",
|
||
}
|
||
],
|
||
}
|
||
],
|
||
"metadata": {"uri": "notes/test-note", "depth": 1},
|
||
"page": 1,
|
||
"page_size": 10,
|
||
"has_more": False,
|
||
}
|
||
|
||
BUILD_CONTEXT_EMPTY = {
|
||
"results": [],
|
||
"metadata": {"uri": "notes/test-note", "depth": 1},
|
||
"page": 1,
|
||
"page_size": 10,
|
||
"has_more": False,
|
||
}
|
||
|
||
RECENT_ACTIVITY_RESULT = [
|
||
# Real _extract_recent_rows output keys: type/title/permalink/file_path/created_at
|
||
# (optional: project). No "updated_at" key in the real output.
|
||
{
|
||
"type": "entity",
|
||
"title": "Note A",
|
||
"permalink": "notes/note-a",
|
||
"file_path": "notes/Note A.md",
|
||
"created_at": "2025-01-01 00:00:00",
|
||
},
|
||
{
|
||
"type": "entity",
|
||
"title": "Note B",
|
||
"permalink": "notes/note-b",
|
||
"file_path": "notes/Note B.md",
|
||
"created_at": "2025-01-02 00:00:00",
|
||
},
|
||
]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helper: simulate a TTY by patching _use_rich to return True
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _tty_runner(args, **kwargs):
|
||
"""Invoke CLI as if stdout is a TTY (Rich output enabled)."""
|
||
with patch("basic_memory.cli.commands.tool._use_rich", return_value=True):
|
||
return runner.invoke(cli_app, args, **kwargs)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# search-notes – Rich output
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_search",
|
||
new_callable=AsyncMock,
|
||
return_value=SEARCH_RESULT,
|
||
)
|
||
def test_search_notes_rich_output_default(mock_mcp):
|
||
"""search-notes produces Rich table output when stdout is a TTY."""
|
||
result = _tty_runner(["tool", "search-notes", "test"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
# Rich output should NOT be valid JSON
|
||
with pytest.raises((json.JSONDecodeError, ValueError)):
|
||
json.loads(result.output)
|
||
# But it should contain the result titles and partial permalink
|
||
assert "Test Note" in result.output
|
||
assert "Another Note" in result.output
|
||
# Rich may truncate long permalinks with ellipsis; check the prefix.
|
||
assert "notes/test-no" in result.output
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_search",
|
||
new_callable=AsyncMock,
|
||
return_value=SEARCH_RESULT_EMPTY,
|
||
)
|
||
def test_search_notes_rich_empty(mock_mcp):
|
||
"""search-notes Rich output handles empty results gracefully."""
|
||
result = _tty_runner(["tool", "search-notes", "nothing"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
assert "No results found" in result.output
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_search",
|
||
new_callable=AsyncMock,
|
||
return_value=SEARCH_RESULT,
|
||
)
|
||
def test_search_notes_json_flag_overrides_tty(mock_mcp):
|
||
"""search-notes --json outputs raw JSON even when stdout is a TTY."""
|
||
result = _tty_runner(["tool", "search-notes", "test", "--json"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
data = json.loads(result.output)
|
||
assert data["total"] == 2
|
||
assert data["results"][0]["title"] == "Test Note"
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_search",
|
||
new_callable=AsyncMock,
|
||
return_value=SEARCH_RESULT,
|
||
)
|
||
def test_search_notes_non_tty_gives_json(mock_mcp):
|
||
"""search-notes outputs JSON when stdout is not a TTY (default runner behaviour)."""
|
||
# CliRunner does not set isatty(); _use_rich() returns False → JSON path.
|
||
result = runner.invoke(cli_app, ["tool", "search-notes", "test"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
data = json.loads(result.output)
|
||
assert data["total"] == 2
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# read-note – Rich output
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_read_note",
|
||
new_callable=AsyncMock,
|
||
return_value=READ_NOTE_RESULT,
|
||
)
|
||
def test_read_note_rich_output_default(mock_mcp):
|
||
"""read-note produces Rich formatted output when stdout is a TTY."""
|
||
result = _tty_runner(["tool", "read-note", "test-note"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
# Rich output contains the note title
|
||
assert "Test Note" in result.output
|
||
# And the rendered markdown content
|
||
assert "hello world" in result.output
|
||
# Not raw JSON
|
||
with pytest.raises((json.JSONDecodeError, ValueError)):
|
||
json.loads(result.output)
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_read_note",
|
||
new_callable=AsyncMock,
|
||
return_value=READ_NOTE_RESULT,
|
||
)
|
||
def test_read_note_json_flag_overrides_tty(mock_mcp):
|
||
"""read-note --json outputs raw JSON even when stdout is a TTY."""
|
||
result = _tty_runner(["tool", "read-note", "test-note", "--json"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
data = json.loads(result.output)
|
||
assert data["title"] == "Test Note"
|
||
assert data["content"] == "# Test Note\n\nhello world"
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_read_note",
|
||
new_callable=AsyncMock,
|
||
return_value={"title": "", "permalink": "", "content": "", "frontmatter": {}},
|
||
)
|
||
def test_read_note_rich_empty_content(mock_mcp):
|
||
"""read-note Rich output handles empty content without crashing."""
|
||
result = _tty_runner(["tool", "read-note", "empty-note"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
assert "no content" in result.output.lower()
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_read_note",
|
||
new_callable=AsyncMock,
|
||
return_value=READ_NOTE_RESULT,
|
||
)
|
||
def test_read_note_non_tty_gives_json(mock_mcp):
|
||
"""read-note outputs JSON when stdout is not a TTY."""
|
||
result = runner.invoke(cli_app, ["tool", "read-note", "test-note"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
data = json.loads(result.output)
|
||
assert data["title"] == "Test Note"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# build-context – Rich output
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_build_context",
|
||
new_callable=AsyncMock,
|
||
return_value=BUILD_CONTEXT_RESULT,
|
||
)
|
||
def test_build_context_rich_output_default(mock_mcp):
|
||
"""build-context produces Rich tree output when stdout is a TTY."""
|
||
result = _tty_runner(["tool", "build-context", "memory://notes/test-note"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
assert "notes/test-note" in result.output
|
||
assert "Related Note" in result.output
|
||
# Not raw JSON
|
||
with pytest.raises((json.JSONDecodeError, ValueError)):
|
||
json.loads(result.output)
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_build_context",
|
||
new_callable=AsyncMock,
|
||
return_value=BUILD_CONTEXT_EMPTY,
|
||
)
|
||
def test_build_context_rich_empty(mock_mcp):
|
||
"""build-context Rich output handles empty results gracefully."""
|
||
result = _tty_runner(["tool", "build-context", "memory://notes/test-note"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
assert "No related content found" in result.output
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_build_context",
|
||
new_callable=AsyncMock,
|
||
return_value=BUILD_CONTEXT_RESULT,
|
||
)
|
||
def test_build_context_json_flag_overrides_tty(mock_mcp):
|
||
"""build-context --json outputs raw JSON even when stdout is a TTY."""
|
||
result = _tty_runner(["tool", "build-context", "memory://notes/test-note", "--json"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
data = json.loads(result.output)
|
||
assert "results" in data
|
||
# Real shape: results[i] is a ContextResult with primary_result nested inside.
|
||
assert data["results"][0]["primary_result"]["title"] == "Test Note"
|
||
assert data["results"][0]["related_results"][0]["title"] == "Related Note"
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_build_context",
|
||
new_callable=AsyncMock,
|
||
return_value=BUILD_CONTEXT_RESULT,
|
||
)
|
||
def test_build_context_non_tty_gives_json(mock_mcp):
|
||
"""build-context outputs JSON when stdout is not a TTY."""
|
||
result = runner.invoke(cli_app, ["tool", "build-context", "memory://notes/test-note"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
data = json.loads(result.output)
|
||
assert "results" in data
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# recent-activity – Rich output
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_recent_activity",
|
||
new_callable=AsyncMock,
|
||
return_value=RECENT_ACTIVITY_RESULT,
|
||
)
|
||
def test_recent_activity_rich_output_default(mock_mcp):
|
||
"""recent-activity produces Rich table output when stdout is a TTY."""
|
||
result = _tty_runner(["tool", "recent-activity"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
assert "Note A" in result.output
|
||
assert "Note B" in result.output
|
||
assert "notes/note-a" in result.output
|
||
# Not raw JSON
|
||
with pytest.raises((json.JSONDecodeError, ValueError)):
|
||
json.loads(result.output)
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_recent_activity",
|
||
new_callable=AsyncMock,
|
||
return_value=[],
|
||
)
|
||
def test_recent_activity_rich_empty(mock_mcp):
|
||
"""recent-activity Rich output handles empty results gracefully."""
|
||
result = _tty_runner(["tool", "recent-activity"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
assert "No recent activity" in result.output
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_recent_activity",
|
||
new_callable=AsyncMock,
|
||
return_value=RECENT_ACTIVITY_RESULT,
|
||
)
|
||
def test_recent_activity_json_flag_overrides_tty(mock_mcp):
|
||
"""recent-activity --json outputs raw JSON even when stdout is a TTY."""
|
||
result = _tty_runner(["tool", "recent-activity", "--json"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
data = json.loads(result.output)
|
||
assert isinstance(data, list)
|
||
assert data[0]["title"] == "Note A"
|
||
|
||
|
||
@patch(
|
||
"basic_memory.cli.commands.tool.mcp_recent_activity",
|
||
new_callable=AsyncMock,
|
||
return_value=RECENT_ACTIVITY_RESULT,
|
||
)
|
||
def test_recent_activity_non_tty_gives_json(mock_mcp):
|
||
"""recent-activity outputs JSON when stdout is not a TTY."""
|
||
result = runner.invoke(cli_app, ["tool", "recent-activity"])
|
||
|
||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||
data = json.loads(result.output)
|
||
assert isinstance(data, list)
|
||
assert len(data) == 2
|