diff --git a/BUGS-BY-HAND.md b/BUGS-BY-HAND.md index f9aaf34..39e34ed 100644 --- a/BUGS-BY-HAND.md +++ b/BUGS-BY-HAND.md @@ -60,3 +60,5 @@ toe (vaak in `tests/e2e/test_every_page.py`) en zet hier een regel bij. | 2026-11-07 | De eigenaar zocht de plek voor de API-sleutel van zijn eigen AI-dienst en vond hem niet: die stond onder Instellingen → "Bonnetjes lezen", een naam zonder AI of sleutel, en op AI-assistenten (waar hij keek) maakt Huismus juist zelf een sleutel (deze PR) | instelling vindbaar alleen voor wie weet waarvoor hij dient | de kaart heet "Bonnetjes lezen met AI" en noemt de API-sleutel, AI-assistenten wijst ernaar; `test_the_card_says_it_is_where_your_own_ai_key_goes`, `test_the_page_points_to_where_your_own_ai_key_goes`. Vindbaarheid zelf: geen geautomatiseerde check | | 2026-11-13 | Desktop-app: "Koppel met Claude" deed niets, en geen enkel formulier meer, tot het venster dichtging. Een htmx-verzoek ging weg van een deel dat zichzelf ververst; een paginawissel verving dat deel voordat het antwoord kwam. htmx meldt het einde alleen aan het element zelf, dus app.js bleef wachten en hield elk POST-formulier tegen (de eigenaar; deze PR) | boekhouding van de pagina hangt aan een htmx-event van een element dat weg kan zijn | `test_browser`: `test_a_request_whose_element_is_gone_does_not_hold_up_the_forms` (geldt voor elk htmx-verzoek: app.js telt nu ook het einde van het verzoek zelf, `loadend`) | | 2026-11-13 | Bonnetjes lezen met Anthropic (Claude Haiku 5.5) niet op te slaan: "Anthropic gaf een antwoord dat Huismus niet begrijpt". Huismus stuurde `temperature: 0` mee en nieuwere modellen weigeren dat met een 400; wat de dienst zei, kwam nergens in het log (de eigenaar, op de Pi; deze PR) | verzoek naar een externe dienst met een instelling die nieuwere versies weigeren | `tests/ai_fakes.py`: de nep-dienst weigert `temperature`, `top_p` en `top_k` bij elk verzoek in elke test; een mislukte test van de dienst zet het antwoord van de dienst in het log (`test_a_failed_test_logs_what_the_service_said`) | +| 2026-11-13 | In de app op het beginscherm (iPhone) opende "Bonnetje openen" bij Verwerken de pdf over het hele scherm, zonder weg terug; wat je had ingevuld was weg. Ook de bonnetjes bij een uitgave en bij Bonnetjes zonder uitgave (de eigenaar, op de Pi; deze PR) | nieuw tabblad binnen Huismus in de geïnstalleerde app | `tests/core/test_pwa.py`: `test_every_new_tab_inside_huismus_has_a_way_back_in_the_installed_app` (elke link of knop naar een nieuw tabblad binnen Huismus heeft het antwoord van app.js); `test_in_the_installed_app_a_receipt_opens_over_verwerken` | +| 2026-11-13 | Factuur van een webwinkel bleef liggen met "Niet gelezen: de bedragen tellen niet op tot het totaal": onder een artikel stonden bedragen met "Incl." (een heffing, een recyclingbijdrage) die al in de prijs zaten; vermoedelijk telde het model die als losse regels mee (de eigenaar, op de Pi; deze PR) | model leest een bedrag dat al in een ander zit als eigen regel | nog geen: wat een model leest, test de poort niet. De vraag aan het model noemt het nu (`test_the_service_is_told_that_included_amounts_are_no_lines`); Huismus's eigen check op het totaal blijft | diff --git a/huismus/core/static/core/app.js b/huismus/core/static/core/app.js index c9ae622..4e7c14c 100644 --- a/huismus/core/static/core/app.js +++ b/huismus/core/static/core/app.js @@ -540,7 +540,7 @@ document.addEventListener("click", async (event) => { // command, with the fallback at the top of this file). In a browser tab, in // the desktop app and without JS it still opens in a new tab. The click // comes before the submit, so the form already goes to the frame. -let previewOpener = null; +let pdfOpener = null; document.addEventListener("click", (event) => { const button = event.target.closest("[data-preview-pdf]"); const dialog = button && document.getElementById(button.dataset.previewPdf); @@ -549,9 +549,34 @@ document.addEventListener("click", (event) => { const frame = template.content.firstElementChild.cloneNode(); template.after(frame); button.formTarget = frame.name; - previewOpener = button; + pdfOpener = button; dialog.showModal(); }); + +// A receipt (: "Bonnetje openen" in Verwerken, a +// thumbnail on an expense or among the receipts without one), in the +// installed app: as with "Voorbeeld-pdf", a new tab there takes over the +// screen with no way back, and in Verwerken what was typed would be gone. +// So it opens in the dialog (expenses/_receipt_dialog.html): a PDF in a +// frame, a photo as itself. In a browser tab, in the desktop app and +// without JS the link still opens the file in a new tab. +document.addEventListener("click", (event) => { + const link = event.target.closest("a[data-view-receipt]"); + const dialog = link && document.getElementById(link.dataset.viewReceipt); + if (!dialog || !window.matchMedia("(display-mode: standalone)").matches) return; + event.preventDefault(); + const template = dialog.querySelector(link.hasAttribute("data-pdf") ? "template[data-pdf]" : "template[data-photo]"); + const shown = template.content.firstElementChild.cloneNode(); + shown.src = link.getAttribute("href"); + if (shown.tagName === "IMG") shown.alt = link.dataset.label; + else shown.title = link.dataset.label; + shown.dataset.shown = ""; + dialog.querySelector("h2").textContent = link.dataset.name; + template.after(shown); + pdfOpener = link; + dialog.showModal(); +}); + document.addEventListener("click", (event) => { // A tap on the backdrop lands on the dialog itself, not on its content. if (event.target.matches?.("dialog.is-pdf")) event.target.close(); @@ -562,13 +587,13 @@ document.addEventListener( (event) => { if (!event.target.matches?.("dialog.is-pdf")) return; // Gone, so the next preview doesn't first show the previous one. - event.target.querySelector("iframe")?.remove(); + event.target.querySelector("iframe, [data-shown]")?.remove(); // Focus back on the button that opened it; on a phone that one is in // the closed "Meer" menu, so on that menu's button. - const menu = previewOpener?.closest("[popover]"); + const menu = pdfOpener?.closest("[popover]"); const target = menu && !menu.matches(":popover-open") ? document.querySelector(`[popovertarget="${menu.id}"]`) - : previewOpener; + : pdfOpener; target?.focus(); }, true, diff --git a/huismus/core/static/core/huismus.css b/huismus/core/static/core/huismus.css index 7ef24fb..5fb555e 100644 --- a/huismus/core/static/core/huismus.css +++ b/huismus/core/static/core/huismus.css @@ -505,6 +505,7 @@ a:hover { color: var(--primary-hover); } .dialog.is-pdf { width: min(960px, calc(100% - 16px)); max-height: calc(100dvh - 16px); } .dialog.is-pdf .dialog-body { padding: var(--s-4); } .dialog.is-pdf .pdf-frame { height: calc(100dvh - 180px); } +.dialog.is-pdf .dialog-photo { display: block; max-width: 100%; max-height: calc(100dvh - 180px); margin: 0 auto; object-fit: contain; } .dialog.is-static { position: static; display: block; margin: 0; animation: none; } .sheet { margin: 0 0 0 auto; width: min(440px, 100%); max-width: none; height: 100dvh; max-height: none; padding: 0; border: 0; background: var(--surface); color: var(--text); box-shadow: var(--sh-pop); display: none; flex-direction: column; } diff --git a/huismus/expenses/reading.py b/huismus/expenses/reading.py index 2b69839..b968164 100644 --- a/huismus/expenses/reading.py +++ b/huismus/expenses/reading.py @@ -192,6 +192,9 @@ def seen() -> None: Rules: - Never guess. Copy amounts as the receipt shows them; use null for what isn't there. - One line per VAT rate is enough. The lines' amounts add up to the total. +- An amount shown as included in an article ("incl.", "inclusief", "including", e.g. + a recycling fee or a levy) is already in that article's price: never a line of + its own, and never added to the total. - "vat": the VAT amount the receipt states for that line or rate. null when the receipt shows no VAT; 0 only when the receipt says 0. - "vat_rate": the VAT rate the receipt states for that line: "21", "9", "0" (0% or no diff --git a/huismus/templates/expenses/_receipt_dialog.html b/huismus/templates/expenses/_receipt_dialog.html new file mode 100644 index 0000000..5f9492b --- /dev/null +++ b/huismus/templates/expenses/_receipt_dialog.html @@ -0,0 +1,16 @@ +{% load i18n %} +{% comment %}A receipt over the page, in the installed app (app.js, +data-view-receipt): there a new tab takes over the screen with no way back, +and in Verwerken what was typed would be gone. The same answer as +"Voorbeeld-pdf" (documents/_editor_dialogs.html). In a browser tab, in the +desktop app and without JS the link opens the file in a new tab and this +dialog stays unused. The templates keep the page itself free of a frame +until the dialog opens.{% endcomment %} + +
+

