summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 09:15:40 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 09:15:40 +0200
commitb93de95dd24ff2a1d3126e6d7d00cd77530a03bf (patch)
tree483a0901a20274152ba3f03f272f8098be4f49c0 /internal
parentc1b954ad517cef00bf480d5229b253a8c6524be7 (diff)
downloadlectio-b93de95dd24ff2a1d3126e6d7d00cd77530a03bf.tar.gz
lectio-b93de95dd24ff2a1d3126e6d7d00cd77530a03bf.zip
tradlit: offline caching + read; update pre-caches traditional; --clean prunes it; v0.2.0
Diffstat (limited to 'internal')
-rw-r--r--internal/cli/cli.go51
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/liturgy/clean_test.go31
-rw-r--r--internal/liturgy/fetch.go32
-rw-r--r--internal/liturgy/fetch_test.go2
-rw-r--r--internal/liturgy/store.go2
-rw-r--r--internal/readings/readings.go9
-rw-r--r--internal/readings/readings_test.go42
-rw-r--r--internal/tradlit/tradlit.go70
-rw-r--r--internal/tradlit/tradlit_test.go121
10 files changed, 314 insertions, 48 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index c514607..130683c 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -18,6 +18,7 @@ import (
"github.com/lukaszkasprzak/lectio/internal/liturgy"
"github.com/lukaszkasprzak/lectio/internal/readings"
"github.com/lukaszkasprzak/lectio/internal/render"
+ "github.com/lukaszkasprzak/lectio/internal/tradlit"
)
const helpText = `lectio — daily Catholic liturgy readings (Polish + 4 versions)
@@ -241,18 +242,62 @@ func normalizeLectionary(lectionary string) (string, error) {
}
// runHarvest handles -u/--update: harvest sigla maximally (to the
-// unpublished horizon) from date, printing the outcome or -- on a genuine
-// interruption (see liturgy.Harvest) -- the error.
+// unpublished horizon) from date for the modern lectionary, printing the
+// outcome or -- on a genuine interruption (see liturgy.Harvest) -- the
+// error. On a successful harvest it also best-effort pre-caches the
+// traditional lectionary's propers (internal/tradlit) for every date in
+// that same [date, furthest] window, so 'lectio update' prepares both
+// lectionaries for offline use in one run. The 1962 calendar has no
+// "unpublished horizon" (every date has propers), so this is a plain
+// date-range fetch; a per-date failure is not fatal here -- it never
+// prevented the traditional lectionary from working live before, and the
+// modern-harvest outcome is still reported either way.
func runHarvest(date string, stdout, stderr io.Writer) int {
added, furthest, err := liturgy.Harvest(date, 0)
if err != nil {
fmt.Fprintln(stderr, "lectio:", err)
return 1
}
- fmt.Fprintf(stdout, "harvested %d day(s), furthest %s\n", added, furthest)
+
+ lang := config.Default().TraditionalLang
+ cachedTrad := 0
+ if furthest != "" {
+ if cfg, cfgErr := config.Load(); cfgErr == nil {
+ lang = cfg.TraditionalLang
+ cachedTrad = cacheTraditionalRange(date, furthest, lang)
+ }
+ }
+
+ fmt.Fprintf(stdout, "harvested %d day(s), furthest %s; cached traditional propers (%s) for %d day(s)\n",
+ added, furthest, lang, cachedTrad)
return 0
}
+// cacheTraditionalRange best-effort pre-caches tradlit's traditional propers
+// for lang for every date in [from, to] inclusive (walking forward a day at
+// a time), returning how many dates succeeded. A per-date failure (e.g. a
+// transient network hiccup) is ignored -- the traditional 1962 calendar has
+// propers for every date, so there is no "unpublished horizon" to stop at
+// the way liturgy.Harvest has for the modern lectionary.
+func cacheTraditionalRange(from, to, lang string) int {
+ start, err := time.Parse("2006-01-02", from)
+ if err != nil {
+ return 0
+ }
+ end, err := time.Parse("2006-01-02", to)
+ if err != nil {
+ return 0
+ }
+
+ cached := 0
+ for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
+ if _, err := tradlit.Load(d.Format("2006-01-02"), lang, false); err == nil {
+ cached++
+ }
+ }
+ return cached
+}
+
// runClean handles -C/--clean: prune cached readings older than one year
// (relative to today()) and print a human-readable summary of what was
// removed. Like -u/--update, this is a maintenance mode -- it ignores DATE
diff --git a/internal/config/config.go b/internal/config/config.go
index 7cef184..74588aa 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -24,7 +24,7 @@ var seedTOML []byte
// Version is lectio's release version, shared by every binary's
// -v/--version output (lectio, lectio-ui, lectio-web).
-const Version = "0.1.0"
+const Version = "0.2.0"
// validVersions are the five scripture versions lectio understands.
var validVersions = map[string]bool{
diff --git a/internal/liturgy/clean_test.go b/internal/liturgy/clean_test.go
index 4dbe0d9..d9ab4fc 100644
--- a/internal/liturgy/clean_test.go
+++ b/internal/liturgy/clean_test.go
@@ -7,12 +7,13 @@ import (
"time"
)
-// TestCleanCache exercises CleanCache's file-selection rules: only
-// "<YYYY-MM-DD>.html"/".json" pairs older than the cutoff are removed; a
-// recent pair and a non-matching file are left untouched. The cutoff is a
-// fixed literal (not time.Now-derived) so the test is deterministic; only
-// the "recent" fixture is anchored to today, and only to make sure it lands
-// safely on the "keep" side of that fixed cutoff.
+// TestCleanCache exercises CleanCache's file-selection rules: date-prefixed
+// cache files (modern ".html"/".json" and traditional ".trad.<lang>.json")
+// older than the cutoff are removed; recent files and a non-matching file
+// are left untouched. The cutoff is a fixed literal (not time.Now-derived)
+// so the test is deterministic; only the "recent" fixtures are anchored to
+// today, and only to make sure they land safely on the "keep" side of that
+// fixed cutoff.
func TestCleanCache(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CACHE_HOME", dir)
@@ -23,10 +24,12 @@ func TestCleanCache(t *testing.T) {
recent := time.Now().Format("2006-01-02")
files := map[string]string{
- "2020-01-01.html": "<html>old</html>",
- "2020-01-01.json": `[{"Heading":"old"}]`,
- recent + ".html": "<html>recent</html>",
- "notes.txt": "not a cache file",
+ "2020-01-01.html": "<html>old</html>",
+ "2020-01-01.json": `[{"Heading":"old"}]`,
+ "2020-01-01.trad.pl.json": `[{"info":{},"sections":[]}]`,
+ recent + ".html": "<html>recent</html>",
+ recent + ".trad.pl.json": `[{"info":{},"sections":[]}]`,
+ "notes.txt": "not a cache file",
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(cacheDir, name), []byte(content), 0o644); err != nil {
@@ -39,19 +42,19 @@ func TestCleanCache(t *testing.T) {
if err != nil {
t.Fatalf("CleanCache: %v", err)
}
- if removed != 2 {
- t.Errorf("removed = %d, want 2", removed)
+ if removed != 3 {
+ t.Errorf("removed = %d, want 3", removed)
}
if freed <= 0 {
t.Errorf("freed = %d, want > 0", freed)
}
- for _, gone := range []string{"2020-01-01.html", "2020-01-01.json"} {
+ for _, gone := range []string{"2020-01-01.html", "2020-01-01.json", "2020-01-01.trad.pl.json"} {
if _, err := os.Stat(filepath.Join(cacheDir, gone)); !os.IsNotExist(err) {
t.Errorf("%s still exists after CleanCache, want removed", gone)
}
}
- for _, kept := range []string{recent + ".html", "notes.txt"} {
+ for _, kept := range []string{recent + ".html", recent + ".trad.pl.json", "notes.txt"} {
if _, err := os.Stat(filepath.Join(cacheDir, kept)); err != nil {
t.Errorf("%s missing after CleanCache, want kept: %v", kept, err)
}
diff --git a/internal/liturgy/fetch.go b/internal/liturgy/fetch.go
index f4f6992..8c887f4 100644
--- a/internal/liturgy/fetch.go
+++ b/internal/liturgy/fetch.go
@@ -41,11 +41,13 @@ var publishedRe = regexp.MustCompile(`id="\w*0all"`)
// validate its own input first.
var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
-// cacheFileRe matches the cache file names Load/Harvest write into
-// cacheDir(): "<YYYY-MM-DD>.html" or "<YYYY-MM-DD>.json". CleanCache uses it
-// to tell cache entries apart from anything else that might be sitting in
-// the directory.
-var cacheFileRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})\.(html|json)$`)
+// cacheFileRe matches the date-prefixed cache file names Load/Harvest/tradlit
+// write into CacheDir(): "<YYYY-MM-DD>.html", "<YYYY-MM-DD>.json", and
+// "<YYYY-MM-DD>.trad.<lang>.json" (the traditional-lectionary cache; see
+// internal/tradlit). CleanCache uses it to tell cache entries apart from
+// anything else that might be sitting in the directory, and to recover the
+// date (group 1) for the age check regardless of which cache file it is.
+var cacheFileRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})\.[a-z0-9.]+$`)
// Options controls how Load resolves a day's readings.
type Options struct {
@@ -58,9 +60,11 @@ type Options struct {
Offline bool
}
-// cacheDir is where the HTML/JSON cache layers live:
+// CacheDir is where the HTML/JSON cache layers live:
// ${XDG_CACHE_HOME:-~/.cache}/lectio/
-func cacheDir() string {
+// Exported so internal/tradlit shares the same cache root for the
+// traditional lectionary's propers.
+func CacheDir() string {
base := os.Getenv("XDG_CACHE_HOME")
if base == "" {
home, err := os.UserHomeDir()
@@ -93,7 +97,7 @@ func Load(opts Options) ([]Section, error) {
return LoadOffline(opts.Date)
}
- dir := cacheDir()
+ dir := CacheDir()
jsonPath := filepath.Join(dir, opts.Date+".json")
htmlPath := filepath.Join(dir, opts.Date+".html")
@@ -142,12 +146,14 @@ func Load(opts Options) ([]Section, error) {
}
// CleanCache removes cached readings whose date is before `before` from
-// cacheDir(). It matches only files named "<YYYY-MM-DD>.html"/".json";
-// anything else in the directory (e.g. a stray notes.txt, or the sigla
-// store, which lives elsewhere entirely) is left alone. A missing cache dir
-// is not an error -- it just means there is nothing to clean yet.
+// CacheDir(). It matches only date-prefixed cache files (see cacheFileRe:
+// "<YYYY-MM-DD>.html", ".json", or the traditional lectionary's
+// ".trad.<lang>.json"); anything else in the directory (e.g. a stray
+// notes.txt, or the sigla store, which lives elsewhere entirely) is left
+// alone. A missing cache dir is not an error -- it just means there is
+// nothing to clean yet.
func CleanCache(before time.Time) (removed int, freed int64, err error) {
- dir := cacheDir()
+ dir := CacheDir()
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
diff --git a/internal/liturgy/fetch_test.go b/internal/liturgy/fetch_test.go
index 1cebb86..738126d 100644
--- a/internal/liturgy/fetch_test.go
+++ b/internal/liturgy/fetch_test.go
@@ -37,7 +37,7 @@ func TestLoadCaches(t *testing.T) {
// before it ever builds a filesystem path from it, so every caller (web,
// cli, tui) is protected even if a future caller forgets to validate.
//
-// The planted "passwd.json" sits one level *above* cacheDir() -- reachable
+// The planted "passwd.json" sits one level *above* CacheDir() -- reachable
// only via a "../" date -- so if Load ever built jsonPath from the raw date
// unchecked, loadJSONCache would read it back and return its section instead
// of an error.
diff --git a/internal/liturgy/store.go b/internal/liturgy/store.go
index cc176e8..69e45f2 100644
--- a/internal/liturgy/store.go
+++ b/internal/liturgy/store.go
@@ -127,7 +127,7 @@ func Harvest(fromDate string, maxDays int) (added int, furthest string, err erro
return 0, "", err
}
- dir := cacheDir()
+ dir := CacheDir()
day := start
var harvestErr error
for i := 0; maxDays == 0 || i < maxDays; i++ {
diff --git a/internal/readings/readings.go b/internal/readings/readings.go
index 82d2fb9..ac7209a 100644
--- a/internal/readings/readings.go
+++ b/internal/readings/readings.go
@@ -5,7 +5,6 @@
package readings
import (
- "fmt"
"strings"
"github.com/lukaszkasprzak/lectio/internal/config"
@@ -21,7 +20,8 @@ type Options struct {
// (modern lectionary only; see liturgy.Options.Refresh).
Refresh bool
// Offline restricts Load to previously cached/harvested data, never
- // hitting the network. Traditional+Offline is not yet supported.
+ // hitting the network (both lectionaries; see liturgy.Options.Offline
+ // and tradlit.Load's offline parameter).
Offline bool
// All, when true, keeps every part the config doesn't explicitly hide;
// when false, only the gospel is kept.
@@ -38,10 +38,7 @@ func Load(cfg config.Config, opts Options) ([]liturgy.Section, error) {
var err error
if cfg.Lectionary == "traditional" {
- if offline {
- return nil, fmt.Errorf("readings: offline traditional not yet supported; use lectionary=new offline")
- }
- secs, err = tradlit.Load(opts.Date, cfg.TraditionalLang)
+ secs, err = tradlit.Load(opts.Date, cfg.TraditionalLang, offline)
} else {
secs, err = liturgy.Load(liturgy.Options{
Date: opts.Date,
diff --git a/internal/readings/readings_test.go b/internal/readings/readings_test.go
index 20df5e0..9a31802 100644
--- a/internal/readings/readings_test.go
+++ b/internal/readings/readings_test.go
@@ -4,6 +4,7 @@ import (
"net/http"
"net/http/httptest"
"os"
+ "path/filepath"
"strings"
"testing"
@@ -107,14 +108,49 @@ func TestLoadModernRoutes(t *testing.T) {
}
}
-func TestLoadTraditionalOfflineErrors(t *testing.T) {
+// TestLoadTraditionalOfflineErrorsWithoutCache exercises the (formerly
+// unsupported) traditional+offline path when nothing has been cached yet
+// for that date/lang: it must fail clearly rather than silently falling
+// back to the network or to the modern lectionary's sigla store.
+func TestLoadTraditionalOfflineErrorsWithoutCache(t *testing.T) {
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+
cfg := config.Config{Lectionary: "traditional", TraditionalLang: "pl"}
_, err := Load(cfg, Options{Date: "2026-07-22", Offline: true})
if err == nil {
t.Fatal("expected error, got nil")
}
msg := strings.ToLower(err.Error())
- if !strings.Contains(msg, "offline") || !strings.Contains(msg, "traditional") {
- t.Errorf("error %q should mention offline and traditional", err.Error())
+ if !strings.Contains(msg, "no cached") {
+ t.Errorf("error %q should mention no cached propers", err.Error())
+ }
+}
+
+// TestLoadTraditionalOfflineReadsCache is the positive counterpart: once a
+// prior online Load (or 'lectio update') has cached a date's traditional
+// propers, Load(offline=true) must serve them from disk, no network
+// involved.
+func TestLoadTraditionalOfflineReadsCache(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_CACHE_HOME", dir)
+ cacheDir := filepath.Join(dir, "lectio")
+ if err := os.MkdirAll(cacheDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ body, err := os.ReadFile("../tradlit/testdata/2026-07-22.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(cacheDir, "2026-07-22.trad.pl.json"), body, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := config.Config{Lectionary: "traditional", TraditionalLang: "pl"}
+ secs, err := Load(cfg, Options{Date: "2026-07-22", Offline: true, All: true})
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if len(secs) == 0 {
+ t.Error("expected traditional offline sections, got none")
}
}
diff --git a/internal/tradlit/tradlit.go b/internal/tradlit/tradlit.go
index edb9ce0..a7b53bc 100644
--- a/internal/tradlit/tradlit.go
+++ b/internal/tradlit/tradlit.go
@@ -9,6 +9,8 @@ import (
"fmt"
"io"
"net/http"
+ "os"
+ "path/filepath"
"regexp"
"strings"
@@ -86,11 +88,57 @@ func Parse(jsonBody []byte) ([]liturgy.Section, error) {
return out, nil
}
-// Load fetches a day's traditional propers from the missalemeum API
-// (https://www.missalemeum.com/{lang}/api/v5/proper/{date}) and parses
-// them. On HTTP 404 (no propers published for that date) it returns a
-// clear error rather than attempting to parse.
-func Load(date, lang string) ([]liturgy.Section, error) {
+// cachePath returns where Load caches a (date, lang) day's raw API response:
+// <CacheDir>/<date>.trad.<lang>.json -- date-prefixed (like the modern
+// lectionary's own cache files) so liturgy.CleanCache can prune it by date.
+func cachePath(date, lang string) string {
+ return filepath.Join(liturgy.CacheDir(), date+".trad."+lang+".json")
+}
+
+// Load returns a day's traditional propers for lang, either read from the
+// on-disk cache (offline) or fetched live from missalemeum and cached for
+// next time (online). Both paths share Parse, so cached and live results
+// are identical.
+//
+// - offline: reads the cache file written by a prior online Load (see
+// cachePath); if it doesn't exist, returns a clear error telling the
+// caller to go online or run 'lectio update' first.
+// - online: fetches https://www.missalemeum.com/{lang}/api/v5/proper/{date}
+// as before. On a successful 200, the raw response body is written to
+// the cache path (best-effort -- a cache-write failure never fails the
+// request) before being parsed. On HTTP 404 (no propers published for
+// that date) it returns a clear error and writes nothing to the cache.
+func Load(date, lang string, offline bool) ([]liturgy.Section, error) {
+ if offline {
+ return loadCached(date, lang)
+ }
+ return loadLive(date, lang)
+}
+
+// loadCached implements Load's offline path.
+func loadCached(date, lang string) ([]liturgy.Section, error) {
+ body, err := os.ReadFile(cachePath(date, lang))
+ if err != nil {
+ return nil, fmt.Errorf("tradlit: no cached traditional propers for %s (%s); view it online or run 'lectio update' first", date, lang)
+ }
+ return Parse(body)
+}
+
+// loadLive implements Load's online path: fetch, best-effort cache the raw
+// body, then parse.
+func loadLive(date, lang string) ([]liturgy.Section, error) {
+ body, err := fetch(date, lang)
+ if err != nil {
+ return nil, err
+ }
+ writeCache(date, lang, body)
+ return Parse(body)
+}
+
+// fetch GETs the day's proper-of-the-day JSON from missalemeum and returns
+// the raw response body. On HTTP 404 (no propers published for that date)
+// it returns a clear error rather than the raw 404 body.
+func fetch(date, lang string) ([]byte, error) {
url := fmt.Sprintf(baseURL, lang, date)
req, err := http.NewRequest(http.MethodGet, url, nil)
@@ -116,6 +164,16 @@ func Load(date, lang string) ([]liturgy.Section, error) {
if err != nil {
return nil, fmt.Errorf("tradlit: load %s: %w", date, err)
}
+ return body, nil
+}
- return Parse(body)
+// writeCache best-effort writes a day's raw API response body to its cache
+// path; a failure to cache (e.g. an unwritable cache dir) must never fail
+// the live request that produced body.
+func writeCache(date, lang string, body []byte) {
+ path := cachePath(date, lang)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return
+ }
+ _ = os.WriteFile(path, body, 0o644)
}
diff --git a/internal/tradlit/tradlit_test.go b/internal/tradlit/tradlit_test.go
new file mode 100644
index 0000000..cd2cc83
--- /dev/null
+++ b/internal/tradlit/tradlit_test.go
@@ -0,0 +1,121 @@
+package tradlit
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/lukaszkasprzak/lectio/internal/liturgy"
+)
+
+// TestLoadOnlineCachesRawBody exercises Load's online path: a successful
+// fetch is cached verbatim (the raw response body, not the parsed
+// sections) at tradlit's cache path, and parses the same way a cached read
+// would.
+func TestLoadOnlineCachesRawBody(t *testing.T) {
+ body, err := os.ReadFile("testdata/2026-07-22.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ hits := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits++
+ w.Write(body)
+ }))
+ defer srv.Close()
+
+ orig := baseURL
+ baseURL = srv.URL + "/%s/api/v5/proper/%s"
+ defer func() { baseURL = orig }()
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+
+ secs, err := Load("2026-07-22", "en", false)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if len(secs) == 0 {
+ t.Fatal("Load returned no sections")
+ }
+ if hits != 1 {
+ t.Errorf("server hit %d times, want 1", hits)
+ }
+
+ cached, err := os.ReadFile(filepath.Join(liturgy.CacheDir(), "2026-07-22.trad.en.json"))
+ if err != nil {
+ t.Fatalf("cache file not written: %v", err)
+ }
+ if string(cached) != string(body) {
+ t.Error("cached content does not match the raw response body")
+ }
+}
+
+// TestLoadOnline404WritesNoCache checks the documented invariant: a 404 (no
+// propers published for that date) returns an error and leaves the cache
+// directory untouched.
+func TestLoadOnline404WritesNoCache(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer srv.Close()
+
+ orig := baseURL
+ baseURL = srv.URL + "/%s/api/v5/proper/%s"
+ defer func() { baseURL = orig }()
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+
+ _, err := Load("2026-07-22", "en", false)
+ if err == nil {
+ t.Fatal("expected error on 404, got nil")
+ }
+
+ cachePath := filepath.Join(liturgy.CacheDir(), "2026-07-22.trad.en.json")
+ if _, statErr := os.Stat(cachePath); !os.IsNotExist(statErr) {
+ t.Errorf("cache file should not exist after a 404 (stat err = %v)", statErr)
+ }
+}
+
+// TestLoadOfflineReadsCache exercises Load's offline path against a
+// pre-written cache file (as an earlier online Load, or 'lectio update',
+// would have left behind) -- no network access at all.
+func TestLoadOfflineReadsCache(t *testing.T) {
+ body, err := os.ReadFile("testdata/2026-07-22.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := t.TempDir()
+ t.Setenv("XDG_CACHE_HOME", dir)
+ cacheDir := filepath.Join(dir, "lectio")
+ if err := os.MkdirAll(cacheDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(cacheDir, "2026-07-22.trad.pl.json"), body, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ secs, err := Load("2026-07-22", "pl", true)
+ if err != nil {
+ t.Fatalf("Load offline: %v", err)
+ }
+ if len(secs) == 0 {
+ t.Fatal("Load offline returned no sections")
+ }
+}
+
+// TestLoadOfflineMissingCacheErrors checks Load's offline path errors
+// clearly (mentioning the missing cache) rather than trying the network,
+// when nothing has been cached yet for that (date, lang).
+func TestLoadOfflineMissingCacheErrors(t *testing.T) {
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+
+ _, err := Load("2026-07-22", "pl", true)
+ if err == nil {
+ t.Fatal("expected error for missing cache, got nil")
+ }
+ msg := strings.ToLower(err.Error())
+ if !strings.Contains(msg, "no cached") {
+ t.Errorf("error %q should mention no cached propers", err.Error())
+ }
+}