File size: 32,145 Bytes
02f4a63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Tests for the unified configuration system."""

import os
import tempfile
from pathlib import Path
from unittest.mock import patch

import pytest
import yaml

from openra_env.config import (
    TOOL_CATEGORIES,
    AlertPromptsConfig,
    AlertsConfig,
    AgentConfig,
    CompressionConfig,
    GameConfig,
    LLMConfig,
    OpenRARLConfig,
    OpponentConfig,
    PlanningConfig,
    PromptsConfig,
    RewardConfig,
    RewardVectorConfig,
    ToolCategoriesConfig,
    ToolsConfig,
    _coerce_value,
    _deep_merge,
    _set_nested,
    load_config,
    should_register_tool,
)


# โ”€โ”€ Default Loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestDefaults:
    def test_default_config_has_sane_values(self):
        cfg = OpenRARLConfig()
        assert cfg.game.mod == "ra"
        assert cfg.game.grpc_port == 9999
        assert cfg.opponent.bot_type == "easy"
        assert cfg.planning.enabled is True
        assert cfg.reward.victory == 1.0
        assert cfg.llm.model == "qwen/qwen3-coder-next"
        assert cfg.agent.max_time_s == 1800

    def test_all_tool_categories_enabled_by_default(self):
        cfg = OpenRARLConfig()
        cats = cfg.tools.categories
        for field in ToolCategoriesConfig.model_fields:
            assert getattr(cats, field) is True, f"Category {field} should default to True"

    def test_all_alerts_enabled_by_default(self):
        cfg = OpenRARLConfig()
        for field in AlertsConfig.model_fields:
            if field == "max_alerts":
                continue  # max_alerts is an int, not a bool toggle
            assert getattr(cfg.alerts, field) is True, f"Alert {field} should default to True"

    def test_disabled_tools_list_empty_by_default(self):
        cfg = OpenRARLConfig()
        assert cfg.tools.disabled == []

    def test_load_config_no_file_returns_defaults(self):
        """load_config() with no file and no env vars should return defaults."""
        with _clean_env():
            cfg = load_config(config_path="__nonexistent__.yaml")
            assert cfg.game.mod == "ra"
            assert cfg.llm.base_url == "https://openrouter.ai/api/v1/chat/completions"


# โ”€โ”€ YAML Loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestYAMLLoading:
    def test_load_from_yaml(self):
        data = {"game": {"mod": "cnc", "grpc_port": 5555}, "opponent": {"bot_type": "hard"}}
        with _temp_yaml(data) as path, _clean_env():
            cfg = load_config(config_path=path)
            assert cfg.game.mod == "cnc"
            assert cfg.game.grpc_port == 5555
            assert cfg.opponent.bot_type == "hard"
            # Unspecified fields keep defaults
            assert cfg.game.map_name == "singles.oramap"

    def test_partial_yaml_merges_with_defaults(self):
        data = {"reward": {"victory": 5.0}}
        with _temp_yaml(data) as path, _clean_env():
            cfg = load_config(config_path=path)
            assert cfg.reward.victory == 5.0
            assert cfg.reward.defeat == -1.0  # default preserved

    def test_empty_yaml_returns_defaults(self):
        with _temp_yaml({}) as path, _clean_env():
            cfg = load_config(config_path=path)
            assert cfg.game.mod == "ra"

    def test_llm_config_from_yaml(self):
        data = {
            "llm": {
                "base_url": "http://localhost:11434/v1/chat/completions",
                "model": "llama3.1:70b",
                "api_key": "",
                "extra_headers": {},
            }
        }
        with _temp_yaml(data) as path, _clean_env():
            cfg = load_config(config_path=path)
            assert cfg.llm.base_url == "http://localhost:11434/v1/chat/completions"
            assert cfg.llm.model == "llama3.1:70b"
            assert cfg.llm.api_key == ""
            assert cfg.llm.extra_headers == {}


# โ”€โ”€ Environment Variable Precedence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestEnvVarPrecedence:
    def test_env_var_overrides_yaml(self):
        data = {"opponent": {"bot_type": "easy"}}
        with _temp_yaml(data) as path:
            with _clean_env(BOT_TYPE="hard"):
                cfg = load_config(config_path=path)
                assert cfg.opponent.bot_type == "hard"

    def test_env_var_overrides_default(self):
        with _clean_env(BOT_TYPE="hard"):
            cfg = load_config(config_path="__nonexistent__.yaml")
            assert cfg.opponent.bot_type == "hard"

    def test_openra_path_env(self):
        with _clean_env(OPENRA_PATH="/custom/openra"):
            cfg = load_config(config_path="__nonexistent__.yaml")
            assert cfg.game.openra_path == "/custom/openra"

    def test_planning_enabled_env(self):
        with _clean_env(PLANNING_ENABLED="false"):
            cfg = load_config(config_path="__nonexistent__.yaml")
            assert cfg.planning.enabled is False

    def test_record_replays_env(self):
        with _clean_env(RECORD_REPLAYS="yes"):
            cfg = load_config(config_path="__nonexistent__.yaml")
            assert cfg.game.record_replays is True

    def test_openrouter_api_key_env(self):
        with _clean_env(OPENROUTER_API_KEY="sk-test-123"):
            cfg = load_config(config_path="__nonexistent__.yaml")
            assert cfg.llm.api_key == "sk-test-123"

    def test_llm_api_key_overrides_openrouter(self):
        """LLM_API_KEY should take precedence over OPENROUTER_API_KEY."""
        with _clean_env(OPENROUTER_API_KEY="sk-old", LLM_API_KEY="sk-new"):
            cfg = load_config(config_path="__nonexistent__.yaml")
            assert cfg.llm.api_key == "sk-new"

    def test_llm_base_url_env(self):
        with _clean_env(LLM_BASE_URL="http://localhost:1234/v1/chat/completions"):
            cfg = load_config(config_path="__nonexistent__.yaml")
            assert cfg.llm.base_url == "http://localhost:1234/v1/chat/completions"

    def test_llm_model_env(self):
        with _clean_env(LLM_MODEL="my-local-model"):
            cfg = load_config(config_path="__nonexistent__.yaml")
            assert cfg.llm.model == "my-local-model"

    def test_max_time_env(self):
        with _clean_env(MAX_TIME="3600"):
            cfg = load_config(config_path="__nonexistent__.yaml")
            assert cfg.agent.max_time_s == 3600


# โ”€โ”€ Constructor Override Precedence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestOverridePrecedence:
    def test_overrides_beat_yaml(self):
        data = {"game": {"mod": "cnc"}}
        with _temp_yaml(data) as path, _clean_env():
            cfg = load_config(config_path=path, game={"mod": "d2k"})
            assert cfg.game.mod == "d2k"

    def test_env_beats_overrides(self):
        with _clean_env(BOT_TYPE="hard"):
            cfg = load_config(config_path="__nonexistent__.yaml", opponent={"bot_type": "easy"})
            assert cfg.opponent.bot_type == "hard"

    def test_cli_overrides_beat_env(self):
        """Explicit CLI flags should beat environment variables."""
        with _clean_env(OPENROUTER_MODEL="env-model"):
            cfg = load_config(
                config_path="__nonexistent__.yaml",
                cli_overrides={"llm": {"model": "cli-model"}},
            )
            assert cfg.llm.model == "cli-model"

    def test_cli_overrides_beat_yaml_and_env(self):
        data = {"llm": {"model": "yaml-model"}}
        with _temp_yaml(data) as path, _clean_env(LLM_MODEL="env-model"):
            cfg = load_config(
                config_path=path,
                cli_overrides={"llm": {"model": "cli-model"}},
            )
            assert cfg.llm.model == "cli-model"


# โ”€โ”€ Boolean Coercion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestCoercion:
    @pytest.mark.parametrize("val,expected", [
        ("true", True), ("True", True), ("TRUE", True),
        ("1", True), ("yes", True), ("Yes", True),
        ("false", False), ("False", False), ("FALSE", False),
        ("0", False), ("no", False), ("No", False),
    ])
    def test_bool_coercion(self, val, expected):
        assert _coerce_value(val) is expected

    def test_int_coercion(self):
        assert _coerce_value("42") == 42
        assert isinstance(_coerce_value("42"), int)

    def test_float_coercion(self):
        assert _coerce_value("3.14") == 3.14
        assert isinstance(_coerce_value("3.14"), float)

    def test_string_passthrough(self):
        assert _coerce_value("hello") == "hello"


# โ”€โ”€ Deep Merge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestDeepMerge:
    def test_simple_merge(self):
        base = {"a": 1, "b": 2}
        _deep_merge(base, {"b": 3, "c": 4})
        assert base == {"a": 1, "b": 3, "c": 4}

    def test_nested_merge(self):
        base = {"game": {"mod": "ra", "port": 9999}}
        _deep_merge(base, {"game": {"mod": "cnc"}})
        assert base == {"game": {"mod": "cnc", "port": 9999}}

    def test_override_replaces_non_dict(self):
        base = {"a": {"b": 1}}
        _deep_merge(base, {"a": "flat"})
        assert base == {"a": "flat"}


class TestSetNested:
    def test_single_level(self):
        d: dict = {}
        _set_nested(d, "key", "val")
        assert d == {"key": "val"}

    def test_multi_level(self):
        d: dict = {}
        _set_nested(d, "game.mod", "cnc")
        assert d == {"game": {"mod": "cnc"}}

    def test_preserves_siblings(self):
        d = {"game": {"mod": "ra", "port": 9999}}
        _set_nested(d, "game.mod", "cnc")
        assert d == {"game": {"mod": "cnc", "port": 9999}}


# โ”€โ”€ Tool Filtering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestToolFiltering:
    def test_all_tools_enabled_by_default(self):
        cfg = ToolsConfig()
        for tool_name in TOOL_CATEGORIES:
            assert should_register_tool(tool_name, cfg) is True

    def test_disable_category(self):
        cfg = ToolsConfig(categories=ToolCategoriesConfig(knowledge=False))
        assert should_register_tool("lookup_unit", cfg) is False
        assert should_register_tool("lookup_building", cfg) is False
        assert should_register_tool("lookup_tech_tree", cfg) is False
        assert should_register_tool("lookup_faction", cfg) is False
        # Other categories unaffected
        assert should_register_tool("advance", cfg) is True
        assert should_register_tool("move_units", cfg) is True

    def test_disable_individual_tool(self):
        cfg = ToolsConfig(disabled=["surrender", "sell_building"])
        assert should_register_tool("surrender", cfg) is False
        assert should_register_tool("sell_building", cfg) is False
        # Other utility tools still enabled
        assert should_register_tool("get_replay_path", cfg) is True

    def test_disabled_list_overrides_category_enable(self):
        cfg = ToolsConfig(
            categories=ToolCategoriesConfig(movement=True),
            disabled=["move_units"],
        )
        assert should_register_tool("move_units", cfg) is False
        assert should_register_tool("attack_move", cfg) is True

    def test_unknown_tool_defaults_to_enabled(self):
        cfg = ToolsConfig()
        assert should_register_tool("some_future_tool", cfg) is True

    def test_all_tools_have_categories(self):
        """Every tool in TOOL_CATEGORIES should map to a valid category field."""
        valid_categories = set(ToolCategoriesConfig.model_fields.keys())
        for tool_name, category in TOOL_CATEGORIES.items():
            assert category in valid_categories, f"Tool {tool_name} maps to unknown category {category}"

    def test_tool_count(self):
        """Verify we have all 48 tools mapped."""
        assert len(TOOL_CATEGORIES) == 48


# โ”€โ”€ Planning Sync Validator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestPlanningSync:
    def test_planning_disabled_auto_disables_planning_tools(self):
        cfg = OpenRARLConfig(planning=PlanningConfig(enabled=False))
        assert cfg.tools.categories.planning is False

    def test_planning_enabled_keeps_planning_tools(self):
        cfg = OpenRARLConfig(planning=PlanningConfig(enabled=True))
        assert cfg.tools.categories.planning is True

    def test_planning_disabled_via_yaml(self):
        data = {"planning": {"enabled": False}}
        with _temp_yaml(data) as path, _clean_env():
            cfg = load_config(config_path=path)
            assert cfg.planning.enabled is False
            assert cfg.tools.categories.planning is False


# โ”€โ”€ LLM Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestLLMConfig:
    def test_local_model_no_key(self):
        cfg = LLMConfig(
            base_url="http://localhost:11434/v1/chat/completions",
            api_key="",
            model="llama3.1:70b",
        )
        assert cfg.api_key == ""
        assert "localhost" in cfg.base_url

    def test_remote_model_with_key(self):
        cfg = LLMConfig(api_key="sk-test-123")
        assert cfg.api_key == "sk-test-123"

    def test_extra_headers_default(self):
        cfg = LLMConfig()
        assert "HTTP-Referer" in cfg.extra_headers
        assert "X-Title" in cfg.extra_headers

    def test_extra_headers_empty_for_local(self):
        cfg = LLMConfig(extra_headers={})
        assert cfg.extra_headers == {}

    def test_temperature_default_none(self):
        cfg = LLMConfig()
        assert cfg.temperature is None

    def test_temperature_set(self):
        cfg = LLMConfig(temperature=0.7)
        assert cfg.temperature == 0.7


# โ”€โ”€ Alert Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestAlertConfig:
    def test_disable_specific_alerts(self):
        cfg = AlertsConfig(under_attack=False, low_power=False)
        assert cfg.under_attack is False
        assert cfg.low_power is False
        assert cfg.damaged_building is True  # others unchanged


# โ”€โ”€ Backwards Compatibility โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestBackwardsCompat:
    def test_load_config_with_no_args(self):
        """Calling load_config() with no args should not raise."""
        with _clean_env():
            cfg = load_config(config_path="__nonexistent__.yaml")
            assert isinstance(cfg, OpenRARLConfig)

    def test_reward_config_matches_reward_weights(self):
        """RewardConfig fields should match the existing RewardWeights dataclass."""
        from openra_env.reward import RewardWeights

        rw = RewardWeights()
        rc = RewardConfig()
        assert rc.survival == rw.survival
        assert rc.economic_efficiency == rw.economic_efficiency
        assert rc.aggression == rw.aggression
        assert rc.defense == rw.defense
        assert rc.victory == rw.victory
        assert rc.defeat == rw.defeat


class TestRewardVectorConfig:
    """Test reward vector configuration."""

    def test_enabled_by_default(self):
        cfg = RewardVectorConfig()
        assert cfg.enabled is True

    def test_default_weights(self):
        cfg = RewardVectorConfig()
        assert cfg.weights["combat"] == 0.30
        assert cfg.weights["economy"] == 0.15
        assert cfg.weights["outcome"] == 1.00
        assert len(cfg.weights) == 8

    def test_present_in_root_config(self):
        cfg = OpenRARLConfig()
        assert hasattr(cfg, "reward_vector")
        assert isinstance(cfg.reward_vector, RewardVectorConfig)
        assert cfg.reward_vector.enabled is True

    def test_enable_via_yaml(self):
        with _clean_env():
            import tempfile
            with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
                yaml.dump({"reward_vector": {"enabled": True}}, f)
                f.flush()
                cfg = load_config(config_path=f.name)
                assert cfg.reward_vector.enabled is True

    def test_custom_weights_via_yaml(self):
        with _clean_env():
            import tempfile
            with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
                yaml.dump({"reward_vector": {"enabled": True, "weights": {"combat": 0.5}}}, f)
                f.flush()
                cfg = load_config(config_path=f.name)
                assert cfg.reward_vector.weights["combat"] == 0.5


# โ”€โ”€ Validation Errors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestValidation:
    def test_invalid_grpc_port_type(self):
        with pytest.raises(Exception):  # Pydantic ValidationError
            GameConfig(grpc_port="not_a_number")

    def test_invalid_reward_weight(self):
        with pytest.raises(Exception):
            RewardConfig(victory="not_a_float")


# โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

_CONFIG_ENV_VARS = [
    "OPENRA_PATH", "RECORD_REPLAYS", "BOT_TYPE", "AI_SLOT",
    "PLANNING_ENABLED", "PLANNING_MAX_TURNS", "PLANNING_MAX_TIME",
    "OPENROUTER_API_KEY", "OPENROUTER_MODEL",
    "LLM_BASE_URL", "LLM_API_KEY", "LLM_MODEL",
    "OPENRA_URL", "MAX_TIME", "LLM_AGENT_LOG",
]


class _clean_env:
    """Context manager that temporarily clears config-related env vars and sets new ones."""

    def __init__(self, **overrides):
        self._overrides = overrides
        self._saved: dict[str, str | None] = {}

    def __enter__(self):
        # Save and clear all config env vars
        for var in _CONFIG_ENV_VARS:
            self._saved[var] = os.environ.pop(var, None)
        # Set overrides
        for key, val in self._overrides.items():
            os.environ[key] = str(val)
        return self

    def __exit__(self, *args):
        # Remove overrides
        for key in self._overrides:
            os.environ.pop(key, None)
        # Restore saved values
        for var, val in self._saved.items():
            if val is not None:
                os.environ[var] = val


def _temp_yaml(data: dict):
    """Context manager that writes *data* to a temp YAML file and yields its path."""
    import contextlib

    @contextlib.contextmanager
    def _ctx():
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            yaml.dump(data, f)
            path = f.name
        try:
            yield path
        finally:
            Path(path).unlink(missing_ok=True)

    return _ctx()


# โ”€โ”€ PromptsConfig Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestPromptsConfig:
    """Tests for the PromptsConfig system."""

    def test_default_prompts_have_values(self):
        """PromptsConfig defaults should have non-empty values for key fields."""
        p = PromptsConfig()
        assert "end_planning_phase" in p.planning_nudge
        assert "tool" in p.no_tool_nudge.lower()
        assert "{building}" in p.power_warning
        assert "{count}" in p.alerts.idle_army

    def test_prompts_in_root_config(self):
        """OpenRARLConfig should have prompts field with defaults."""
        config = OpenRARLConfig()
        assert isinstance(config.prompts, PromptsConfig)
        assert isinstance(config.prompts.alerts, AlertPromptsConfig)
        assert config.prompts.planning_complete == "Planning complete. Game is now live."

    def test_prompts_from_yaml(self):
        """Override prompts via config YAML."""
        data = {
            "prompts": {
                "no_tool_nudge": "Please call a tool now.",
                "alerts": {
                    "low_power": "Power is low: {balance}",
                },
            },
        }
        with _temp_yaml(data) as path:
            config = load_config(config_path=path)
        assert config.prompts.no_tool_nudge == "Please call a tool now."
        assert config.prompts.alerts.low_power == "Power is low: {balance}"
        # Other fields keep defaults
        assert "combat units idle" in config.prompts.alerts.idle_army

    def test_alert_template_format(self):
        """Alert templates should render with .format()."""
        p = AlertPromptsConfig()
        result = p.low_power.format(balance="-30")
        assert "LOW POWER" in result
        assert "-30" in result

    def test_placement_template_format(self):
        """Placement templates should render with .format()."""
        p = PromptsConfig()
        result = p.placement_failed.format(building="powr", reason="no valid position")
        assert "powr" in result
        assert "no valid position" in result

    def test_planning_prompt_template(self):
        """Planning prompt template should accept all expected variables."""
        p = PromptsConfig()
        result = p.planning_prompt.format(
            max_turns=10, map_name="test", map_width=64, map_height=64,
            base_x=10, base_y=10, enemy_x=50, enemy_y=50,
            faction="russia", side="Soviet",
            opponent_summary="Easy AI", planning_nudge=p.planning_nudge,
        )
        assert "10 turns" in result
        assert "russia" in result
        assert "end_planning_phase" in result

    def test_backward_compat_system_prompt_migration(self):
        """agent.system_prompt should migrate to prompts.system_prompt."""
        config = OpenRARLConfig(agent=AgentConfig(system_prompt="My custom prompt"))
        assert config.prompts.system_prompt == "My custom prompt"

    def test_prompts_system_prompt_takes_precedence(self):
        """prompts.system_prompt should win over agent.system_prompt."""
        config = OpenRARLConfig(
            agent=AgentConfig(system_prompt="agent version"),
            prompts=PromptsConfig(system_prompt="prompts version"),
        )
        assert config.prompts.system_prompt == "prompts version"

    def test_backward_compat_system_prompt_file_migration(self):
        """agent.system_prompt_file should migrate to prompts.system_prompt_file."""
        config = OpenRARLConfig(agent=AgentConfig(system_prompt_file="/tmp/test.txt"))
        assert config.prompts.system_prompt_file == "/tmp/test.txt"

    def test_env_var_prompts_file(self):
        """PROMPTS_FILE env var should set prompts.prompts_file."""
        with patch.dict(os.environ, {"PROMPTS_FILE": "/tmp/prompts.yaml"}, clear=False):
            config = load_config(config_path="/nonexistent/config.yaml")
        assert config.prompts.prompts_file == "/tmp/prompts.yaml"

    def test_game_start_template(self):
        """Game start template should render correctly."""
        p = PromptsConfig()
        result = p.game_start.format(
            strategy_section="\n\nRush strategy",
            briefing="Map: test",
            barracks_type="barr",
            mcv_note=" Your MCV is unit 42.",
        )
        assert "Game started!" in result
        assert "Rush strategy" in result
        assert "barr" in result
        assert "unit 42" in result

    def test_insufficient_funds_template(self):
        """Insufficient funds template should render correctly."""
        p = PromptsConfig()
        result = p.insufficient_funds.format(available=500, item="3tnk", cost=950)
        assert "500" in result
        assert "3tnk" in result
        assert "950" in result

    def test_build_queued_template(self):
        """Build queued template should render correctly."""
        p = PromptsConfig()
        result = p.build_queued.format(building="powr", cost=300, ticks=180, seconds=7.2)
        assert "powr" in result
        assert "300" in result
        assert "180" in result
        assert "auto-places" in result

    def test_build_unit_queued_template(self):
        """Build unit queued template should render correctly."""
        p = PromptsConfig()
        result = p.build_unit_queued.format(
            count=3, unit="e1", cost=100, ticks_each=60,
            ticks_total=180, seconds_total=7.2)
        assert "3x" in result
        assert "e1" in result
        assert "60" in result
        assert "180" in result

    def test_build_already_pending_template(self):
        """Build already pending template should render correctly."""
        p = PromptsConfig()
        result = p.build_already_pending.format(building="powr")
        assert "powr" in result
        assert "already queued" in result

    def test_max_alerts_default(self):
        """AlertsConfig max_alerts should default to 0 (unlimited)."""
        cfg = AlertsConfig()
        assert cfg.max_alerts == 0


# โ”€โ”€ Compression Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestCompressionConfig:
    def test_defaults(self):
        c = CompressionConfig()
        assert c.include_strategy is True
        assert c.include_military is True
        assert c.include_production is True

    def test_disable_strategy(self):
        c = CompressionConfig(include_strategy=False)
        assert c.include_strategy is False

    def test_llm_compression_strategy_default(self):
        llm = LLMConfig()
        assert llm.compression_strategy == "sliding_window"

    def test_llm_compression_trigger_default(self):
        llm = LLMConfig()
        assert llm.compression_trigger == 0

    def test_compression_strategy_none(self):
        llm = LLMConfig(compression_strategy="none")
        assert llm.compression_strategy == "none"

    def test_compression_trigger_custom(self):
        llm = LLMConfig(compression_trigger=60)
        assert llm.compression_trigger == 60

    def test_prompts_compression_field(self):
        p = PromptsConfig()
        assert isinstance(p.compression, CompressionConfig)
        assert p.compression.include_strategy is True

    def test_move_eta_template(self):
        p = PromptsConfig()
        result = p.move_eta.format(ticks=183, seconds=7.3)
        assert "183" in result
        assert "7.3" in result

    def test_full_config_compression_yaml(self):
        """Compression fields round-trip through YAML config loading."""
        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
            yaml.dump({
                "llm": {
                    "compression_strategy": "none",
                    "compression_trigger": 60,
                    "keep_last_messages": 20,
                },
                "prompts": {
                    "compression": {
                        "include_strategy": False,
                        "include_military": True,
                        "include_production": False,
                    }
                }
            }, f)
            f.flush()
            cfg = load_config(f.name)
        os.unlink(f.name)
        assert cfg.llm.compression_strategy == "none"
        assert cfg.llm.compression_trigger == 60
        assert cfg.llm.keep_last_messages == 20
        assert cfg.prompts.compression.include_strategy is False
        assert cfg.prompts.compression.include_production is False
        assert cfg.prompts.compression.include_military is True


# โ”€โ”€ Opponent Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestOpponentConfig:
    def test_default_spawns_enemy(self):
        """Default opponent config spawns an enemy in Multi0."""
        cfg = OpponentConfig()
        assert cfg.ai_slot == "Multi0"
        assert cfg.bot_type == "easy"

    def test_disable_enemy_via_empty_slot(self):
        cfg = OpponentConfig(ai_slot="")
        assert cfg.ai_slot == ""

    def test_custom_bot_type(self):
        cfg = OpponentConfig(bot_type="hard")
        assert cfg.bot_type == "hard"


# โ”€โ”€ Bot Type Mapping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestBotTypeMapping:
    def test_beginner_maps_to_beginner(self):
        from openra_env.server.openra_process import BOT_TYPE_MAP
        assert BOT_TYPE_MAP["beginner"] == "beginner"

    def test_easy_maps_to_easy(self):
        from openra_env.server.openra_process import BOT_TYPE_MAP
        assert BOT_TYPE_MAP["easy"] == "easy"

    def test_medium_maps_to_medium(self):
        from openra_env.server.openra_process import BOT_TYPE_MAP
        assert BOT_TYPE_MAP["medium"] == "medium"

    def test_hard_maps_to_normal(self):
        from openra_env.server.openra_process import BOT_TYPE_MAP
        assert BOT_TYPE_MAP["hard"] == "normal"

    def test_brutal_maps_to_rush(self):
        from openra_env.server.openra_process import BOT_TYPE_MAP
        assert BOT_TYPE_MAP["brutal"] == "rush"

    def test_raw_names_pass_through(self):
        from openra_env.server.openra_process import BOT_TYPE_MAP
        for raw in ["rush", "normal", "turtle", "naval", "beginner", "easy", "medium"]:
            assert BOT_TYPE_MAP.get(raw, raw) == raw

    def test_build_command_maps_hard(self):
        from openra_env.server.openra_process import OpenRAConfig, OpenRAProcessManager
        openra_path = str(Path(__file__).parent.parent / "OpenRA")
        config = OpenRAConfig(openra_path=openra_path, bot_type="hard")
        manager = OpenRAProcessManager(config)
        cmd = manager._build_command()
        bots_arg = [a for a in cmd if "Launch.Bots" in a][0]
        assert "normal" in bots_arg
        assert "hard" not in bots_arg

    def test_build_command_maps_brutal(self):
        from openra_env.server.openra_process import OpenRAConfig, OpenRAProcessManager
        openra_path = str(Path(__file__).parent.parent / "OpenRA")
        config = OpenRAConfig(openra_path=openra_path, bot_type="brutal")
        manager = OpenRAProcessManager(config)
        cmd = manager._build_command()
        bots_arg = [a for a in cmd if "Launch.Bots" in a][0]
        assert "rush" in bots_arg
        assert "brutal" not in bots_arg

    def test_build_command_no_enemy_with_empty_slot(self):
        from openra_env.server.openra_process import OpenRAConfig, OpenRAProcessManager
        openra_path = str(Path(__file__).parent.parent / "OpenRA")
        config = OpenRAConfig(openra_path=openra_path, ai_slot="")
        manager = OpenRAProcessManager(config)
        cmd = manager._build_command()
        bots_arg = [a for a in cmd if "Launch.Bots" in a][0]
        assert bots_arg == "Launch.Bots=Multi1:rl-agent"

    def test_default_config_spawns_enemy(self):
        from openra_env.server.openra_process import OpenRAConfig, OpenRAProcessManager
        openra_path = str(Path(__file__).parent.parent / "OpenRA")
        config = OpenRAConfig(openra_path=openra_path)
        manager = OpenRAProcessManager(config)
        cmd = manager._build_command()
        bots_arg = [a for a in cmd if "Launch.Bots" in a][0]
        # Default should include enemy (Multi0:normal)
        assert "Multi0" in bots_arg
        assert "normal" in bots_arg