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`