"""Tests for the template value source: model, factory, cycle/depth, refs.""" from datetime import datetime, timezone import pytest from ledgrab.storage.value_source import TemplateValueSource class TestModelRoundTrip: def _make(self, **over): now = datetime.now(timezone.utc) defaults = dict( id="vs_t1", name="Combo", source_type="template", created_at=now, updated_at=now, template="min(a * 2, 1)", inputs=[{"name": "a", "value_source_id": "vs_a"}], default_value=0.2, eval_interval=1.5, ) defaults.update(over) return TemplateValueSource(**defaults) def test_to_from_dict_idempotent(self): src = self._make() rebuilt = TemplateValueSource.from_dict(src.to_dict()) assert rebuilt.template == src.template assert rebuilt.inputs == src.inputs assert rebuilt.default_value == src.default_value assert rebuilt.eval_interval == src.eval_interval assert rebuilt.to_dict()["return_type"] == "float" def test_old_row_deserializes_with_defaults(self): """A row written before template fields existed must load safely.""" now = datetime.now(timezone.utc).isoformat() src = TemplateValueSource.from_dict( { "id": "vs_old", "name": "Old", "source_type": "template", "created_at": now, "updated_at": now, } ) assert src.template == "" assert src.inputs == [] assert src.default_value == 0.0 assert src.eval_interval is None def test_dirty_scalars_coerce_to_defaults(self): """Non-numeric stored scalars must not drop the whole row on load.""" src = TemplateValueSource.from_dict( { "id": "x", "name": "n", "source_type": "template", "template": "a", "default_value": "not-a-number", "eval_interval": "bad", } ) assert src.default_value == 0.0 assert src.eval_interval is None def test_inputs_normalized_from_dirty_data(self): src = TemplateValueSource.from_dict( { "id": "x", "name": "n", "source_type": "template", "inputs": [{"name": "a", "value_source_id": "vs_a"}, "junk", {"bad": 1}], } ) # non-dict entries dropped; dict entries coerced to {name, value_source_id} assert src.inputs == [ {"name": "a", "value_source_id": "vs_a"}, {"name": "", "value_source_id": ""}, ] class TestFactoryCreate: def test_create_valid(self, value_source_store): src = value_source_store.create_source( "Combo", "template", template="min(a * 2, 1)", inputs=[{"name": "a", "value_source_id": ""}], default_value=0.3, ) assert isinstance(src, TemplateValueSource) assert src.id.startswith("vs_") assert src.default_value == 0.3 def test_empty_template_rejected(self, value_source_store): with pytest.raises(ValueError): value_source_store.create_source("X", "template", template=" ", inputs=[]) def test_compile_error_rejected(self, value_source_store): with pytest.raises(ValueError): value_source_store.create_source("X", "template", template="a +", inputs=[]) def test_cost_bomb_rejected(self, value_source_store): with pytest.raises(ValueError): value_source_store.create_source("X", "template", template="10 ** 10", inputs=[]) def test_reserved_input_name_rejected(self, value_source_store): with pytest.raises(ValueError): value_source_store.create_source( "X", "template", template="min(0, 1)", inputs=[{"name": "min", "value_source_id": "vs_a"}], ) def test_duplicate_input_name_rejected(self, value_source_store): with pytest.raises(ValueError): value_source_store.create_source( "X", "template", template="a", inputs=[ {"name": "a", "value_source_id": "vs_a"}, {"name": "a", "value_source_id": "vs_b"}, ], ) def test_default_value_out_of_range_rejected(self, value_source_store): with pytest.raises(ValueError): value_source_store.create_source( "X", "template", template="a", inputs=[{"name": "a", "value_source_id": ""}], default_value=5.0, ) def test_unbound_variable_rejected(self, value_source_store): # 'ha_enti' is referenced but only 'ha_entity' is bound (typo) → reject. with pytest.raises(ValueError): value_source_store.create_source( "X", "template", template="ha_enti", inputs=[{"name": "ha_entity", "value_source_id": ""}], ) class TestFactoryUpdate: def test_partial_update_template_only(self, value_source_store): src = value_source_store.create_source( "X", "template", template="a", inputs=[{"name": "a", "value_source_id": ""}], default_value=0.1, ) updated = value_source_store.update_source(src.id, template="clamp(a * 3)") assert updated.template == "clamp(a * 3)" assert updated.default_value == 0.1 # unchanged def test_update_invalid_template_rejected(self, value_source_store): src = value_source_store.create_source("X", "template", template="clamp(0.5)", inputs=[]) with pytest.raises(ValueError): value_source_store.update_source(src.id, template="a |") class TestCycleAndDepth: def test_self_reference_rejected(self, value_source_store): t = value_source_store.create_source("T", "template", template="clamp(0.5)", inputs=[]) with pytest.raises(ValueError): value_source_store.update_source(t.id, inputs=[{"name": "x", "value_source_id": t.id}]) def test_circular_reference_rejected(self, value_source_store): t1 = value_source_store.create_source("T1", "template", template="clamp(0.5)", inputs=[]) t2 = value_source_store.create_source( "T2", "template", template="x", inputs=[{"name": "x", "value_source_id": t1.id}], ) # t1 -> t2 -> t1 would be a cycle with pytest.raises(ValueError): value_source_store.update_source( t1.id, inputs=[{"name": "x", "value_source_id": t2.id}] ) def test_deep_chain_rejected(self, value_source_store): prev = value_source_store.create_source("L0", "template", template="clamp(0.5)", inputs=[]) created = 1 with pytest.raises(ValueError): for i in range(1, 12): node = value_source_store.create_source( f"L{i}", "template", template="x", inputs=[{"name": "x", "value_source_id": prev.id}], ) prev = node created += 1 # Should have rejected before building an unbounded chain. assert created <= 8 def test_get_transitive_dependencies(self, value_source_store): leaf = value_source_store.create_source( "leaf", "template", template="clamp(0.5)", inputs=[] ) mid = value_source_store.create_source( "mid", "template", template="x", inputs=[{"name": "x", "value_source_id": leaf.id}] ) top = value_source_store.create_source( "top", "template", template="x", inputs=[{"name": "x", "value_source_id": mid.id}] ) deps = value_source_store.get_transitive_dependencies(top.id) assert deps == {mid.id, leaf.id} class TestReferencingSources: def test_find_referencing_sources(self, value_source_store): base = value_source_store.create_source("Base", "static", value=0.5) tmpl = value_source_store.create_source( "Uses", "template", template="b", inputs=[{"name": "b", "value_source_id": base.id}], ) refs = value_source_store.find_referencing_sources(base.id) assert tmpl.name in refs assert value_source_store.find_referencing_sources(tmpl.id) == []