Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions BUGS-BY-HAND.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
35 changes: 30 additions & 5 deletions huismus/core/static/core/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 (<a data-view-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();
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions huismus/core/static/core/huismus.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
3 changes: 3 additions & 0 deletions huismus/expenses/reading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions huismus/templates/expenses/_receipt_dialog.html
Original file line number Diff line number Diff line change
@@ -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 %}
<dialog id="bonnetje-bekijken" class="dialog is-pdf" aria-labelledby="bonnetje-bekijken-titel">
<div class="dialog-body">
<h2 id="bonnetje-bekijken-titel" class="r-name"></h2>
<template data-pdf><iframe class="pdf-frame"></iframe></template>
<template data-photo><img class="dialog-photo" alt=""></template>
</div>
<div class="dialog-foot"><button type="button" class="btn btn-secondary" command="close" commandfor="bonnetje-bekijken" autofocus>{% translate "Sluiten" %}</button></div>
</dialog>
4 changes: 3 additions & 1 deletion huismus/templates/expenses/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ <h2>{% translate "Bonnetjes" %}</h2>
<div class="receipts">
{% for attachment in expense.attachments.all %}
<div class="receipt">
<a class="thumb" href="{{ attachment.file.url }}" hx-boost="false" target="_blank" rel="noopener">
{% blocktranslate with name=attachment.filename asvar receipt_label %}Bonnetje {{ name }}{% endblocktranslate %}
<a class="thumb" href="{{ attachment.file.url }}" hx-boost="false" target="_blank" rel="noopener" data-view-receipt="bonnetje-bekijken" data-name="{{ attachment.filename }}" data-label="{{ receipt_label }}"{% if attachment.is_pdf %} data-pdf{% endif %}>
{% if attachment.is_pdf %}<svg class="i i-lg" aria-hidden="true"><use href="#file"></use></svg>
{% else %}<img src="{{ attachment.file.url }}" alt="" style="width:100%;height:100%;object-fit:cover">{% endif %}
</a>
Expand Down Expand Up @@ -129,4 +130,5 @@ <h2 id="verwijder-uitgave-titel">{% translate "Deze uitgave verwijderen?" %}</h2
</form>
</dialog>
{% endif %}
{% include "expenses/_receipt_dialog.html" %}
{% endblock %}
3 changes: 2 additions & 1 deletion huismus/templates/expenses/process.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ <h2>{% translate "Niets meer te verwerken." %}</h2>
{% else %}
<img class="process-img" src="{{ receipt.file.url }}" alt="{{ receipt_label }}">
{% endif %}
<a class="small" id="bonnetje-openen" href="{{ receipt.file.url }}" hx-boost="false" target="_blank" rel="noopener">{% translate "Bonnetje openen" %} ↗</a>
<a class="small" id="bonnetje-openen" href="{{ receipt.file.url }}" hx-boost="false" target="_blank" rel="noopener" data-view-receipt="bonnetje-bekijken" data-name="{{ receipt.filename }}" data-label="{{ receipt_label }}"{% if receipt.is_pdf %} data-pdf{% endif %}>{% translate "Bonnetje openen" %} ↗</a>
{% else %}
<p class="muted" id="geen-bonnetje">{% translate "Geen bonnetje bij deze uitgave." %}</p>
<form method="post" action="{% url 'expenses:receipt_upload' expense.pk %}" enctype="multipart/form-data" class="dropzone" data-dropzone>
Expand Down Expand Up @@ -88,5 +88,6 @@ <h2>{% translate "Niets meer te verwerken." %}</h2>
</form>
</div>
</div>
{% include "expenses/_receipt_dialog.html" %}
{% endif %}
{% endblock %}
4 changes: 3 additions & 1 deletion huismus/templates/expenses/tray.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ <h1>{% translate "Bonnetjes zonder uitgave" %}</h1>
<div class="receipts" style="grid-template-columns:repeat(auto-fill,minmax(120px,1fr))">
{% for item in receipts %}{% with receipt=item.receipt %}
<div class="receipt" id="bonnetje-{{ receipt.pk }}">
<a class="thumb" href="{{ receipt.file.url }}" hx-boost="false" target="_blank" rel="noopener" aria-label="{% blocktranslate with name=receipt.filename %}{{ name }} openen{% endblocktranslate %}">
{% blocktranslate with name=receipt.filename asvar receipt_label %}Bonnetje {{ name }}{% endblocktranslate %}
<a class="thumb" href="{{ receipt.file.url }}" hx-boost="false" target="_blank" rel="noopener" data-view-receipt="bonnetje-bekijken" data-name="{{ receipt.filename }}" data-label="{{ receipt_label }}"{% if receipt.is_pdf %} data-pdf{% endif %} aria-label="{% blocktranslate with name=receipt.filename %}{{ name }} openen{% endblocktranslate %}">
{% if receipt.is_pdf %}<svg class="i i-lg" aria-hidden="true"><use href="#file"></use></svg>
{% else %}<img src="{{ receipt.file.url }}" alt="" style="width:100%;height:100%;object-fit:cover">{% endif %}
</a>
Expand Down Expand Up @@ -81,4 +82,5 @@ <h2>{% translate "Nog geen bonnetjes" %}</h2>
</div>
{% endif %}
</div>
{% include "expenses/_receipt_dialog.html" %}
{% endblock %}
28 changes: 28 additions & 0 deletions tests/core/test_pwa.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,31 @@ def test_pages_link_the_manifest_and_the_home_screen_icon(client):
assert f'<link rel="manifest" href="{reverse("pwa_manifest")}">' 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
87 changes: 87 additions & 0 deletions tests/e2e/test_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
13 changes: 13 additions & 0 deletions tests/expenses/test_reading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading