File size: 41,066 Bytes
28a08e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
"""
test_cognitive_gaps.py β€” Regression tests per COG-1/2/3/4

Firme reali verificate il 2026-06-16 su HEAD c82d71b:
  should_replan(exec_warn, exec_done) β†’ bool
  replan(planner, original_goal, exec_warn, exec_done, error_context="") β†’ dict|None
  _should_test_ts(content, path="") β†’ bool   (>= 5 linee non-commento + TS regex + path .ts/.tsx)
  run_tdd_check_ts(content, path, executor, on_warn=None) β†’ dict
  needs_dynamic_tool(tool_name, description) β†’ bool  (True solo se COMPUTE_RE match)
  generate_and_register(description, tool_name, llm, executor) β†’ dict
"""

from __future__ import annotations

import asyncio
import inspect
import os
import sys
import unittest
from unittest.mock import AsyncMock, MagicMock

_BACKEND = os.path.join(os.path.dirname(__file__), "..")
if _BACKEND not in sys.path:
    sys.path.insert(0, _BACKEND)

def _run(coro):
    return asyncio.get_event_loop().run_until_complete(coro)


# ═══════════════════════════════════════════════════════════════════════════════
# COG-1: Dynamic Replanner
# ═══════════════════════════════════════════════════════════════════════════════

