File size: 7,321 Bytes
14184e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
from app.schemas.graph import HTML5VisualPayload
from app.services.visualization_service import VisualizationService


class _CalledOrRaise:
    """Fake engine base: records calls, or raises if it must never be called."""

    def __init__(self, marker: str, should_raise: bool = False):
        self.marker = marker
        self.should_raise = should_raise
        self.calls = []

    def _payload(self, animation_type="2d_text"):
        return HTML5VisualPayload(
            html_code="<html></html>",
            animation_type=animation_type,
            explanation=self.marker,
        )


class FakeRouter:
    def __init__(self, decision=None):
        self.decision = decision
        self.calls = []

    def classify(self, selection_text, card_markdown, chunks, familiarity):
        self.calls.append((selection_text, card_markdown, chunks, familiarity))
        return self.decision


class FakeFormula(_CalledOrRaise):
    def __init__(self):
        super().__init__("FAKE_FORMULA")

    def generate(self, concept, chunks, familiarity):
        self.calls.append((concept, chunks, familiarity))
        if self.should_raise:
            raise AssertionError("formula engine should not have been called")
        return self._payload("formula")


class FakeTextRef(_CalledOrRaise):
    def __init__(self):
        super().__init__("FAKE_TEXT_REF")

    def generate(self, concept, chunks, familiarity, web_results=None):
        self.calls.append((concept, chunks, familiarity, web_results))
        if self.should_raise:
            raise AssertionError("text_ref engine should not have been called")
        return self._payload("2d_text")


class FakeD3(_CalledOrRaise):
    def __init__(self):
        super().__init__("FAKE_D3")

    def generate(self, concept, chunks, familiarity):
        self.calls.append((concept, chunks, familiarity))
        if self.should_raise:
            raise AssertionError("d3 engine should not have been called")
        return self._payload("graph")


class FakeShell(_CalledOrRaise):
    def __init__(self):
        super().__init__("FAKE_SHELL")

    def generate(self, concept, kind, chunks, familiarity):
        self.calls.append((concept, kind, chunks, familiarity))
        if self.should_raise:
            raise AssertionError("shell engine should not have been called")
        return self._payload(kind)


def _make_service(router=None):
    return VisualizationService(
        router=router or FakeRouter(),
        formula=FakeFormula(),
        text_ref=FakeTextRef(),
        d3=FakeD3(),
        shell=FakeShell(),
    )


_CHUNKS = [{"source": "s", "text": "some grounded text"}]


def test_generate_formula_routes_to_formula_engine():
    service = _make_service()
    out = service.generate("E=mc^2", "formula", "graduate", chunks=_CHUNKS)
    assert out.explanation == "FAKE_FORMULA"
    assert service.formula.calls == [("E=mc^2", _CHUNKS, "graduate")]
    assert service.text_ref.calls == []
    assert service.d3.calls == []
    assert service.shell.calls == []


def test_generate_2d_text_routes_to_text_ref_engine_with_web_results():
    service = _make_service()
    web_results = [{"title": "t", "url": "u"}]
    out = service.generate(
        "some concept", "2d_text", "graduate", chunks=_CHUNKS, web_results=web_results
    )
    assert out.explanation == "FAKE_TEXT_REF"
    assert service.text_ref.calls == [("some concept", _CHUNKS, "graduate", web_results)]
    assert service.formula.calls == []
    assert service.d3.calls == []
    assert service.shell.calls == []


def test_generate_graph_routes_to_d3_engine():
    service = _make_service()
    out = service.generate("GDP over time", "graph", "graduate", chunks=_CHUNKS)
    assert out.explanation == "FAKE_D3"
    assert service.d3.calls == [("GDP over time", _CHUNKS, "graduate")]
    assert service.formula.calls == []
    assert service.text_ref.calls == []
    assert service.shell.calls == []


def test_generate_3d_routes_to_shell_engine_with_kind_3d():
    service = _make_service()
    out = service.generate("a molecule", "3d", "graduate", chunks=_CHUNKS)
    assert out.explanation == "FAKE_SHELL"
    assert service.shell.calls == [("a molecule", "3d", _CHUNKS, "graduate")]
    assert service.formula.calls == []
    assert service.text_ref.calls == []
    assert service.d3.calls == []


def test_generate_2d_anim_routes_to_shell_engine_with_kind_2d_anim():
    service = _make_service()
    out = service.generate("a mechanism", "2d_anim", "graduate", chunks=_CHUNKS)
    assert out.explanation == "FAKE_SHELL"
    assert service.shell.calls == [("a mechanism", "2d_anim", _CHUNKS, "graduate")]
    assert service.formula.calls == []
    assert service.text_ref.calls == []
    assert service.d3.calls == []


def test_generate_decline_returns_decline_card_without_calling_any_engine():
    service = VisualizationService(
        router=FakeRouter(),
        formula=FakeFormula(),
        text_ref=FakeTextRef(),
        d3=FakeD3(),
        shell=FakeShell(),
    )
    for engine in (service.formula, service.text_ref, service.d3, service.shell):
        engine.should_raise = True

    out = service.generate("pure philosophy", "decline", "graduate", chunks=_CHUNKS)

    assert out.animation_type == "2d_text"
    assert "better discussed in chat" in out.html_code
    assert service.formula.calls == []
    assert service.text_ref.calls == []
    assert service.d3.calls == []
    assert service.shell.calls == []


def test_generate_no_chunks_declines_regardless_of_modality():
    for engine in (FakeFormula(), FakeTextRef(), FakeD3(), FakeShell()):
        engine.should_raise = True

    for modality in ("formula", "2d_text", "graph", "3d", "2d_anim", "decline"):
        service = VisualizationService(
            router=FakeRouter(),
            formula=FakeFormula(),
            text_ref=FakeTextRef(),
            d3=FakeD3(),
            shell=FakeShell(),
        )
        for engine in (service.formula, service.text_ref, service.d3, service.shell):
            engine.should_raise = True

        out = service.generate("no source concept", modality, "graduate", chunks=[])

        assert out.animation_type == "2d_text"
        assert "source grounding" in out.explanation or "better discussed in chat" in out.html_code
        assert service.formula.calls == []
        assert service.text_ref.calls == []
        assert service.d3.calls == []
        assert service.shell.calls == []

    # Also confirm chunks=None short-circuits the same way.
    service = _make_service()
    for engine in (service.formula, service.text_ref, service.d3, service.shell):
        engine.should_raise = True
    out = service.generate("no source concept", "formula", "graduate", chunks=None)
    assert out.animation_type == "2d_text"
    assert service.formula.calls == []


def test_classify_delegates_to_router():
    from app.agents.modality_router import ModalityDecision

    decision = ModalityDecision(modality="formula", reasoning="has an equation")
    router = FakeRouter(decision=decision)
    service = _make_service(router=router)

    out = service.classify("selected text", "# card markdown", _CHUNKS, "graduate")

    assert out is decision
    assert router.calls == [("selected text", "# card markdown", _CHUNKS, "graduate")]