diff --git a/macros/examples/corrected_coords.csv b/macros/examples/corrected_coords.csv new file mode 100644 index 00000000..c82b569b --- /dev/null +++ b/macros/examples/corrected_coords.csv @@ -0,0 +1,7 @@ +code;coords;note +GC1;N47 21.689 E006 18.718;Mystery solved via checksum +GC2;N 47° 08.905' E 009° 42.534';Final behind the bridge +GC3;47.514093, 7.470118;Decimal degrees work too +GC4;N46 40.099 E006 33.842;Bonus from the multi stages +GC5;N47° 25' 00.37" E008° 05' 31.97";DMS format +GC6;N 46.66695 E 8.32197;Decimal degrees with hemisphere letters diff --git a/macros/examples/corrected_coords_from_csv.lua b/macros/examples/corrected_coords_from_csv.lua new file mode 100644 index 00000000..17411f99 --- /dev/null +++ b/macros/examples/corrected_coords_from_csv.lua @@ -0,0 +1,125 @@ +-- corrected_coords_from_csv.lua — set corrected coordinates from a CSV file +-- +-- Reads corrected_coords.csv (next to this macro) and stores the solved +-- coordinates of each listed cache as its corrected coordinates. +-- +-- CSV layout (header line required, separator , ; or tab is detected): +-- +-- code;coords;note +-- GC1;N47 22.123 E008 32.456;Mystery solved via checksum +-- +-- "coords" accepts every format OpenSAK understands (DMM, DMS, decimal +-- degrees). Instead of one "coords" column the file may also have separate +-- "lat" and "lon" columns in decimal degrees. +-- +-- Empty never deletes anything — a blank cell is far more often a typo or a +-- cache not solved yet than a wish to throw away a solution: +-- +-- * no coordinates in the row → skipped (listed in the output) +-- * only lat or only lon → error for that row; the rest carries on +-- * coords cell reads "clear" → corrected coordinates are removed +-- +-- Nothing is written until you confirm the summary ("12 will be set, +-- 2 cleared — continue?"). +-- +-- Open this file via Macros → Run macro… → Open… so the relative CSV path +-- is resolved against this folder. + +local CSV_FILE = "corrected_coords.csv" + +local function has(v) return v ~= nil and v ~= "" end + +-- What a row asks for: "set", "clear" or "skip". Raises an error for a +-- row without GC code or with half-filled coordinates. +local function plan(row) + if not has(row.code) then + error("no GC code", 0) + elseif has(row.coords) then + return row.coords:lower() == "clear" and "clear" or "set" + elseif has(row.lat) and has(row.lon) then + return "set" + elseif has(row.lat) then + error("lon missing", 0) + elseif has(row.lon) then + error("lat missing", 0) + end + return "skip" +end + +local function apply(row, action) + if action == "clear" then + return opensak.clear_corrected(row.code) + elseif has(row.coords) then + return opensak.set_corrected(row.code, row.coords) + end + return opensak.set_corrected(row.code, row.lat, row.lon) +end + +local rows = opensak.read_csv(CSV_FILE) +print(("Read %d row(s) from %s"):format(#rows, CSV_FILE)) + +-- Pass 1: check every row, write nothing +local todo = {} -- {row, action} for the rows to apply +local to_set, to_clear, skipped, failed = 0, 0, 0, 0 + +for i, row in ipairs(rows) do + local ok, action = pcall(plan, row) + if not ok then + print(("%s: %s"):format(has(row.code) and row.code or ("Row " .. i), action)) + failed = failed + 1 + elseif action == "skip" then + print(("%s: no coordinates — skipped"):format(row.code)) + skipped = skipped + 1 + else + todo[#todo + 1] = { row = row, action = action } + if action == "set" then to_set = to_set + 1 else to_clear = to_clear + 1 end + end +end + +if #todo == 0 then + print(("Nothing to do: %d skipped, %d failed"):format(skipped, failed)) + return +end + +local question = ("%d will be set, %d cleared (%d skipped, %d invalid).\nContinue?") + :format(to_set, to_clear, skipped, failed) +if not opensak.confirm(question) then + print("Cancelled — nothing changed") + return +end + +-- Pass 2: write +local changed = {} -- GC codes that were updated, for the filter below +local set, cleared, missing = 0, 0, 0 + +for _, t in ipairs(todo) do + local row = t.row + -- pcall so one bad coordinate does not stop the whole run + local ok, found = pcall(apply, row, t.action) + if not ok then + print(("%s: %s"):format(row.code, found)) -- found = error message + failed = failed + 1 + elseif not found then + print(("%s: not in the database — skipped"):format(row.code)) + missing = missing + 1 + elseif t.action == "clear" then + print(("%s: corrected coordinates removed"):format(row.code)) + cleared = cleared + 1 + else + local coords = has(row.coords) and row.coords or (row.lat .. ", " .. row.lon) + print(("%s: corrected → %s %s"):format(row.code, coords, row.note or "")) + set = set + 1 + changed[#changed + 1] = "'" .. row.code:upper():gsub("'", "''") .. "'" + end +end + +print(("Done: %d set, %d cleared, %d skipped, %d not found, %d failed"):format( + set, cleared, skipped, missing, failed)) + +-- Show the caches that just got corrected coordinates +if #changed > 0 then + opensak.filter{ + where = "gc_code IN (" .. table.concat(changed, ", ") .. ")", + label = "Corrected via CSV", + } +end diff --git a/src/opensak/gui/dialogs/macro_dialog.py b/src/opensak/gui/dialogs/macro_dialog.py index 4498f817..5f4f257b 100644 --- a/src/opensak/gui/dialogs/macro_dialog.py +++ b/src/opensak/gui/dialogs/macro_dialog.py @@ -25,6 +25,9 @@ -- OpenSAK macro (Lua) — proof of concept -- opensak.filter{...}, opensak.filter_profile(name), opensak.clear_filter(), -- opensak.count(), opensak.profiles(), print(...) +-- opensak.set_corrected(code, lat, lon | "N47 22.123 E008 32.456"), +-- opensak.clear_corrected(code), opensak.read_csv(path [, sep]), +-- opensak.confirm(message) local n = opensak.filter{ type = {"Traditional", "Multi-cache"}, @@ -60,6 +63,7 @@ def __init__(self, host: MacroHost, parent=None): ) self._runtime = MacroRuntime(host, output=self._append_output) self._chunk_name = "macro" + self._base_dir: Path | None = None self._setup_ui() def _setup_ui(self) -> None: @@ -103,6 +107,7 @@ def _open_file(self) -> None: return self._editor.setPlainText(Path(path).read_text(encoding="utf-8")) self._chunk_name = Path(path).name + self._base_dir = Path(path).parent def _append_output(self, text: str) -> None: self._output.appendPlainText(text) @@ -111,7 +116,11 @@ def _run(self) -> None: self._output.clear() self._btn_run.setEnabled(False) try: - self._runtime.run(self._editor.toPlainText(), chunk_name=self._chunk_name) + self._runtime.run( + self._editor.toPlainText(), + chunk_name=self._chunk_name, + base_dir=self._base_dir, + ) self._append_output(tr("macro_done")) except MacroError as exc: self._append_output(tr("macro_error", msg=str(exc))) diff --git a/src/opensak/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index 2b8dc1bb..39c85de4 100644 --- a/src/opensak/gui/mainwindow.py +++ b/src/opensak/gui/mainwindow.py @@ -197,6 +197,11 @@ def update_counts( self._owned_lbl.setText(str(owned)) +# Up to this many caches changed by one macro run are refreshed row by row; +# more trigger a single full reload of the cache list (see end_macro()). +_MACRO_ROW_REFRESH_LIMIT = 50 + + class MainWindow(QMainWindow): def __init__(self): super().__init__() @@ -225,6 +230,9 @@ def __init__(self): # RefreshWorker's docstring and _on_refresh_result() below. self._refresh_generation: int = 0 self._active_refresh_workers: list[RefreshWorker] = [] + # GC codes whose corrected coordinates a running Lua macro changed; + # refreshed in one go by end_macro() instead of once per call. + self._macro_changed_codes: set[GcCode] = set() # Issue #558: the toolbar Where box's expression currently in # effect — only set once validated on Enter, so a half-typed # expression never leaks into refreshes triggered elsewhere. @@ -3090,6 +3098,48 @@ def cache_count(self) -> int: with get_session() as session: return len(apply_filters_auto(session, self._build_active_filterset())) + def set_corrected_coords(self, gc_code, lat, lon) -> bool: + """MacroHost: set (or clear, with lat/lon = None) corrected coordinates. + + The view is not refreshed here but once in end_macro(): a macro + importing a large CSV would otherwise reload the table row, map pin + and detail panel for every single row.""" + from opensak.db.corrected_coords import set_corrected_coords + if not set_corrected_coords(gc_code, lat, lon): + return False + self._macro_changed_codes.add(gc_code) + return True + + def confirm(self, message: str) -> bool: + """MacroHost: Yes/No question, on top of the macro dialog.""" + parent = getattr(self, "_macro_dialog", None) or self + reply = QMessageBox.question( + parent, + tr("macro_title"), + message, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + return reply == QMessageBox.StandardButton.Yes + + def end_macro(self) -> None: + """MacroHost: refresh what the macro's corrected-coordinate changes + affect. A handful of caches get the same per-cache refresh as the + other entry points; beyond that, one full reload of table and map is + cheaper than updating each row (refresh_cache_row() scans the whole + model and _load_full_cache() loads logs etc. per call).""" + changed, self._macro_changed_codes = self._macro_changed_codes, set() + if len(changed) <= _MACRO_ROW_REFRESH_LIMIT: + for gc_code in sorted(changed): + self._on_corrected_coords_changed(gc_code) + return + self._refresh_cache_list() + current = getattr(self._detail_panel, "_current_gc_code", None) + if current in changed: + full = self._load_full_cache(current) + if full: + self._detail_panel.show_cache(full) + def _open_found_updater(self) -> None: if self._trip_planner_active(): self._warn_trip_planner_active() diff --git a/src/opensak/macro/runtime.py b/src/opensak/macro/runtime.py index 4592644e..c8eca5d6 100644 --- a/src/opensak/macro/runtime.py +++ b/src/opensak/macro/runtime.py @@ -15,6 +15,21 @@ opensak.clear_filter() -- show all caches again opensak.count() -- caches matching the active filter opensak.profiles() -- list of saved filter profile names + opensak.set_corrected(code, lat, lon) + -- set corrected coordinates (decimal + -- degrees); returns false if the cache + -- is not in the database + opensak.set_corrected(code, "N47 22.123 E008 32.456") + -- same, from a coordinate string in any + -- format OpenSAK understands + opensak.clear_corrected(code) -- remove corrected coordinates; returns + -- false if the cache is not in the database + opensak.read_csv(path [, sep]) -- read a CSV file (UTF-8) into an array of + -- rows keyed by the header line; the + -- separator (, ; or tab) is detected + -- unless given. A relative path is + -- resolved against the macro file's folder + opensak.confirm(message) -- ask the user Yes/No; returns true on Yes print(...) -- write to the macro output pane Keys understood by opensak.filter{} (all combined with AND): @@ -34,6 +49,10 @@ local n = opensak.filter{ type = "Traditional", difficulty = {1, 2}, found = false } print("Easy unfound traditionals: " .. n) + for _, row in ipairs(opensak.read_csv("solved.csv")) do + opensak.set_corrected(row.code, row.coords) + end + Known limitation (#938 step 4): the instruction limit only counts Lua VM instructions, not work inside C functions. Lua pattern matching backtracks @@ -50,6 +69,8 @@ from __future__ import annotations +import csv +import io from pathlib import Path from typing import Any, Callable, Optional, Protocol @@ -71,6 +92,7 @@ TerrainFilter, WhereClauseFilter, ) +from opensak.coords import parse_coords from opensak.utils.constants import CACHE_TYPES # A runaway `while true do end` would freeze the GUI thread, so the script is @@ -78,6 +100,9 @@ DEFAULT_INSTRUCTION_LIMIT = 50_000_000 # Upper bound for the Lua heap, so e.g. string.rep("x", 1e10) cannot exhaust RAM. DEFAULT_MEMORY_LIMIT = 256 * 1024 * 1024 +# opensak.read_csv() refuses larger files — it is meant for small lists +# (solved puzzles, corrections), not for bulk imports. +MAX_CSV_BYTES = 10 * 1024 * 1024 _TEXT_FILTERS = { "name": NameFilter, @@ -189,6 +214,24 @@ def cache_count(self) -> int: not the UI. """ + def set_corrected_coords( + self, gc_code: str, lat: Optional[float], lon: Optional[float] + ) -> bool: + """Set (or clear, with lat/lon = None) corrected coordinates. + + Returns False if the cache is not in the database. The host should + refresh whatever shows the cache (table row, map pin, detail panel), + but may defer that to end_macro() so a macro changing thousands of + caches does not refresh the view thousands of times. + """ + + def confirm(self, message: str) -> bool: + """Ask the user a Yes/No question; True on Yes.""" + + def end_macro(self) -> None: + """Called once after every run, also when the macro failed — apply + any refreshes deferred while it was running.""" + # ── Lua table → FilterSet ──────────────────────────────────────────────────── @@ -260,6 +303,90 @@ def build_filterset(spec: dict) -> tuple[FilterSet, str]: return fs, str(spec.get("label") or "Macro") +# ── Corrected coordinates / CSV ────────────────────────────────────────────── + + +def _gc_code(code: Any, func: str) -> str: + if not isinstance(code, str) or not code.strip(): + raise MacroError(f"{func} expects a GC code as first argument, got {code!r}") + return code.strip().upper() + + +def _number(value: Any) -> Optional[float]: + """A Lua number, or a string holding one (CSV cells are strings).""" + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value.strip()) + except ValueError: + return None + return None + + +def resolve_coords(lat: Any, lon: Any = None) -> tuple[float, float]: + """(lat, lon) from two numbers or from one coordinate string. + + Raises MacroError if the values cannot be parsed or are out of range. + """ + if lon is None: + if not isinstance(lat, str): + raise MacroError( + 'expected lat, lon or a coordinate string such as "N47 22.123 E008 32.456"' + ) + parsed = parse_coords(lat) + if parsed is None: + raise MacroError(f"cannot parse coordinates {lat!r}") + return parsed + la, lo = _number(lat), _number(lon) + if la is None or lo is None: + raise MacroError(f"lat/lon must be numbers, got {lat!r}, {lon!r}") + if not (-90.0 <= la <= 90.0 and -180.0 <= lo <= 180.0): + raise MacroError(f"coordinates out of range: {la}, {lo}") + return la, lo + + +def read_csv_rows(path: Path, sep: Optional[str] = None) -> list[dict[str, str]]: + """Parse a UTF-8 CSV file (BOM allowed) into a list of header-keyed dicts. + + Header names and cells are stripped; blank lines are skipped. Without + *sep* the separator is sniffed among "," ";" and tab. + """ + try: + if path.stat().st_size > MAX_CSV_BYTES: + raise MacroError( + f"{path.name} is larger than {MAX_CSV_BYTES // (1024 * 1024)} MB" + ) + text = path.read_text(encoding="utf-8-sig") + except FileNotFoundError: + raise MacroError(f"file not found: {path}") from None + except (OSError, UnicodeDecodeError) as exc: + raise MacroError(f"cannot read {path}: {exc}") from None + + if sep is None: + try: + first_line = text.split("\n", 1)[0] + sep = csv.Sniffer().sniff(first_line, delimiters=",;\t").delimiter + except csv.Error: + sep = "," + elif len(sep) != 1: + raise MacroError(f"separator must be a single character, got {sep!r}") + + reader = csv.reader(io.StringIO(text), delimiter=sep) + header = [h.strip() for h in next(reader, [])] + rows = [] + for cells in reader: + if not any(c.strip() for c in cells): + continue + rows.append({ + h: (cells[i].strip() if i < len(cells) else "") + for i, h in enumerate(header) if h + }) + return rows + + # ── Runtime ────────────────────────────────────────────────────────────────── @@ -283,6 +410,7 @@ def __init__( self._profiles_dir = profiles_dir self._instruction_limit = instruction_limit self._memory_limit = memory_limit + self._base_dir: Optional[Path] = None # -- API functions exposed to Lua ----------------------------------------- @@ -319,10 +447,43 @@ def _profile_names(self) -> list[str]: continue return names + def _set_corrected(self, code=None, lat=None, lon=None) -> bool: + gc_code = _gc_code(code, "opensak.set_corrected") + la, lo = resolve_coords(lat, lon) + return bool(self._host.set_corrected_coords(gc_code, la, lo)) + + def _clear_corrected(self, code=None) -> bool: + gc_code = _gc_code(code, "opensak.clear_corrected") + return bool(self._host.set_corrected_coords(gc_code, None, None)) + + def _confirm(self, message=None) -> bool: + if not isinstance(message, str) or not message.strip(): + raise MacroError("opensak.confirm expects a message") + return bool(self._host.confirm(message)) + + def _read_csv(self, lua, path=None, sep=None): + if not isinstance(path, str) or not path.strip(): + raise MacroError("opensak.read_csv expects a file path") + if sep is not None and not isinstance(sep, str): + raise MacroError("opensak.read_csv: separator must be a string") + file = Path(path).expanduser() + if not file.is_absolute(): + file = (self._base_dir or Path.cwd()) / file + rows = read_csv_rows(file, sep) + return lua.table_from([lua.table_from(r) for r in rows]) + # -- Running --------------------------------------------------------------- - def run(self, source: str, chunk_name: str = "macro") -> None: - """Execute *source*. Raises MacroError on any failure.""" + def run( + self, source: str, chunk_name: str = "macro", base_dir: Optional[Path] = None + ) -> None: + """Execute *source*. Raises MacroError on any failure. + + *base_dir* (usually the macro file's folder) is where relative paths + given to opensak.read_csv() are looked up; the working directory + otherwise. + """ + self._base_dir = base_dir try: from lupa.lua54 import LuaError, LuaMemoryError, LuaRuntime except ImportError as exc: @@ -350,6 +511,10 @@ def run(self, source: str, chunk_name: str = "macro") -> None: "clear_filter": self._wrap(self._host.clear_filter), "count": self._wrap(self._host.cache_count), "profiles": self._wrap(lambda: lua.table_from(self._profile_names())), + "set_corrected": self._wrap(self._set_corrected), + "clear_corrected": self._wrap(self._clear_corrected), + "read_csv": self._wrap(lambda *a: self._read_csv(lua, *a)), + "confirm": self._wrap(self._confirm), } ) @@ -368,6 +533,8 @@ def run(self, source: str, chunk_name: str = "macro") -> None: # A Python exception raised inside a callback (e.g. the # attribute_filter) propagates as itself, not as a LuaError. raise MacroError(f"{type(exc).__name__}: {exc}") from exc + finally: + self._host.end_macro() @staticmethod def _deny_attribute(obj, attr_name, is_setting): diff --git a/tests/fixtures/macros/csv_import.lua b/tests/fixtures/macros/csv_import.lua new file mode 100644 index 00000000..f2436c3b --- /dev/null +++ b/tests/fixtures/macros/csv_import.lua @@ -0,0 +1,109 @@ +-- csv_import.lua — test fixture for tests/unit-tests/test_macro_runtime.py +-- +-- A CSV-import macro in the style of macros/examples/corrected_coords_from_csv.lua, +-- kept separate so the example can change without breaking the tests (and +-- the tests pin the behaviour regardless of what the example looks like). +-- Each test copies one of the CSV fixtures next to it as corrected_coords.csv. +-- +-- Rules: coords (any format) or lat+lon → set; coords = "clear" → clear; +-- no coordinates → skipped; only lat or only lon → error for that row. +-- Nothing is written until opensak.confirm() returns true. + +local CSV_FILE = "corrected_coords.csv" + +local function has(v) return v ~= nil and v ~= "" end + +-- What a row asks for: "set", "clear" or "skip". Raises an error for a +-- row without GC code or with half-filled coordinates. +local function plan(row) + if not has(row.code) then + error("no GC code", 0) + elseif has(row.coords) then + return row.coords:lower() == "clear" and "clear" or "set" + elseif has(row.lat) and has(row.lon) then + return "set" + elseif has(row.lat) then + error("lon missing", 0) + elseif has(row.lon) then + error("lat missing", 0) + end + return "skip" +end + +local function apply(row, action) + if action == "clear" then + return opensak.clear_corrected(row.code) + elseif has(row.coords) then + return opensak.set_corrected(row.code, row.coords) + end + return opensak.set_corrected(row.code, row.lat, row.lon) +end + +local rows = opensak.read_csv(CSV_FILE) +print(("Read %d row(s) from %s"):format(#rows, CSV_FILE)) + +-- Pass 1: check every row, write nothing +local todo = {} -- {row, action} for the rows to apply +local to_set, to_clear, skipped, failed = 0, 0, 0, 0 + +for i, row in ipairs(rows) do + local ok, action = pcall(plan, row) + if not ok then + print(("%s: %s"):format(has(row.code) and row.code or ("Row " .. i), action)) + failed = failed + 1 + elseif action == "skip" then + print(("%s: no coordinates — skipped"):format(row.code)) + skipped = skipped + 1 + else + todo[#todo + 1] = { row = row, action = action } + if action == "set" then to_set = to_set + 1 else to_clear = to_clear + 1 end + end +end + +if #todo == 0 then + print(("Nothing to do: %d skipped, %d failed"):format(skipped, failed)) + return +end + +local question = ("%d will be set, %d cleared (%d skipped, %d invalid).\nContinue?") + :format(to_set, to_clear, skipped, failed) +if not opensak.confirm(question) then + print("Cancelled — nothing changed") + return +end + +-- Pass 2: write +local changed = {} -- GC codes that were updated, for the filter below +local set, cleared, missing = 0, 0, 0 + +for _, t in ipairs(todo) do + local row = t.row + -- pcall so one bad coordinate does not stop the whole run + local ok, found = pcall(apply, row, t.action) + if not ok then + print(("%s: %s"):format(row.code, found)) -- found = error message + failed = failed + 1 + elseif not found then + print(("%s: not in the database — skipped"):format(row.code)) + missing = missing + 1 + elseif t.action == "clear" then + print(("%s: corrected coordinates removed"):format(row.code)) + cleared = cleared + 1 + else + local coords = has(row.coords) and row.coords or (row.lat .. ", " .. row.lon) + print(("%s: corrected → %s %s"):format(row.code, coords, row.note or "")) + set = set + 1 + changed[#changed + 1] = "'" .. row.code:upper():gsub("'", "''") .. "'" + end +end + +print(("Done: %d set, %d cleared, %d skipped, %d not found, %d failed"):format( + set, cleared, skipped, missing, failed)) + +-- Show the caches that just got corrected coordinates +if #changed > 0 then + opensak.filter{ + where = "gc_code IN (" .. table.concat(changed, ", ") .. ")", + label = "Corrected via CSV", + } +end diff --git a/tests/fixtures/macros/declined.csv b/tests/fixtures/macros/declined.csv new file mode 100644 index 00000000..ad682a70 --- /dev/null +++ b/tests/fixtures/macros/declined.csv @@ -0,0 +1,4 @@ +code;coords +GCD1;47.5, 8.5 +GCD2;clear +GCD3; diff --git a/tests/fixtures/macros/formats.csv b/tests/fixtures/macros/formats.csv new file mode 100644 index 00000000..a8f688d7 --- /dev/null +++ b/tests/fixtures/macros/formats.csv @@ -0,0 +1,7 @@ +code;coords;note +GCF1;N47 21.689 E006 18.718;DMM +GCF2;N 47° 08.905' E 009° 42.534';DMM with symbols +GCF3;47.514093, 7.470118;Decimal degrees +GCF4;N46 40.099 E006 33.842;Overwrites existing +GCF5;N47° 25' 00.37" E008° 05' 31.97";DMS +GCF6;N 46.66695 E 8.32197;Decimal with hemisphere letters diff --git a/tests/fixtures/macros/partial_rows.csv b/tests/fixtures/macros/partial_rows.csv new file mode 100644 index 00000000..c3ad05f7 --- /dev/null +++ b/tests/fixtures/macros/partial_rows.csv @@ -0,0 +1,6 @@ +code;coords;lat;lon +GCE1;;; +GCE2;;47.5; +GCE3;;;8.5 +GCE4;Clear;; +GCE5;;47.5;8.5 diff --git a/tests/unit-tests/test_macro_runtime.py b/tests/unit-tests/test_macro_runtime.py index 972a53e5..a42f709d 100644 --- a/tests/unit-tests/test_macro_runtime.py +++ b/tests/unit-tests/test_macro_runtime.py @@ -5,8 +5,11 @@ database, so "a Lua script selects caches" is covered end to end. """ +from pathlib import Path + import pytest +from opensak.db.corrected_coords import set_corrected_coords from opensak.db.database import get_session from opensak.db.models import Cache from opensak.filters.engine import ( @@ -32,6 +35,21 @@ def clear_filter(self): def cache_count(self): return 99 + def set_corrected_coords(self, gc_code, lat, lon): + self.corrected = getattr(self, "corrected", []) + self.corrected.append((gc_code, lat, lon)) + return gc_code != "GCNONE" + + answer = True + + def confirm(self, message): + self.asked = getattr(self, "asked", []) + self.asked.append(message) + return self.answer + + def end_macro(self): + self.ended = getattr(self, "ended", 0) + 1 + class DbHost(FakeHost): """Applies the filter against the test DB and remembers the selection.""" @@ -47,6 +65,9 @@ def apply_filter(self, filterset, label): self.selected = codes return len(codes) + def set_corrected_coords(self, gc_code, lat, lon): + return set_corrected_coords(gc_code, lat, lon) + def _run(source, host=None, **kwargs): out: list[str] = [] @@ -207,3 +228,185 @@ def test_empty_result_keeps_previous_selection(): assert(opensak.filter{ name = "does-not-exist" } == 0) """, host=host) assert host.selected == {"GCMAC3"} + + +# ── Corrected coordinates / CSV ────────────────────────────────────────────── + +EXAMPLES = Path(__file__).resolve().parents[2] / "macros" / "examples" +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "macros" + + +def _run_csv_import(csv_fixture, tmp_path, host=None): + """Run the csv_import.lua fixture against a copy of *csv_fixture*.""" + (tmp_path / "corrected_coords.csv").write_bytes((FIXTURES / csv_fixture).read_bytes()) + host, out = host or DbHost(), [] + MacroRuntime(host, output=out.append).run( + (FIXTURES / "csv_import.lua").read_text(encoding="utf-8"), base_dir=tmp_path) + return host, out + + +def _add_caches(codes): + with get_session() as s: + for code in codes: + s.add(Cache(gc_code=code, name=code, cache_type="Unknown Cache", + latitude=47.0, longitude=8.0)) + + +def test_set_corrected_accepts_numbers_strings_and_coord_text(): + host, out = _run(""" + print(opensak.set_corrected("gc123", 47.5, 8.25)) + print(opensak.set_corrected("GC124", "47.5", "8.25")) + print(opensak.set_corrected("GC125", "N47 30.000 E008 15.000")) + print(opensak.clear_corrected("GC126")) + print(opensak.set_corrected("GCNONE", 1, 2)) + """) + assert out == ["true", "true", "true", "true", "false"] + assert host.corrected[:3] == [("GC123", 47.5, 8.25)] + [("GC124", 47.5, 8.25)] + [ + ("GC125", pytest.approx(47.5), pytest.approx(8.25))] + assert host.corrected[3] == ("GC126", None, None) + + +@pytest.mark.parametrize("call,msg", [ + ('opensak.set_corrected("GC1", 91, 0)', "out of range"), + ('opensak.set_corrected("GC1", "somewhere")', "cannot parse coordinates"), + ('opensak.set_corrected("GC1", "x", 8)', "must be numbers"), + ('opensak.set_corrected(nil, 1, 2)', "expects a GC code"), + ('opensak.set_corrected("GC1", 47)', "coordinate string"), +]) +def test_set_corrected_rejects_bad_input(call, msg): + host = FakeHost() + with pytest.raises(MacroError, match=msg): + _run(call, host=host) + assert not getattr(host, "corrected", []) + + +def test_read_csv_sniffs_separator_and_resolves_relative_path(tmp_path): + (tmp_path / "a.csv").write_text( + "code ; lat;lon\nGC1;47.1;8.2\n\nGC2;46;7\n", encoding="utf-8") + (tmp_path / "b.csv").write_text("code|x\nGC3|y\n", encoding="utf-8") + out: list[str] = [] + MacroRuntime(FakeHost(), output=out.append).run(""" + local rows = opensak.read_csv("a.csv") + print(#rows, rows[1].code, rows[1].lat, rows[2].lon) + print(opensak.read_csv("b.csv", "|")[1].x) + """, base_dir=tmp_path) + assert out == ["2\tGC1\t47.1\t7", "y"] + + +def test_read_csv_missing_file(tmp_path): + with pytest.raises(MacroError, match="file not found"): + MacroRuntime(FakeHost(), output=lambda _: None).run( + 'opensak.read_csv("nope.csv")', base_dir=tmp_path) + + +def test_confirm_returns_host_answer(): + host, out = _run('print(opensak.confirm("Go?"))') + assert host.asked == ["Go?"] and out == ["true"] + host = FakeHost() + host.answer = False + _, out = _run('print(opensak.confirm("Go?"))', host=host) + assert out == ["false"] + with pytest.raises(MacroError, match="expects a message"): + _run("opensak.confirm()") + + +def test_end_macro_called_once_after_run_even_on_error(): + host, _ = _run('opensak.set_corrected("GC1", 47, 8) opensak.clear_corrected("GC2")') + assert host.ended == 1 + host = FakeHost() + with pytest.raises(MacroError): + MacroRuntime(host, output=lambda _: None).run('opensak.set_corrected("GC1", 47, 8) error("boom")') + assert host.ended == 1 + + +@pytest.mark.parametrize("n, row_refreshes, full_reloads", [(3, 3, 0), (51, 0, 1)]) +def test_mainwindow_batches_macro_refresh(n, row_refreshes, full_reloads): + from types import SimpleNamespace + from opensak.gui import mainwindow as mw + + calls = {"row": [], "full": 0, "detail": []} + win = SimpleNamespace( + _macro_changed_codes={f"GC{i}" for i in range(n)}, + _on_corrected_coords_changed=calls["row"].append, + _refresh_cache_list=lambda: calls.__setitem__("full", calls["full"] + 1), + _detail_panel=SimpleNamespace(_current_gc_code="GC1", + show_cache=calls["detail"].append), + _load_full_cache=lambda code: code, + ) + mw.MainWindow.end_macro(win) + + assert len(calls["row"]) == row_refreshes + assert calls["full"] == full_reloads + assert calls["detail"] == (["GC1"] if full_reloads else []) + assert win._macro_changed_codes == set() + + +@pytest.mark.parametrize("script", sorted(EXAMPLES.glob("*.lua")), ids=lambda p: p.name) +def test_example_macros_compile(script): + """Compile only: catches syntax errors in the shipped examples. Calls to + a renamed or removed opensak.* function are only found when run.""" + from lupa.lua54 import LuaRuntime + LuaRuntime().compile(script.read_text(encoding="utf-8")) + + +def test_csv_import_sets_corrected_coords_in_every_format(tmp_path): + codes = ["GCF1", "GCF2", "GCF3", "GCF4", "GCF5", "GCF6"] + _add_caches(codes) + set_corrected_coords("GCF4", 1.0, 1.0) # overwritten by the CSV + + host, out = _run_csv_import("formats.csv", tmp_path) + + assert out[0] == "Read 6 row(s) from corrected_coords.csv" + assert host.asked == ["6 will be set, 0 cleared (0 skipped, 0 invalid).\nContinue?"] + assert out[-1] == "Done: 6 set, 0 cleared, 0 skipped, 0 not found, 0 failed" + assert host.selected == set(codes) + with get_session() as s: + got = {c.gc_code: (c.user_note.corrected_lat, c.user_note.corrected_lon, + c.user_note.is_corrected) + for c in s.query(Cache).filter(Cache.gc_code.in_(codes))} + assert got["GCF1"] == (pytest.approx(47 + 21.689 / 60), pytest.approx(6 + 18.718 / 60), True) + assert got["GCF2"][:2] == (pytest.approx(47 + 8.905 / 60), pytest.approx(9 + 42.534 / 60)) + assert got["GCF3"] == (pytest.approx(47.514093), pytest.approx(7.470118), True) + assert got["GCF4"][:2] == (pytest.approx(46 + 40.099 / 60), pytest.approx(6 + 33.842 / 60)) + assert got["GCF5"][:2] == (pytest.approx(47 + 25 / 60 + 0.37 / 3600), + pytest.approx(8 + 5 / 60 + 31.97 / 3600)) + assert got["GCF6"][:2] == (pytest.approx(46.66695), pytest.approx(8.32197)) + + +def test_csv_import_writes_nothing_when_summary_is_declined(tmp_path): + _add_caches(["GCD1"]) + host = DbHost() + host.answer = False + + host, out = _run_csv_import("declined.csv", tmp_path, host) + + assert host.asked == ["1 will be set, 1 cleared (1 skipped, 0 invalid).\nContinue?"] + assert out[-1] == "Cancelled — nothing changed" + with get_session() as s: + cache = s.query(Cache).filter_by(gc_code="GCD1").one() + assert cache.user_note is None or not cache.user_note.is_corrected + + +def test_csv_import_never_clears_on_empty_or_half_filled_rows(tmp_path): + codes = ["GCE1", "GCE2", "GCE3", "GCE4", "GCE5"] + _add_caches(codes) + for code in codes: + set_corrected_coords(code, 1.0, 2.0) # already solved + + # partial_rows.csv: GCE1 nothing, GCE2 lat only, GCE3 lon only, + # GCE4 "Clear", GCE5 lat + lon + _, out = _run_csv_import("partial_rows.csv", tmp_path) + + assert "GCE1: no coordinates — skipped" in out + assert "GCE2: lon missing" in out + assert "GCE3: lat missing" in out + assert "GCE4: corrected coordinates removed" in out + assert out[-1] == "Done: 1 set, 1 cleared, 1 skipped, 0 not found, 2 failed" + with get_session() as s: + got = {c.gc_code: (c.user_note.corrected_lat, c.user_note.corrected_lon) + for c in s.query(Cache).filter(Cache.gc_code.in_(codes))} + assert got["GCE1"] == (1.0, 2.0) + assert got["GCE2"] == (1.0, 2.0) + assert got["GCE3"] == (1.0, 2.0) + assert got["GCE4"] == (None, None) + assert got["GCE5"] == (pytest.approx(47.5), pytest.approx(8.5))