Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions backend/tests/test_chart_data_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""canonicalize_chart_data's own validation rules, and ChartDataAgent.
validate_chart_data's use of that same contract (Qt-removal plan R7.3).

R7.3 gap: graphlink_chart_data.py and graphlink_chart_agent.py are both
confirmed Qt-free survivor modules backend/ imports directly (canvas.py:32,
agents.py:75) - live production logic, not legacy-only code. Before this
file, their own validation/rejection rules had no backend/tests coverage at
all: backend/tests/test_canvas.py's chart tests only cover the "unsupported
chart_type string" rejection path (SceneDocument.add_chart_node's own guard),
never canonicalize_chart_data's own ChartDataError branch for malformed-but-
otherwise-valid-type data (non-finite numbers, wrong container types, sankey
cycles) - the exact defensive `except ChartDataError` at canvas.py:3503 this
file's absence left completely unexercised. Ported from graphlink_app/tests/
test_chart_nodes.py's own Qt-free test functions (the file's other tests
construct a real Qt ChatScene and stay behind).
"""

from graphlink_chart_agent import ChartDataAgent
from graphlink_chart_data import ChartDataError, canonicalize_chart_data


def _bar_data(**overrides):
data = {
"type": "bar",
"title": "Sales",
"labels": ["A", "B", "C"],
"values": [1, 2, 3],
}
data.update(overrides)
return data


def test_canonicalize_chart_data_rejects_wrong_containers_and_non_finite_numbers():
try:
canonicalize_chart_data(_bar_data(values={"A": 1}), "bar")
assert False, "expected ChartDataError for a non-list values container"
except ChartDataError as exc:
assert "both be lists" in str(exc)

try:
canonicalize_chart_data(_bar_data(values=[1, float("nan"), 3]), "bar")
assert False, "expected ChartDataError for a non-finite value"
except ChartDataError as exc:
assert "finite" in str(exc)


def test_canonicalize_chart_data_aggregates_duplicate_sankey_flows_and_rejects_cycles():
canonical = canonicalize_chart_data(
{
"type": "sankey",
"flows": [
{"source": "A", "target": "B", "value": 1},
{"source": "A", "target": "B", "value": 2},
],
}
)
assert canonical["flows"] == [{"source": "A", "target": "B", "value": 3.0}]

try:
canonicalize_chart_data(
{
"type": "sankey",
"flows": [
{"source": "A", "target": "B", "value": 1},
{"source": "B", "target": "A", "value": 1},
],
}
)
assert False, "expected ChartDataError for a sankey cycle"
except ChartDataError as exc:
assert "cannot contain cycles" in str(exc)


def test_chart_data_agent_validate_chart_data_uses_the_canonical_contract():
# ChartDataAgent.validate_chart_data (graphlink_chart_agent.py:541-558) is
# a thin wrapper: call canonicalize_chart_data, report (True, None) on
# success or (False, message) on ChartDataError. This proves the wrapper
# actually delegates rather than reimplementing (and drifting from) its
# own copy of the validation rules.
agent = ChartDataAgent()

valid, error = agent.validate_chart_data(_bar_data(values="123"), "bar")
assert valid is False
assert "both be lists" in error

valid, error = agent.validate_chart_data(
{"type": "sankey", "flows": [{"source": "A", "target": "B", "value": float("nan")}]},
"sankey",
)
assert valid is False
assert "finite" in error

valid, error = agent.validate_chart_data(_bar_data(), "bar")
assert valid is True
assert error is None


def test_chart_data_agent_validate_chart_data_canonicalizes_the_dict_in_place():
# validate_chart_data's own contract (graphlink_chart_agent.py:555-556):
# on success it clears and repopulates the SAME dict with the canonical
# shape, rather than returning a new one - callers rely on the caller's
# own `data` reference already being canonical afterward.
agent = ChartDataAgent()
data = _bar_data(values=["1", "2", "3"]) # strings - canonical form is floats

valid, error = agent.validate_chart_data(data, "bar")

assert valid is True
assert error is None
assert data["values"] == [1.0, 2.0, 3.0]
81 changes: 81 additions & 0 deletions backend/tests/test_ollama_capability_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""api_provider's Ollama model-capability cache and its invalidation
(Qt-removal plan R7.3).

R7.3 gap: api_provider.py is a confirmed Qt-free survivor module backend/
imports directly (agents.py:72, response_parsing.py:25) and _get_ollama_
capabilities (api_provider.py:658) is exercised on every real chat dispatch
through Ollama to decide whether to send image/audio content - live
production logic, not legacy-only code. Before this file, its own cache-
invalidation rules (a real historical bug fix per the module's own docstring:
the capability cache never expired, so a model that gained a capability via
a fresh pull mid-session kept whatever answer was cached the first time it
was seen) had zero backend/tests coverage. Ported from graphlink_app/tests/
test_ollama_capability_cache_invalidation.py's TestInvalidateOllamaCapability
Cache class - its sibling TestModelPullWorkerThreadInvalidatesCacheOnSuccess
class is NOT ported: ModelPullWorkerThread (graphlink_agents_tools.py) is a
QThread subclass with no backend/ equivalent yet - there is no model-pull
mechanism in the new stack at all until R7.4 builds the deferred Ollama
settings page, so there is nothing yet for that half to regress.
"""

