Parse transferable days from FK instead of deriving them
The scraper never read "överlåtbara dagar" from Försäkringskassan. It only visited ansok-och-planera-foraldrapenning#/valjdagar and derived the reserved/transferable split in build_snapshot, assuming transferable days are spent before reserved ones. On real data that gave 49 for the user and 0 for the partner where FK states 129 and 71. The assumption cannot be repaired: reserved-first, transferable-first, proportional and cap-based models were all checked against real figures and none reproduces FK's accounting (reserved-first fits the partner but is 10 days off for the user). FK publishes the real numbers per parent per tier on the min-foraldrapenning page, which the scraper had never visited. - Scrape the "Dagar ni kan föra över" panel after the valjdagar extraction, summing sjukpenningnivå + lägstanivå per parent. The totals are not literals on the page. Parse only direct-child <p> elements: the first group embeds a tooltip with decoy "45 dagar"/"90 dagar" text. - build_snapshot accepts the reported value and derives reserved days from it; the old arithmetic remains only as a fallback. An implausible FK value degrades to that fallback rather than failing the fetch. - Record provenance in transferable_days_reported (migration 006) and badge derived values as estimates in the dashboard breakdown. Drop the footnote's claim that at most 150 days are transferable — real data contradicts it as a per-family figure. - Select the child's section by name when FK lists several; never guess. Verified against a live BankID scrape: scraped 240/101/139 with 129 transferable, deduced partner 240/169/71 with 71, both flagged as reported. Incidental fixes found along the way: - The fetch router never forwarded the partner's transferable figure to persist_partner_deduction, so the partner's stored snapshot would have kept the flawed split even once the parse landed. - 'deduced' snapshots were labelled "Manually entered" by a two-way ternary over a three-value union. Replaced with a compiler-checked exhaustive map. - Alembic resolved its database from alembic.ini alone, ignoring APP_SQLITE_PATH, so migrations silently targeted the developer's real database even when pointed elsewhere. env.py now resolves it, preferring -x sqlalchemy.url, then a real APP_SQLITE_PATH, then the ini value. - backend/captures/ was not ignored: the root pattern was anchored as /captures/ despite a comment saying scraper dumps must never be tracked. - CLAUDE.md quoted test counts and coverage percentages that had gone stale, which made a correct verification report look suspect. Gate thresholds kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
This commit is contained in:
@@ -27,9 +27,13 @@ from app.scrapers.fk_scraper import (
|
||||
FamilySummary,
|
||||
FKScraperService,
|
||||
PartnerDeduction,
|
||||
TransferableDays,
|
||||
_apply_transferable,
|
||||
_deduce_partner,
|
||||
_do_extract,
|
||||
_extract_family_summary,
|
||||
_extract_transferable_days,
|
||||
_SEL,
|
||||
_SessionState,
|
||||
_set_status,
|
||||
_sessions,
|
||||
@@ -642,6 +646,578 @@ class TestDeducePartner:
|
||||
assert deduction.lagsta_niva_left >= 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transferable days ("Dagar ni kan föra över") -> min-foraldrapenning page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Small balanced-<div> HTML toolkit for the mock page below. Regex alone
|
||||
# (as `_make_mock_page` above uses) can't safely slice `.barn-sektion` or
|
||||
# `.fora-over-group`, since they contain further nested <div>s (the tooltip,
|
||||
# in particular) — a naive ".*?</div>" would stop at the first nested close.
|
||||
|
||||
|
||||
def _balanced_div_slice(html: str, open_tag_start: int) -> str:
|
||||
"""From the index of a '<div' opening tag, return that div's full outer
|
||||
HTML (opening tag through its matching closing tag), counting nested
|
||||
<div>/</div> pairs."""
|
||||
depth = 0
|
||||
for m in re.finditer(r"<div\b[^>]*>|</div\s*>", html[open_tag_start:], re.IGNORECASE):
|
||||
if m.group(0).lower().startswith("</div"):
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return html[open_tag_start : open_tag_start + m.end()]
|
||||
else:
|
||||
depth += 1
|
||||
raise ValueError("unbalanced <div> in fixture")
|
||||
|
||||
|
||||
def _find_divs_by_class(html: str, class_name: str) -> list[str]:
|
||||
"""Balanced outer-HTML slices for every <div class="...class_name...">
|
||||
in document order (non-overlapping — resumes the search after each
|
||||
match's own closing tag, so nested divs of the same class, unused here,
|
||||
aren't double-counted)."""
|
||||
pattern = re.compile(rf'<div\b[^>]*\bclass="[^"]*\b{re.escape(class_name)}\b[^"]*"[^>]*>')
|
||||
results: list[str] = []
|
||||
pos = 0
|
||||
while True:
|
||||
m = pattern.search(html, pos)
|
||||
if not m:
|
||||
break
|
||||
sl = _balanced_div_slice(html, m.start())
|
||||
results.append(sl)
|
||||
pos = m.start() + len(sl)
|
||||
return results
|
||||
|
||||
|
||||
def _find_barn_rubrik_text(section_html: str) -> str | None:
|
||||
idx = section_html.find("barn-rubrik")
|
||||
if idx == -1:
|
||||
return None
|
||||
m = re.search(r"<strong>(.*?)</strong>", section_html[idx:], re.DOTALL)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
|
||||
def _direct_child_p_texts(div_slice: str) -> list[str]:
|
||||
"""Text of <p> elements that are direct children of div_slice's own root
|
||||
element — i.e. not nested inside any further <div> — mirroring
|
||||
Playwright's `:scope > p`."""
|
||||
open_m = re.match(r"<div\b[^>]*>", div_slice, re.IGNORECASE)
|
||||
inner = div_slice[open_m.end() : -len("</div>")]
|
||||
texts: list[str] = []
|
||||
depth = 0
|
||||
for m in re.finditer(r"<(/?)(div|p)\b[^>]*>", inner, re.IGNORECASE):
|
||||
closing = m.group(1) == "/"
|
||||
tag = m.group(2).lower()
|
||||
if tag == "div":
|
||||
depth += -1 if closing else 1
|
||||
continue
|
||||
if tag == "p" and not closing and depth == 0:
|
||||
end_m = re.search(r"</p\s*>", inner[m.end() :], re.IGNORECASE)
|
||||
if end_m:
|
||||
raw = inner[m.end() : m.end() + end_m.start()]
|
||||
texts.append(re.sub(r"<[^>]+>", "", raw).strip())
|
||||
return texts
|
||||
|
||||
|
||||
def _make_min_fp_mock_page(html: str):
|
||||
"""Build a minimal async mock Page for the min-foraldrapenning "Dagar ni
|
||||
kan föra över" panel selectors. Balanced-<div> slicing + regex over the
|
||||
fixture HTML, not a real browser — see `_find_divs_by_class` above.
|
||||
"""
|
||||
min_fp_slices = _find_divs_by_class(html, "min-fp")
|
||||
min_fp_html = min_fp_slices[0] if min_fp_slices else ""
|
||||
barn_sektioner = _find_divs_by_class(min_fp_html, "barn-sektion") if min_fp_html else []
|
||||
|
||||
def _make_group_locator(group_html: str) -> MagicMock:
|
||||
loc = MagicMock()
|
||||
|
||||
def sub_locator(sel: str) -> MagicMock:
|
||||
if sel == _SEL.MINFP_LABEL:
|
||||
m = re.search(
|
||||
r'<h4\b[^>]*class="[^"]*minfp-label[^"]*"[^>]*>(.*?)</h4>',
|
||||
group_html,
|
||||
re.DOTALL,
|
||||
)
|
||||
sub = MagicMock()
|
||||
sub.count = AsyncMock(return_value=1 if m else 0)
|
||||
if m:
|
||||
text = re.sub(r"<[^>]+>", "", m.group(1)).strip()
|
||||
sub.first.inner_text = AsyncMock(return_value=text)
|
||||
return sub
|
||||
if sel == ":scope > p":
|
||||
sub = MagicMock()
|
||||
sub.all_inner_texts = AsyncMock(return_value=_direct_child_p_texts(group_html))
|
||||
return sub
|
||||
raise AssertionError(f"unexpected selector on group locator: {sel!r}")
|
||||
|
||||
loc.locator.side_effect = sub_locator
|
||||
return loc
|
||||
|
||||
def _make_panel_locator(panel_html: str) -> MagicMock:
|
||||
loc = MagicMock()
|
||||
loc.count = AsyncMock(return_value=1)
|
||||
|
||||
def sub_locator(sel: str) -> MagicMock:
|
||||
if sel == _SEL.EXPANDABLE_BUTTON:
|
||||
m = re.search(
|
||||
r'<button\b[^>]*class="[^"]*expandable-paragraph__button[^"]*"[^>]*>',
|
||||
panel_html,
|
||||
)
|
||||
sub = MagicMock()
|
||||
sub.count = AsyncMock(return_value=1 if m else 0)
|
||||
if m:
|
||||
aria_m = re.search(r'aria-expanded="(true|false)"', m.group(0))
|
||||
sub.first.get_attribute = AsyncMock(
|
||||
return_value=aria_m.group(1) if aria_m else None
|
||||
)
|
||||
sub.first.click = AsyncMock()
|
||||
return sub
|
||||
if sel == _SEL.FORA_OVER_GROUP:
|
||||
groups = _find_divs_by_class(panel_html, "fora-over-group")
|
||||
sub = MagicMock()
|
||||
sub.count = AsyncMock(return_value=len(groups))
|
||||
sub.nth.side_effect = lambda i: _make_group_locator(groups[i])
|
||||
return sub
|
||||
raise AssertionError(f"unexpected selector on panel locator: {sel!r}")
|
||||
|
||||
loc.locator.side_effect = sub_locator
|
||||
return loc
|
||||
|
||||
def _make_section_locator(section_html: str) -> MagicMock:
|
||||
loc = MagicMock()
|
||||
|
||||
def sub_locator(sel: str) -> MagicMock:
|
||||
if sel == _SEL.BARN_RUBRIK:
|
||||
text = _find_barn_rubrik_text(section_html)
|
||||
sub = MagicMock()
|
||||
sub.count = AsyncMock(return_value=1 if text is not None else 0)
|
||||
sub.first.inner_text = AsyncMock(return_value=text or "")
|
||||
return sub
|
||||
if sel == _SEL.PANEL_FORA_OVER_DAGAR:
|
||||
panels = _find_divs_by_class(section_html, "panel-fora-over-dagar")
|
||||
sub = MagicMock()
|
||||
sub.count = AsyncMock(return_value=len(panels))
|
||||
if panels:
|
||||
panel_loc = _make_panel_locator(panels[0])
|
||||
sub.locator.side_effect = panel_loc.locator.side_effect
|
||||
return sub
|
||||
raise AssertionError(f"unexpected selector on section locator: {sel!r}")
|
||||
|
||||
loc.locator.side_effect = sub_locator
|
||||
return loc
|
||||
|
||||
def _make_sections_root_locator() -> MagicMock:
|
||||
loc = MagicMock()
|
||||
loc.count = AsyncMock(return_value=len(barn_sektioner))
|
||||
if barn_sektioner:
|
||||
loc.first = _make_section_locator(barn_sektioner[0])
|
||||
loc.nth.side_effect = lambda i: _make_section_locator(barn_sektioner[i])
|
||||
return loc
|
||||
|
||||
page = MagicMock()
|
||||
page.goto = AsyncMock()
|
||||
page.wait_for_selector = AsyncMock()
|
||||
page.wait_for_function = AsyncMock()
|
||||
|
||||
def locator_factory(sel: str) -> MagicMock:
|
||||
if sel == _SEL.MIN_FP_BARN_SEKTION:
|
||||
return _make_sections_root_locator()
|
||||
raise AssertionError(f"unexpected top-level selector: {sel!r}")
|
||||
|
||||
page.locator.side_effect = locator_factory
|
||||
return page
|
||||
|
||||
|
||||
_MULTI_CHILD_MIN_FP_HTML = """
|
||||
<div class="min-fp">
|
||||
<div class="barn-sektion">
|
||||
<div class="barn-header"><h2 class="barn-rubrik"><strong>Testbarn</strong></h2></div>
|
||||
<div class="panel-fora-over-dagar">
|
||||
<div class="fora-over-group">
|
||||
<h4 class="minfp-label">Du kan föra över</h4>
|
||||
<p>84 dagar på sjukpenningnivå</p>
|
||||
<p>45 dagar på lägstanivå</p>
|
||||
</div>
|
||||
<div class="fora-over-group">
|
||||
<h4 class="minfp-label">Annan förälder kan föra över</h4>
|
||||
<p>26 dagar på sjukpenningnivå</p>
|
||||
<p>45 dagar på lägstanivå</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="barn-sektion">
|
||||
<div class="barn-header"><h2 class="barn-rubrik"><strong>Andrabarn</strong></h2></div>
|
||||
<div class="panel-fora-over-dagar">
|
||||
<div class="fora-over-group">
|
||||
<h4 class="minfp-label">Du kan föra över</h4>
|
||||
<p>10 dagar på sjukpenningnivå</p>
|
||||
<p>5 dagar på lägstanivå</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
_SOLO_PARENT_MIN_FP_HTML = """
|
||||
<div class="min-fp">
|
||||
<div class="barn-sektion">
|
||||
<div class="barn-header"><h2 class="barn-rubrik"><strong>Testbarn</strong></h2></div>
|
||||
<div class="panel-fora-over-dagar">
|
||||
<div class="fora-over-group">
|
||||
<h4 class="minfp-label">Du kan föra över</h4>
|
||||
<p>84 dagar på sjukpenningnivå</p>
|
||||
<p>45 dagar på lägstanivå</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
_NO_TRANSFER_PANEL_MIN_FP_HTML = """
|
||||
<div class="min-fp">
|
||||
<div class="barn-sektion">
|
||||
<div class="barn-header"><h2 class="barn-rubrik"><strong>Testbarn</strong></h2></div>
|
||||
<div class="some-other-panel"></div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
_INCOMPLETE_MINE_MIN_FP_HTML = """
|
||||
<div class="min-fp">
|
||||
<div class="barn-sektion">
|
||||
<div class="barn-header"><h2 class="barn-rubrik"><strong>Testbarn</strong></h2></div>
|
||||
<div class="panel-fora-over-dagar">
|
||||
<div class="fora-over-group">
|
||||
<h4 class="minfp-label">Du kan föra över</h4>
|
||||
<p>84 dagar på sjukpenningnivå</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _min_fp_state() -> _SessionState:
|
||||
return _SessionState(
|
||||
session_id="min-fp-test",
|
||||
workspace_id=uuid.uuid4(),
|
||||
personal_data_uuid=uuid.uuid4(),
|
||||
child_id=uuid.uuid4(),
|
||||
owner_user_id=uuid.uuid4(),
|
||||
)
|
||||
|
||||
|
||||
class TestExtractTransferableDays:
|
||||
"""`_extract_transferable_days` reads FK's own "Dagar ni kan föra över"
|
||||
figures off the min-foraldrapenning page — the fix for the bug where
|
||||
`transferable_days_left` could never be reconstructed from #/valjdagar
|
||||
alone (see facts.md: FK reports 129/71, the old derivation gave 49/0)."""
|
||||
|
||||
async def test_both_groups_parsed_golden_regression(self) -> None:
|
||||
"""Pinning regression: mine 84+45=129, partner 26+45=71 — the exact
|
||||
`facts.md` figures FK itself reports, which the old arithmetic
|
||||
derivation could never reproduce."""
|
||||
html = _load_fixture("fk_min_foraldrapenning_synthetic.html")
|
||||
page = _make_min_fp_mock_page(html)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is not None
|
||||
assert result.mine_total == 129
|
||||
assert result.partner_total == 71
|
||||
|
||||
async def test_tooltip_decoys_ignored(self) -> None:
|
||||
"""The tooltip's decoy "45 dagar"/"90 dagar" text must not leak into
|
||||
`mine_sjukpenning`/`mine_lagsta_niva` via a non-scoped read."""
|
||||
html = _load_fixture("fk_min_foraldrapenning_synthetic.html")
|
||||
page = _make_min_fp_mock_page(html)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is not None
|
||||
assert result.mine_sjukpenning == 84
|
||||
assert result.mine_lagsta_niva == 45
|
||||
|
||||
async def test_collapsed_accordion_still_yields_correct_numbers(self) -> None:
|
||||
html = _load_fixture("fk_min_foraldrapenning_synthetic.html").replace(
|
||||
'aria-expanded="true"', 'aria-expanded="false"'
|
||||
)
|
||||
page = _make_min_fp_mock_page(html)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is not None
|
||||
assert result.mine_total == 129
|
||||
assert result.partner_total == 71
|
||||
|
||||
async def test_panel_absent_returns_none(self) -> None:
|
||||
page = _make_min_fp_mock_page(_NO_TRANSFER_PANEL_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_single_section_used_without_name_match(self) -> None:
|
||||
"""A single `.barn-sektion` is used as-is — no `child_name` needed."""
|
||||
page = _make_min_fp_mock_page(_SOLO_PARENT_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), None)
|
||||
|
||||
assert result is not None
|
||||
assert result.mine_total == 129
|
||||
|
||||
async def test_multi_child_correct_section_chosen_by_name(self) -> None:
|
||||
page = _make_min_fp_mock_page(_MULTI_CHILD_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Andrabarn")
|
||||
|
||||
assert result is not None
|
||||
assert result.mine_sjukpenning == 10
|
||||
assert result.mine_lagsta_niva == 5
|
||||
|
||||
async def test_multi_child_unmatched_name_returns_none(self) -> None:
|
||||
page = _make_min_fp_mock_page(_MULTI_CHILD_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Okänt Barn")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_multi_child_no_name_returns_none(self) -> None:
|
||||
"""Never guess a section when there's more than one and no name to match."""
|
||||
page = _make_min_fp_mock_page(_MULTI_CHILD_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), None)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_solo_parent_partner_fields_none(self) -> None:
|
||||
page = _make_min_fp_mock_page(_SOLO_PARENT_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is not None
|
||||
assert result.partner_sjukpenning is None
|
||||
assert result.partner_lagsta_niva is None
|
||||
assert result.partner_total is None
|
||||
|
||||
async def test_incomplete_mine_group_returns_none(self) -> None:
|
||||
page = _make_min_fp_mock_page(_INCOMPLETE_MINE_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_exception_during_parse_returns_none(self) -> None:
|
||||
"""Any unexpected failure (e.g. a locator raising) degrades to None
|
||||
rather than propagating — mirrors `_extract_family_summary`."""
|
||||
page = _make_min_fp_mock_page(_load_fixture("fk_min_foraldrapenning_synthetic.html"))
|
||||
page.wait_for_selector = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestApplyTransferable:
|
||||
"""`_apply_transferable` re-derives `data` with FK's own reported split."""
|
||||
|
||||
def _mine(self, **overrides: object) -> ParentalLeaveFetch:
|
||||
now = datetime.now()
|
||||
base = dict(
|
||||
id=uuid.uuid4(),
|
||||
personal_data_uuid=uuid.uuid4(),
|
||||
workspace_id=uuid.uuid4(),
|
||||
child_id=uuid.uuid4(),
|
||||
source="scraped",
|
||||
fetched_at=now,
|
||||
total_days=240,
|
||||
days_used=140,
|
||||
days_left=100,
|
||||
reserved_days_total=90,
|
||||
reserved_days_left=90,
|
||||
transferable_days_left=10,
|
||||
dubbeldagar_left=20,
|
||||
tier_data=TierData(
|
||||
sjukpenning_total=195,
|
||||
sjukpenning_used=135,
|
||||
sjukpenning_left=60,
|
||||
lagsta_niva_total=45,
|
||||
lagsta_niva_used=5,
|
||||
lagsta_niva_left=40,
|
||||
),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
base.update(overrides)
|
||||
return ParentalLeaveFetch(**base)
|
||||
|
||||
def test_valid_transferable_updates_reserved_split(self) -> None:
|
||||
mine = self._mine()
|
||||
transferable = TransferableDays(mine_sjukpenning=50, mine_lagsta_niva=10)
|
||||
|
||||
result = _apply_transferable(mine, transferable)
|
||||
|
||||
assert result.transferable_days_left == 60
|
||||
assert result.reserved_days_left == 40 # days_left(100) - transferable(60)
|
||||
assert result.transferable_days_reported is True
|
||||
|
||||
def test_implausible_transferable_returns_original_unchanged(self) -> None:
|
||||
"""transferable_days_left > days_left is impossible — build_snapshot
|
||||
raises ValidationError (reserved_days_left would go negative), and
|
||||
the caller's original estimate must survive unchanged, never raise."""
|
||||
mine = self._mine()
|
||||
transferable = TransferableDays(mine_sjukpenning=500, mine_lagsta_niva=500)
|
||||
|
||||
result = _apply_transferable(mine, transferable)
|
||||
|
||||
assert result is mine
|
||||
|
||||
|
||||
class TestDoExtractTransferableIntegration:
|
||||
"""`_do_extract`'s new transferable-days block, end to end.
|
||||
|
||||
`_mine`'s figures mirror `facts.md` exactly: days_left=94+45=139, and the
|
||||
old transferable-first derivation (no FK figure available) computes
|
||||
transferable_days_left=49 — the bug this feature fixes (FK itself
|
||||
reports 129). The family fixture below likewise mirrors facts.md's
|
||||
"210 av 480 dagar kvar totalt" / "Annan förälder: 71 dagar kvar".
|
||||
"""
|
||||
|
||||
_FAMILY_HTML = """
|
||||
<div data-test="forDigOchAnnanForalder">
|
||||
<div class="visa-dagar" data-test="body">
|
||||
<span class="visa-dagar__dagar-kvar">210</span>
|
||||
<span class="visa-dagar__discrete">av 480 dagar kvar efter ansökan</span>
|
||||
<div class="visa-dagar__desktop">
|
||||
<span class="niva-statistik__dagar"><span>(120 dagar)</span></span>
|
||||
<span class="niva-statistik__dagar"><span>(90 dagar)</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
_sessions.clear()
|
||||
|
||||
def teardown_method(self) -> None:
|
||||
_sessions.clear()
|
||||
|
||||
def _mine(self, **overrides: object) -> ParentalLeaveFetch:
|
||||
now = datetime.now()
|
||||
base = dict(
|
||||
id=uuid.uuid4(),
|
||||
personal_data_uuid=uuid.uuid4(),
|
||||
workspace_id=uuid.uuid4(),
|
||||
child_id=uuid.uuid4(),
|
||||
source="scraped",
|
||||
fetched_at=now,
|
||||
total_days=240,
|
||||
days_used=101,
|
||||
days_left=139,
|
||||
reserved_days_total=90,
|
||||
reserved_days_left=90,
|
||||
transferable_days_left=49,
|
||||
dubbeldagar_left=39,
|
||||
tier_data=TierData(
|
||||
sjukpenning_total=195,
|
||||
sjukpenning_used=101,
|
||||
sjukpenning_left=94,
|
||||
lagsta_niva_total=45,
|
||||
lagsta_niva_used=0,
|
||||
lagsta_niva_left=45,
|
||||
),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
base.update(overrides)
|
||||
return ParentalLeaveFetch(**base)
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
mine: ParentalLeaveFetch,
|
||||
min_fp_html: str,
|
||||
family_html: str = "",
|
||||
child_name: str | None = "Testbarn",
|
||||
) -> _SessionState:
|
||||
sid = str(uuid.uuid4())
|
||||
state = _SessionState(
|
||||
session_id=sid,
|
||||
workspace_id=uuid.uuid4(),
|
||||
personal_data_uuid=uuid.uuid4(),
|
||||
child_id=uuid.uuid4(),
|
||||
owner_user_id=uuid.uuid4(),
|
||||
child_name=child_name,
|
||||
)
|
||||
_sessions[sid] = state
|
||||
|
||||
min_fp_page = _make_min_fp_mock_page(min_fp_html)
|
||||
family_page = _make_mock_page(family_html)
|
||||
|
||||
page = MagicMock()
|
||||
page.wait_for_selector = AsyncMock()
|
||||
page.wait_for_function = AsyncMock()
|
||||
page.goto = min_fp_page.goto
|
||||
|
||||
def locator_factory(sel: str) -> MagicMock:
|
||||
if sel == _SEL.MIN_FP_BARN_SEKTION:
|
||||
return min_fp_page.locator(sel)
|
||||
if sel.startswith(_SEL.FAMILY):
|
||||
return family_page.locator(sel)
|
||||
loc = MagicMock()
|
||||
loc.first.click = AsyncMock()
|
||||
loc.count = AsyncMock(return_value=0)
|
||||
return loc
|
||||
|
||||
page.locator.side_effect = locator_factory
|
||||
|
||||
with patch(
|
||||
"app.scrapers.fk_scraper._extract_parental_leave", new_callable=AsyncMock
|
||||
) as mock_extract:
|
||||
mock_extract.return_value = mine
|
||||
await _do_extract(page, state)
|
||||
|
||||
return state
|
||||
|
||||
async def test_transferable_days_applied_to_result(self) -> None:
|
||||
"""Golden regression via `_do_extract`: the persisted result carries
|
||||
FK's own 129, not the old broken derivation's 49."""
|
||||
mine = self._mine()
|
||||
state = await self._run(mine, _load_fixture("fk_min_foraldrapenning_synthetic.html"))
|
||||
|
||||
assert state.status == "done"
|
||||
assert state.result is not None
|
||||
assert state.result.transferable_days_left == 129
|
||||
assert state.result.reserved_days_left == 10 # 139 - 129
|
||||
|
||||
async def test_partner_deduction_picks_up_transferable_days(self) -> None:
|
||||
mine = self._mine()
|
||||
state = await self._run(
|
||||
mine,
|
||||
_load_fixture("fk_min_foraldrapenning_synthetic.html"),
|
||||
family_html=self._FAMILY_HTML,
|
||||
)
|
||||
|
||||
assert state.partner_deduction is not None
|
||||
assert state.partner_deduction.days_left == 71
|
||||
assert state.partner_deduction.transferable_days_left == 71
|
||||
|
||||
async def test_exception_in_block_leaves_data_untouched(self) -> None:
|
||||
"""Mirrors `test_absent_family_panel_does_not_affect_main_extraction`:
|
||||
a failure in the new block must never affect the caller's own,
|
||||
already-validated `data`."""
|
||||
mine = self._mine()
|
||||
with patch(
|
||||
"app.scrapers.fk_scraper._extract_transferable_days",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
state = await self._run(mine, _load_fixture("fk_min_foraldrapenning_synthetic.html"))
|
||||
|
||||
assert state.status == "done"
|
||||
assert state.result is mine
|
||||
assert state.result.transferable_days_left == 49 # unchanged from the original estimate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session state machine
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -728,6 +1304,7 @@ class TestSessionStateMachine:
|
||||
"sjukpenning_left": 130,
|
||||
"lagsta_niva_left": 20,
|
||||
"dubbeldagar_left": 20,
|
||||
"transferable_days_left": None,
|
||||
}
|
||||
|
||||
async def test_poll_done_partner_deduction_is_none_when_absent(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user