diff options
Diffstat (limited to 'mobile')
| -rw-r--r-- | mobile/mobile.go | 229 | ||||
| -rw-r--r-- | mobile/mobile_test.go | 211 |
2 files changed, 440 insertions, 0 deletions
diff --git a/mobile/mobile.go b/mobile/mobile.go new file mode 100644 index 0000000..7e4c109 --- /dev/null +++ b/mobile/mobile.go @@ -0,0 +1,229 @@ +// 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,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 +// engine — the app does none of it. Keep every exported signature to types +// 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 ( + "encoding/json" + "strings" + "time" + + "github.com/lukaszkasprzak/lectio/internal/config" + "github.com/lukaszkasprzak/lectio/internal/i18n" + "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"` + 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. +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 + out.Rank = info.Rank + out.RankLabel = i18n.Get(lang).Rank[info.Rank] + } 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) +} + +// 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) +} + +// 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 new file mode 100644 index 0000000..85de500 --- /dev/null +++ b/mobile/mobile_test.go @@ -0,0 +1,211 @@ +package mobile + +import ( + "encoding/json" + "strings" + "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") + } +} + +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}, + {"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) + } + } +} + +// 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") + } +} + +// 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]) + } + + // 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) + } + got := map[string]bool{} + for _, e := range of { + 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) + } + } + 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) + } + } +} + +func TestPartLabelsUnknownForm(t *testing.T) { + // 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) + } +} |