+ + +
+
+
diff --git a/huismus/templates/expenses/form.html b/huismus/templates/expenses/form.html index deff1e8..8620bf6 100644 --- a/huismus/templates/expenses/form.html +++ b/huismus/templates/expenses/form.html @@ -48,7 +48,8 @@

{% translate "Bonnetjes" %}

{% for attachment in expense.attachments.all %}
- + {% blocktranslate with name=attachment.filename asvar receipt_label %}Bonnetje {{ name }}{% endblocktranslate %} + {% if attachment.is_pdf %} {% else %}{% endif %} @@ -129,4 +130,5 @@

{% translate "Deze uitgave verwijderen?" %}

{% endif %} +{% include "expenses/_receipt_dialog.html" %} {% endblock %} diff --git a/huismus/templates/expenses/process.html b/huismus/templates/expenses/process.html index 84df7f4..134fccc 100644 --- a/huismus/templates/expenses/process.html +++ b/huismus/templates/expenses/process.html @@ -45,7 +45,7 @@

{% translate "Niets meer te verwerken." %}

{% else %} {{ receipt_label }} {% endif %} - {% translate "Bonnetje openen" %} ↗ + {% translate "Bonnetje openen" %} ↗ {% else %}

{% translate "Geen bonnetje bij deze uitgave." %}

