mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
183 lines
8.2 KiB
Python
183 lines
8.2 KiB
Python
"""Batch plan write path — save_batch() correctness and safety tests.
|
|
|
|
Guards the four user stories from #1355:
|
|
1. save()/save_batch() parity: a single-element save_batch() produces
|
|
identical DB state (plan row + recommendation rows) as the equivalent
|
|
save() call.
|
|
2. Recommendation FK isolation: two properties in the same save_batch() each
|
|
get their own recommendation rows; no FK cross-wiring between properties.
|
|
3. Demote correctness: a second save_batch() for the same properties demotes
|
|
the prior default Plans and inserts fresh ones (history preserved).
|
|
4. Non-default batch: a save_batch() where all writes have is_default=False
|
|
leaves any pre-existing default Plan untouched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy import Engine
|
|
from sqlmodel import Session, col, select
|
|
|
|
from domain.modelling.measure_type import MeasureType
|
|
from domain.modelling.plan import Plan, PlanMeasure
|
|
from domain.modelling.recommendation import Cost
|
|
from domain.modelling.scoring.package_scorer import Score
|
|
from domain.modelling.scoring.scoring import MeasureImpact
|
|
from infrastructure.postgres.modelling import PlanModel, RecommendationModel
|
|
from repositories.plan.plan_postgres_repository import PlanPostgresRepository
|
|
from repositories.plan.plan_repository import PlanSaveRequest
|
|
|
|
|
|
def _plan(*, sap: float = 70.0, measures: int = 1) -> Plan:
|
|
ms: tuple[PlanMeasure, ...] = tuple(
|
|
PlanMeasure(
|
|
measure_type=MeasureType.CAVITY_WALL_INSULATION,
|
|
description="Cavity wall insulation",
|
|
cost=Cost(total=1000.0, contingency_rate=0.10),
|
|
impact=MeasureImpact(
|
|
sap_points=8.0,
|
|
co2_savings_kg_per_yr=500.0,
|
|
energy_savings_kwh_per_yr=2000.0,
|
|
),
|
|
kwh_savings=1500.0,
|
|
energy_cost_savings=300.0,
|
|
)
|
|
for _ in range(measures)
|
|
)
|
|
return Plan(
|
|
measures=ms,
|
|
baseline=Score(sap_continuous=40.0, co2_kg_per_yr=4000.0, primary_energy_kwh_per_yr=20000.0),
|
|
post_retrofit=Score(sap_continuous=sap, co2_kg_per_yr=3500.0, primary_energy_kwh_per_yr=18000.0),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tracer bullet: single-element save_batch() is loss-free vs save()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_single_request_save_batch_matches_save(db_engine: Engine) -> None:
|
|
# Arrange
|
|
plan = _plan()
|
|
scenario_id = 7
|
|
|
|
with Session(db_engine) as session:
|
|
repo = PlanPostgresRepository(session)
|
|
save_id = repo.save(plan, property_id=5001, scenario_id=scenario_id, portfolio_id=1, is_default=True)
|
|
batch_id = repo.save_batch([PlanSaveRequest(plan, property_id=5002, scenario_id=scenario_id, portfolio_id=1, is_default=True)])[0]
|
|
session.commit()
|
|
|
|
# Act
|
|
with Session(db_engine) as session:
|
|
via_save = session.get(PlanModel, save_id)
|
|
via_batch = session.get(PlanModel, batch_id)
|
|
recs_save = session.exec(select(RecommendationModel).where(col(RecommendationModel.plan_id) == save_id)).all()
|
|
recs_batch = session.exec(select(RecommendationModel).where(col(RecommendationModel.plan_id) == batch_id)).all()
|
|
|
|
# Assert — both paths produce one plan row + one recommendation row with the
|
|
# same field values (modulo property_id which differs by design).
|
|
assert via_save is not None
|
|
assert via_batch is not None
|
|
assert via_save.is_default is True
|
|
assert via_batch.is_default is True
|
|
assert via_save.post_sap_points == via_batch.post_sap_points
|
|
assert via_save.post_co2_emissions == via_batch.post_co2_emissions
|
|
assert via_save.co2_savings == via_batch.co2_savings
|
|
assert len(recs_save) == 1
|
|
assert len(recs_batch) == 1
|
|
assert recs_save[0].type == recs_batch[0].type
|
|
assert recs_save[0].estimated_cost == recs_batch[0].estimated_cost
|
|
assert recs_save[0].sap_points == recs_batch[0].sap_points
|
|
assert recs_batch[0].plan_id == batch_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FK isolation: recommendation rows must not be crossed between properties
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_multi_property_recommendation_fks_are_not_crossed(db_engine: Engine) -> None:
|
|
# Arrange — property A gets 2 measures, property B gets 1 measure.
|
|
plan_a = _plan(measures=2)
|
|
plan_b = _plan(measures=1)
|
|
|
|
with Session(db_engine) as session:
|
|
[id_a, id_b] = PlanPostgresRepository(session).save_batch([
|
|
PlanSaveRequest(plan_a, property_id=6001, scenario_id=7, portfolio_id=1, is_default=True),
|
|
PlanSaveRequest(plan_b, property_id=6002, scenario_id=7, portfolio_id=1, is_default=True),
|
|
])
|
|
session.commit()
|
|
|
|
# Act
|
|
with Session(db_engine) as session:
|
|
recs_a = session.exec(select(RecommendationModel).where(col(RecommendationModel.plan_id) == id_a)).all()
|
|
recs_b = session.exec(select(RecommendationModel).where(col(RecommendationModel.plan_id) == id_b)).all()
|
|
|
|
# Assert — A has 2 rows, B has 1; none cross-wired.
|
|
assert len(recs_a) == 2
|
|
assert len(recs_b) == 1
|
|
assert all(r.plan_id == id_a and r.property_id == 6001 for r in recs_a)
|
|
assert all(r.plan_id == id_b and r.property_id == 6002 for r in recs_b)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Demote correctness: second save_batch() demotes prior defaults
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_second_save_batch_demotes_prior_default_plans(db_engine: Engine) -> None:
|
|
# Arrange — first batch creates default Plans for two properties.
|
|
plan = _plan()
|
|
requests = [
|
|
PlanSaveRequest(plan, property_id=7001, scenario_id=7, portfolio_id=1, is_default=True),
|
|
PlanSaveRequest(plan, property_id=7002, scenario_id=7, portfolio_id=1, is_default=True),
|
|
]
|
|
with Session(db_engine) as session:
|
|
first_ids = PlanPostgresRepository(session).save_batch(requests)
|
|
session.commit()
|
|
|
|
# Act — re-run the same batch; new Plans should become default, old ones demoted.
|
|
with Session(db_engine) as session:
|
|
second_ids = PlanPostgresRepository(session).save_batch(requests)
|
|
session.commit()
|
|
|
|
# Assert — history is preserved (4 plan rows total); exactly one default per property.
|
|
with Session(db_engine) as session:
|
|
rows_7001 = session.exec(select(PlanModel).where(col(PlanModel.property_id) == 7001)).all()
|
|
rows_7002 = session.exec(select(PlanModel).where(col(PlanModel.property_id) == 7002)).all()
|
|
|
|
by_id_7001 = {p.id: p for p in rows_7001}
|
|
by_id_7002 = {p.id: p for p in rows_7002}
|
|
|
|
assert len(rows_7001) == 2
|
|
assert len(rows_7002) == 2
|
|
assert by_id_7001[first_ids[0]].is_default is False
|
|
assert by_id_7001[second_ids[0]].is_default is True
|
|
assert by_id_7002[first_ids[1]].is_default is False
|
|
assert by_id_7002[second_ids[1]].is_default is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Non-default batch: existing default Plan is untouched
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_non_default_save_batch_does_not_demote_existing_default(db_engine: Engine) -> None:
|
|
# Arrange — a default Plan already exists for the property.
|
|
plan = _plan()
|
|
with Session(db_engine) as session:
|
|
default_id = PlanPostgresRepository(session).save(
|
|
plan, property_id=8001, scenario_id=7, portfolio_id=1, is_default=True
|
|
)
|
|
session.commit()
|
|
|
|
# Act — save a non-default Plan via save_batch(); no demote UPDATE should fire.
|
|
with Session(db_engine) as session:
|
|
PlanPostgresRepository(session).save_batch([
|
|
PlanSaveRequest(plan, property_id=8001, scenario_id=7, portfolio_id=1, is_default=False),
|
|
])
|
|
session.commit()
|
|
|
|
# Assert — the original default Plan is still the default.
|
|
with Session(db_engine) as session:
|
|
rows = session.exec(select(PlanModel).where(col(PlanModel.property_id) == 8001)).all()
|
|
by_id = {p.id: p for p in rows}
|
|
|
|
assert len(rows) == 2
|
|
assert by_id[default_id].is_default is True
|
|
assert sum(1 for p in rows if p.is_default) == 1
|