From d45d0b4e4534253d782cc6312d7edda43cca196d Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 3 Aug 2026 23:30:12 +0200 Subject: baseline: track the gomobile engine facade as-is mobile/mobile.go and its go.mod/go.sum additions were sitting untracked in the working tree. Recorded here unchanged so the work that follows has a diff baseline; no lines of it are modified by this commit. Also ignore the cmd/dlectio-gen build product and the SDD scratch dir. --- mobile/mobile.go | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 mobile/mobile.go (limited to 'mobile') diff --git a/mobile/mobile.go b/mobile/mobile.go new file mode 100644 index 0000000..c2c6347 --- /dev/null +++ b/mobile/mobile.go @@ -0,0 +1,112 @@ +// Package mobile is lectio's engine facade for the dlectio Android app. It is +// bound to a native Android library with gomobile: +// +// gomobile bind -target=android/arm64 -tags fullbible \ +// -o dlectio-engine.aar github.com/lukaszkasprzak/lectio/mobile +// +// The app calls Day() with a plain date/form/version and receives one JSON +// document with the day's identity and rendered Mass readings. All liturgical +// computation and text resolution happens here, in the exact validated lectio +// engine — the app does none of it. Keep every exported signature to types +// gomobile can marshal (string in, string out): that is why the payload is JSON. +package mobile + +import ( + "encoding/json" + "strings" + + "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/readings" + "github.com/lukaszkasprzak/lectio/internal/render" +) + +// reading is one Mass reading, rendered in the requested corpus. +type reading struct { + Ord int `json:"ord"` + Part string `json:"part"` // machine key, e.g. "ewangelium" / "psalm" + Heading string `json:"heading"` // human label in the interface language, e.g. "Gospel" + Citation string `json:"citation"` + Text string `json:"text"` +} + +// dayDoc is the JSON returned by Day. +type dayDoc struct { + Date string `json:"date"` + Form string `json:"form"` + Lang string `json:"lang"` + Version string `json:"version"` + Name string `json:"name"` + Season string `json:"season"` + Colour string `json:"colour"` + Readings []reading `json:"readings"` + Error string `json:"error,omitempty"` +} + +// lectByForm maps the app's form code to lectio's lectionary value. +func lectByForm(form string) string { + if form == "ef" { + return "traditional" + } + return "new" +} + +// Day computes one day's identity and Mass readings and returns them as JSON. +// +// date - "YYYY-MM-DD" +// form - "of" (Ordinary) or "ef" (1962) +// version - corpus: "drb" | "wuj" | "vul" | "grb" +// lang - interface language for the celebration name: "en" | "pl" +// +// It never panics: any engine error is reported in the JSON "error" field so the +// JNI boundary always returns a well-formed string. +func Day(date, form, version, lang string) string { + lect := lectByForm(form) + out := dayDoc{Date: date, Form: form, Lang: lang, Version: version} + + // Day identity (name/season/colour) in the interface language. + cfgID := config.Config{UILanguage: lang, Lectionary: lect, All: true} + if _, info, err := readings.Load(cfgID, readings.Options{Date: date, All: true}); err == nil { + out.Name = info.Name + out.Season = info.Season + out.Colour = info.Colour + } else { + out.Error = err.Error() + } + + // Readings, resolved and rendered in the requested corpus. The interface + // language localizes the structural labels (Heading) and citation dialect; + // the corpus (version) alone decides the scripture text. + cfgR := config.Config{UILanguage: lang, Lectionary: lect, ReadingVersion: version, All: true} + secs, _, err := readings.Load(cfgR, readings.Options{Date: date, All: true}) + if err != nil { + if out.Error == "" { + out.Error = err.Error() + } + } else { + for i, sec := range secs { + // GatherVersion's lang localizes only the label/error wording, never + // the verse text (which the corpus alone decides), so the interface + // language is correct here. HeadingWithRef gives the same localized + // "label (citation)" heading lectio shows in its cli/tui/web. + _, blocks := render.GatherVersion(version, sec, lect, lang) + out.Readings = append(out.Readings, reading{ + Ord: i, + Part: sec.PartID, + Heading: render.HeadingWithRef(sec, lang), + Citation: sec.Citation, + Text: strings.Join(blocks, "\n"), + }) + } + } + + b, err := json.Marshal(out) + if err != nil { + // Should be impossible with these types; degrade gracefully. + return `{"error":"marshal failed"}` + } + return string(b) +} + +// Ping is a trivial JNI smoke test: it returns "pong" so the app can confirm the +// native engine loaded before issuing a real query. +func Ping() string { return "pong" } -- cgit v1.3 From 13d5e99ab90ab872c8cc072a0246348f0d24fa9a Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 3 Aug 2026 23:56:14 +0200 Subject: mobile: return the day's rank and its localised label --- mobile/mobile.go | 23 ++++++++++++++--------- mobile/mobile_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 mobile/mobile_test.go (limited to 'mobile') diff --git a/mobile/mobile.go b/mobile/mobile.go index c2c6347..6312ee0 100644 --- a/mobile/mobile.go +++ b/mobile/mobile.go @@ -16,6 +16,7 @@ import ( "strings" "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/i18n" "github.com/lukaszkasprzak/lectio/internal/readings" "github.com/lukaszkasprzak/lectio/internal/render" ) @@ -31,15 +32,17 @@ type reading struct { // dayDoc is the JSON returned by Day. type dayDoc struct { - Date string `json:"date"` - Form string `json:"form"` - Lang string `json:"lang"` - Version string `json:"version"` - Name string `json:"name"` - Season string `json:"season"` - Colour string `json:"colour"` - Readings []reading `json:"readings"` - Error string `json:"error,omitempty"` + Date string `json:"date"` + Form string `json:"form"` + Lang string `json:"lang"` + Version string `json:"version"` + Name string `json:"name"` + Season string `json:"season"` + Colour string `json:"colour"` + Rank string `json:"rank"` + RankLabel string `json:"rank_label"` + Readings []reading `json:"readings"` + Error string `json:"error,omitempty"` } // lectByForm maps the app's form code to lectio's lectionary value. @@ -69,6 +72,8 @@ func Day(date, form, version, lang string) string { out.Name = info.Name out.Season = info.Season out.Colour = info.Colour + out.Rank = info.Rank + out.RankLabel = i18n.Get(lang).Rank[info.Rank] } else { out.Error = err.Error() } diff --git a/mobile/mobile_test.go b/mobile/mobile_test.go new file mode 100644 index 0000000..c7df63c --- /dev/null +++ b/mobile/mobile_test.go @@ -0,0 +1,42 @@ +package mobile + +import ( + "encoding/json" + "testing" +) + +func decodeDay(t *testing.T, s string) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(s), &m); err != nil { + t.Fatalf("Day returned invalid JSON: %v\n%s", err, s) + } + return m +} + +func TestDayCarriesLocalisedRank(t *testing.T) { + pl := decodeDay(t, Day("2026-08-01", "of", "vul", "pl")) + if pl["rank"] != "memorial" { + t.Errorf("pl rank = %v, want memorial", pl["rank"]) + } + if pl["rank_label"] != "wspomnienie obowiązkowe" { + t.Errorf("pl rank_label = %v", pl["rank_label"]) + } + en := decodeDay(t, Day("2026-08-01", "of", "vul", "en")) + if en["rank_label"] != "memorial" { + t.Errorf("en rank_label = %v", en["rank_label"]) + } +} + +// The app shows the colour as a swatch and never as a word, so the raw key must +// be present and no localised label may be. This test exists so the app cannot +// quietly start depending on a colour word. +func TestDayHasColourKeyButNoColourLabel(t *testing.T) { + m := decodeDay(t, Day("2026-08-01", "of", "vul", "pl")) + if m["colour"] != "white" { + t.Errorf("colour = %v, want white", m["colour"]) + } + if _, present := m["colour_label"]; present { + t.Error("colour_label must not be returned: the app draws a swatch") + } +} -- cgit v1.3 From 27ceffcb90c0716179342109163c559d492a8abc Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 3 Aug 2026 23:59:09 +0200 Subject: mobile: add Days, a text-free week projection for the calendar A week through Days benchmarks at ~9-10ms/op, versus ~23ms for seven full days through Day. --- mobile/mobile.go | 72 ++++++++++++++++++++++++++++++++++++++++++++ mobile/mobile_test.go | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) (limited to 'mobile') diff --git a/mobile/mobile.go b/mobile/mobile.go index 6312ee0..e79e748 100644 --- a/mobile/mobile.go +++ b/mobile/mobile.go @@ -14,6 +14,7 @@ package mobile import ( "encoding/json" "strings" + "time" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/i18n" @@ -112,6 +113,77 @@ func Day(date, form, version, lang string) string { return string(b) } +// summaryPart is one section of a day, citation only -- no rendered text. +type summaryPart struct { + Part string `json:"part"` + Citation string `json:"citation"` +} + +// daySummary is one day's identity and its reading citations. It is Day's +// cheaper projection: the calendar view needs the day's shape, not its text. +type daySummary struct { + Date string `json:"date"` + Name string `json:"name"` + Rank string `json:"rank"` + RankLabel string `json:"rank_label"` + Colour string `json:"colour"` + Parts []summaryPart `json:"parts"` + Error string `json:"error,omitempty"` +} + +// Days returns count consecutive days from start as a JSON array, each with its +// identity and reading citations but no reading text. It is the calendar view's +// source. +// +// start - "YYYY-MM-DD", taken literally: aligning to a week is the caller's job +// count - 1..366; anything else yields "[]" +// form - "of" or "ef" +// lang - interface language for the name, rank word and citation dialect +// +// No version parameter: the corpus decides only scripture text, which this never +// renders, while the citation's sigla dialect follows lang. +// +// A day that fails to compute carries its own "error" and does not abort the +// rest, so one bad date cannot blank a whole week. Malformed input yields "[]" +// rather than an error string, so the caller always parses an array. +func Days(start string, count int, form, lang string) string { + if count < 1 || count > 366 { + return "[]" + } + d, err := time.Parse("2006-01-02", start) + if err != nil { + return "[]" + } + lect := lectByForm(form) + ui := i18n.Get(lang) + cfg := config.Config{UILanguage: lang, Lectionary: lect, All: true} + + out := make([]daySummary, 0, count) + for i := 0; i < count; i++ { + date := d.AddDate(0, 0, i).Format("2006-01-02") + row := daySummary{Date: date, Parts: []summaryPart{}} + secs, info, err := readings.Load(cfg, readings.Options{Date: date, All: true}) + if err != nil { + row.Error = err.Error() + } else { + row.Name = info.Name + row.Colour = info.Colour + row.Rank = info.Rank + row.RankLabel = ui.Rank[info.Rank] + for _, sec := range secs { + row.Parts = append(row.Parts, summaryPart{Part: sec.PartID, Citation: sec.Citation}) + } + } + out = append(out, row) + } + + b, err := json.Marshal(out) + if err != nil { + return "[]" + } + return string(b) +} + // Ping is a trivial JNI smoke test: it returns "pong" so the app can confirm the // native engine loaded before issuing a real query. func Ping() string { return "pong" } diff --git a/mobile/mobile_test.go b/mobile/mobile_test.go index c7df63c..57e835a 100644 --- a/mobile/mobile_test.go +++ b/mobile/mobile_test.go @@ -2,6 +2,7 @@ package mobile import ( "encoding/json" + "strings" "testing" ) @@ -40,3 +41,85 @@ func TestDayHasColourKeyButNoColourLabel(t *testing.T) { t.Error("colour_label must not be returned: the app draws a swatch") } } + +func decodeDays(t *testing.T, s string) []map[string]any { + t.Helper() + var a []map[string]any + if err := json.Unmarshal([]byte(s), &a); err != nil { + t.Fatalf("Days returned invalid JSON: %v\n%s", err, s) + } + return a +} + +func TestDaysReturnsConsecutiveDates(t *testing.T) { + a := decodeDays(t, Days("2026-07-27", 7, "of", "pl")) + if len(a) != 7 { + t.Fatalf("got %d elements, want 7", len(a)) + } + want := []string{"2026-07-27", "2026-07-28", "2026-07-29", "2026-07-30", + "2026-07-31", "2026-08-01", "2026-08-02"} + for i, w := range want { + if a[i]["date"] != w { + t.Errorf("element %d date = %v, want %v", i, a[i]["date"], w) + } + } +} + +// Days must agree with Day about the same date -- it is a cheaper projection of +// the same computation, not a second implementation. +func TestDaysAgreesWithDay(t *testing.T) { + one := decodeDay(t, Day("2026-08-01", "of", "vul", "pl")) + week := decodeDays(t, Days("2026-08-01", 1, "of", "pl")) + if len(week) != 1 { + t.Fatalf("got %d elements, want 1", len(week)) + } + row := week[0] + for _, k := range []string{"name", "colour", "rank", "rank_label"} { + if row[k] != one[k] { + t.Errorf("%s: Days = %v, Day = %v", k, row[k], one[k]) + } + } + parts := row["parts"].([]any) + readings := one["readings"].([]any) + if len(parts) != len(readings) { + t.Fatalf("parts = %d, readings = %d", len(parts), len(readings)) + } + for i := range parts { + p := parts[i].(map[string]any) + r := readings[i].(map[string]any) + if p["part"] != r["part"] || p["citation"] != r["citation"] { + t.Errorf("element %d: Days %v/%v vs Day %v/%v", + i, p["part"], p["citation"], r["part"], r["citation"]) + } + } +} + +// No reading text may cross the boundary: that is the whole point of Days. +func TestDaysCarriesNoReadingText(t *testing.T) { + s := Days("2026-08-01", 7, "of", "pl") + if strings.Contains(s, `"text"`) { + t.Error("Days must not return reading text") + } + if strings.Contains(s, "colour_label") { + t.Error("Days must not return a colour label") + } +} + +func TestDaysRejectsBadInput(t *testing.T) { + for _, c := range []struct { + start string + count int + }{ + {"not-a-date", 7}, {"2026-08-01", 0}, {"2026-08-01", -1}, {"2026-08-01", 400}, + } { + if got := Days(c.start, c.count, "of", "pl"); got != "[]" { + t.Errorf("Days(%q, %d) = %s, want []", c.start, c.count, got) + } + } +} + +func BenchmarkDaysWeek(b *testing.B) { + for i := 0; i < b.N; i++ { + Days("2026-07-27", 7, "of", "pl") + } +} -- cgit v1.3 From 1106568c52265ea3076b9d3f1943c052c83a2a89 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 3 Aug 2026 23:59:50 +0200 Subject: mobile: add PartLabels so the app stops hardcoding part IDs --- mobile/mobile.go | 31 +++++++++++++++++++++++++++++++ mobile/mobile_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) (limited to 'mobile') diff --git a/mobile/mobile.go b/mobile/mobile.go index e79e748..d863463 100644 --- a/mobile/mobile.go +++ b/mobile/mobile.go @@ -184,6 +184,37 @@ func Days(start string, count int, form, lang string) string { return string(b) } +// partLabel is one section's machine ID and its human label. +type partLabel struct { + Part string `json:"part"` + Label string `json:"label"` +} + +// PartLabels returns the form's section IDs and their labels in lang, in display +// order, as a JSON array. The app builds its reading filters from this instead +// of hardcoding part IDs -- which is how it came to ship seven 1962 checkboxes +// the engine never emits. +// +// An array, not an object: JSON object key order is not guaranteed and the app +// renders these in order. +func PartLabels(form, lang string) string { + ids := readings.PartIDs(lectByForm(form)) + ui := i18n.Get(lang) + out := make([]partLabel, 0, len(ids)) + for _, id := range ids { + label := ui.PartLabel[id] + if label == "" { + label = id + } + out = append(out, partLabel{Part: id, Label: label}) + } + b, err := json.Marshal(out) + if err != nil { + return "[]" + } + return string(b) +} + // Ping is a trivial JNI smoke test: it returns "pong" so the app can confirm the // native engine loaded before issuing a real query. func Ping() string { return "pong" } diff --git a/mobile/mobile_test.go b/mobile/mobile_test.go index 57e835a..1a115f3 100644 --- a/mobile/mobile_test.go +++ b/mobile/mobile_test.go @@ -123,3 +123,48 @@ func BenchmarkDaysWeek(b *testing.B) { Days("2026-07-27", 7, "of", "pl") } } + +// This is the assertion that would have caught the app's dead 1962 checkboxes: +// it listed nine part IDs where the engine emits two. +func TestPartLabelsMatchesWhatTheEngineEmits(t *testing.T) { + var ef []map[string]any + if err := json.Unmarshal([]byte(PartLabels("ef", "pl")), &ef); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(ef) != 2 { + t.Fatalf("ef: got %d labels, want 2: %v", len(ef), ef) + } + if ef[0]["part"] != "epistola" || ef[0]["label"] != "Lekcja" { + t.Errorf("ef[0] = %v, want epistola/Lekcja", ef[0]) + } + if ef[1]["part"] != "evangelium" || ef[1]["label"] != "Ewangelia" { + t.Errorf("ef[1] = %v, want evangelium/Ewangelia", ef[1]) + } + + var of []map[string]any + if err := json.Unmarshal([]byte(PartLabels("of", "pl")), &of); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(of) != 5 { + t.Fatalf("of: got %d labels, want 5: %v", len(of), of) + } + // aklamacja was missing from the app's hardcoded list. + var seen []string + for _, e := range of { + seen = append(seen, e["part"].(string)) + } + want := []string{"pierwsze_czytanie", "psalm", "drugie_czytanie", "aklamacja", "ewangelia"} + for i := range want { + if seen[i] != want[i] { + t.Errorf("of order = %v, want %v", seen, want) + break + } + } +} + +func TestPartLabelsUnknownForm(t *testing.T) { + // An unknown form is treated as the modern one, matching lectByForm. + if got := PartLabels("nonsense", "en"); got == "[]" { + t.Error("unknown form should fall back to the modern lectionary, not empty") + } +} -- cgit v1.3 From 197ce6980daa1c3773eed1548f54861d616d35e4 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 4 Aug 2026 00:11:23 +0200 Subject: mobile: strengthen the PartLabels fallback test and count bounds --- mobile/mobile.go | 4 +++- mobile/mobile_test.go | 20 +++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) (limited to 'mobile') diff --git a/mobile/mobile.go b/mobile/mobile.go index d863463..d91831c 100644 --- a/mobile/mobile.go +++ b/mobile/mobile.go @@ -8,7 +8,9 @@ // document with the day's identity and rendered Mass readings. All liturgical // computation and text resolution happens here, in the exact validated lectio // engine — the app does none of it. Keep every exported signature to types -// gomobile can marshal (string in, string out): that is why the payload is JSON. +// gomobile can marshal -- strings and ints only -- with JSON as the payload +// format for everything structured. A Go int parameter (e.g. Days' count) +// surfaces on the Kotlin side as a long, not an Int. package mobile import ( diff --git a/mobile/mobile_test.go b/mobile/mobile_test.go index 1a115f3..cf75b7f 100644 --- a/mobile/mobile_test.go +++ b/mobile/mobile_test.go @@ -111,6 +111,7 @@ func TestDaysRejectsBadInput(t *testing.T) { count int }{ {"not-a-date", 7}, {"2026-08-01", 0}, {"2026-08-01", -1}, {"2026-08-01", 400}, + {"2026-08-01", 367}, // one past the documented upper bound } { if got := Days(c.start, c.count, "of", "pl"); got != "[]" { t.Errorf("Days(%q, %d) = %s, want []", c.start, c.count, got) @@ -118,6 +119,16 @@ func TestDaysRejectsBadInput(t *testing.T) { } } +// count's documented range is 1..366; 367 (above) is the first invalid value +// and 366 (here) is the last valid one -- the boundary an off-by-one would +// actually live on. +func TestDaysAcceptsUpperBoundCount(t *testing.T) { + a := decodeDays(t, Days("2026-01-01", 366, "of", "en")) + if len(a) != 366 { + t.Errorf("got %d elements, want 366", len(a)) + } +} + func BenchmarkDaysWeek(b *testing.B) { for i := 0; i < b.N; i++ { Days("2026-07-27", 7, "of", "pl") @@ -163,8 +174,11 @@ func TestPartLabelsMatchesWhatTheEngineEmits(t *testing.T) { } func TestPartLabelsUnknownForm(t *testing.T) { - // An unknown form is treated as the modern one, matching lectByForm. - if got := PartLabels("nonsense", "en"); got == "[]" { - t.Error("unknown form should fall back to the modern lectionary, not empty") + // An unknown form is treated as the modern one, matching lectByForm: the + // fallback must be byte-identical to "of", not merely non-empty. + got := PartLabels("nonsense", "en") + want := PartLabels("of", "en") + if got != want { + t.Errorf("PartLabels(\"nonsense\", \"en\") = %s, want %s (same as \"of\")", got, want) } } -- cgit v1.3 From eb4a02205f5b3b972c44b854d5becbbd0b522981 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 5 Aug 2026 14:10:34 +0200 Subject: readings: PartIDs(new) stops declaring aklamacja, which the engine never emits caldata.go:42 parses only first/psalm/second/gospel out of the lectionary data, so no OF reading ever carries Part == "acclamation". PartIDs("new") listed aklamacja anyway, so the app built a checkbox from it that filters an ID that never appears -- a dead control, same defect class the app previously shipped for the whole 1962 form. ofPartOrder stays the full five-ID set: it also drives render.LocalizeHeading's label matching, where a scraped heading can still read "Aklamacja" even though this engine's own readings never produce that section. PartIDs now draws from a new, narrower ofEmittedPartOrder instead. Rewrote TestPartLabelsMatchesWhatTheEngineEmits's OF half: it compared PartLabels("of") to a hand-copied duplicate of ofPartOrder, asserting a declaration against itself, which cannot fail for this class of bug. It now sweeps Days over calendar year 2026 and asserts PartLabels("of") matches the observed part-ID set exactly (0.5s). Confirmed red against the pre-fix code, green after. --- internal/readings/offline.go | 14 ++++++++++- internal/readings/partids_test.go | 2 +- mobile/mobile_test.go | 49 ++++++++++++++++++++++++++++++--------- 3 files changed, 52 insertions(+), 13 deletions(-) (limited to 'mobile') diff --git a/internal/readings/offline.go b/internal/readings/offline.go index 608abd8..be7c430 100644 --- a/internal/readings/offline.go +++ b/internal/readings/offline.go @@ -98,6 +98,18 @@ var ( efPartOrder = []string{"epistola", "evangelium"} ) +// ofEmittedPartOrder is the subset of ofPartOrder the offline engine can +// actually produce, in display order. It excludes "aklamacja": +// internal/caldata/caldata.go:42 parses only "first", "psalm", "second" and +// "gospel" out of the lectionary data, so no computed OF reading ever carries +// Part == "acclamation" and ofPart's "aklamacja" mapping above is never +// reached. ofPartOrder stays the full five-ID set on purpose -- it also +// drives render.LocalizeHeading's *label* matching, where a scraped heading +// can still read "Aklamacja" even though this engine's own readings never +// produce that section -- so PartIDs, which promises IDs an app can filter +// on, needs this narrower list rather than reusing or shrinking ofPartOrder. +var ofEmittedPartOrder = []string{"pierwsze_czytanie", "psalm", "drugie_czytanie", "ewangelia"} + // PartIDs returns the part IDs the given lectionary can emit, in display order. // lect takes config.Config.Lectionary's values: "new" or "traditional". An // unknown lectionary returns nil. Callers that build per-part UI (the dlectio @@ -106,7 +118,7 @@ var ( func PartIDs(lect string) []string { switch lect { case "new": - return append([]string(nil), ofPartOrder...) + return append([]string(nil), ofEmittedPartOrder...) case "traditional": return append([]string(nil), efPartOrder...) } diff --git a/internal/readings/partids_test.go b/internal/readings/partids_test.go index 1ba9373..fdbd816 100644 --- a/internal/readings/partids_test.go +++ b/internal/readings/partids_test.go @@ -35,7 +35,7 @@ func TestDayInfoCarriesRank(t *testing.T) { // them instead, seven of the nine 1962 IDs were wrong and the epistle's // checkbox did nothing. func TestPartIDs(t *testing.T) { - wantNew := []string{"pierwsze_czytanie", "psalm", "drugie_czytanie", "aklamacja", "ewangelia"} + wantNew := []string{"pierwsze_czytanie", "psalm", "drugie_czytanie", "ewangelia"} wantOld := []string{"epistola", "evangelium"} if got := PartIDs("new"); !equalSlice(got, wantNew) { t.Errorf("PartIDs(new) = %v, want %v", got, wantNew) diff --git a/mobile/mobile_test.go b/mobile/mobile_test.go index cf75b7f..85de500 100644 --- a/mobile/mobile_test.go +++ b/mobile/mobile_test.go @@ -152,23 +152,50 @@ func TestPartLabelsMatchesWhatTheEngineEmits(t *testing.T) { t.Errorf("ef[1] = %v, want evangelium/Ewangelia", ef[1]) } + // OF: derive the expected set from what Days actually emits over a full + // year (2026 -- a full Sunday cycle, so second readings appear too), + // rather than hand-copying the production list, which asserts a + // declaration against itself and can never fail for this class of bug + // (that is exactly how the app came to render a checkbox -- aklamacja -- + // that filters an ID the engine never emits). + observed := map[string]bool{} + s := Days("2026-01-01", 365, "of", "pl") + var days []map[string]any + if err := json.Unmarshal([]byte(s), &days); err != nil { + t.Fatalf("Days returned invalid JSON: %v", err) + } + for _, d := range days { + parts, _ := d["parts"].([]any) + for _, p := range parts { + part, _ := p.(map[string]any) + if id, ok := part["part"].(string); ok { + observed[id] = true + } + } + } + if len(observed) == 0 { + t.Fatal("swept zero part IDs from Days over 2026 -- the sweep is broken, not necessarily the engine") + } + var of []map[string]any if err := json.Unmarshal([]byte(PartLabels("of", "pl")), &of); err != nil { t.Fatalf("invalid JSON: %v", err) } - if len(of) != 5 { - t.Fatalf("of: got %d labels, want 5: %v", len(of), of) - } - // aklamacja was missing from the app's hardcoded list. - var seen []string + got := map[string]bool{} for _, e := range of { - seen = append(seen, e["part"].(string)) + got[e["part"].(string)] = true + } + if len(got) != len(of) { + t.Fatalf("PartLabels(of) lists a part ID more than once: %v", of) + } + for id := range observed { + if !got[id] { + t.Errorf("Days emits part %q somewhere in 2026 but PartLabels(of) does not list it: %v", id, of) + } } - want := []string{"pierwsze_czytanie", "psalm", "drugie_czytanie", "aklamacja", "ewangelia"} - for i := range want { - if seen[i] != want[i] { - t.Errorf("of order = %v, want %v", seen, want) - break + for id := range got { + if !observed[id] { + t.Errorf("PartLabels(of) lists part %q but Days never emits it anywhere in 2026: %v", id, of) } } } -- cgit v1.3 From 8929ab96ad44b707bafd7dfe6f1f7773843c8e85 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 5 Aug 2026 14:12:13 +0200 Subject: mobile: fix the documented gomobile bind command so it reproduces the shipped .aar The comment omitted -javapkg, the second -target ABI and -androidapi. A clean clone following it got a .aar whose classes sit under go.mobile.gojni instead of xyz.labunix.dlectio.engine.mobile, carries only arm64-v8a, and declares no minSdkVersion match to the app's 26 -- the app cannot import the result. Verified by rebuilding the binding with the corrected command and confirming the artifact's ABIs, package and minSdkVersion against the shipped one. --- mobile/mobile.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) (limited to 'mobile') diff --git a/mobile/mobile.go b/mobile/mobile.go index d91831c..7e4c109 100644 --- a/mobile/mobile.go +++ b/mobile/mobile.go @@ -1,9 +1,16 @@ // Package mobile is lectio's engine facade for the dlectio Android app. It is // bound to a native Android library with gomobile: // -// gomobile bind -target=android/arm64 -tags fullbible \ +// gomobile bind -target=android/arm64,android/amd64 -androidapi 26 \ +// -javapkg=xyz.labunix.dlectio.engine -tags fullbible \ // -o dlectio-engine.aar github.com/lukaszkasprzak/lectio/mobile // +// -javapkg gives the bound Mobile class its xyz.labunix.dlectio.engine.mobile +// package (the app imports it from there); the two -target ABIs match the +// app's arm64-v8a and x86_64 device/emulator targets; -androidapi 26 matches +// the app's minSdk. Omitting any of the three still produces an .aar, just +// one the app cannot import or that lacks an ABI it needs. +// // The app calls Day() with a plain date/form/version and receives one JSON // document with the day's identity and rendered Mass readings. All liturgical // computation and text resolution happens here, in the exact validated lectio -- cgit v1.3