Translate wish names in plan proposal output

Preset wish names follow the UI language in the wish editor, but the plan
proposal still rendered outcome.wishName straight from the stored
entity.name, so a proposal could show "Höstlov" with the UI in English.

The proposal now carries wish_name_message_key alongside each wish name,
and the frontend resolves it with the same helper the editor uses --
falling back to the stored literal when the key is absent or unknown.
Nothing is translated on the backend; text crosses the wire as a key.

Four surfaces carried a name and all four are covered: WindowOutcome
itself, the explanation summary's interpolated data, and two remedies --
including REMEDY_LOWER_PRIORITY_WISH, which names a *different* sibling
wish whose key can differ from the outcome's own.

The proposal response mapping is hand-written and has dropped new fields
twice before, so this is covered by an API-level test asserting the field
reaches the response body, not only a service test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
2026-09-14 22:51:15 +02:00
parent e22007dc50
commit 20f45d6c7b
12 changed files with 466 additions and 8 deletions
+165
View File
@@ -336,6 +336,171 @@ class TestProposeMessageKeysOverTheWire:
assert remedy["data"]["spare_total"] == remedy["days_freed"] == 5
class TestProposeWishNameMessageKeyOverTheWire:
"""A preset-created wish (`LeaveWish.name_message_key` set, see
`app/entities/leave_wish.py` and `frontend/src/utils/wishName.ts`) must
carry that key through `POST /api/plan/propose`, not just `POST
/api/leave-wishes` -- `WindowOutcomeResponse.wish_name_message_key` is a
field the hand-written `PlanProposalResponse.from_domain` mapping in
`app/models/plan.py` could silently drop, the same class of bug
`TestProposeSurplusAdviceOverTheWire`/`TestProposeAge4ProjectionsOverTheWire`
above guard against for other fields.
"""
async def test_wish_name_message_key_reaches_the_proposal_response(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
personal_data_repo = SQLitePersonalDataRepository(connection)
child_repo = SQLiteChildRepository(connection)
parental_leave_repo = SQLiteParentalLeaveRepository(connection)
vacation_repo = SQLiteVacationDaysRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=ws.id,
name="Alex",
salary_type="monthly",
created_at=now,
)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date.today() - relativedelta(months=6),
created_at=now,
)
)
await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id))
next_year = date.today().year + 1
await vacation_repo.create(
VacationDays(
id=uuid4(),
personal_data_uuid=parent.uuid,
workspace_id=ws.id,
year=next_year,
total_days=25,
saved_days_in=0,
)
)
# Preset-created: name is the (server-agnostic) translated literal,
# name_message_key records which preset produced it -- exactly what
# `WishEditor.tsx`'s `addPreset` sends.
create_wish = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": str(child.id),
"name": "Höstlov",
"name_message_key": "wishEditor.presetHostlov",
"window_start_month": 10,
"window_start_day": 25,
"window_end_month": 11,
"window_end_day": 1,
"target_weeks": 1,
"recurrence": "once",
"first_year": next_year,
},
)
assert create_wish.status_code == 201
wish_id = create_wish.json()["id"]
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
assert response.status_code == 200
body = response.json()
outcome = next(o for o in body["outcomes"] if o["wish_id"] == wish_id)
assert outcome["wish_name"] == "Höstlov"
assert outcome["wish_name_message_key"] == "wishEditor.presetHostlov"
# Surface #1 (outcome.explanation) mirrors the key in `data` too, so
# `buildWindowExplanation` in PlanResults.tsx can re-translate it.
assert outcome["data"]["wish_name_message_key"] == "wishEditor.presetHostlov"
async def test_wish_name_message_key_is_null_for_a_free_typed_wish(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
"""A row predating this field, or a free-typed/user-renamed wish, has
no key -- the response must expose `null`, never omit the field or
leak a stale/empty-string placeholder.
"""
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
personal_data_repo = SQLitePersonalDataRepository(connection)
child_repo = SQLiteChildRepository(connection)
parental_leave_repo = SQLiteParentalLeaveRepository(connection)
vacation_repo = SQLiteVacationDaysRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=ws.id,
name="Alex",
salary_type="monthly",
created_at=now,
)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date.today() - relativedelta(months=6),
created_at=now,
)
)
await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id))
next_year = date.today().year + 1
await vacation_repo.create(
VacationDays(
id=uuid4(),
personal_data_uuid=parent.uuid,
workspace_id=ws.id,
year=next_year,
total_days=25,
saved_days_in=0,
)
)
create_wish = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": str(child.id),
"name": "Resa till mormor",
"window_start_month": 6,
"window_start_day": 1,
"window_end_month": 6,
"window_end_day": 7,
"target_weeks": 1,
"recurrence": "once",
"first_year": next_year,
},
)
assert create_wish.status_code == 201
wish_id = create_wish.json()["id"]
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
assert response.status_code == 200
body = response.json()
outcome = next(o for o in body["outcomes"] if o["wish_id"] == wish_id)
assert outcome["wish_name"] == "Resa till mormor"
assert outcome["wish_name_message_key"] is None
class TestApplyRejectionsOverTheWireMessageKeys:
"""§3.5 — `PlanApplyResponse.rejections` must expose `message_key` + `data`
@@ -99,12 +99,14 @@ def _wish(
workspace_id: UUID | None = None,
window_mode: str = "fixed",
wish_id: UUID | None = None,
name_message_key: str | None = None,
) -> LeaveWish:
return LeaveWish(
id=wish_id or uuid4(),
workspace_id=workspace_id or uuid4(),
child_id=child_id,
name=name,
name_message_key=name_message_key,
window_start_month=start[0],
window_start_day=start[1],
window_end_month=end[0],
@@ -1452,6 +1454,7 @@ class TestCase20FlexibleWishUnplacedWhenEveryCandidateCollides:
flexible = _wish(
child_id=child.id,
name="Flex",
name_message_key="wishEditor.seasonVar",
start=VAR_2027[0],
end=VAR_2027[1],
weeks=1,
@@ -1476,6 +1479,11 @@ class TestCase20FlexibleWishUnplacedWhenEveryCandidateCollides:
flex_outcome = by_name["Flex"]
assert flex_outcome.strategy == STRATEGY_UNPLACED
# build_unplaced_outcome (§10.4) is a separate WindowOutcome
# construction site from fund_window's -- assert its
# wish_name_message_key mapping independently so neither can drop it.
assert flex_outcome.wish_name_message_key == "wishEditor.seasonVar"
assert flex_outcome.data["wish_name_message_key"] == "wishEditor.seasonVar"
assert flex_outcome.weeks_funded == 0.0
assert flex_outcome.shortfall_days == 7 # target_weeks * 7
assert flex_outcome.start_date == date(2027, 3, 1)
@@ -484,6 +484,63 @@ class TestRemedies:
assert remedy.data["from_weeks"] != remedy.data["to_weeks"]
assert remedy.days_freed > 0
def test_wish_name_message_key_propagates_to_outcome_and_remedies(self) -> None:
"""`WindowOutcome.wish_name_message_key` (and its `data` mirror) must
carry the *outcome's own* wish's i18n key, `lower_priority_wish`'s
`data.wish_name_message_key` must carry the *sibling* wish's key (not
the outcome's own -- they can differ), and
`shift_window_into_free_days`'s `data.wish_name_message_key` must
again carry the outcome's own wish's key, so `PlanResults.tsx` can
re-translate a preset-created name in every surface, not just the
top-level field.
"""
child = _child(birth_date=date(2025, 1, 1))
parent = _parent(salary_type="hourly", min_vacation_days_per_year=20)
wish_high = _wish(
child_id=child.id,
name="Vinter",
name_message_key="wishEditor.seasonJul",
start=FEB_2027[0],
end=FEB_2027[1],
weeks=4,
priority=1,
)
wish_low = _wish(
child_id=child.id,
name="Sommar",
name_message_key="wishEditor.presetSommar",
start=AUG_2027[0],
end=AUG_2027[1],
weeks=2,
priority=2,
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
planning_goal="max_money",
planning_mode="solo",
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(5, 0, 60)},
vacation={(parent.uuid, 2026): VacationBudget(0, 0, 0)},
wishes=[wish_high, wish_low],
)
proposal = Allocator().propose(alloc_input)
outcome_high = next(o for o in proposal.outcomes if o.wish_name == "Vinter")
assert outcome_high.wish_name_message_key == "wishEditor.seasonJul"
assert outcome_high.data["wish_name_message_key"] == "wishEditor.seasonJul"
lower_priority = next(r for r in outcome_high.remedies if r.kind == "lower_priority_wish")
assert lower_priority.data["wish_name_message_key"] == "wishEditor.presetSommar"
shift_window = next(
r for r in outcome_high.remedies if r.kind == "shift_window_into_free_days"
)
assert shift_window.data["wish_name_message_key"] == "wishEditor.seasonJul"
class TestDayOverrides:
"""`AllocationInput.day_overrides` — see `DayOverride` / `is_free_day`.