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:
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user