diff --git a/backend/tests/test_chart_data_validation.py b/backend/tests/test_chart_data_validation.py new file mode 100644 index 0000000..6617b64 --- /dev/null +++ b/backend/tests/test_chart_data_validation.py @@ -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] diff --git a/backend/tests/test_ollama_capability_cache.py b/backend/tests/test_ollama_capability_cache.py new file mode 100644 index 0000000..1f25531 --- /dev/null +++ b/backend/tests/test_ollama_capability_cache.py @@ -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"} diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 114d4f5..8d3b9af 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -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