class TestCOG1DynamicReplanner(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        try:
            import agents.dynamic_replanner as dr
            cls.dr = dr
        except ImportError as e:
            raise unittest.SkipTest(f"agents.dynamic_replanner non importabile: {e}")

    def test_should_replan_exists(self):
        """COG-1: should_replan() Γ¨ esportata da dynamic_replanner."""
        self.assertTrue(callable(self.dr.should_replan))

    def test_should_replan_signature(self):
        """COG-1: should_replan(exec_warn, exec_done) β€” 2 parametri."""
        sig = inspect.signature(self.dr.should_replan)
        params = list(sig.parameters)
        self.assertEqual(len(params), 2,
                         f"should_replan deve avere 2 params, got {params}")

    def test_should_replan_false_no_failures(self):
        """COG-1: should_replan β†’ False quando exec_warn Γ¨ vuoto."""
        result = self.dr.should_replan([], ["subtask ok"])
        self.assertFalse(result)

    def test_should_replan_false_no_real_errors(self):
        """COG-1: should_replan β†’ False quando exec_warn non contiene errori reali."""
        # Risk skips non sono failure reali
        exec_warn = ["⚠ subtask #1 skip: risk:high", "⚠ subtask #2 skip: risk:high"]
        result = self.dr.should_replan(exec_warn, ["ok1", "ok2", "ok3"])
        self.assertFalse(result,
                         "should_replan deve restituire False per warning non-failure")

    def test_should_replan_true_with_real_failure(self):
        """COG-1: should_replan β†’ True con failures reali >= successi."""
        exec_warn = ["⚠ subtask #1 fallito: timeout", "⚠ subtask #2 failed: ConnectionError"]
        exec_done = []
        result = self.dr.should_replan(exec_warn, exec_done)
        self.assertTrue(result,
                        "should_replan deve restituire True con failures reali >= successi")

    def test_should_replan_false_too_many_successes(self):
        """COG-1: should_replan β†’ False se fallimenti < successi."""
        exec_warn = ["⚠ subtask #1 fallito: timeout"]
        exec_done = ["ok1", "ok2", "ok3", "ok4"]  # 4 successi > 1 fallimento
        result = self.dr.should_replan(exec_warn, exec_done)
        self.assertFalse(result,
                         "should_replan deve restituire False se fallimenti < successi")

    def test_replan_exists(self):
        """COG-1: replan() Γ¨ esportata da dynamic_replanner."""
        self.assertTrue(callable(self.dr.replan))

    def test_replan_signature(self):
        """COG-1: replan ha almeno 4 param obbligatori (planner, original_goal, exec_warn, exec_done)."""
        sig = inspect.signature(self.dr.replan)
        params = list(sig.parameters)
        self.assertGreaterEqual(len(params), 4,
                         f"replan deve avere >= 4 params, got {params}")

    def test_replan_is_coroutine(self):
        """COG-1: replan() deve essere async def."""
        self.assertTrue(asyncio.iscoroutinefunction(self.dr.replan))

    def test_replan_returns_none_without_planner(self):
        """COG-1: replan(None, ...) β†’ None β€” fallback sicuro senza planner."""
        result = _run(self.dr.replan(None, "goal", [], []))
        self.assertIsNone(result,
                          "replan con planner=None deve restituire None")


# ═══════════════════════════════════════════════════════════════════════════════
# COG-2: Reflection Recording in unified_loop.py
# ═══════════════════════════════════════════════════════════════════════════════

class TestCOG2ReflectionRecording(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        ul_path = os.path.join(_BACKEND, "agents", "unified_loop.py")
        if not os.path.exists(ul_path):
            raise unittest.SkipTest("agents/unified_loop.py non trovato")
        with open(ul_path, encoding="utf-8") as fh:
            cls.src = fh.read()
        # P20-TD1-F3b: _reflective_debug estratto in unified_loop_helpers.py
        # src concatena entrambi i file β†’ tutti gli assertIn rimangono validi
        helpers_path = os.path.join(_BACKEND, "agents", "unified_loop_helpers.py")
        if os.path.exists(helpers_path):
            with open(helpers_path, encoding="utf-8") as fh:
                cls.src += "\n" + fh.read()

    def test_record_failure_hook_present(self):
        """COG-2: record_failure() Γ¨ chiamata in unified_loop (hook post-errore)."""
        self.assertIn("record_failure", self.src)

    def test_record_success_hook_present(self):
        """COG-2: record_success() Γ¨ chiamata in unified_loop (hook post-successo)."""
        self.assertIn("record_success", self.src)

    def test_cog2_marker_in_source(self):
        """COG-2: marker COG-2 presente nel codice."""
        self.assertIn("COG-2", self.src)

    def test_reflection_record_failure_in_reflective_debug(self):
        """COG-2: record_failure chiamata dentro _reflective_debug."""
        idx_fn = self.src.find("_reflective_debug")
        self.assertGreater(idx_fn, 0, "_reflective_debug non trovato")
        idx_rf = self.src.find("record_failure", idx_fn)
        self.assertGreater(idx_rf, 0,
                           "record_failure non trovata dopo _reflective_debug")


# ═══════════════════════════════════════════════════════════════════════════════
# COG-3: TypeScript TDD
# ═══════════════════════════════════════════════════════════════════════════════

_TS_SAMPLE = """\
import { useState } from 'react';
interface Props { name: string; }
const Greet = ({ name }: Props) => {
  const [count, setCount] = useState(0);
  return <div>{name} {count}</div>;
};
export default Greet;
"""

class TestCOG3TypeScriptTDD(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        try:
            import agents.tdd_runner as tdd
            cls.tdd = tdd
        except ImportError as e:
            raise unittest.SkipTest(f"agents.tdd_runner non importabile: {e}")

    def test_should_test_ts_exists(self):
        """COG-3: _should_test_ts() Γ¨ definita in tdd_runner."""
        self.assertTrue(callable(self.tdd._should_test_ts))

    def test_should_test_ts_returns_false_for_python_path(self):
        """COG-3: _should_test_ts β†’ False se path termina con .py."""
        result = self.tdd._should_test_ts("print('hello')\n" * 6, "main.py")
        self.assertFalse(result,
                         "_should_test_ts deve restituire False per file .py")

    def test_should_test_ts_returns_true_for_ts_path_with_code(self):
        """COG-3: _should_test_ts β†’ True per file .ts con >= 5 righe TS."""
        result = self.tdd._should_test_ts(_TS_SAMPLE, "App.ts")
        self.assertTrue(result,
                        "_should_test_ts deve restituire True per file .ts con codice TS")

    def test_should_test_ts_returns_true_for_tsx(self):
        """COG-3: _should_test_ts β†’ True per file .tsx con >= 5 righe TS."""
        result = self.tdd._should_test_ts(_TS_SAMPLE, "App.tsx")
        self.assertTrue(result,
                        "_should_test_ts deve restituire True per file .tsx")

    def test_should_test_ts_returns_false_too_few_lines(self):
        """COG-3: _should_test_ts β†’ False per file .ts con < 5 righe non-commento."""
        result = self.tdd._should_test_ts("const x: number = 1;", "app.ts")
        self.assertFalse(result,
                         "_should_test_ts deve restituire False per file con poche righe")

    def test_should_test_ts_returns_false_no_ts_code_pattern(self):
        """COG-3: _should_test_ts β†’ False se contenuto non ha pattern TS."""
        non_ts = "\n".join(["# nessun ts"] * 6)
        result = self.tdd._should_test_ts(non_ts, "app.ts")
        self.assertFalse(result,
                         "_should_test_ts deve restituire False senza pattern TS nel contenuto")

    def test_run_tdd_check_ts_exists(self):
        """COG-3: run_tdd_check_ts() Γ¨ definita in tdd_runner."""
        self.assertTrue(callable(self.tdd.run_tdd_check_ts))

    def test_run_tdd_check_ts_is_coroutine(self):
        """COG-3: run_tdd_check_ts() deve essere async def."""
        self.assertTrue(asyncio.iscoroutinefunction(self.tdd.run_tdd_check_ts))

    def test_run_tdd_check_ts_skips_non_ts(self):
        """COG-3: run_tdd_check_ts con file .py non chiama on_warn (early return)."""
        called = []

        async def _fake_on_warn(msg):
            called.append(msg)

        result = _run(self.tdd.run_tdd_check_ts(
            content="print('hello')\n" * 6,
            path="main.py",
            executor=None,
            on_warn=_fake_on_warn,
        ))
        self.assertEqual(called, [],
                         "run_tdd_check_ts non deve chiamare on_warn su file .py")


# ═══════════════════════════════════════════════════════════════════════════════
# COG-4: Tool Generator
# ═══════════════════════════════════════════════════════════════════════════════

class TestCOG4ToolGenerator(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        try:
            import agents.tool_generator as tg
            cls.tg = tg
        except ImportError as e:
            raise unittest.SkipTest(f"agents.tool_generator non importabile: {e}")

    def tearDown(self):
        # Reset _GENERATED_COUNT after tests that might increment it
        try:
            self.tg._GENERATED_COUNT = 0
        except Exception:
            pass

    def test_needs_dynamic_tool_exists(self):
        """COG-4: needs_dynamic_tool() Γ¨ esportata da tool_generator."""
        self.assertTrue(callable(self.tg.needs_dynamic_tool))

    def test_needs_dynamic_tool_false_for_network_description(self):
        """COG-4: needs_dynamic_tool β†’ False per descrizioni con operazioni di rete."""
        for desc in ("fetch url", "download file via http", "scrape page", "api request"):
            with self.subTest(desc=desc):
                result = self.tg.needs_dynamic_tool("mio_tool", desc)
                self.assertFalse(result,
                                 f"needs_dynamic_tool deve restituire False per '{desc}'")

    def test_needs_dynamic_tool_false_for_empty(self):
        """COG-4: needs_dynamic_tool β†’ False per tool name vuoto."""
        result = self.tg.needs_dynamic_tool("", "calcola la media")
        # Note: empty name + compute desc β†’ True per logica attuale (solo description conta)
        # Testiamo solo che non sollevi eccezioni
        self.assertIsInstance(result, bool)

    def test_needs_dynamic_tool_true_for_compute_description(self):
        """COG-4: needs_dynamic_tool β†’ True per descrizione computazionale locale."""
        for desc in ("calcola la media dei valori", "converti JSON in CSV",
                     "analizza il testo", "estrai le parole chiave"):
            with self.subTest(desc=desc):
                self.tg._GENERATED_COUNT = 0  # reset guard
                result = self.tg.needs_dynamic_tool("mio_tool", desc)
                self.assertTrue(result,
                                f"needs_dynamic_tool deve restituire True per '{desc}'")

    def test_needs_dynamic_tool_false_when_limit_reached(self):
        """COG-4: needs_dynamic_tool β†’ False quando _MAX_GENERATED raggiunto."""
        self.tg._GENERATED_COUNT = self.tg._MAX_GENERATED
        result = self.tg.needs_dynamic_tool("qualsiasi", "calcola la media")
        self.assertFalse(result,
                         "needs_dynamic_tool deve restituire False al limite sessione")

    def test_generate_and_register_exists(self):
        """COG-4: generate_and_register() Γ¨ esportata da tool_generator."""
        self.assertTrue(callable(self.tg.generate_and_register))

    def test_generate_and_register_is_coroutine(self):
        """COG-4: generate_and_register() deve essere async def."""
        self.assertTrue(asyncio.iscoroutinefunction(self.tg.generate_and_register))

    def test_generate_and_register_signature(self):
        """COG-4: generate_and_register ha >= 3 param (description, tool_name, llm, ...)."""
        sig = inspect.signature(self.tg.generate_and_register)
        params = list(sig.parameters)
        self.assertGreaterEqual(len(params), 3,
                         f"generate_and_register deve avere >= 3 params, got {params}")

    def test_security_blocked_imports_list_exists(self):
        """COG-4: lista di import bloccati esiste in tool_generator."""
        src = inspect.getsource(self.tg)
        blocked_keywords = ["requests", "httpx", "socket", "subprocess"]
        found = [kw for kw in blocked_keywords if kw in src]
        self.assertGreater(len(found), 0,
                           "Nessun import di rete bloccato trovato β€” security gate assente")

    def test_session_limit_constant_exists(self):
        """COG-4: costante _MAX_GENERATED esiste con valore <= 10."""
        self.assertTrue(hasattr(self.tg, "_MAX_GENERATED"),
                        "_MAX_GENERATED non trovato in tool_generator")
        self.assertLessEqual(self.tg._MAX_GENERATED, 10,
                             "_MAX_GENERATED dovrebbe essere <= 10 per sicurezza")


# ═══════════════════════════════════════════════════════════════════════════════
# COG-4 Wiring: unified_loop.py ha l'elif _s_tool: block
# ═══════════════════════════════════════════════════════════════════════════════

class TestCOG4WiringInUnifiedLoop(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        ul_path = os.path.join(_BACKEND, "agents", "unified_loop.py")
        if not os.path.exists(ul_path):
            raise unittest.SkipTest("agents/unified_loop.py non trovato")
        with open(ul_path, encoding="utf-8") as fh:
            cls.src = fh.read()

    def test_tool_map_miss_handled(self):
        """COG-4 wiring: dopo TOOL_MAP miss, c'Γ¨ elif _s_tool: (no silent skip)."""
        idx_get = self.src.find("_TOOL_MAP.get(_s_tool")
        self.assertGreater(idx_get, 0, "_TOOL_MAP.get(_s_tool non trovato")
        block = self.src[idx_get: idx_get + 2000]
        self.assertIn("elif _s_tool:", block)

    def test_tool_generator_import_in_elif_block(self):
        """COG-4 wiring: import tool_generator presente nel elif _s_tool: block."""
        idx_elif = self.src.find("elif _s_tool:")
        self.assertGreater(idx_elif, 0)
        block = self.src[idx_elif: idx_elif + 800]
        self.assertIn("tool_generator", block)

    def test_generate_and_register_called_in_elif(self):
        """COG-4 wiring: generate_and_register chiamata nel block elif _s_tool:."""
        idx_elif = self.src.find("elif _s_tool:")
        self.assertGreater(idx_elif, 0)
        block = self.src[idx_elif: idx_elif + 800]
        self.assertIn("generate_and_register", block)


# ═══════════════════════════════════════════════════════════════════════════════
# COG-1 Wiring: unified_loop.py ha il dynamic replan post-gather
# ═══════════════════════════════════════════════════════════════════════════════

class TestCOG1WiringInUnifiedLoop(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        ul_path = os.path.join(_BACKEND, "agents", "unified_loop.py")
        if not os.path.exists(ul_path):
            raise unittest.SkipTest("agents/unified_loop.py non trovato")
        with open(ul_path, encoding="utf-8") as fh:
            cls.src = fh.read()

    def test_cog1_real_failures_filter_present(self):
        """COG-1 wiring: _cog1_real_failures filtra exec_warn per keyword."""
        self.assertIn("_cog1_real_failures", self.src)

    def test_should_replan_called_in_loop(self):
        """COG-1 wiring: should_replan() chiamata in unified_loop post-gather."""
        self.assertIn("should_replan", self.src)

    def test_dynamic_replanner_imported_in_loop(self):
        """COG-1 wiring: dynamic_replanner importato nel loop."""
        self.assertIn("dynamic_replanner", self.src)

    def test_replanned_flag_blocks_recursion(self):
        """COG-1 wiring: plan._replanned flag presente per evitare loop infiniti."""
        self.assertIn("_replanned", self.src)

    def test_replan_result_clears_exec_state(self):
        """COG-1 wiring: exec_done.clear() e exec_warn.clear() presenti nel replan block."""
        idx_replan = self.src.find("_cog1_real_failures")
        self.assertGreater(idx_replan, 0)
        block = self.src[idx_replan: idx_replan + 4000]
        self.assertIn("exec_done.clear()", block)
        self.assertIn("exec_warn.clear()", block)


# ═══════════════════════════════════════════════════════════════════════════════
# COG-3 Wiring: unified_loop.py chiama run_tdd_check_ts dopo write_file/.ts
# ═══════════════════════════════════════════════════════════════════════════════

class TestCOG3WiringInUnifiedLoop(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        ul_path = os.path.join(_BACKEND, "agents", "unified_loop.py")
        if not os.path.exists(ul_path):
            raise unittest.SkipTest("agents/unified_loop.py non trovato")
        with open(ul_path, encoding="utf-8") as fh:
            cls.src = fh.read()

    def test_tdd_runner_imported_near_write_file(self):
        """COG-3 wiring: tdd_runner importato vicino al blocco write_file."""
        self.assertIn("tdd_runner", self.src)

    def test_run_tdd_check_ts_called_in_write_block(self):
        """COG-3 wiring: run_tdd_check_ts chiamata dopo write_file/apply_patch."""
        self.assertIn("run_tdd_check_ts", self.src)

    def test_cog3_guard_checks_ts_extension(self):
        """COG-3 wiring: guard che controlla .ts/.tsx prima di chiamare type_check."""
        self.assertIn('.endswith((".ts", ".tsx"))', self.src)

    def test_cog3_failure_adds_to_exec_warn(self):
        """COG-3 wiring: type_check failure aggiunge warning a exec_warn."""
        idx_cog3 = self.src.find("run_tdd_check_ts")
        self.assertGreater(idx_cog3, 0)
        block = self.src[idx_cog3: idx_cog3 + 1200]
        self.assertIn("exec_warn.append", block)

    def test_cog3_timeout_protected(self):
        """COG-3 wiring: run_tdd_check_ts protetta da asyncio.wait_for timeout."""
        idx_import = self.src.find("from agents.tdd_runner import run_tdd_check_ts")
        self.assertGreater(idx_import, 0)
        # La chiamata con wait_for viene dopo l'import β€” finestra piΓΉ ampia
        block = self.src[idx_import: idx_import + 1000]
        self.assertIn("wait_for", block)

    def test_cog3_scaffold_project_hooked(self):
        """COG-3 wiring: hook post-scaffold_project presente in unified_loop."""
        self.assertIn("scaffold_project", self.src)
        # Verifica che il hook COG-3 per scaffold sia presente
        idx_scaf = self.src.find('rn == "scaffold_project" and _r.get("success")')
        self.assertGreater(idx_scaf, 0, "COG-3 scaffold hook non trovato")

    def test_cog3_scaffold_uses_ts_stub(self):
        """COG-3 wiring: scaffold hook usa contenuto TS stub per trigger _should_test_ts."""
        self.assertIn("_SCAF_TS_STUB", self.src)


# ═══════════════════════════════════════════════════════════════════════════════
# Entrypoint
# ═══════════════════════════════════════════════════════════════════════════════


# ==============================================================================
# COG-5: Goal Drift Detector
# ==============================================================================

class TestCOG5GoalDriftDetector(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        try:
            import agents.goal_drift_detector as gdd
            cls.gdd = gdd
        except ImportError as e:
            raise unittest.SkipTest(f"agents.goal_drift_detector non importabile: {e}")

    # ── _extract_keywords ────────────────────────────────────────────────

    def test_extract_keywords_filters_short_words(self):
        """COG-5: _extract_keywords esclude parole < 4 char."""
        kws = self.gdd._extract_keywords("il lo la di a e un una")
        self.assertEqual(len(kws), 0, "Stop-word brevi non devono essere estratte")

    def test_extract_keywords_returns_meaningful_words(self):
        """COG-5: _extract_keywords restituisce parole significative."""
        kws = self.gdd._extract_keywords("analizza il meteo di Roma oggi")
        self.assertIn("analizza", kws)
        self.assertIn("meteo", kws)
        self.assertIn("roma", kws)

    def test_extract_keywords_case_insensitive(self):
        """COG-5: _extract_keywords normalizza a lowercase."""
        kws = self.gdd._extract_keywords("Roma METEO Temperatura")
        self.assertIn("roma", kws)
        self.assertIn("meteo", kws)
        self.assertIn("temperatura", kws)

    # ── compute_drift_score ──────────────────────────────────────────────

    def test_drift_score_zero_for_empty_exec(self):
        """COG-5: drift score = 0.0 se exec_done vuoto."""
        score = self.gdd.compute_drift_score("trova la temperatura a Roma", [])
        self.assertEqual(score, 0.0)

    def test_drift_score_low_when_on_target(self):
        """COG-5: drift score basso quando exec_done copre le keyword del goal."""
        exec_done = [
            "[subtask 1 β€” cerca temperatura]: temperatura Roma oggi 22 gradi",
            "[subtask 2 β€” meteo]: previsione meteo Roma domani",
        ]
        score = self.gdd.compute_drift_score("trova la temperatura a Roma", exec_done)
        self.assertLess(score, 0.5, f"Score troppo alto per task on-target: {score}")

    def test_drift_score_high_when_off_target(self):
        """COG-5: drift score alto quando exec_done non copre il goal."""
        exec_done = [
            "[subtask 1 β€” ricetta]: pasta carbonara uova pancetta",
            "[subtask 2 β€” cucina]: aggiungere sale pepe parmigiano",
        ]
        score = self.gdd.compute_drift_score("calcola l'equazione differenziale integrale", exec_done)
        self.assertGreater(score, 0.5, f"Score troppo basso per task off-target: {score}")

    def test_drift_score_in_range(self):
        """COG-5: drift score sempre in [0.0, 1.0]."""
        score = self.gdd.compute_drift_score("test", ["risultato casuale xyz abc"])
        self.assertGreaterEqual(score, 0.0)
        self.assertLessEqual(score, 1.0)

    # ── should_check_drift ───────────────────────────────────────────────

    def test_should_check_drift_false_below_min(self):
        """COG-5: should_check_drift β†’ False se step_count < _MIN_EXEC_DONE."""
        result = self.gdd.should_check_drift(1, 0)
        self.assertFalse(result, "Non deve controllare prima di _MIN_EXEC_DONE step")

    def test_should_check_drift_false_too_soon(self):
        """COG-5: should_check_drift β†’ False se non sono passati DRIFT_CHECK_EVERY_N step."""
        result = self.gdd.should_check_drift(3, 2)
        self.assertFalse(result, "Non deve controllare se non sono passati N step dal check")

    def test_should_check_drift_true_at_interval(self):
        """COG-5: should_check_drift β†’ True esattamente ogni DRIFT_CHECK_EVERY_N step."""
        n = self.gdd.DRIFT_CHECK_EVERY_N
        result = self.gdd.should_check_drift(n, 0)
        self.assertTrue(result, f"Deve controllare al passo {n}")

    def test_should_check_drift_true_after_prev_check(self):
        """COG-5: should_check_drift β†’ True dopo DRIFT_CHECK_EVERY_N dal precedente check."""
        n = self.gdd.DRIFT_CHECK_EVERY_N
        result = self.gdd.should_check_drift(6, 3)  # 6-3 == 3 == N
        self.assertTrue(result)

    # ── detect_drift ─────────────────────────────────────────────────────

    def test_detect_drift_not_checked_below_interval(self):
        """COG-5: detect_drift.checked = False se intervallo non raggiunto."""
        res = self.gdd.detect_drift("goal test qualsiasi", [], 1, 0)
        self.assertFalse(res["checked"])
        self.assertFalse(res["drifted"])

    def test_detect_drift_checked_at_interval(self):
        """COG-5: detect_drift.checked = True quando should_check_drift Γ¨ True."""
        n = self.gdd.DRIFT_CHECK_EVERY_N
        exec_done = [f"[subtask {i}]: risultato generico" for i in range(n)]
        res = self.gdd.detect_drift("goal test qualsiasi", exec_done, n, 0)
        self.assertTrue(res["checked"])

    def test_detect_drift_updates_last_check(self):
        """COG-5: detect_drift aggiorna new_last_check al valore step_count."""
        n = self.gdd.DRIFT_CHECK_EVERY_N
        exec_done = [f"[subtask {i}]: risultato" for i in range(n)]
        res = self.gdd.detect_drift("test", exec_done, n, 0)
        self.assertEqual(res["new_last_check"], n)

    def test_detect_drift_returns_score(self):
        """COG-5: detect_drift restituisce score float in [0,1] quando checked."""
        n = self.gdd.DRIFT_CHECK_EVERY_N
        exec_done = ["[subtask 1]: cibo ricetta pasta", "[subtask 2]: ingredienti cucina", "[subtask 3]: cottura"]
        res = self.gdd.detect_drift("analizza dati finanziari borsa mercato", exec_done, n, 0)
        self.assertTrue(res["checked"])
        self.assertIsInstance(res["score"], float)
        self.assertGreaterEqual(res["score"], 0.0)
        self.assertLessEqual(res["score"], 1.0)

    def test_detect_drift_no_mutation_on_exec_done(self):
        """COG-5: detect_drift non modifica exec_done (side-effect free)."""
        exec_done = ["[subtask 1]: risultato A", "[subtask 2]: risultato B", "[subtask 3]: C"]
        original = list(exec_done)
        n = self.gdd.DRIFT_CHECK_EVERY_N
        self.gdd.detect_drift("goal test", exec_done, n, 0)
        self.assertEqual(exec_done, original, "detect_drift non deve mutare exec_done")

    def test_detect_drift_reason_filled_on_drift(self):
        """COG-5: detect_drift.reason non vuoto se drifted=True."""
        n = self.gdd.DRIFT_CHECK_EVERY_N
        # exec_done completamente off-topic rispetto al goal
        exec_done = [
            "[subtask 1 β€” poesia]: verso strofa rima metafora",
            "[subtask 2 β€” letteratura]: romanzo narrativa personaggio",
            "[subtask 3 β€” arte]: pittura scultura museo opera",
        ]
        res = self.gdd.detect_drift(
            "calcola derivata integrale equazione differenziale", exec_done, n, 0
        )
        if res["drifted"]:
            self.assertGreater(len(res["reason"]), 0, "reason deve essere non vuoto se drifted")

    def test_constants_sane(self):
        """COG-5: costanti hanno valori ragionevoli."""
        self.assertGreaterEqual(self.gdd.DRIFT_CHECK_EVERY_N, 2)
        self.assertLessEqual(self.gdd.DRIFT_CHECK_EVERY_N, 10)
        self.assertGreater(self.gdd.DRIFT_OVERLAP_THRESHOLD, 0.0)
        self.assertLess(self.gdd.DRIFT_OVERLAP_THRESHOLD, 1.0)


class TestCOG5WiringInUnifiedLoop(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        ul_path = os.path.join(_BACKEND, "agents", "unified_loop.py")
        if not os.path.exists(ul_path):
            raise unittest.SkipTest("agents/unified_loop.py non trovato")
        with open(ul_path, encoding="utf-8") as fh:
            cls.src = fh.read()

    def test_cog5_last_check_initialized(self):
        """COG-5 wiring: _cog5_last_check inizializzato nel loop."""
        self.assertIn("_cog5_last_check", self.src)

    def test_goal_drift_detector_imported(self):
        """COG-5 wiring: goal_drift_detector importato in unified_loop."""
        self.assertIn("goal_drift_detector", self.src)

    def test_detect_drift_called(self):
        """COG-5 wiring: detect_drift (o alias) chiamato in unified_loop."""
        self.assertIn("detect_drift", self.src)

    def test_cog5_uses_state_goal(self):
        """COG-5 wiring: detect_drift riceve state.goal come goal originale."""
        idx = self.src.find("goal_drift_detector")
        self.assertGreater(idx, 0)
        block = self.src[idx: idx + 800]
        self.assertIn("state.goal", block)

    def test_cog5_uses_exec_done(self):
        """COG-5 wiring: detect_drift riceve exec_done come lista risultati."""
        idx = self.src.find("goal_drift_detector")
        self.assertGreater(idx, 0)
        block = self.src[idx: idx + 800]
        self.assertIn("exec_done", block)

    def test_cog5_drift_appends_to_exec_warn(self):
        """COG-5 wiring: in caso di drift, il messaggio viene aggiunto a exec_warn."""
        idx = self.src.find("goal_drift_detector")
        self.assertGreater(idx, 0)
        block = self.src[idx: idx + 800]
        self.assertIn("exec_warn.append", block)

    def test_cog5_is_non_blocking(self):
        """COG-5 wiring: detect_drift Γ¨ dentro try/except (non-blocking)."""
        idx = self.src.find("goal_drift_detector")
        self.assertGreater(idx, 0)
        # La try/except deve precedere l'import
        pre_block = self.src[max(0, idx - 200): idx + 800]
        self.assertIn("except Exception as _cog5_err", pre_block)

    def test_cog5_marker_in_source(self):
        """COG-5 wiring: marker COG-5 presente in unified_loop."""
        self.assertIn("COG-5", self.src)


# ==============================================================================
# BUG-6: tool_generator _BLOCKED_RE open() write-mode bypass regression
# ==============================================================================

class TestBUG6BlockedReOpenBypass(unittest.TestCase):
    """
    Regressione per BUG-6: _BLOCKED_RE non bloccava open() con modalitΓ 
    write binaria o con modificatori ('wb', 'w+', 'ab', 'wb+').
    Fix: [wa][+bt]* invece di [wa] nel pattern open().
    """

    @classmethod
    def setUpClass(cls):
        try:
            import agents.tool_generator as tg
            cls.tg = tg
        except ImportError as e:
            raise unittest.SkipTest(f"agents.tool_generator non importabile: {e}")

    def _is_blocked(self, code: str) -> bool:
        """True se il codice Γ¨ bloccato da _BLOCKED_RE."""
        return bool(self.tg._BLOCKED_RE.search(code))

    # ── Write-mode bypassati prima del fix ───────────────────────────────

    def test_blocks_open_write_binary_wb(self):
        """BUG-6 regression: open('file', 'wb') deve essere bloccato."""
        self.assertTrue(
            self._is_blocked("open('output.bin', 'wb')"),
            "_BLOCKED_RE deve bloccare open() in modalitΓ  'wb'",
        )

    def test_blocks_open_write_plus(self):
        """BUG-6 regression: open('file', 'w+') deve essere bloccato."""
        self.assertTrue(
            self._is_blocked("open('data.txt', 'w+')"),
            "_BLOCKED_RE deve bloccare open() in modalitΓ  'w+'",
        )

    def test_blocks_open_append_binary_ab(self):
        """BUG-6 regression: open('file', 'ab') deve essere bloccato."""
        self.assertTrue(
            self._is_blocked("open('log.bin', 'ab')"),
            "_BLOCKED_RE deve bloccare open() in modalitΓ  'ab'",
        )

    def test_blocks_open_write_binary_plus_wbplus(self):
        """BUG-6 regression: open('file', 'wb+') deve essere bloccato."""
        self.assertTrue(
            self._is_blocked("open('f', 'wb+')"),
            "_BLOCKED_RE deve bloccare open() in modalitΓ  'wb+'",
        )

    # ── ModalitΓ  lecite non devono essere bloccate ───────────────────────

    def test_allows_open_read_mode(self):
        """BUG-6: open('file', 'r') NON deve essere bloccato (read-only)."""
        self.assertFalse(
            self._is_blocked("open('data.csv', 'r')"),
            "_BLOCKED_RE non deve bloccare open() in modalitΓ  'r'",
        )

    def test_allows_open_read_binary(self):
        """BUG-6: open('file', 'rb') NON deve essere bloccato (read binary)."""
        self.assertFalse(
            self._is_blocked("open('data.bin', 'rb')"),
            "_BLOCKED_RE non deve bloccare open() in modalitΓ  'rb'",
        )

    # ── Pattern storici (non-regressione) ────────────────────────────────

    def test_still_blocks_open_write_single(self):
        """BUG-6 non-regressione: open('file', 'w') originale ancora bloccato."""
        self.assertTrue(
            self._is_blocked("open('out.txt', 'w')"),
            "_BLOCKED_RE deve ancora bloccare open() in modalitΓ  'w'",
        )

    def test_still_blocks_import_requests(self):
        """BUG-6 non-regressione: import requests ancora bloccato."""
        self.assertTrue(
            self._is_blocked("import requests"),
            "_BLOCKED_RE deve ancora bloccare import requests",
        )

    def test_still_blocks_eval(self):
        """BUG-6 non-regressione: eval() ancora bloccato."""
        self.assertTrue(
            self._is_blocked("eval('1+1')"),
            "_BLOCKED_RE deve ancora bloccare eval()",
        )


# ============================================================================
# BUG-10: unified_loop_tools.py _REAL_DATA_PREFIXES had "[PAGINA WEB" but
#         _t_read_page() returns "[PAGINA REALE: {url}]" -- prefix mismatch
#         means every successful read_page was counted as an error.
# PFX-1..PFX-5 regression tests.
# ============================================================================

class TestBUG10ReadPagePrefixMismatch(unittest.TestCase):
    """
    BUG-10: _REAL_DATA_PREFIXES contained "[PAGINA WEB" but _t_read_page()
    returns strings starting with "[PAGINA REALE: {url}]".
    str.startswith("[PAGINA WEB") always False for those strings -> every
    successful read_page was counted as an error, not a success.
    Fix: replace "[PAGINA WEB" with "[PAGINA REALE" in _REAL_DATA_PREFIXES.
    """

    _OLD_PREFIXES = (
        "[RICERCA WEB REALE", "[METEO", "[CALCOLO REALE",
        "[IMMAGINE AI GENERATA", "[CODICE PYTHON ESEGUITO",
        "[PAGINA WEB",
        "[DATI REALI", "[RICERCA APPROFONDITA", "[STRUTTURA PROGETTO",
        "[FILE TROVATI", "[NOTIZIE", "[STATO GIT",
    )
    _FIXED_PREFIXES = (
        "[RICERCA WEB REALE", "[METEO", "[CALCOLO REALE",
        "[IMMAGINE AI GENERATA", "[CODICE PYTHON ESEGUITO",
        "[PAGINA REALE",
        "[DATI REALI", "[RICERCA APPROFONDITA", "[STRUTTURA PROGETTO",
        "[FILE TROVATI", "[NOTIZIE", "[STATO GIT",
    )
    _SAMPLE = "[PAGINA REALE: https://example.com]\n(status 200)\nContent here"

    # PFX-1: old prefix does not match read_page output
    def test_pfx1_old_prefix_does_not_match(self):
        """PFX-1: [PAGINA WEB] does NOT match [PAGINA REALE:...] -- the old bug."""
        self.assertFalse(
            self._SAMPLE.startswith("[PAGINA WEB"),
            "[PAGINA WEB] must NOT match read_page output (demonstrates bug)",
        )

    # PFX-2: fixed prefix matches read_page output
    def test_pfx2_fixed_prefix_matches(self):
        """PFX-2: [PAGINA REALE] DOES match [PAGINA REALE:...] -- fix correct."""
        self.assertTrue(
            self._SAMPLE.startswith("[PAGINA REALE"),
            "[PAGINA REALE] must match read_page output",
        )

    # PFX-3: n_success = 0 with old broken prefix set
    def test_pfx3_old_prefix_gives_zero_success(self):
        """PFX-3: old prefix set => n_success=0 for a read_page result (the bug)."""
        results = [self._SAMPLE]
        n = sum(1 for r in results if any(r.startswith(p) for p in self._OLD_PREFIXES))
        self.assertEqual(n, 0, "Old [PAGINA WEB] prefix => n_success=0 (bug confirmed)")

    # PFX-4: n_success = 1 with fixed prefix set
    def test_pfx4_fixed_prefix_gives_correct_success(self):
        """PFX-4: fixed prefix set => n_success=1 for a read_page result."""
        results = [self._SAMPLE]
        n = sum(1 for r in results if any(r.startswith(p) for p in self._FIXED_PREFIXES))
        self.assertEqual(n, 1, "Fixed [PAGINA REALE] => n_success=1")

    # PFX-5: all other tool prefixes still match correctly (non-regression)
    def test_pfx5_other_prefixes_non_regression(self):
        """PFX-5: non-regression -- all other tool prefixes still work."""
        cases = [
            ("[RICERCA WEB REALE: 'AI']\nresult", True),
            ("[METEO REALE -- Roma]\n22C", True),
            ("[CALCOLO REALE]\n2+2=4", True),
            ("[IMMAGINE AI GENERATA]\nURL: https://img", True),
            ("[CODICE PYTHON ESEGUITO]\noutput", True),
            ("[PAGINA REALE: https://x.com]\ncontent", True),
            ("[STRUTTURA PROGETTO: '.']\nfiles", True),
            ("[FILE TROVATI: pattern='*.py', 3]\nmatches", True),
            ("[NOTIZIE: 'AI']\narticle", True),
            ("[STATO GIT (branch: main)\nmodified: x", True),
            ("[web_search: NESSUN_RISULTATO]", False),
            ("[read_page: errore -- conn refused]", False),
            ("[git_status: timeout 8s]", False),
        ]
        for output, expected in cases:
            result = any(output.startswith(p) for p in self._FIXED_PREFIXES)
            self.assertEqual(
                result, expected,
                "Prefix check failed for: %r" % output[:60],
            )


if __name__ == "__main__":
    unittest.main(verbosity=2)