aboutsummaryrefslogtreecommitdiff
path: root/mobile/mobile_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-14 13:22:16 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-14 13:22:16 +0200
commit913974b10a993af251b25125d1a417c452ad785c (patch)
tree5dd82811c100de6daa995bebd115a12434296508 /mobile/mobile_test.go
parentd7da4b09f7775276231d0241cfe2700d247728ee (diff)
parent3b32c002d3eddda5ece9422442717657b9fee63b (diff)
downloadlectio-913974b10a993af251b25125d1a417c452ad785c.tar.gz
lectio-913974b10a993af251b25125d1a417c452ad785c.zip
Merge branch 'polish-ui-and-calendar': the gomobile facade and the EF calendar fixes
Two bodies of work that shared a branch. The gomobile facade (2026-08-03..05): mobile.PartLabels, Days, and the observed rank on DayInfo, so dlectio stops hardcoding part IDs and rank strings; the 1962 part labels become i18n data; the documented gomobile bind command is corrected so it reproduces the shipped .aar. The EF calendar fixes (2026-08-12): seven defects found by differencing this engine against colitur, a second 1962 implementation built from the Missal's General Rubrics rather than from this codebase. RG 96 transfers were not skipping II-class days; a II-class privileged feria was not yielding to a feast; Sunday ranks, the two Rose Sundays and Holy Thursday's colour were wrong; and scripts/gen-sanctoral-ef inferred ranks, deduped and tagged classes wrongly, which put 15 III-class feasts into the shipped tridentine-calendar.ini as bare commemorations and dropped four entries outright. Holy Thursday was violet in both engines, which is how a shared lineage hides a defect: this project's ini is generated from missalemeum and colitur's data was bootstrapped from here, so an error inherited by both is invisible to a differential. It took the Missal itself to see it. The EF oracle test now asserts rank and colour, not season alone. One known gap is recorded in the source rather than fixed, as out of scope: RG 95 chained transfers (calendar.go).
Diffstat (limited to 'mobile/mobile_test.go')
-rw-r--r--mobile/mobile_test.go211
1 files changed, 211 insertions, 0 deletions
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)
+ }
+}