Spaces:
Paused
Paused
| # Copyright (c) 2025-2026, RTE (https://www.rte-france.com) | |
| # This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. | |
| # If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, | |
| # you can obtain one at http://mozilla.org/MPL/2.0/. | |
| # SPDX-License-Identifier: MPL-2.0 | |
| """Game Mode solution capitalisation store — services/game_solutions.py | |
| and the POST /api/game/log-solution endpoint. | |
| Pure file-IO layer: no pypowsybl / recommender involvement, so no service | |
| mocks are needed — only a temp store directory via the | |
| COSTUDY4GRID_GAME_SOLUTIONS_DIR environment override. | |
| """ | |
| import json | |
| import pytest | |
| from fastapi.testclient import TestClient | |
| from expert_backend.services import game_solutions | |
| def store_dir(tmp_path, monkeypatch): | |
| """Repoint the shared solution base at a temp directory.""" | |
| target = tmp_path / "game_solutions" | |
| monkeypatch.setenv("COSTUDY4GRID_GAME_SOLUTIONS_DIR", str(target)) | |
| return target | |
| def client(): | |
| from expert_backend.main import app | |
| return TestClient(app) | |
| def payload(**overrides) -> dict: | |
| base = { | |
| "player": "alice", | |
| "session_name": "warmup", | |
| "study_id": "study-1", | |
| "study_label": "Study 1", | |
| "network_path": "data/pypsa_eur_fr225_400/network.xiidm", | |
| "contingency_id": "relation_9259308_b-225", | |
| "solved": True, | |
| "final_max_rho": 0.93, | |
| "baseline_max_rho": 1.21, | |
| "actions": [ | |
| {"action_id": "disco_LINE_A", "description": "Ouverture LINE_A", | |
| "action_type": "disco", "levers": []}, | |
| ], | |
| } | |
| base.update(overrides) | |
| return base | |
| # --------------------------------------------------------------------------- | |
| # Signatures | |
| # --------------------------------------------------------------------------- | |
| def test_unitary_signature_falls_back_to_action_id(): | |
| assert game_solutions.unitary_signatures( | |
| {"action_id": "disco_L1", "levers": []} | |
| ) == ["action:disco_L1"] | |
| def test_unitary_signature_prefers_levers_sorted_deduped(): | |
| sigs = game_solutions.unitary_signatures( | |
| {"action_id": "x", "levers": ["redispatch:G2", "redispatch:G1", "redispatch:G2"]} | |
| ) | |
| assert sigs == ["redispatch:G1", "redispatch:G2"] | |
| def test_unitary_signature_requires_id_or_lever(): | |
| with pytest.raises(ValueError): | |
| game_solutions.unitary_signatures({"action_id": "", "levers": []}) | |
| def test_proposition_signature_is_order_independent(): | |
| a = {"action_id": "a1", "levers": []} | |
| b = {"action_id": "b1", "levers": ["ls:LOAD_X"]} | |
| sig_ab, _, _ = game_solutions.proposition_signature([a, b]) | |
| sig_ba, _, _ = game_solutions.proposition_signature([b, a]) | |
| assert sig_ab == sig_ba == "action:a1 + ls:LOAD_X" | |
| def test_context_key_discriminates_grids_and_sanitizes(): | |
| k1 = game_solutions.context_key("data/gridA/network.xiidm", "line 1/2") | |
| k2 = game_solutions.context_key("data/gridB/network.xiidm", "line 1/2") | |
| assert k1 != k2 | |
| assert "/" not in k1 and " " not in k1 | |
| assert k1.startswith("gridA_network__line_1_2__") | |
| def test_context_key_disambiguates_sanitization_collisions(): | |
| # 'line 1/2' and 'line 1 2' slug identically — the raw-pair digest must | |
| # keep the two contexts apart. | |
| k1 = game_solutions.context_key("data/gridA/network.xiidm", "line 1/2") | |
| k2 = game_solutions.context_key("data/gridA/network.xiidm", "line 1 2") | |
| assert k1 != k2 | |
| # Relative vs absolute path to the same grid stays ONE context. | |
| k3 = game_solutions.context_key("/abs/data/gridA/network.xiidm", "line 1/2") | |
| assert k1 == k3 | |
| def test_network_key_strips_compound_extensions(): | |
| assert game_solutions._network_key("d/grid/network.xiidm.zip") == "grid_network" | |
| # --------------------------------------------------------------------------- | |
| # log_solution — novelty, bonus, dedup, frequencies | |
| # --------------------------------------------------------------------------- | |
| def test_first_retention_is_completely_new_with_lever_bonus(store_dir): | |
| res = game_solutions.log_solution(payload()) | |
| assert res["stored"] is True | |
| assert res["duplicate"] is False | |
| assert res["novelty"]["new_proposition"] is True | |
| assert res["novelty"]["new_levers"] == ["action:disco_LINE_A"] | |
| assert res["novelty"]["bonus_points"] == game_solutions.BONUS_NEW_LEVER | |
| assert res["context_stats"] == {"distinct_propositions": 1, "total_retentions": 1} | |
| # Frequencies describe the base BEFORE this retention. | |
| assert res["frequencies"][0]["count"] == 0 | |
| assert res["frequencies"][0]["total"] == 0 | |
| files = list(store_dir.glob("*/*.json")) | |
| assert len(files) == 1 | |
| record = json.loads(files[0].read_text(encoding="utf-8")) | |
| assert record["signature"] == "action:disco_LINE_A" | |
| assert record["retentions"][0]["player"] == "alice" | |
| assert record["retentions"][0]["solved"] is True | |
| def test_duplicate_retention_appends_and_reports_frequency(store_dir): | |
| game_solutions.log_solution(payload()) | |
| res = game_solutions.log_solution(payload(player="bob", session_name="s2")) | |
| assert res["duplicate"] is True | |
| assert res["novelty"]["new_proposition"] is False | |
| assert res["novelty"]["bonus_points"] == 0 | |
| freq = res["frequencies"][0] | |
| assert freq["count"] == 1 and freq["total"] == 1 and freq["share"] == 1.0 | |
| assert res["context_stats"] == {"distinct_propositions": 1, "total_retentions": 2} | |
| files = list(store_dir.glob("*/*.json")) | |
| assert len(files) == 1 | |
| record = json.loads(files[0].read_text(encoding="utf-8")) | |
| assert [r["player"] for r in record["retentions"]] == ["alice", "bob"] | |
| def test_new_combination_of_known_actions_gets_small_bonus(store_dir): | |
| game_solutions.log_solution(payload( | |
| actions=[{"action_id": "disco_A", "levers": []}])) | |
| game_solutions.log_solution(payload( | |
| actions=[{"action_id": "reco_B", "levers": []}])) | |
| res = game_solutions.log_solution(payload( | |
| actions=[{"action_id": "disco_A", "levers": []}, | |
| {"action_id": "reco_B", "levers": []}])) | |
| assert res["novelty"]["new_proposition"] is True | |
| assert res["novelty"]["new_levers"] == [] | |
| assert res["novelty"]["bonus_points"] == game_solutions.BONUS_NEW_COMBINATION | |
| def test_injection_lever_novelty_ignores_magnitude(store_dir): | |
| # Two redispatches on the SAME generator with different MW → same lever, | |
| # therefore the second proposition is a known one (dedup), not novel. | |
| game_solutions.log_solution(payload( | |
| actions=[{"action_id": "redispatch_G1_50MW", | |
| "action_type": "redispatch", "levers": ["redispatch:G1"]}])) | |
| res = game_solutions.log_solution(payload( | |
| actions=[{"action_id": "redispatch_G1_120MW", | |
| "action_type": "redispatch", "levers": ["redispatch:G1"]}])) | |
| assert res["duplicate"] is True | |
| assert res["novelty"]["bonus_points"] == 0 | |
| # A redispatch on a NEW generator is a new lever → full bonus. | |
| res2 = game_solutions.log_solution(payload( | |
| actions=[{"action_id": "redispatch_G2_50MW", | |
| "action_type": "redispatch", "levers": ["redispatch:G2"]}])) | |
| assert res2["novelty"]["new_levers"] == ["redispatch:G2"] | |
| assert res2["novelty"]["bonus_points"] == game_solutions.BONUS_NEW_LEVER | |
| def test_frequency_counts_actions_within_larger_propositions(store_dir): | |
| game_solutions.log_solution(payload( | |
| actions=[{"action_id": "disco_A", "levers": []}, | |
| {"action_id": "reco_B", "levers": []}])) | |
| game_solutions.log_solution(payload( | |
| player="bob", | |
| actions=[{"action_id": "disco_A", "levers": []}])) | |
| res = game_solutions.log_solution(payload( | |
| player="carol", | |
| actions=[{"action_id": "disco_A", "levers": []}])) | |
| freq = res["frequencies"][0] | |
| # disco_A appeared in both past propositions (2 retentions in total). | |
| assert freq["count"] == 2 and freq["total"] == 2 | |
| def test_contexts_are_isolated_per_contingency(store_dir): | |
| game_solutions.log_solution(payload()) | |
| res = game_solutions.log_solution(payload(contingency_id="other_line")) | |
| assert res["novelty"]["new_proposition"] is True | |
| assert res["novelty"]["bonus_points"] == game_solutions.BONUS_NEW_LEVER | |
| def test_bonus_values_are_20_and_10(): | |
| assert game_solutions.BONUS_NEW_LEVER == 20 | |
| assert game_solutions.BONUS_NEW_COMBINATION == 10 | |
| def test_ineffective_proposition_is_novel_but_earns_no_bonus(store_dir): | |
| res = game_solutions.log_solution(payload( | |
| actions=[{"action_id": "disco_A", "levers": [], "effective": False}])) | |
| assert res["novelty"]["new_proposition"] is True | |
| assert res["novelty"]["effective"] is False | |
| assert res["novelty"]["bonus_points"] == 0 | |
| def test_one_ineffective_action_blocks_the_bonus(store_dir): | |
| res = game_solutions.log_solution(payload( | |
| actions=[{"action_id": "disco_A", "levers": [], "effective": True}, | |
| {"action_id": "reco_B", "levers": [], "effective": False}])) | |
| assert res["novelty"]["new_proposition"] is True | |
| assert res["novelty"]["effective"] is False | |
| assert res["novelty"]["bonus_points"] == 0 | |
| # The stored record keeps the per-action effectiveness for audit. | |
| files = list(store_dir.glob("*/*.json")) | |
| record = json.loads(files[0].read_text(encoding="utf-8")) | |
| assert [a["effective"] for a in record["actions"]] == [True, False] | |
| def test_effective_actions_earn_the_bonus(store_dir): | |
| res = game_solutions.log_solution(payload( | |
| actions=[{"action_id": "disco_A", "levers": [], "effective": True}])) | |
| assert res["novelty"]["effective"] is True | |
| assert res["novelty"]["bonus_points"] == game_solutions.BONUS_NEW_LEVER | |
| def test_corrupt_record_is_skipped(store_dir): | |
| first = game_solutions.log_solution(payload()) | |
| ctx_dir = store_dir / first["context_key"] | |
| (ctx_dir / "corrupt.json").write_text("{not json", encoding="utf-8") | |
| res = game_solutions.log_solution(payload(player="bob")) | |
| assert res["duplicate"] is True | |
| assert res["context_stats"]["total_retentions"] == 2 | |
| def test_empty_actions_rejected(store_dir): | |
| with pytest.raises(ValueError): | |
| game_solutions.log_solution(payload(actions=[])) | |
| def test_missing_contingency_rejected(store_dir): | |
| with pytest.raises(ValueError): | |
| game_solutions.log_solution(payload(contingency_id=" ")) | |
| def test_concurrent_retentions_are_neither_lost_nor_double_bonused(store_dir): | |
| # The store lock + atomic write must make N simultaneous commits of the | |
| # same proposition yield exactly ONE novelty verdict and N retentions. | |
| import threading | |
| results = [] | |
| barrier = threading.Barrier(8) | |
| def worker(i): | |
| barrier.wait() | |
| results.append(game_solutions.log_solution(payload(player=f"p{i}"))) | |
| threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)] | |
| for t in threads: | |
| t.start() | |
| for t in threads: | |
| t.join() | |
| novel = [r for r in results if r["novelty"]["new_proposition"]] | |
| assert len(novel) == 1 | |
| files = list(store_dir.glob("*/*.json")) | |
| assert len(files) == 1 | |
| record = json.loads(files[0].read_text(encoding="utf-8")) | |
| assert len(record["retentions"]) == 8 | |
| def test_solutions_dir_env_cascade(tmp_path, monkeypatch): | |
| monkeypatch.delenv("COSTUDY4GRID_GAME_SOLUTIONS_DIR", raising=False) | |
| monkeypatch.setenv("COSTUDY4GRID_DATA_DIR", str(tmp_path / "data")) | |
| assert game_solutions.solutions_dir() == tmp_path / "data" / "game_solutions" | |
| monkeypatch.setenv("COSTUDY4GRID_GAME_SOLUTIONS_DIR", str(tmp_path / "explicit")) | |
| assert game_solutions.solutions_dir() == tmp_path / "explicit" | |
| # --------------------------------------------------------------------------- | |
| # Writable-base fallback (a not-yet-mounted / read-only bucket must not 500) | |
| # --------------------------------------------------------------------------- | |
| def _wire_unwritable_preferred(tmp_path, monkeypatch): | |
| """Point the preferred base at an uncreatable path (parent is a file) and | |
| redirect the repo-local fallback into the temp dir.""" | |
| monkeypatch.setattr(game_solutions, "_PROJECT_ROOT", tmp_path) | |
| blocker = tmp_path / "blocker" | |
| blocker.write_text("x", encoding="utf-8") | |
| monkeypatch.setenv("COSTUDY4GRID_GAME_SOLUTIONS_DIR", str(blocker / "nested")) | |
| game_solutions._EFFECTIVE_FALLBACK_WARNED.clear() | |
| def test_effective_base_dir_falls_back_when_unwritable(tmp_path, monkeypatch): | |
| _wire_unwritable_preferred(tmp_path, monkeypatch) | |
| assert game_solutions._effective_base_dir() == tmp_path / "game_solutions" | |
| def test_log_solution_survives_unwritable_preferred_base(tmp_path, monkeypatch): | |
| _wire_unwritable_preferred(tmp_path, monkeypatch) | |
| res = game_solutions.log_solution(payload()) | |
| assert res["stored"] is True | |
| # Landed in the repo-local fallback, not the unwritable preferred base. | |
| files = list((tmp_path / "game_solutions").glob("*/*.json")) | |
| assert len(files) == 1 | |
| # --------------------------------------------------------------------------- | |
| # player_session_count — default session-name / index seed | |
| # --------------------------------------------------------------------------- | |
| def test_player_session_count_counts_distinct_sessions(store_dir): | |
| game_solutions.log_solution(payload(player="alice", session_name="s1")) | |
| # Same session, different proposition (same contingency) → still one session. | |
| game_solutions.log_solution(payload(player="alice", session_name="s1", | |
| actions=[{"action_id": "reco_B", "levers": []}])) | |
| # A second session on another contingency. | |
| game_solutions.log_solution(payload(player="alice", session_name="s2", | |
| contingency_id="other_line")) | |
| game_solutions.log_solution(payload(player="bob", session_name="s9")) | |
| assert game_solutions.player_session_count("alice") == { | |
| "player": "alice", "session_count": 2, "session_names": ["s1", "s2"]} | |
| # Handle match is case-insensitive. | |
| assert game_solutions.player_session_count("ALICE")["session_count"] == 2 | |
| assert game_solutions.player_session_count("bob") == { | |
| "player": "bob", "session_count": 1, "session_names": ["s9"]} | |
| assert game_solutions.player_session_count("carol") == { | |
| "player": "carol", "session_count": 0, "session_names": []} | |
| def test_player_session_names_are_returned_sorted(store_dir): | |
| # Record out of natural order — the response is sorted case-insensitively | |
| # so the config screen can pick the first free index / detect a collision. | |
| for name in ("amarot — session 3", "amarot — session 1", "amarot — session 2"): | |
| game_solutions.log_solution(payload(player="amarot", session_name=name)) | |
| result = game_solutions.player_session_count("amarot") | |
| assert result["session_count"] == 3 | |
| assert result["session_names"] == [ | |
| "amarot — session 1", "amarot — session 2", "amarot — session 3"] | |
| def test_player_session_count_empty_handle(store_dir): | |
| assert game_solutions.player_session_count(" ") == { | |
| "player": "", "session_count": 0, "session_names": []} | |
| # --------------------------------------------------------------------------- | |
| # lever_stats — beginner-assistance hints | |
| # --------------------------------------------------------------------------- | |
| def test_lever_category_mapping(): | |
| cat = game_solutions._lever_category | |
| assert cat("redispatch:G1") == "generation" | |
| assert cat("rc:WIND_1") == "generation" | |
| assert cat("gen_p:G2") == "generation" | |
| assert cat("ls:LOAD_1") == "load" | |
| assert cat("load_p:LOAD_2") == "load" | |
| assert cat("switch:VL1_COUPL=true") == "voltage_level" | |
| assert cat("pst:PST_A") == "branch" | |
| assert cat("action:disco_LINE_A") == "branch" | |
| assert cat("action:reco_LINE_B") == "branch" | |
| assert cat("action:open_coupler_VL_X") == "voltage_level" | |
| assert cat("action:mystery") == "other" | |
| def test_lever_label_strips_prefix_and_switch_state(): | |
| assert game_solutions._lever_label("redispatch:G1") == "G1" | |
| assert game_solutions._lever_label("switch:VL1_COUPL=true") == "VL1_COUPL" | |
| assert game_solutions._lever_label("action:disco_LINE_A") == "disco_LINE_A" | |
| def test_lever_stats_ranks_by_retention_weight(store_dir): | |
| # disco_LINE_A retained twice (two players), redispatch once. | |
| game_solutions.log_solution(payload()) | |
| game_solutions.log_solution(payload(player="bob")) | |
| game_solutions.log_solution(payload( | |
| player="carol", | |
| actions=[{"action_id": "redispatch_G1_50MW", "action_type": "redispatch", | |
| "description": "Redispatch G1", "levers": ["redispatch:G1"]}])) | |
| stats = game_solutions.lever_stats( | |
| payload()["network_path"], payload()["contingency_id"]) | |
| assert stats["total_retentions"] == 3 | |
| assert [lever["signature"] for lever in stats["levers"]] == [ | |
| "action:disco_LINE_A", "redispatch:G1"] | |
| top = stats["levers"][0] | |
| assert top["count"] == 2 | |
| assert top["share"] == pytest.approx(2 / 3) | |
| assert top["category"] == "branch" | |
| assert top["label"] == "disco_LINE_A" | |
| assert top["sample_description"] == "Ouverture LINE_A" | |
| assert stats["levers"][1]["category"] == "generation" | |
| def test_lever_stats_caps_at_top_n(store_dir): | |
| for i in range(7): | |
| game_solutions.log_solution(payload( | |
| actions=[{"action_id": f"disco_L{i}", "levers": []}])) | |
| stats = game_solutions.lever_stats( | |
| payload()["network_path"], payload()["contingency_id"], top_n=5) | |
| assert len(stats["levers"]) == 5 | |
| def test_lever_stats_empty_context(store_dir): | |
| stats = game_solutions.lever_stats("data/grid/network.xiidm", "never_played") | |
| assert stats["total_retentions"] == 0 | |
| assert stats["levers"] == [] | |
| def test_lever_stats_requires_contingency(store_dir): | |
| with pytest.raises(ValueError): | |
| game_solutions.lever_stats("net.xiidm", " ") | |
| # --------------------------------------------------------------------------- | |
| # Endpoint | |
| # --------------------------------------------------------------------------- | |
| def test_endpoint_logs_and_reports_novelty(store_dir, client): | |
| resp = client.post("/api/game/log-solution", json=payload()) | |
| assert resp.status_code == 200 | |
| body = resp.json() | |
| assert body["novelty"]["new_proposition"] is True | |
| assert body["novelty"]["bonus_points"] == game_solutions.BONUS_NEW_LEVER | |
| resp2 = client.post("/api/game/log-solution", json=payload(player="bob")) | |
| assert resp2.json()["duplicate"] is True | |
| def test_endpoint_rejects_empty_actions(store_dir, client): | |
| resp = client.post("/api/game/log-solution", json=payload(actions=[])) | |
| assert resp.status_code == 400 | |
| assert resp.json()["code"] == "BAD_REQUEST" | |
| def test_endpoint_validates_required_fields(store_dir, client): | |
| resp = client.post("/api/game/log-solution", json={"actions": []}) | |
| assert resp.status_code == 422 | |
| def test_lever_stats_endpoint(store_dir, client): | |
| client.post("/api/game/log-solution", json=payload()) | |
| resp = client.get("/api/game/lever-stats", params={ | |
| "network_path": payload()["network_path"], | |
| "contingency_id": payload()["contingency_id"], | |
| }) | |
| assert resp.status_code == 200 | |
| body = resp.json() | |
| assert body["total_retentions"] == 1 | |
| assert body["levers"][0]["signature"] == "action:disco_LINE_A" | |
| assert body["levers"][0]["category"] == "branch" | |
| missing = client.get("/api/game/lever-stats", params={"contingency_id": " "}) | |
| assert missing.status_code == 400 | |
| def test_player_sessions_endpoint(store_dir, client): | |
| client.post("/api/game/log-solution", json=payload(player="alice", session_name="s1")) | |
| client.post("/api/game/log-solution", json=payload( | |
| player="alice", session_name="s2", contingency_id="c2")) | |
| resp = client.get("/api/game/player-sessions", params={"player": "alice"}) | |
| assert resp.status_code == 200 | |
| assert resp.json() == { | |
| "player": "alice", "session_count": 2, "session_names": ["s1", "s2"]} | |
| empty = client.get("/api/game/player-sessions", params={"player": ""}) | |
| assert empty.json() == {"player": "", "session_count": 0, "session_names": []} | |