@@ -88,5 +88,6 @@

{% translate "Niets meer te verwerken." %}

+{% include "expenses/_receipt_dialog.html" %} {% endif %} {% endblock %} diff --git a/huismus/templates/expenses/tray.html b/huismus/templates/expenses/tray.html index 4c8c3ef..6c309ab 100644 --- a/huismus/templates/expenses/tray.html +++ b/huismus/templates/expenses/tray.html @@ -36,7 +36,8 @@

{% translate "Bonnetjes zonder uitgave" %}

{% for item in receipts %}{% with receipt=item.receipt %}
- + {% blocktranslate with name=receipt.filename asvar receipt_label %}Bonnetje {{ name }}{% endblocktranslate %} + {% if receipt.is_pdf %} {% else %}{% endif %} @@ -81,4 +82,5 @@

{% translate "Nog geen bonnetjes" %}

{% endif %}
+{% include "expenses/_receipt_dialog.html" %} {% endblock %} diff --git a/tests/core/test_pwa.py b/tests/core/test_pwa.py index 21c4378..2bc66a2 100644 --- a/tests/core/test_pwa.py +++ b/tests/core/test_pwa.py @@ -71,3 +71,31 @@ def test_pages_link_the_manifest_and_the_home_screen_icon(client): assert f'' in html assert "core/icons/apple-touch-icon.png" in html assert 'name="theme-color"' in html + + +# Addresses outside Huismus: in the installed app iOS opens those in a sheet +# with "Gereed", so a new tab there is fine. +_ELSEWHERE = ("{{ whats_new_url }}", "{{ authorize_url }}", "{{ release.page_url }}") + + +def test_every_new_tab_inside_huismus_has_a_way_back_in_the_installed_app(): + # In the app on the home screen a new tab of Huismus's own takes over the + # screen with no back button (a receipt in Verwerken, the owner on an + # iPhone, BUGS-BY-HAND.md). Each such link or button needs app.js's + # answer there: a dialog over the page (data-view-receipt, + # data-preview-pdf) or a sheet (data-file). + import re + from pathlib import Path + + templates = Path(__file__).resolve().parents[2] / "huismus" / "templates" + stranded = [] + for path in templates.rglob("*.html"): + if path.parts[-2] == "site": # the public site: no app there + continue + for tag in re.findall(r"<(?:a|button)\b[^>]*target=\"_blank\"[^>]*>", path.read_text()): + href = re.search(r'href="([^"]*)"', tag) + if href and href.group(1) in _ELSEWHERE: + continue + if not re.search(r"data-(view-receipt|preview-pdf|file)\b", tag): + stranded.append(f"{path.relative_to(templates)}: {tag[:120]}") + assert not stranded, stranded diff --git a/tests/e2e/test_process.py b/tests/e2e/test_process.py index f7e55fa..73eae13 100644 --- a/tests/e2e/test_process.py +++ b/tests/e2e/test_process.py @@ -118,3 +118,90 @@ def test_on_a_phone_the_receipt_is_above_the_fields_and_a_pdf_is_not_framed(app) expect(frame).to_be_hidden() assert frame.get_attribute("src") is None expect(app.get_by_role("link", name="Bonnetje openen ↗")).to_be_visible() + + +_INSTALLED_APP = """const own = window.matchMedia.bind(window); +window.matchMedia = (query) => query === "(display-mode: standalone)" + ? { matches: true, media: query, addEventListener() {}, removeEventListener() {} } + : own(query);""" + + +def test_in_the_installed_app_a_receipt_opens_over_verwerken(app): + # In the app on the home screen a new tab took over the screen with no + # way back (the owner, on an iPhone). Playwright can't install the app: + # matchMedia says it is installed. + _stack() + app.add_init_script(_INSTALLED_APP) + app.set_viewport_size({"width": 390, "height": 800}) + app.goto(app.base + "/uitgaven/verwerken/") + dialog = app.locator("#bonnetje-bekijken") + pages = [] + app.context.on("page", lambda page: pages.append(page)) + + # A photo: the image itself, whole, over the page. + app.get_by_role("link", name="Bonnetje openen ↗").click() + expect(dialog).to_be_visible() + photo = dialog.get_by_role("img", name="Bonnetje kassabon.png") + expect(photo).to_be_visible() + assert photo.bounding_box()["width"] <= 390 + dialog.get_by_role("button", name="Sluiten").click() + expect(dialog).to_be_hidden() + expect(photo).to_have_count(0) + + # A PDF: in a frame, and what was typed stays. + app.get_by_role("link", name="Overslaan").click() + expect(app.locator("#verwerken-teller")).to_have_text("2 van 3") + supplier = app.get_by_label("Leverancier") + supplier.fill("NS Reizigers") + url = app.url + for close in ("button", "Escape"): + app.get_by_role("link", name="Bonnetje openen ↗").click() + expect(dialog).to_be_visible() + frame = dialog.locator("iframe.pdf-frame") + expect(frame).to_have_attribute( + "src", Expense.objects.get(supplier="NS").attachments.get().file.url + ) + expect(dialog.get_by_role("button", name="Sluiten")).to_be_in_viewport() + if close == "button": + dialog.get_by_role("button", name="Sluiten").click() + else: + app.keyboard.press("Escape") + expect(dialog).to_be_hidden() + expect(frame).to_have_count(0) + expect(app.get_by_role("link", name="Bonnetje openen ↗")).to_be_focused() + assert app.url == url + expect(supplier).to_have_value("NS Reizigers") + assert pages == [] + + +@pytest.mark.parametrize("where", ["tray", "expense"]) +def test_in_the_installed_app_a_receipt_thumbnail_opens_over_the_page(app, where): + if where == "tray": + receipt = _in_tray("reis.pdf", _pdf([["NS", "blad 1"]])) + path = "/uitgaven/bonnetjes/" + else: + expense = _to_check("NS", 4, "reis.pdf", _pdf([["NS", "blad 1"]])) + receipt = expense.attachments.get() + path = f"/uitgaven/{expense.pk}/" + app.add_init_script(_INSTALLED_APP) + app.set_viewport_size({"width": 390, "height": 800}) + app.goto(app.base + path) + url = app.url + + app.locator(f"a.thumb[href='{receipt.file.url}']").click() + dialog = app.locator("#bonnetje-bekijken") + expect(dialog).to_be_visible() + expect(dialog.locator("iframe.pdf-frame")).to_have_attribute("src", receipt.file.url) + dialog.get_by_role("button", name="Sluiten").click() + expect(dialog).to_be_hidden() + assert app.url == url + + +def test_in_a_browser_tab_a_receipt_still_opens_in_a_new_tab(app): + _stack() + app.set_viewport_size({"width": 390, "height": 800}) + app.goto(app.base + "/uitgaven/verwerken/") + with app.context.expect_page() as opened: + app.get_by_role("link", name="Bonnetje openen ↗").click() + expect(app.locator("#bonnetje-bekijken")).to_be_hidden() + opened.value.close() diff --git a/tests/expenses/test_reading.py b/tests/expenses/test_reading.py index 3f99d33..bcb88e6 100644 --- a/tests/expenses/test_reading.py +++ b/tests/expenses/test_reading.py @@ -116,6 +116,19 @@ def test_what_is_sent_is_the_receipt_as_the_assistant_reads_it(service): assert content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,") +def test_the_service_is_told_that_included_amounts_are_no_lines(service): + # A webshop's invoice lists a levy or recycling fee under an article as + # "Incl. ... 2,80": already in the article's price. Read as a line of + # its own, the lines came to more than the total and the receipt stayed + # in the tray ("de bedragen tellen niet op tot het totaal"). + _receipt() + service.answer(ANSWER) + reading.read_waiting() + text = service.requests[-1]["messages"][0]["content"][0]["text"] + assert '"incl."' in text + assert "already in that article's price" in text + + def test_a_pdf_with_nothing_to_read_is_never_sent(service): from pypdf import PdfWriter