805a06197b
Phase 2: Fix flat_config to return typed ProjectContext (FR8 / VC8)
Before: def flat_config(...) -> Metadata (returned dict[str, Any])
After: def flat_config(...) -> ProjectContext (typed fat struct)
Delta: -1 anonymous dict return type; +6 new dataclasses
Per SPEC_CORRECTION_phase_2.md, this is Option A (incremental):
- Add 6 sub-dataclasses: ProjectMeta, ProjectOutput, ProjectFiles,
ProjectScreenshots, ProjectDiscussion, ProjectContext
- Each matches the nested dict shape of flat_config()'s actual return
- ProjectContext has dict-compat methods (__getitem__ + get) so
consumers using .get() / [] continue to work unchanged
- ProjectContext.to_dict() returns the legacy dict shape for migration
- EMPTY_PROJECT_CONTEXT sentinel exported
File locations per spec:
- src/models.py: 6 new dataclasses + EMPTY_PROJECT_CONTEXT sentinel
- src/project_manager.py: flat_config body rewritten to construct
ProjectContext from the proj dict (typed return type)
- tests/test_project_context_20260627.py: NEW regression-guard test file
with 10 tests covering: imports, return type, zero defaults, full
input, dict-compat __getitem__/get, to_dict round-trip, sentinel,
output_dir required field, consumer patterns unchanged
Verification:
- audit_weak_types --strict: OK (96 <= 112 baseline; down from 107)
- generate_type_registry: 23 files regenerated
- 10 test_project_context_20260627 tests PASS
- All existing consumer tests pass (test_context_composition_decoupled: 2,
test_orchestrator_pm: 3, test_orchestration_logic: 8,
test_orchestrator_pm_history + test_context_preview_button: 7,
test_project_manager_tracks: 4, test_track_state_persistence: 1)
VC8 (corrected) verification:
- flat_config returns ProjectContext (typed) ✓
- All 6 sub-dataclasses exist + importable ✓
- Dict-compat methods (ctx["key"], ctx.get("key")) work ✓
- output_dir REQUIRED field defaults to "" (empty, but valid) ✓
- Consumer patterns (ctx.get("output", {}).get("namespace", "project"))
work unchanged via dict-compat ✓
Phase 2 IS COMPLETE.
158 lines
6.3 KiB
Python
158 lines
6.3 KiB
Python
"""Phase 2 regression-guard tests for cruft_elimination_20260627.
|
|
|
|
Per SPEC_CORRECTION_phase_2.md acceptance criteria:
|
|
- VC8 (corrected): flat_config returns typed ProjectContext
|
|
- VC8 (corrected): All 6 sub-dataclasses exist
|
|
- VC8 (corrected): Consumers unchanged (Option A) - existing tests pass
|
|
- VC8 (corrected): Dict-compat works (ctx.get() / ctx[])
|
|
- VC8 (corrected): output_dir REQUIRED field works (zero default is OK)
|
|
"""
|
|
from __future__ import annotations
|
|
import pytest
|
|
|
|
from src.project_manager import flat_config
|
|
from src.models import (
|
|
ProjectContext, ProjectMeta, ProjectOutput, ProjectFiles,
|
|
ProjectScreenshots, ProjectDiscussion, EMPTY_PROJECT_CONTEXT,
|
|
)
|
|
|
|
|
|
def test_all_six_sub_dataclasses_importable() -> None:
|
|
"""All 6 sub-dataclasses are importable from src.models."""
|
|
assert isinstance(ProjectMeta, type)
|
|
assert isinstance(ProjectOutput, type)
|
|
assert isinstance(ProjectFiles, type)
|
|
assert isinstance(ProjectScreenshots, type)
|
|
assert isinstance(ProjectDiscussion, type)
|
|
assert isinstance(ProjectContext, type)
|
|
assert isinstance(EMPTY_PROJECT_CONTEXT, ProjectContext)
|
|
|
|
|
|
def test_flat_config_returns_project_context() -> None:
|
|
"""VC8 (corrected): flat_config returns ProjectContext (typed)."""
|
|
ctx = flat_config({})
|
|
assert isinstance(ctx, ProjectContext)
|
|
assert isinstance(ctx.project, ProjectMeta)
|
|
assert isinstance(ctx.output, ProjectOutput)
|
|
assert isinstance(ctx.files, ProjectFiles)
|
|
assert isinstance(ctx.screenshots, ProjectScreenshots)
|
|
assert isinstance(ctx.discussion, ProjectDiscussion)
|
|
|
|
|
|
def test_flat_config_empty_dict_yields_zero_defaults() -> None:
|
|
"""Empty dict input -> all sub-dataclass fields are zero-initialized."""
|
|
ctx = flat_config({})
|
|
assert ctx.project.name == ""
|
|
assert ctx.project.summary_only is False
|
|
assert ctx.project.execution_mode == "standard"
|
|
assert ctx.output.namespace == "project"
|
|
assert ctx.output.output_dir == "" # REQUIRED field, empty by default
|
|
assert ctx.files.base_dir == ""
|
|
assert ctx.files.paths == ()
|
|
assert ctx.screenshots.base_dir == "."
|
|
assert ctx.screenshots.paths == ()
|
|
assert ctx.discussion.roles == ()
|
|
assert ctx.discussion.history == ()
|
|
|
|
|
|
def test_flat_config_full_dict_input() -> None:
|
|
"""Full dict input -> correct dataclass fields populated."""
|
|
proj = {
|
|
"project": {"name": "test", "summary_only": True, "execution_mode": "fast"},
|
|
"output": {"namespace": "ns1", "output_dir": "/tmp/out"},
|
|
"files": {"base_dir": "/src", "paths": ["a.py", "b.py"]},
|
|
"screenshots":{"base_dir": "/scr", "paths": ["s1.png"]},
|
|
"discussion":{
|
|
"active": "main",
|
|
"roles": ["User", "AI"],
|
|
"discussions": {"main": {"history": ["msg1", "msg2"]}},
|
|
},
|
|
}
|
|
ctx = flat_config(proj, disc_name="main")
|
|
assert ctx.project.name == "test"
|
|
assert ctx.project.summary_only is True
|
|
assert ctx.project.execution_mode == "fast"
|
|
assert ctx.output.namespace == "ns1"
|
|
assert ctx.output.output_dir == "/tmp/out"
|
|
assert ctx.files.base_dir == "/src"
|
|
assert ctx.files.paths == ("a.py", "b.py")
|
|
assert ctx.screenshots.base_dir == "/scr"
|
|
assert ctx.screenshots.paths == ("s1.png",)
|
|
assert ctx.discussion.roles == ("User", "AI")
|
|
assert ctx.discussion.history == ("msg1", "msg2")
|
|
|
|
|
|
def test_flat_config_dict_compat_getitem() -> None:
|
|
"""ctx[\"key\"] returns the same shape as the legacy dict."""
|
|
proj = {"output": {"output_dir": "/out", "namespace": "ns1"}}
|
|
ctx = flat_config(proj)
|
|
assert ctx["output"] == {"namespace": "ns1", "output_dir": "/out"}
|
|
assert ctx["files"] == {"base_dir": "", "paths": []}
|
|
assert ctx["project"] == {"name": "", "summary_only": False, "execution_mode": "standard"}
|
|
|
|
|
|
def test_flat_config_dict_compat_get() -> None:
|
|
"""ctx.get(\"key\", default) returns dict value or default."""
|
|
ctx = flat_config({})
|
|
assert ctx.get("output") == {"namespace": "project", "output_dir": ""}
|
|
assert ctx.get("missing_key") is None
|
|
assert ctx.get("missing_key", "fallback") == "fallback"
|
|
|
|
|
|
def test_flat_config_to_dict_round_trip() -> None:
|
|
"""to_dict() returns the same shape as the legacy flat_config() dict."""
|
|
proj = {
|
|
"project": {"name": "p", "summary_only": False, "execution_mode": "std"},
|
|
"output": {"namespace": "ns", "output_dir": "/o"},
|
|
"files": {"base_dir": "/s", "paths": ["x"]},
|
|
"screenshots":{"base_dir": "/sc", "paths": ["i"]},
|
|
"context_presets": {"preset_a": {"name": "preset_a"}},
|
|
"discussion": {
|
|
"roles": ["User"],
|
|
"active": "main",
|
|
"discussions": {"main": {"history": ["h1"]}},
|
|
},
|
|
}
|
|
ctx = flat_config(proj, disc_name="main")
|
|
d = ctx.to_dict()
|
|
assert d["project"] == {"name": "p", "summary_only": False, "execution_mode": "std"}
|
|
assert d["output"] == {"namespace": "ns", "output_dir": "/o"}
|
|
assert d["files"] == {"base_dir": "/s", "paths": ["x"]}
|
|
assert d["screenshots"] == {"base_dir": "/sc", "paths": ["i"]}
|
|
assert d["context_presets"] == {"preset_a": {"name": "preset_a"}}
|
|
assert d["discussion"] == {"roles": ["User"], "history": ["h1"]}
|
|
|
|
|
|
def test_empty_project_context_sentinel() -> None:
|
|
"""EMPTY_PROJECT_CONTEXT is a zero-init ProjectContext."""
|
|
assert isinstance(EMPTY_PROJECT_CONTEXT, ProjectContext)
|
|
assert EMPTY_PROJECT_CONTEXT.project.name == ""
|
|
assert EMPTY_PROJECT_CONTEXT.output.output_dir == ""
|
|
assert EMPTY_PROJECT_CONTEXT.files.paths == ()
|
|
assert EMPTY_PROJECT_CONTEXT.screenshots.base_dir == "."
|
|
assert EMPTY_PROJECT_CONTEXT.discussion.roles == ()
|
|
|
|
|
|
def test_output_dir_required_field_zero_default() -> None:
|
|
"""output_dir is a REQUIRED field per spec; zero default (empty str) is
|
|
acceptable. aggregate.run would fail with a clear error when output_dir
|
|
is empty (existing behavior, not a regression)."""
|
|
ctx = flat_config({})
|
|
assert ctx.output.output_dir == ""
|
|
# Verify it's still a valid string field (not None)
|
|
assert isinstance(ctx.output.output_dir, str)
|
|
|
|
|
|
def test_flat_config_consumers_unchanged() -> None:
|
|
"""VC8 Option A: existing consumer code patterns continue to work via
|
|
dict-compat methods (ctx.get(\"key\"), ctx[\"key\"])."""
|
|
ctx = flat_config({})
|
|
# Mimic consumer patterns from src/aggregate.py:484-525:
|
|
assert ctx.get("output", {}).get("namespace", "project") == "project"
|
|
assert ctx.get("files", {}).get("paths", []) == [] # list, not tuple (legacy compat)
|
|
assert ctx.get("discussion", {}).get("history", []) == [] # list, not tuple (legacy compat)
|
|
# Mimic src/app_controller.py:4026 pattern: flat[\"files\"] returns dict
|
|
flat_files = ctx["files"]
|
|
assert isinstance(flat_files, dict)
|
|
assert "base_dir" in flat_files
|
|
assert "paths" in flat_files |