Zum Inhalt springen
Familienkonto

Development and testing

Running from source

sh
docker compose up -d db                 # database only
jennifer serve web/app.j                # the web interface
bin/fk doctor                           # the CLI

config.load(".env") reads .env for anything the environment does not set.

Tests

Co-located MODULE_test.j overlays, run by jennifer test. The overlay has white-box access: it sees the module's private functions without importing it, so a helper can be tested directly.

sh
export APP_TEST_DB_DSN='expenses:expenses@tcp(127.0.0.1:3307)/expenses_test?charset=utf8mb4'
jennifer test src/core/ledger_test.j
for t in src/*/*_test.j tests/*_test.j; do jennifer test "$t"; done

APP_TEST_DB_DSN must point at a separate database: testdb.fresh() wipes every table between tests. The database-backed tests run against real MariaDB - the schema, the foreign keys and the ordering are what production uses, and a test that passes against a stub but fails against the server is worth nothing.

tests/ holds the black-box tests, which drive a real server over HTTP rather than calling into a module. web/app.j calls web.run at the top level, so it cannot be imported by a test - importing it would start the server and never return - and a web/app_test.j overlay is therefore impossible. Those tests spawn jennifer serve web/app.j as a subprocess instead, so they need jennifer on PATH and a free port (18099, or APP_TEST_HTTP_PORT).

The pure modules need no database at all: money, dates, currency, ledger, coverage, roles, bankcsv and config are testable on a machine with nothing running.

House style

sh
jennifer fmt -w $(ls src/*/*.j web/*.j tests/*.j | grep -v demodata) bin/fk
jennifer lint src/*/*.j web/*.j tests/*.j bin/fk

Both must be clean - with one exclusion, src/app/demodata.j. Its demonstration rows are one line each, a table of what the two families bought, and that reads better than the same rows unfolded into a stack of one-argument-per-line calls. The line-length check is switched off in that file's header (# lint-disable-file: L203); fmt has no equivalent switch and its hundred columns are not configurable, so it is kept off the file instead. fmt reflows aggressively, which is worth knowing before scripting an edit against a source file.

Every exported function carries a docblock with @param, @return and @throws. Comments explain why, not what - the code already says what.

Jennifer traps this codebase hit

Worth knowing before writing more:

  • Value semantics. Passing a list to a helper copies it; the helper cannot append to the caller's. Return the result instead. This produced two silent bugs here, both caught by tests.
  • 1..0 is an error, not an empty range. Guard a loop over a possibly empty list.
  • bytes has no literal. def b as bytes; then $b[] = 65;, or convert.bytesFromString.
  • Cooked strings interpolate {...}. Regex quantifiers like {1,3} must live in a raw '...' string.
  • Identifiers are ≤ 64 characters, letters and digits only. Long test names hit this.
  • list is a type keyword and cannot name a function.
  • The HTTP request body can be read once. See Authentication.

Adding a feature

  1. Put the rule in a pure module if it is arithmetic or a decision, and test it without a database.
  2. Put persistence in a repository, scoped by household_id in the lookup rather than checked afterwards - an id from another family should simply not resolve.
  3. Add the capability to roles.j as an explicit set, never derived from an ordering.
  4. Wire it into web/ and bin/fk. If the two could disagree, the rule is in the wrong layer.
  5. Say so in docs/. A capability that only the code knows about is a capability nobody uses.

Messages

The code raises its errors in English; a family reads them in German. src/messages.j is what sits between: the English source string is the key, the German sentence is the translation, and intl.tr falls back to the key, so a message nobody has translated yet shows up in English rather than vanishing.

jennifer
fail("the payment amount must be greater than zero");
failp("no such expense: %id%", {"id": convert.toString($id)});

Every module has fail and, where a message carries values, failp. Both go through the catalogue, so a call site never spells out German and never concatenates a value into a sentence - the %name% slots are filled after the translation is chosen, which is the only order that works when the German word order differs from the English.

messages.install() runs from both entry points before anything can throw; tr and trp also install on first use, so a unit test that calls a module directly still gets German.

src/messages_test.j reads src/*.j, collects the literal behind every fail / failp, and fails if the catalogue has no entry for it. That is what keeps a message added next year from reaching a family in English. It also checks that every %slot% in an English key survives into the German, because a dropped slot loses the one value the reader needed.

What stays English: whatever MariaDB and the drivers say for themselves. Those messages come from the server, land in the log, and are documented in English here.

Schema changes

Add a migration to src/migrations.j with a working down. Never edit an applied migration - the initial schema was edited freely during development only because nothing was deployed.

Documentation

docs/ is the book. It is plain Markdown, so it reads on GitHub as it is, and Grimoire renders it into a site plus a single PDF.

sh
scripts/books.sh                        # site + both PDFs into book-output/
grimoire build                          # the site alone
grimoire serve --watch                  # http://127.0.0.1:8080, reloads on save

Two PDFs, one site. The site carries every page in one navigation, because a reader in a browser can go where they like. A PDF is printed, mailed to a tax adviser or read on a train, and there a parent has no use for the data model and an administrator none for "wie du eine Ausgabe erfasst":

FileContains
familienkonto-handbuch.pdfthe German pages: welcome, children, parents, tax adviser, administrators
familienkonto-technical.pdfthe English pages: this section

grimoire.toml therefore sets [pdf] enabled = false and the two books have configurations of their own (grimoire-user.toml, grimoire-technical.toml). Grimoire takes its outline from SUMMARY.md in the source directory and a directory can hold only one of those, so scripts/books.sh stages each book - a copy of its pages plus a SUMMARY.md of its own under var/book-* - and runs grimoire pdf against it. Both are rendered from the same Markdown as the site, so no two of the three can describe a different application. Three pages link to it - introduction.md, user-kinder.md and user-eltern.md - with a plain relative link, because those three sit at the top of src and the PDF lands at the root of the output directory. A page one level down, such as anything under technical/, would need ../.

The handbook the application serves

The built site is not only for the web: the image copies book-output/ to /app/book-output/, the application serves it at /docs, and every page links to it under Handbuch. An installation therefore carries the manual for the version it is actually running, instead of pointing at a website that may have moved on.

Two consequences:

  • scripts/books.sh runs before docker build. The COPY fails otherwise, which is the intended order of complaint: at build time, not in a running container whose Handbuch link is dead.
  • APP_DOCS_DIR names the directory (book-output by default, and /app/book-output in compose). Set it empty and the link answers with a page saying no handbook is installed - which is also what a checkout that has never built the book gets, rather than a bare 404.

The handbook needs no sign-in. Somebody stuck at the login page is exactly the person reaching for the manual, and it says nothing that is not already public. Requests are mapped onto the directory by hand rather than with web.serveDir, because that verb maps the whole request path and would look for book-output/docs/...; tests/handbook_test.j covers the ../ cases that mapping has to refuse.

Two things to know before editing:

  • A watch rebuild renders the PDF too, not just the pages. build.run reads the same [pdf] enabled on every pass, and the PDF is laid out alongside the site rather than after it, so a save costs about as long as the PDF takes and the download stays in step with the page you just changed. It lands a moment after the rebuilt N pages line.
  • The PDF is made from these same pages, so the three "read this as a PDF" callouts appear inside the PDF too, pointing at the file the reader already has open. [pdf] exclude drops whole chapters, not paragraphs, so this stays as it is.

book-output/ is generated and ignored by git. Nothing in it is edited by hand.

Screenshots

The pictures in the German guides come out of a running development server with demo data, so they show the app rather than a mock-up, and the numbers in the prose are the numbers on the screen.

sh
bash scripts/demo-seed.sh                       # two families, into expenses_demo
APP_DB_DSN='...expenses_demo...' APP_LISTEN=127.0.0.1:8099 APP_AUTH_MODE=dev \
    jennifer serve web/app.j &
python3 scripts/screenshots.py                  # writes docs/images/*.png
pngquant --quality=65-88 --force --ext .png docs/images/*.png

The button labelled Demodaten wiederherstellen on the development login page does the same thing through demodata.install, which is why the numbers in the prose keep matching the screenshots.

demo-seed.sh drops its database first, which is why it has one of its own and why the DSN is not the one in .env. The seed is deliberately not random: Familie Maier sits above the Familienbeihilfe threshold and Familie Gruber below it, so both states of the coverage bar can be shown. The third outcome - a child whose own income covers every recorded expense, which fails the rule with an empty bar - is not in the seed; src/core/coverage_test.j and tests/coveragepage_test.j are what pin that one down.

screenshots.py fetches each page with a session cookie and renders the saved HTML in headless Chromium. That works because the stylesheet is inline - there is nothing for the browser to fetch - so a file on disk renders exactly as the served page does. Phone screenshots are scaled down afterwards: the site shows an image at its own pixel width, and a 2x phone capture left alone fills the column and reads as a tablet.

The PDF carries them as of Grimoire 0.3.0, which draws images into the layout. Alt text still earns its keep - it is what a screen reader announces, and what Grimoire falls back to in brackets when it cannot draw a picture - so keep writing it as a sentence that stands on its own.

[pdf] imageDpi decides how big they print, and it reads backwards: it is the resolution the pixels are read at, so a higher number gives a smaller picture. At the default 96 every screenshot came out wider than the 17,7 cm text column, was scaled down to it, and filled most of a page on its own - the tall ones ran to 22 cm. At 192 a desktop screenshot is 13,0 cm across and a phone screenshot 6,3 cm, which is about life size for a phone; two figures and their prose now share a page, and the handbook came back from 35 pages to 30.

The column is a ceiling: a picture wider than it is scaled down whatever the dpi says, which is why lowering the number does almost nothing here. Note also that imageDpi changes only the printed size - the full-resolution PNG is embedded either way, so it is not the knob for a smaller file. That would mean capturing at a lower scale factor in scripts/screenshots.py.