from unittest.mock import patch

import api_provider


def test_invalidate_ollama_capability_cache_clears_a_specific_model_entry(monkeypatch):
monkeypatch.setattr(api_provider, "_OLLAMA_CAPABILITY_CACHE", {"model-a": {"vision"}, "model-b": {"audio"}})

api_provider.invalidate_ollama_capability_cache("model-a")

assert "model-a" not in api_provider._OLLAMA_CAPABILITY_CACHE
assert "model-b" in api_provider._OLLAMA_CAPABILITY_CACHE


def test_invalidate_ollama_capability_cache_model_name_lookup_is_case_and_whitespace_insensitive(monkeypatch):
monkeypatch.setattr(api_provider, "_OLLAMA_CAPABILITY_CACHE", {"gemma4:e4b": {"audio"}})

api_provider.invalidate_ollama_capability_cache(" Gemma4:E4B ")

assert api_provider._OLLAMA_CAPABILITY_CACHE == {}


def test_invalidate_ollama_capability_cache_clearing_an_uncached_model_is_a_safe_no_op(monkeypatch):
monkeypatch.setattr(api_provider, "_OLLAMA_CAPABILITY_CACHE", {"model-a": {"vision"}})

api_provider.invalidate_ollama_capability_cache("never-cached-model")

assert api_provider._OLLAMA_CAPABILITY_CACHE == {"model-a": {"vision"}}


def test_invalidate_ollama_capability_cache_no_argument_clears_the_entire_cache(monkeypatch):
monkeypatch.setattr(api_provider, "_OLLAMA_CAPABILITY_CACHE", {"model-a": {"vision"}, "model-b": {"audio"}})

api_provider.invalidate_ollama_capability_cache()

assert api_provider._OLLAMA_CAPABILITY_CACHE == {}


def test_next_lookup_after_invalidation_re_fetches_from_ollama_show(monkeypatch):
monkeypatch.setattr(api_provider, "_OLLAMA_CAPABILITY_CACHE", {"model-a": {"vision"}})

with patch("api_provider.ollama.show", return_value={"capabilities": ["audio"]}) as mock_show:
api_provider.invalidate_ollama_capability_cache("model-a")
result = api_provider._get_ollama_capabilities("model-a")

mock_show.assert_called_once()
assert result == {"audio"}


def test_a_lookup_with_no_invalidation_never_touches_ollama_show_a_second_time(monkeypatch):
# The other half of the contract the test above proves: a REPEAT lookup
# with no invalidation in between must serve the cached answer, not
# re-fetch - this is the entire reason the cache exists.
monkeypatch.setattr(api_provider, "_OLLAMA_CAPABILITY_CACHE", {})

with patch("api_provider.ollama.show", return_value={"capabilities": ["vision"]}) as mock_show:
first = api_provider._get_ollama_capabilities("model-a")
second = api_provider._get_ollama_capabilities("model-a")

mock_show.assert_called_once()
assert first == second == {"vision"}
29 changes: 29 additions & 0 deletions backend/tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,32 @@ def test_clear_github_token_intent(manager):
asyncio.run(bus.dispatch_intent("app-settings", "clearGithubToken", []))
assert manager.get_github_token() == ""
assert settings_payload(manager)["githubTokenConfigured"] is False


# -- R7.3: SettingsManager.schema_version (graphlink_licensing.py) - ported
# -- from graphlink_app/tests/test_schema_version.py's own Qt-free class.
# -- Its sibling TestChatPayloadSchemaVersion is NOT ported here: it exercises
# -- graphlink_scene.ChatScene/graphlink_session.serializers.SceneSerializer,
# -- both Qt-tainted with no backend/ equivalent to test against.


def test_a_fresh_settings_file_has_the_current_schema_version(manager):
assert manager.get_schema_version() == SettingsManager.CURRENT_SCHEMA_VERSION


def test_an_old_settings_file_without_schema_version_is_backfilled(tmp_path):
import json

state_file = tmp_path / "session.dat"
state_file.write_text(json.dumps({"theme": "mono"}), encoding="utf-8")

old_manager = SettingsManager(state_file)

# Backfilled in memory immediately, the same as every other pre-existing
# key here (theme, show_token_counter, ...) - none of those write back to
# disk until the next explicit set_*() call either, so schema_version
# matching that existing pattern is correct, not a gap.
assert old_manager.get_schema_version() == SettingsManager.CURRENT_SCHEMA_VERSION

old_manager.set_theme("mono") # any setter call persists the whole (now-backfilled) state
assert json.loads(state_file.read_text(encoding="utf-8"))["schema_version"] == SettingsManager.CURRENT_SCHEMA_VERSION
Loading