diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91c605e..cb23547 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,10 @@ workflow smoke test. Set `HORTICALC_BROWSER_PATH` when the browser is outside a standard location. The repository does not declare a separate Node version; use a maintained Node.js release with npm. +Tests use temporary application data rather than the checkout's `user/` +directory. Browser subprocesses also receive an isolated application root; +they do not load your local preferences, recipes, or catalogue overrides. + The entrypoint runs Ruff format and lint checks, ESLint, Stylelint, frontend unit tests, the browser smoke test, and the complete Pytest suite. For a focused Pytest run: diff --git a/RELEASE.md b/RELEASE.md index 862ed55..32e56b3 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -53,6 +53,9 @@ in its standard installation directory. Build behavior is owned by `scripts/packaging/horticalc.spec`, the platform build scripts, and `scripts/packaging/horticalc.iss`. +Windows scripts check native command exit codes and stop on failed version +discovery, version-resource generation, packaging, or installer compilation. + The Linux application is written to `dist/horticalc/`; the Windows application is written to `dist/Horticalc/`; and the installer is written to the repository root. Launch `dist/horticalc/horticalc` or diff --git a/api/app.py b/api/app.py index ac340c7..33eca26 100644 --- a/api/app.py +++ b/api/app.py @@ -4,6 +4,7 @@ import logging import math import os +import threading from contextlib import asynccontextmanager from datetime import datetime, timezone from pathlib import Path @@ -234,15 +235,16 @@ async def security_headers(request: Request, call_next): def _validation_details(errors: list[dict]) -> list[dict]: - for error in errors: - invalid_input = error.get("input") - if isinstance(invalid_input, float) and not math.isfinite(invalid_input): - error["input"] = str(invalid_input) - if "ctx" in error: - error["ctx"] = { - key: str(value) if isinstance(value, ValueError) else value for key, value in error["ctx"].items() - } - return errors + def safe_value(value: Any) -> Any: + if isinstance(value, ValueError) or isinstance(value, float) and not math.isfinite(value): + return str(value) + if isinstance(value, dict): + return {key: safe_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [safe_value(item) for item in value] + return value + + return [safe_value(error) for error in errors] @app.exception_handler(RequestValidationError) @@ -253,6 +255,7 @@ async def request_validation_error(_request: Request, exc: RequestValidationErro FERTILIZERS: Dict[str, Fertilizer] = {} MOLAR_MASSES: Dict[str, float] = {} PORTABLE_LAYOUT: PortableLayout | None = None +_preferences_lock = threading.RLock() FRONTEND_DIR = app_root() / "frontend" @@ -432,10 +435,6 @@ def _validated_request_model(model_type: type[BaseModel], payload: dict) -> Base raise HTTPException(status_code=422, detail=_validation_details(exc.errors(include_url=False))) from exc -def _model_dump(model: BaseModel) -> dict: - return model.model_dump() - - def _safe_filename(name: str) -> str: return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in name).strip("_") @@ -725,11 +724,12 @@ def put_preferences(payload: PreferencesPayload) -> dict[str, Any]: updates[field_name], field_name=field_name, ) - preferences = load_user_preferences() - preferences.update(updates) - save_user_preferences(preferences) - if "solver_history_limit" in updates: - trim_solver_history(_solver_history_path(), _effective_solver_history_limit(preferences)) + with _preferences_lock: + preferences = load_user_preferences() + preferences.update(updates) + save_user_preferences(preferences) + if "solver_history_limit" in updates: + trim_solver_history(_solver_history_path(), _effective_solver_history_limit(preferences)) return preferences @@ -1028,7 +1028,7 @@ async def save_recipe_profile(request: Request) -> dict: payload_out = { "name": name, "liters": recipe.liters, - "fertilizers": [_model_dump(entry) for entry in recipe.fertilizers], + "fertilizers": [entry.model_dump() for entry in recipe.fertilizers], "fertilizers_allowed": fertilizers_allowed, "urea_as_nh4": recipe.urea_as_nh4, } @@ -1070,7 +1070,7 @@ def _calculation_inputs(payload: RecipeRequest) -> tuple[dict, dict[str, float], recipe = { "liters": payload.liters, - "fertilizers": [_model_dump(entry) for entry in payload.fertilizers], + "fertilizers": [entry.model_dump() for entry in payload.fertilizers], "urea_as_nh4": payload.urea_as_nh4, } return recipe, water_mg_l, osmosis_percent diff --git a/docs/architecture.md b/docs/architecture.md index fba0f9a..28be5b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,6 +112,13 @@ shipped resources; fertilizer edits are stored as catalogue deltas. WebView state lives in `user/webview/`, separate from Solver history and structured profiles. +Preference updates serialize their read/merge/write operation within the API +process and replace the file atomically. A write failure preserves the previous +file. Changing history retention persists preferences before trimming history; +a trim failure is reported without rolling back the saved preference. The +frontend queues writes and reports failed operations without blocking later +updates. + The API owns validation and persistence. The core owns chemistry and optimization inputs but not filesystem or HTTP presentation. The UI owns display units, locale, navigation, and transient interaction state. diff --git a/docs/solver.md b/docs/solver.md index 177ca6a..e00bac6 100644 --- a/docs/solver.md +++ b/docs/solver.md @@ -34,6 +34,7 @@ the reported solution. affected a solve. - Numeric zero targets are normally report-only. +- In `n_forms_only` mode, explicit zero nitrogen-form targets remain objectives. - `Na` and `Cl` are always report-only. - `nitrogen_objective_mode` selects provided nitrogen targets, total nitrogen only, or individual nitrogen forms only. The default is `n_total_only`. @@ -42,9 +43,14 @@ affected a solve. elemental `S` target. - Hierarchical target directions at priority `0` are report-only. -`ignored_elements` remains part of accepted persisted/API data. The -hierarchical model interprets each listed element as priority `0` in both -directions; the GUI persists the canonical `target_priorities` representation. +Urea mode applies to both optimization coefficients and reported results. With +`urea_as_nh4=true`, fertilizer urea contributes to `N_NH4` instead of `N_UREA`; +its contribution to total nitrogen is unchanged. + +`ignored_elements` remains part of accepted persisted/API data and survives GUI +load/save cycles for every model. The hierarchical model displays each listed +element at priority `0` in both directions. Editing either priority replaces +that element's ignore entry with the selected directional priorities. ## NNLS + tuning @@ -61,6 +67,10 @@ The standard path can apply relative weighting, iterative reweighted least squares (IRLS) overshoot penalties, an optional nitrogen-total governor, and dominant-supplier correction passes. These controls affect only `nnls_tuning`. +Candidate comparisons include explicit zero nitrogen-form objectives. Their +errors are measured against `scale_eps_mg_per_l`; non-zero targets use their +percentage error. Zero targets outside the selected objectives stay report-only. + ## Mass NNLS (experimental) `mass_nnls` minimizes unweighted squared residuals in elemental `mg/L`: diff --git a/docs/usage.md b/docs/usage.md index 3ec1682..4fc46e3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -24,6 +24,9 @@ display unit converts the visible quantity without changing the stored batch or dose. The sidebar shows nitrate, ammonium and urea shares as **NO3:NH4:UR (%)**, calculated from their nitrogen contributions. +Batch-volume changes scale doses without rounding the stored quantities. +Display formatting does not limit the precision of small trace doses. + Saved calculator recipes contain their batch, fertilizer doses, water profile, RO-water proportion, urea setting, optional solver configuration, and any stock preparation settings. The star @@ -95,6 +98,13 @@ The feature plans preparation; it does not track bottles already mixed. Use **Water analysis** to load, edit, and save the water used by Calculator and Solver. Values describe the source water before reverse-osmosis mixing. +Loaded profiles show their combined phosphorus as P and set PO4 to zero, so +loading or saving a profile does not count the same phosphorus twice. Separately +entered P and PO4 values add together. + +A pending load is ignored if a different profile is selected before it finishes. +This also applies to recipe and Solver target-profile loading. + The **RO-water proportion** is the percentage of the final batch modelled as pure RO water with `0 mg/L` dissolved input. At 25%, Horticalc uses 75% of every entered source-water concentration. @@ -102,8 +112,9 @@ entered source-water concentration. ## Fertilizer editor Use **Fertilizer editor** to search and edit the effective catalogue. Composition -values are mass fractions: `0.14` means 14%. A liquid product uses its weight -factor as density when converting the displayed volume dose. +values are entered as percentages: enter `14` for 14%. CSV and API composition +values remain mass fractions (`0.14` for the same analysis). A liquid product uses +its weight factor as density when converting the displayed volume dose. Select an editor row and use minus to delete it from the editable list. The table fills the remaining workspace height at the selected interface scale; @@ -135,6 +146,9 @@ allowed fertilizers, fixed doses, urea mode, and solver settings. Loading with the option disabled changes only the targets. Horticalc checks referenced water and fertilizer names before replacing the current setup. +Changing or scaling targets clears the previous result. Calculate again before +copying, saving, applying, or preparing stocks from the new targets. + The target-profile star is independent of recipe favorites. Delete is available only for user-saved recipes and target profiles. Deleting a user override of a shipped profile reveals the shipped profile again. @@ -147,6 +161,9 @@ a run above normal history and protects it from retention and the regular clear action. The history limit counts unpinned runs; setting it to `0` retains pins but stops normal history recording. +Closing a history dialog or opening another run discards pending responses for +the old dialog. Copy and Restore use the run currently displayed. + ## Preferences and languages Configuration controls volume, solid-dose and liquid-dose display units, theme, @@ -156,6 +173,11 @@ wheel uses the same scale steps. Horticalc provides German, English, Dutch, Spanish, and Simplified Chinese UI text. Data keys, element symbols, file formats, and API fields remain unchanged when the language changes. +Changing language preserves the selected water profile and its analysis. +Preference save failures are reported. Appearance and unit changes remain active +for the session; failed history-limit and favorite changes return to their last +confirmed values. + ## Files and backups Horticalc keeps shipped defaults in `data/` and `recipes/`. Your edits, diff --git a/frontend/app/api.js b/frontend/app/api.js index 1be79d7..30de354 100644 --- a/frontend/app/api.js +++ b/frontend/app/api.js @@ -54,16 +54,16 @@ export function loadPreferences() { } export function persistPreferences(updates) { - preferences = { ...preferences, ...updates }; - preferenceWritePromise = preferenceWritePromise.then(() => + const write = preferenceWritePromise.then(() => putJson("/preferences", updates, "Unable to save preferences", { keepalive: true }) .then((data) => { preferences = data && typeof data === "object" ? data : preferences; return preferences; }) - .catch(() => preferences) ); - return preferenceWritePromise; + // Recover the queue while leaving the operation's rejection with its caller. + preferenceWritePromise = write.catch(() => {}); + return write; } export const fetchFertilizers = (message) => getJson("/fertilizers", message); diff --git a/frontend/app/calculator.js b/frontend/app/calculator.js index 431af80..e749e80 100644 --- a/frontend/app/calculator.js +++ b/frontend/app/calculator.js @@ -12,7 +12,7 @@ import { qsa, renderTableRows, } from "./dom.js"; -import { buildAlignedRows, formatNumber, roundScaledValue } from "./formatting.js"; +import { buildAlignedRows, formatNumber } from "./formatting.js"; import { applyScaledValues, bindScaleButtons } from "./scaling.js"; import { createLatestRequestGate } from "../request_gate.js"; import { storageSet } from "./storage.js"; @@ -62,7 +62,7 @@ function createCalculatorRow(name = "", grams = 0) { return { name, grams: normalizedGrams, - baseGrams: roundScaledValue(normalizedGrams), + baseGrams: normalizedGrams, }; } @@ -320,7 +320,7 @@ function renderCalculatorTable() { if (calculatorRow.baseGrams === undefined) { const currentAmount = Math.max(0, Number(calculatorRow.grams) || 0); calculatorRow.baseGrams = - calculatorScaleFactor > 0 ? roundScaledValue(currentAmount / calculatorScaleFactor) : 0; + calculatorScaleFactor > 0 ? currentAmount / calculatorScaleFactor : 0; } const indexCell = document.createElement("td"); @@ -344,7 +344,7 @@ function renderCalculatorTable() { } calculatorRow.grams = canonicalValue; calculatorRow.baseGrams = - calculatorScaleFactor > 0 ? roundScaledValue(canonicalValue / calculatorScaleFactor) : 0; + calculatorScaleFactor > 0 ? canonicalValue / calculatorScaleFactor : 0; scheduleRecalculate(); }); input.addEventListener("change", () => { @@ -531,10 +531,10 @@ function initializeTables() { function scaleBatch(previousLiters, nextLiters) { const factor = nextLiters / previousLiters; calculatorRows.forEach((row) => { - const scaled = roundScaledValue((Number(row.grams) || 0) * factor); + const scaled = (Number(row.grams) || 0) * factor; row.grams = scaled; row.baseGrams = calculatorScaleFactor > 0 - ? roundScaledValue(scaled / calculatorScaleFactor) + ? scaled / calculatorScaleFactor : scaled; }); renderCalculatorTable(); @@ -596,11 +596,9 @@ return { collectSelectedFertilizerNames, get lastCalculation() { return lastCalculation; }, get currentRequest() { return calculatorResultCurrent ? JSON.parse(JSON.stringify(lastCalculationRequest)) : null; }, - get resultCurrent() { return calculatorResultCurrent; }, mount, refreshDoseUnits() { renderSelectionTable(); renderCalculatorTable(); }, refreshLocalized, - render: renderCalculation, scaleBatch, scheduleRecalculate, setFertilizers, diff --git a/frontend/app/formatting.js b/frontend/app/formatting.js index 50ef2d1..b1255cb 100644 --- a/frontend/app/formatting.js +++ b/frontend/app/formatting.js @@ -26,10 +26,6 @@ export function formatNumber(value, formatter = defaultNumberFormatter) { return Number.isFinite(value) ? formatter.format(value) : "-"; } -export function roundScaledValue(value) { - return Math.round(value * 1000) / 1000; -} - export function buildAlignedRows(headers, rows, numericColumns = []) { const allRows = headers ? [headers, ...rows] : rows; const widths = allRows.reduce((current, row) => { diff --git a/frontend/app/history.js b/frontend/app/history.js index 7ebb770..babc941 100644 --- a/frontend/app/history.js +++ b/frontend/app/history.js @@ -1,5 +1,6 @@ import { qs } from "./dom.js"; import { buildSolverPrintableText } from "./solver_printable.js"; +import { createLatestRequestGate } from "../request_gate.js"; const HISTORY_DEFAULT_LIMIT = 1000; const HISTORY_PREVIEW_MAX_LINES = 18; @@ -47,6 +48,7 @@ export function createHistoryController({ api, i18n, notifications, units, onRes const copyButton = qs("#copySolverHistoryEntry"); const restoreButton = qs("#restoreSolverHistoryEntry"); const detailCache = new Map(); + const dialogRequests = createLatestRequestGate(); const t = (key, params) => i18n.t(key, params); let entries = []; let limit = HISTORY_DEFAULT_LIMIT; @@ -126,6 +128,7 @@ export function createHistoryController({ api, i18n, notifications, units, onRes } async function openDetail(entry) { + const version = dialogRequests.reserve(); activePreviewId = ""; preview.classList.add("is-hidden"); activeDialogDetail = null; @@ -138,11 +141,13 @@ export function createHistoryController({ api, i18n, notifications, units, onRes else dialog.setAttribute("open", ""); try { const detail = await fetchDetail(entry.id); + if (!dialogRequests.isCurrent(version) || !dialog.hasAttribute("open")) return; activeDialogDetail = detail; dialogOutput.textContent = detailText(detail); copyButton.disabled = false; restoreButton.disabled = false; } catch (error) { + if (!dialogRequests.isCurrent(version) || !dialog.hasAttribute("open")) return; dialogOutput.textContent = t("history.previewUnavailable"); notifications.reportError(error, t("errors.loadSolverHistoryEntry")); } @@ -235,7 +240,11 @@ export function createHistoryController({ api, i18n, notifications, units, onRes } }); dialog.addEventListener("close", () => { + if (dialog.hasAttribute("open")) return; + dialogRequests.invalidate(); activeDialogDetail = null; + copyButton.disabled = true; + restoreButton.disabled = true; dialogStatus.textContent = ""; }); } diff --git a/frontend/app/main.js b/frontend/app/main.js index 0d4bc84..10b4a59 100644 --- a/frontend/app/main.js +++ b/frontend/app/main.js @@ -429,7 +429,7 @@ async function init() { stocks.mount(); document.querySelector("#makeSolverStocks").addEventListener("click", openSolverStocks); calculator.setFertilizers(resources.fertilizers); - solver.setFertilizers(resources.fertilizers); + solver.setFertilizers(resources.fertilizers, { persist: false }); solver.mount({ configDefinitions: resources.solverConfigDefinitions, config: preferences.solver_config || {} }); editor.setData(resources.fertilizers, resources.fertilizerKeys); editor.mount(); diff --git a/frontend/app/profiles.js b/frontend/app/profiles.js index 0315ed1..a0c4d38 100644 --- a/frontend/app/profiles.js +++ b/frontend/app/profiles.js @@ -31,6 +31,7 @@ export function createProfilesController({ api, i18n, notifications, actions }) let favoriteRecipes = new Set(); let favoriteNutrientSolutions = new Set(); let mounted = false; + let favoriteWritePending = false; const t = (key, params) => i18n.t(key, params); function activeFavorites() { @@ -46,7 +47,7 @@ export function createProfilesController({ api, i18n, notifications, actions }) syncSelectedOptionTitle(select); const favorite = Boolean(selected) && activeFavorites().has(selected); const label = t(favorite ? "profile.removeFavorite" : "profile.addFavorite"); - favoriteButton.disabled = !selected; + favoriteButton.disabled = !selected || favoriteWritePending; favoriteButton.textContent = favorite ? "★" : "☆"; favoriteButton.setAttribute("aria-pressed", String(favorite)); favoriteButton.setAttribute("aria-label", label); @@ -90,15 +91,26 @@ export function createProfilesController({ api, i18n, notifications, actions }) async function toggleFavorite() { const filename = select.value; - if (!filename) return; + if (!filename || favoriteWritePending) return; const favorites = activeFavorites(); - if (favorites.has(filename)) favorites.delete(filename); + const wasFavorite = favorites.has(filename); + if (wasFavorite) favorites.delete(filename); else favorites.add(filename); + favoriteWritePending = true; const preferenceKey = mode === "solver" ? "favorite_nutrient_solutions" : "favorite_recipes"; renderOptions(); select.value = filename; refreshSelectionActions(); - await api.persistPreferences({ [preferenceKey]: [...favorites] }); + try { + await api.persistPreferences({ [preferenceKey]: [...favorites] }); + } catch (error) { + if (wasFavorite) favorites.add(filename); + else favorites.delete(filename); + notifications.reportError(error, t("errors.saveFailed")); + } finally { + favoriteWritePending = false; + renderOptions(); + } } async function deleteSelected() { @@ -132,7 +144,12 @@ export function createProfilesController({ api, i18n, notifications, actions }) const preferenceKey = activeMode === "solver" ? "favorite_nutrient_solutions" : "favorite_recipes"; - await api.persistPreferences({ [preferenceKey]: [...favorites] }); + try { + await api.persistPreferences({ [preferenceKey]: [...favorites] }); + } catch (error) { + favorites.add(filename); + notifications.reportError(error, t("errors.saveFailed")); + } } if (!requests.isCurrent(version) || mode !== activeMode) return; const replacement = updatedProfiles.find((profile) => profile.filename === filename); @@ -162,31 +179,34 @@ export function createProfilesController({ api, i18n, notifications, actions }) } async function loadSelected() { - if (!select.value) { + const filename = select.value; + if (!filename) { notifications.reportError(null, t("errors.profileRequired")); return; } const version = requests.reserve(); const activeMode = mode; const includeSolverSetup = includeSolverSetupInput.checked; + const isCurrent = () => requests.isCurrent(version) && mode === activeMode && select.value === filename; try { if (activeMode === "solver") { - const solution = await api.fetchNutrientSolutionData(select.value, t("errors.loadNutrientSolution")); - if (!requests.isCurrent(version) || mode !== activeMode) return; + const solution = await api.fetchNutrientSolutionData(filename, t("errors.loadNutrientSolution")); + if (!isCurrent()) return; await actions.applyNutrientSolution(solution, { includeSetup: includeSolverSetup, - isCurrent: () => requests.isCurrent(version), + isCurrent, }); - if (!requests.isCurrent(version) || mode !== activeMode) return; + if (!isCurrent()) return; nameInput.value = solution.name || ""; } else { - const recipe = await api.fetchRecipeData(select.value, t("errors.loadRecipe")); - if (!requests.isCurrent(version) || mode !== activeMode) return; - await actions.applyRecipeProfile(recipe, select.value, () => requests.isCurrent(version)); + const recipe = await api.fetchRecipeData(filename, t("errors.loadRecipe")); + if (!isCurrent()) return; + await actions.applyRecipeProfile(recipe, filename, isCurrent); + if (!isCurrent()) return; nameInput.value = recipe.name || ""; } } catch (error) { - if (requests.isCurrent(version) && mode === activeMode) { + if (isCurrent()) { notifications.reportError(error, t("errors.loadProfile")); } } diff --git a/frontend/app/scaling.js b/frontend/app/scaling.js index ad58e69..a391cf8 100644 --- a/frontend/app/scaling.js +++ b/frontend/app/scaling.js @@ -1,5 +1,3 @@ -import { roundScaledValue } from "./formatting.js"; - export function roundScaleFactor(value) { return Math.round(value * 100) / 100; } @@ -10,7 +8,7 @@ export function scaledValues(definitions, nextFactor, getBaseValue) { factor, values: definitions.map((definition) => Math.max( 0, - roundScaledValue((getBaseValue(definition) || 0) * factor), + (getBaseValue(definition) || 0) * factor, )), }; } @@ -25,7 +23,7 @@ export function scaleAmountsByVolume(values, previousLiters, nextLiters) { const factor = nextLiters / previousLiters; return Object.fromEntries(Object.entries(values).map(([key, value]) => [ key, - roundScaledValue((Number(value) || 0) * factor), + (Number(value) || 0) * factor, ])); } diff --git a/frontend/app/settings.js b/frontend/app/settings.js index d36fe3a..ee9f77d 100644 --- a/frontend/app/settings.js +++ b/frontend/app/settings.js @@ -41,6 +41,10 @@ export function createSettingsController({ const normalizeTheme = (theme) => themeOptions.has(theme) ? theme : defaultTheme; + function savePreferences(updates) { + persistPreferences(updates).catch((error) => reportError(error, i18n.t("errors.saveFailed"))); + } + function themeTranslationKey(theme) { return `theme.${theme.split("-").map((part, index) => index === 0 ? part : `${part[0].toUpperCase()}${part.slice(1)}`).join("")}`; } @@ -103,7 +107,7 @@ export function createSettingsController({ const nextIndex = Math.min(uiScales.length - 1, Math.max(0, currentIndex + direction)); const nextScale = uiScales[nextIndex]; if (nextScale === uiScale) return; - persistPreferences({ ui_scale: applyUiScale(nextScale) }); + savePreferences({ ui_scale: applyUiScale(nextScale) }); } function handleUiScaleShortcut(event) { @@ -114,7 +118,7 @@ export function createSettingsController({ if (!isReset && !isIncrease && !isDecrease) return; event.preventDefault(); if (isReset) { - persistPreferences({ ui_scale: applyUiScale(defaultUiScale) }); + savePreferences({ ui_scale: applyUiScale(defaultUiScale) }); } else { changeUiScale(isIncrease ? 1 : -1); } @@ -165,14 +169,14 @@ export function createSettingsController({ if (mounted) return; mounted = true; themeSelect?.addEventListener("change", (event) => { - persistPreferences({ theme: applyTheme(event.target.value) }); + savePreferences({ theme: applyTheme(event.target.value) }); }); languageSelect?.addEventListener("change", (event) => { i18n.setLocale(event.target.value); - persistPreferences({ locale: i18n.getLocale() }); + savePreferences({ locale: i18n.getLocale() }); }); uiScaleSelect?.addEventListener("change", (event) => { - persistPreferences({ ui_scale: applyUiScale(event.target.value) }); + savePreferences({ ui_scale: applyUiScale(event.target.value) }); }); window.addEventListener("keydown", handleUiScaleShortcut); window.addEventListener("wheel", handleUiScaleWheel, { passive: false }); @@ -180,27 +184,28 @@ export function createSettingsController({ const liters = units.displayVolumeToLiters(event.target.value); if (liters === null || liters <= 0) return; units.setLiters(liters, { scaleBatch: true, recalculate: true, invalidateSolver: true }); - persistPreferences({ default_liters: liters }); + savePreferences({ default_liters: liters }); render(); }); litersInput?.addEventListener("change", render); volumeUnitSelect?.addEventListener("change", (event) => { units.setVolumeUnit(event.target.value); - persistPreferences({ volume_unit: units.volumeUnit }); + savePreferences({ volume_unit: units.volumeUnit }); render(); onUnitsChange(); }); solidDoseUnitSelect?.addEventListener("change", (event) => { units.setSolidDoseUnit(event.target.value, true); - persistPreferences({ solid_dose_unit: units.solidDoseUnit }); + savePreferences({ solid_dose_unit: units.solidDoseUnit }); render(); }); liquidDoseUnitSelect?.addEventListener("change", (event) => { units.setLiquidDoseUnit(event.target.value, true); - persistPreferences({ liquid_dose_unit: units.liquidDoseUnit }); + savePreferences({ liquid_dose_unit: units.liquidDoseUnit }); render(); }); solverHistoryLimitInput?.addEventListener("change", async (event) => { + if (solverHistoryLimitInput.disabled) return; const rawValue = String(event.target.value).trim(); const value = Number(rawValue); if (!rawValue || !Number.isInteger(value) || value < 0 || value > 10000) { @@ -211,10 +216,19 @@ export function createSettingsController({ event.target.value = String(solverHistoryLimit); return; } - solverHistoryLimit = value; - await persistPreferences({ solver_history_limit: value }); - solverHistorySettingsStatus.textContent = i18n.t("history.limitSaved"); - onSolverHistoryChange(); + solverHistoryLimitInput.disabled = true; + solverHistorySettingsStatus.textContent = ""; + try { + await persistPreferences({ solver_history_limit: value }); + solverHistoryLimit = value; + solverHistorySettingsStatus.textContent = i18n.t("history.limitSaved"); + onSolverHistoryChange(); + } catch (error) { + solverHistoryLimitInput.value = String(solverHistoryLimit); + reportError(error, i18n.t("errors.saveFailed")); + } finally { + solverHistoryLimitInput.disabled = false; + } }); clearSolverHistoryButton?.addEventListener("click", async () => { if (!window.confirm(i18n.t("history.confirmClear"))) return; @@ -255,5 +269,5 @@ export function createSettingsController({ render(); } - return { mount, render, applyTheme }; + return { mount, render }; } diff --git a/frontend/app/shell.js b/frontend/app/shell.js index 9940766..c7b66ed 100644 --- a/frontend/app/shell.js +++ b/frontend/app/shell.js @@ -77,7 +77,6 @@ export function createShellController({ i18n, onViewChange }) { } return { - get currentView() { return currentView; }, isActive(view) { return currentView === view; }, mount, refreshLocalized() { updateLiveResult(); }, diff --git a/frontend/app/solver.js b/frontend/app/solver.js index 9b2673c..813f8ed 100644 --- a/frontend/app/solver.js +++ b/frontend/app/solver.js @@ -11,7 +11,6 @@ import { formatNumber, normalizeDecimalInputElement, parseDecimalInput, - roundScaledValue, } from "./formatting.js"; import { applyScaledValues, bindScaleButtons, scaleAmountsByVolume } from "./scaling.js"; import { @@ -112,15 +111,16 @@ let solverAllowedFilter = ""; let solverAllowedHideInactive = false; let searchTimer; let mounted = false; +let solverIgnoredElements = new Set(); const MASS_MODEL = "mass_nnls"; const REPORT_ONLY_TARGETS = new Set(["Na", "Cl"]); const TARGET_PRIORITY_LEVELS = [1, 2, 3, 4, 0]; function setTargetPriorities(priorities = {}, ignoredElements = []) { - const ignored = new Set(Array.isArray(ignoredElements) ? ignoredElements : []); + solverIgnoredElements = new Set(Array.isArray(ignoredElements) ? ignoredElements : []); solverTargetDefinitions.forEach(({ key }) => { - const reportOnly = REPORT_ONLY_TARGETS.has(key) || ignored.has(key); + const reportOnly = REPORT_ONLY_TARGETS.has(key) || solverIgnoredElements.has(key); const configured = priorities?.[key] || {}; solverTargetPriorities[key] = { under: reportOnly ? 0 : normalizedPriority(configured.under), @@ -131,7 +131,7 @@ function setTargetPriorities(priorities = {}, ignoredElements = []) { function serializedTargetPriorities() { return Object.fromEntries(solverTargetDefinitions.flatMap(({ key }) => { - if (REPORT_ONLY_TARGETS.has(key)) return []; + if (REPORT_ONLY_TARGETS.has(key) || solverIgnoredElements.has(key)) return []; const priorities = solverTargetPriorities[key] || { under: DEFAULT_TARGET_PRIORITY, over: DEFAULT_TARGET_PRIORITY, @@ -144,12 +144,16 @@ function serializedTargetPriorities() { function buildCurrentSolverConfig() { return { ...buildSolverConfigPayload(solverConfigDefinitions, solverConfigControls), + ignored_elements: [...solverIgnoredElements], target_priorities: serializedTargetPriorities(), }; } -function persistCurrentSolverConfig() { - api.persistPreferences({ solver_config: buildCurrentSolverConfig() }); +function persistSolverConfig(config = buildCurrentSolverConfig()) { + return api.persistPreferences({ solver_config: config }).then(() => true).catch((error) => { + notifications.reportError(error, t("errors.saveFailed")); + return false; + }); } function syncSolverModelControls() { @@ -191,6 +195,7 @@ function applySolverTargetScaleFactor(nextFactor) { ); updateSolverTargetScaleDisplay(); renderSolverTargetsTable(); + renderSolverResults(null); } function buildSolvePayload() { @@ -230,7 +235,7 @@ function renderSolverTargetsTable() { const rawValue = Math.max(0, decimalInputValue(event.target.value)); solverTargetValues[field.key] = rawValue; solverTargetBaseValues[field.key] = - solverTargetScaleFactor > 0 ? roundScaledValue(rawValue / solverTargetScaleFactor) : 0; + solverTargetScaleFactor > 0 ? rawValue / solverTargetScaleFactor : 0; renderSolverResults(null); }); input.addEventListener("change", () => { @@ -268,11 +273,12 @@ function renderSolverTargetsTable() { ? t(`solver.priority.${direction}Hint`, { element: field.label }) : t("solver.priority.selectHierarchicalHint"); select.addEventListener("change", () => { + solverIgnoredElements.delete(field.key); solverTargetPriorities[field.key][direction] = normalizedPriority(select.value); const current = solverTargetPriorities[field.key]; row.classList.toggle("solver-target-report-only", current.under === 0 && current.over === 0); renderSolverResults(null); - persistCurrentSolverConfig(); + persistSolverConfig(); }); cell.appendChild(select); return cell; @@ -636,7 +642,7 @@ function missingFertilizers(names = []) { return names.filter((name) => !available.has(name)); } -function updateSolverAllowedFertilizers(names, mode = "merge", { rerenderPicker = true } = {}) { +function updateSolverAllowedFertilizers(names, mode = "merge", { rerenderPicker = true, persist = true } = {}) { const uniqueNames = Array.from(new Set(names.filter(Boolean))); if (mode === "replace") { solverAllowedFertilizers.length = 0; @@ -655,7 +661,7 @@ function updateSolverAllowedFertilizers(names, mode = "merge", { rerenderPicker updateSolverAllowedCount(); } renderSolverFixedTable(); - persistSolverAllowedToStorage(); + if (persist) persistSolverAllowedToStorage(); renderSolverResults(null); } @@ -700,12 +706,13 @@ function restoreSolverAllowedFromStorage(context = solverAllowedContext) { return true; } -function setFertilizers(fertilizers) { +function setFertilizers(fertilizers, { persist = true } = {}) { fertilizerOptions = fertilizers || []; const available = new Set(fertilizerOptions.map(({ name }) => name)); updateSolverAllowedFertilizers( solverAllowedFertilizers.filter((name) => available.has(name)), "replace", + { persist }, ); } @@ -742,9 +749,7 @@ function bindConfigEvents() { renderSolverTargetsTable(); } renderSolverResults(null); - api.persistPreferences({ - solver_config: buildCurrentSolverConfig(), - }); + persistSolverConfig(); }); if (definition.type !== "boolean" && definition.type !== "string" @@ -793,14 +798,13 @@ function mount({ configDefinitions = [], config = {} } = {}) { }); solverAllowedClearButton?.addEventListener("click", () => updateSolverAllowedFertilizers([], "replace")); solverAutoApplyInput?.addEventListener("change", persistSolverAutoApplyPreference); - solverConfigResetDefaultsButton?.addEventListener("click", () => { + solverConfigResetDefaultsButton?.addEventListener("click", async () => { setTargetPriorities(); applySolverConfig(solverConfigDefinitions, solverConfigControls); syncSolverModelControls(); renderSolverTargetsTable(); renderSolverResults(null); - api.persistPreferences({ solver_config: {} }); - notifications.setSolverApplyStatus(t("solver.configResetDone")); + if (await persistSolverConfig({})) notifications.setSolverApplyStatus(t("solver.configResetDone")); }); solverUreaToggle.addEventListener("change", () => renderSolverResults(null)); solveButton.addEventListener("click", async () => { @@ -868,7 +872,6 @@ return { refreshLocalized, renderResults: renderSolverResults, resetTargets: resetSolverTargets, - restoreAllowed: restoreSolverAllowedFromStorage, scaleFixedAmounts, setAllowedContext, setAllowedFertilizers: updateSolverAllowedFertilizers, diff --git a/frontend/app/solver_payload.js b/frontend/app/solver_payload.js index 4e03386..5b88234 100644 --- a/frontend/app/solver_payload.js +++ b/frontend/app/solver_payload.js @@ -70,7 +70,7 @@ export function buildSolvePayload({ }) { return { liters, - targets: positiveEntries(targetValues), + targets: { ...targetValues }, water_profile: { mg_per_l: waterMgPerL, osmosis_percent: osmosisPercent }, fertilizers_allowed: allowedFertilizers, fixed_grams: positiveEntries(fixedGrams), diff --git a/frontend/app/water.js b/frontend/app/water.js index e556b95..00faccf 100644 --- a/frontend/app/water.js +++ b/frontend/app/water.js @@ -134,10 +134,10 @@ function handleWaterValueInput(key, rawValue, { updateCurrent = false, invalidat if (invalidate) waterProfileRequests.invalidate(); const parsed = decimalInputValue(rawValue); waterValues[key] = waterUnit === "mol_l" ? molToMg(key, parsed) : parsed; - const updatedKeys = applyWaterHelpers(waterValues, getMolarMass); - updatedKeys - .filter((updatedKey) => updateCurrent || updatedKey !== key) - .forEach((updatedKey) => updateWaterInputValue(updatedKey)); + const updatedKeys = applyWaterHelpers(waterValues); + updatedKeys.forEach((updatedKey) => { + if (updateCurrent || updatedKey !== key) updateWaterInputValue(updatedKey); + }); scheduleRecalculate(); } @@ -630,12 +630,6 @@ function formatOxideValue(key, value) { return nutrientFormatter.format(value); } -function buildWaterPayloadForApi(rawValues) { - const values = { ...rawValues }; - applyWaterHelpers(values, getMolarMass); - return values; -} - function waterElementsForDisplay(elements) { if (waterUnit !== "mol_l") { return elements; @@ -735,7 +729,9 @@ function applyWaterProfile(profile) { waterFieldDefinitions.forEach((field) => { waterValues[field.key] = Number(mg[field.key]) || 0; }); - applyWaterHelpers(waterValues, getMolarMass); + // Augmented profiles contain equivalent P and PO4 values. + if (profile.normalized_mg_per_l) waterValues.PO4 = 0; + applyWaterHelpers(waterValues); waterProfileNameInput.value = profile.name || ""; osmosisPercentInput.value = profile.osmosis_percent ?? 0; @@ -744,6 +740,7 @@ function applyWaterProfile(profile) { } function renderWaterProfileOptions() { + const selected = waterProfileSelect.value; waterProfileSelect.innerHTML = ""; const empty = document.createElement("option"); empty.value = ""; @@ -756,6 +753,7 @@ function renderWaterProfileOptions() { option.textContent = profile.name || profile.filename; waterProfileSelect.appendChild(option); }); + waterProfileSelect.value = selected; syncSelectedOptionTitle(waterProfileSelect); } @@ -771,7 +769,7 @@ function renderWaterProfileOptions() { } function getPayload() { - return buildWaterPayloadForApi(waterValues); + return { ...waterValues }; } function getSnapshot() { @@ -820,18 +818,22 @@ function renderWaterProfileOptions() { } async function loadSelectedProfile() { - if (!waterProfileSelect.value) { + const filename = waterProfileSelect.value; + if (!filename) { notifications.reportError(null, t("errors.waterProfileRequired")); return; } const version = waterProfileRequests.reserve(); try { - const profile = await api.fetchWaterProfileData(waterProfileSelect.value, t("errors.loadWaterProfile")); - if (!waterProfileRequests.isCurrent(version)) return; + const profile = await api.fetchWaterProfileData(filename, t("errors.loadWaterProfile")); + if (!waterProfileRequests.isCurrent(version) || waterProfileSelect.value !== filename) return; applyWaterProfile(profile); - api.persistPreferences({ last_water_profile: waterProfileSelect.value }); + await api.persistPreferences({ last_water_profile: filename }) + .catch((error) => notifications.reportError(error, t("errors.saveFailed"))); } catch (error) { - if (waterProfileRequests.isCurrent(version)) notifications.reportError(error, t("errors.loadWaterProfile")); + if (waterProfileRequests.isCurrent(version) && waterProfileSelect.value === filename) { + notifications.reportError(error, t("errors.loadWaterProfile")); + } } } @@ -843,7 +845,8 @@ function renderWaterProfileOptions() { waterProfileSelect.value = "default.yml"; syncSelectedOptionTitle(waterProfileSelect); applyWaterProfile(profile); - api.persistPreferences({ last_water_profile: "default.yml" }); + await api.persistPreferences({ last_water_profile: "default.yml" }) + .catch((error) => notifications.reportError(error, t("errors.saveFailed"))); } catch (error) { if (waterProfileRequests.isCurrent(version)) notifications.reportError(error, t("errors.loadWaterProfile")); } @@ -912,7 +915,6 @@ function renderWaterProfileOptions() { renderEmptyCalculation, renderTable: renderWaterTable, restoreSnapshot, - setProfiles, setResources, setOsmosisPercent(value) { osmosisPercentInput.value = Number(value) || 0; }, setSelectedProfile(filename) { diff --git a/scripts/packaging/build_windows.ps1 b/scripts/packaging/build_windows.ps1 index eb98d5f..121312a 100644 --- a/scripts/packaging/build_windows.ps1 +++ b/scripts/packaging/build_windows.ps1 @@ -9,13 +9,23 @@ $env:HORTICALC_PROJECT_ROOT = $repoRoot $releaseVersion = $env:HORTICALC_VERSION if ([string]::IsNullOrWhiteSpace($releaseVersion)) { - $releaseVersion = (& python -c "from horticalc import __version__; print(__version__)").Trim() + $releaseVersion = & python -c "from horticalc import __version__; print(__version__)" + if ($LASTEXITCODE -ne 0) { + throw "Version discovery failed with exit code $LASTEXITCODE." + } + $releaseVersion = $releaseVersion.Trim() } python scripts/packaging/write_windows_version_info.py --version $releaseVersion --output $versionInfoPath +if ($LASTEXITCODE -ne 0) { + throw "Version resource generation failed with exit code $LASTEXITCODE." +} $env:HORTICALC_VERSION_FILE = $versionInfoPath python -m PyInstaller --noconfirm --clean $specPath +if ($LASTEXITCODE -ne 0) { + throw "PyInstaller failed with exit code $LASTEXITCODE." +} $distRoot = Join-Path $repoRoot "dist" $appRoot = Join-Path $distRoot "Horticalc" diff --git a/scripts/packaging/build_windows_installer.ps1 b/scripts/packaging/build_windows_installer.ps1 index da4ef3c..e593669 100644 --- a/scripts/packaging/build_windows_installer.ps1 +++ b/scripts/packaging/build_windows_installer.ps1 @@ -11,7 +11,11 @@ $sourceDir = Join-Path $repoRoot "dist/Horticalc" $installerScript = Join-Path $repoRoot "scripts/packaging/horticalc.iss" if ([string]::IsNullOrWhiteSpace($Version)) { - $Version = (& python -c "from horticalc import __version__; print(__version__)").Trim() + $Version = & python -c "from horticalc import __version__; print(__version__)" + if ($LASTEXITCODE -ne 0) { + throw "Version discovery failed with exit code $LASTEXITCODE." + } + $Version = $Version.Trim() } if ([string]::IsNullOrWhiteSpace($ArtifactBaseName)) { $ArtifactBaseName = "horticalc-v$Version-windows-setup" diff --git a/scripts/state_smoke.cjs b/scripts/state_smoke.cjs new file mode 100644 index 0000000..e1cfa5d --- /dev/null +++ b/scripts/state_smoke.cjs @@ -0,0 +1,536 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const { URL } = require("node:url"); +const { chromium } = require("playwright"); + +const browserCandidates = process.platform === "win32" + ? ["C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"] + : ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"]; +const executablePath = process.env.HORTICALC_BROWSER_PATH + || browserCandidates.find((candidate) => fs.existsSync(candidate)); +const selectedFertilizer = "Yara Tera CALCINIT"; +const allowedStorageKey = "last_fertilizers_allowed::global"; + +async function calculationReady(page) { + await page.locator("#copyCalculatorResults:not([disabled])").waitFor({ state: "attached" }); +} + +async function assertSolverSelection(page) { + await page.locator('[data-shell-view="solver"]').click(); + assert.deepEqual(await page.locator("#solverAllowedFertilizers input:checked") + .evaluateAll((inputs) => inputs.map((input) => input.value)), [selectedFertilizer]); + const selected = page.locator(`#solverAllowedFertilizers input[value="${selectedFertilizer}"]`); + await selected.uncheck(); + assert.deepEqual(await page.evaluate((key) => JSON.parse(globalThis.localStorage.getItem(key)), allowedStorageKey), []); + await selected.check(); + await page.reload(); + await page.locator('#apiStatus[data-state="ready"]').waitFor(); + await page.locator('[data-shell-view="solver"]').click(); + assert.equal(await selected.isChecked(), true); +} + +async function assertCatalogPruning(page) { + const catalog = await (await page.request.get("/fertilizers")).json(); + const withoutSelected = catalog.filter(({ name }) => name !== selectedFertilizer); + const handler = (route) => route.fulfill({ json: withoutSelected }); + await page.route("**/fertilizers", handler); + await page.locator('[data-shell-view="editor"]').click(); + await page.locator("#fertEditorLoad").click(); + await page.waitForFunction(() => !Array.from(document.querySelectorAll( + '#fertilizerEditorTable input[data-field="name"]', + )).some((input) => input.value === "Yara Tera CALCINIT")); + assert.deepEqual(await page.evaluate((key) => JSON.parse(globalThis.localStorage.getItem(key)), allowedStorageKey), []); + await page.unroute("**/fertilizers", handler); + await page.locator("#fertEditorLoad").click(); + await page.waitForFunction(() => Array.from(document.querySelectorAll( + '#fertilizerEditorTable input[data-field="name"]', + )).some((input) => input.value === "Yara Tera CALCINIT")); + await page.locator('[data-shell-view="solver"]').click(); + await page.locator(`#solverAllowedFertilizers input[value="${selectedFertilizer}"]`).check(); +} + +async function assertTraceScaling(page) { + await page.locator('[data-shell-view="fertilizers"]').click(); + await page.locator(".rail-settings").evaluate((details) => { details.open = true; }); + const volume = page.locator("#configLiters"); + await volume.fill("1"); + const fertilizer = page.locator("#fertilizerSelectTable .searchable-combobox-input").first(); + await fertilizer.fill("Calcinit"); + await fertilizer.press("Enter"); + const amount = page.locator("#calculatorTable tbody input").first(); + await amount.fill("0.0002"); + await calculationReady(page); + await page.locator("#calculatorScaleUp").click(); + await calculationReady(page); + assert.ok(Math.abs(Number(await amount.inputValue()) / 0.00021 - 1) < 1e-10); + await volume.fill("2"); + await calculationReady(page); + assert.ok(Math.abs(Number(await amount.inputValue()) / 0.00042 - 1) < 1e-10); + await volume.fill("1"); + await calculationReady(page); + await page.locator("#calculatorScaleDown").click(); + await calculationReady(page); + assert.equal(Number(await amount.inputValue()), 0.0002); +} + +async function assertWaterEditing(page) { + await page.locator('[data-shell-view="water"]').click(); + await page.locator("#osmosisPercent").fill("0"); + await calculationReady(page); + const ordinary = page.waitForResponse((response) => response.url().endsWith("/calculate") + && response.request().postDataJSON().water_mg_l.Ca === 123); + await page.locator('[data-water-key="Ca"]').fill("123"); + assert.equal(await page.locator("#copyCalculatorResults").isDisabled(), true); + const result = await (await ordinary).json(); + assert.ok(Math.abs(result.water_elements_mg_per_l.Ca - 123) < 1e-10); + await calculationReady(page); + const masses = await (await page.request.get("/molar-masses")).json(); + const bicarbonate = 2 * 17.848 * masses.HCO3 / (masses.CaCO3 / 2); + const helper = page.waitForResponse((response) => response.url().endsWith("/calculate") + && Math.abs(response.request().postDataJSON().water_mg_l.HCO3 - bicarbonate) < 1e-10); + const hardness = page.locator('[data-water-key="KH"]'); + await hardness.fill("2"); + await hardness.press("Enter"); + await helper; + await calculationReady(page); + assert.equal(Number(await hardness.inputValue()), 0); + const selected = await page.locator("#waterProfileSelect").inputValue(); + await page.locator("#languageSelect").selectOption("de"); + assert.equal(await page.locator("#waterProfileSelect").inputValue(), selected); + assert.equal(Number(await page.locator('[data-water-key="Ca"]').inputValue()), 123); + await page.locator("#languageSelect").selectOption("en"); + + const profiles = await (await page.request.get("/water-profiles")).json(); + let updatedProfiles = [...profiles, { name: "Refresh", filename: "Refresh.yml" }]; + const collectionHandler = (route) => route.fulfill({ + json: route.request().method() === "GET" ? updatedProfiles : { status: "ok" }, + }); + await page.route("**/water-profiles", collectionHandler); + await page.locator("#waterProfileName").fill("State test"); + await page.locator("#saveWaterProfile").click(); + await page.locator('#waterProfileSelect option[value="Refresh.yml"]').waitFor({ state: "attached" }); + assert.equal(await page.locator("#waterProfileSelect").inputValue(), selected); + updatedProfiles = []; + await page.locator("#saveWaterProfile").click(); + await page.waitForFunction(() => document.querySelector("#waterProfileSelect").options.length === 1); + assert.equal(await page.locator("#waterProfileSelect").inputValue(), ""); + updatedProfiles = profiles; + await page.locator("#saveWaterProfile").click(); + await page.locator('#waterProfileSelect option[value="default.yml"]').waitFor({ state: "attached" }); + await page.unroute("**/water-profiles", collectionHandler); + await page.locator("#waterProfileSelect").selectOption("default.yml"); + await page.locator("#loadWaterProfile").click(); + await calculationReady(page); +} + +async function assertWaterPhosphorus(page) { + const masses = await (await page.request.get("/molar-masses")).json(); + const cases = [ + { P: 5 }, + { PO4: 5 * masses.PO4 / masses.P }, + { P: 2, PO4: 3 * masses.PO4 / masses.P }, + { P2O5: 5 * masses.P2O5 / (2 * masses.P) }, + ]; + for (const [index, mgPerL] of cases.entries()) { + const saved = await page.request.post("/water-profiles", { + data: { name: `Phosphorus ${index}`, mg_per_l: mgPerL, osmosis_percent: 25 }, + }); + assert.equal(saved.ok(), true, await saved.text()); + } + await page.reload(); + await page.locator('#apiStatus[data-state="ready"]').waitFor(); + await page.locator('[data-shell-view="water"]').click(); + const assertLoaded = async (name) => { + await page.locator("#waterProfileSelect").selectOption({ label: name }); + const calculated = page.waitForResponse((response) => response.url().endsWith("/calculate") + && response.request().postDataJSON().water_mg_l.P > 0); + await page.locator("#loadWaterProfile").click(); + const response = await calculated; + const request = response.request().postDataJSON(); + assert.ok(Math.abs(request.water_mg_l.P - 5) < 1e-10); + assert.equal(request.water_mg_l.PO4, 0); + assert.ok(Math.abs((await response.json()).water_elements_mg_per_l.P - 3.75) < 1e-10); + await calculationReady(page); + }; + for (const index of cases.keys()) { + await assertLoaded(`Phosphorus ${index}`); + const name = `Phosphorus roundtrip ${index}`; + await page.locator("#waterProfileName").fill(name); + await page.locator("#saveWaterProfile").click(); + await page.locator("#waterProfileSelect option").filter({ hasText: name }).waitFor({ state: "attached" }); + await assertLoaded(name); + } + await page.locator("#waterProfileSelect").selectOption("default.yml"); + await page.locator("#loadWaterProfile").click(); + await calculationReady(page); +} + +async function assertSolverInvalidation(page) { + await page.locator('[data-shell-view="solver"]').click(); + await page.locator("#solverMode details.solver-advanced-config").evaluate((details) => { details.open = true; }); + await page.locator("#solverAutoApply").uncheck(); + await page.locator("#solverTargetsTable tbody input").first().fill("155"); + await page.locator("#solveBtn").click(); + await page.locator("#copySolverResults:not([disabled])").waitFor(); + await page.locator("#solverTargetScaleUp").click(); + for (const id of ["copySolverResults", "saveSolverAsRecipe", "applySolverToCalculatorInline", "makeSolverStocks"]) { + assert.equal(await page.locator(`#${id}`).isDisabled(), true, id); + } + let release; + const held = new Promise((resolve) => { release = resolve; }); + const handler = async (route) => { + const response = await route.fetch(); + const targets = route.request().postDataJSON().targets; + assert.equal(targets.N_NH4, 0); + assert.equal(targets.N_UREA, 0); + await held; + await route.fulfill({ response }); + }; + await page.route("**/solve", handler); + const requested = page.waitForRequest("**/solve"); + await page.locator("#solveBtn").click(); + await requested; + await page.locator("#solverTargetScaleDown").click(); + const response = page.waitForResponse((item) => item.url().endsWith("/solve")); + release(); + await (await response).finished(); + await page.waitForTimeout(100); + assert.equal(await page.locator("#copySolverResults").isDisabled(), true); + assert.equal(await page.locator("#solverTargetsResultsTable tbody tr").count(), 0); + await page.unroute("**/solve", handler); + await page.locator("#solveBtn").click(); + await page.locator("#copySolverResults:not([disabled])").waitFor(); +} + +async function assertProfileLoadIdentity(page, getPreferences) { + const waterSaved = await page.request.post("/water-profiles", { + data: { name: "Delayed water", mg_per_l: { Ca: 43 } }, + }); + assert.equal(waterSaved.ok(), true, await waterSaved.text()); + const waterFile = (await waterSaved.json()).filename; + const recipeFiles = []; + for (const [index, label] of ["A", "B"].entries()) { + const saved = await page.request.post("/recipes", { data: { + name: `Delayed recipe ${label}`, + liters: (index + 1) * 11, + water_profile: index === 0 ? waterFile : "default", + fertilizers: [{ name: selectedFertilizer, grams: index + 1 }], + fertilizers_allowed: index === 0 ? [selectedFertilizer] : [], + } }); + assert.equal(saved.ok(), true, await saved.text()); + recipeFiles.push((await saved.json()).filename); + } + await page.reload(); + await page.locator('#apiStatus[data-state="ready"]').waitFor(); + await calculationReady(page); + const origin = new URL(page.url()).origin; + const withDelayedResponse = async (path, start, changeSelection) => { + let markRequested; + let release; + const requested = new Promise((resolve) => { markRequested = resolve; }); + const held = new Promise((resolve) => { release = resolve; }); + const url = `${origin}${path}`; + const handler = async (route) => { + const response = await route.fetch(); + markRequested(); + await held; + await route.fulfill({ response }); + }; + await page.route(url, handler); + await start(); + await requested; + await changeSelection(); + const received = page.waitForResponse(url); + release(); + await (await received).finished(); + await page.evaluate(() => new Promise((resolve) => { + globalThis.requestAnimationFrame(() => globalThis.requestAnimationFrame(resolve)); + })); + await page.unroute(url, handler); + }; + const beforeLiters = await page.locator("#configLiters").inputValue(); + const beforeName = await page.locator("#profileName").inputValue(); + const startRecipe = async () => { + await page.locator("#profileSelect").selectOption(recipeFiles[0]); + await page.locator("#loadProfile").click(); + }; + for (const path of [`/recipes/${recipeFiles[0]}`, `/water-profiles/${waterFile}`]) { + await withDelayedResponse(path, startRecipe, () => page.locator("#profileSelect").selectOption(recipeFiles[1])); + assert.equal(await page.locator("#configLiters").inputValue(), beforeLiters); + assert.equal(await page.locator("#profileName").inputValue(), beforeName); + assert.equal(await page.evaluate((key) => globalThis.localStorage.getItem(key), + `last_fertilizers_allowed::${recipeFiles[0]}`), null); + } + await page.locator('[data-shell-view="solver"]').click(); + await page.locator("#includeSolverSetup").check(); + const solutions = await (await page.request.get("/nutrient-solutions")).json(); + const first = solutions.find(({ name }) => name === "Ignored objectives mass_nnls").filename; + const second = solutions.find(({ name }) => name === "Ignored objectives hierarchical").filename; + const beforeTarget = await page.locator("#solverTargetsTable tbody input").first().inputValue(); + const beforeSolutionName = await page.locator("#profileName").inputValue(); + const startSolution = async () => { + await page.locator("#profileSelect").selectOption(first); + await page.locator("#loadProfile").click(); + }; + for (const path of [`/nutrient-solutions/${first}`, "/water-profiles/default"]) { + await withDelayedResponse(path, startSolution, () => page.locator("#profileSelect").selectOption(second)); + assert.equal(await page.locator("#solverTargetsTable tbody input").first().inputValue(), beforeTarget); + assert.equal(await page.locator("#profileName").inputValue(), beforeSolutionName); + } + await page.locator('[data-shell-view="water"]').click(); + const beforeCalcium = await page.locator('[data-water-key="Ca"]').inputValue(); + const beforePreference = getPreferences().last_water_profile; + await withDelayedResponse(`/water-profiles/${waterFile}`, async () => { + await page.locator("#waterProfileSelect").selectOption(waterFile); + await page.locator("#loadWaterProfile").click(); + }, () => page.locator("#waterProfileSelect").selectOption("default.yml")); + assert.equal(await page.locator('[data-water-key="Ca"]').inputValue(), beforeCalcium); + assert.equal(getPreferences().last_water_profile, beforePreference); + await page.locator('[data-shell-view="fertilizers"]').click(); + for (const [index, filename] of recipeFiles.entries()) { + await page.locator("#profileSelect").selectOption(filename); + await page.locator("#loadProfile").click(); + await page.waitForFunction((liters) => Number(document.querySelector("#configLiters").value) === liters, + (index + 1) * 11); + assert.equal(await page.locator("#profileName").inputValue(), `Delayed recipe ${index === 0 ? "A" : "B"}`); + assert.deepEqual(await page.evaluate((key) => JSON.parse(globalThis.localStorage.getItem(key)), + `last_fertilizers_allowed::${filename}`), index === 0 ? [selectedFertilizer] : []); + } + await calculationReady(page); +} + +async function assertHistoryDialogRequests(page) { + const entries = ["A", "B", "C", "D"].map((id, index) => ({ + id, + created_at: "2026-01-01T00:00:00Z", + liters: (index + 1) * 10, + targets_mg_per_l: { N_total: 101 + index }, + })); + const pending = new Map(); + for (const id of ["A", "C", "D"]) { + let markRequested; + let release; + const requested = new Promise((resolve) => { markRequested = resolve; }); + const response = new Promise((resolve) => { release = resolve; }); + pending.set(id, { requested, response, markRequested, release }); + } + const origin = new URL(page.url()).origin; + await page.route(`${origin}/solver-history`, (route) => route.fulfill({ json: { entries, limit: 1000 } })); + await page.route(`${origin}/solver-history/*`, async (route) => { + const id = new URL(route.request().url()).pathname.split("/").at(-1); + const held = pending.get(id); + if (held) { + held.markRequested(); + await held.response; + } + if (id === "C") { + await route.fulfill({ status: 500, json: { detail: "obsolete history failure" } }); + return; + } + const entry = entries.find((item) => item.id === id); + await route.fulfill({ json: { + setup: { + liters: entry.liters, + targets: entry.targets_mg_per_l, + fertilizers_allowed: [selectedFertilizer], + water_profile: { mg_per_l: {} }, + }, + result: { targets_mg_per_l: entry.targets_mg_per_l }, + } }); + }); + await page.mouse.move(0, 0); + await page.reload(); + await page.locator('#apiStatus[data-state="ready"]').waitFor(); + const open = (id) => page.locator(`[data-history-id="${id}"]`).click(); + const close = () => page.locator("#solverHistoryDialog form button").click(); + const settle = async (id) => { + const received = page.waitForResponse(`${origin}/solver-history/${id}`); + pending.get(id).release(); + await (await received).finished(); + await page.evaluate(() => new Promise((resolve) => { + globalThis.requestAnimationFrame(() => globalThis.requestAnimationFrame(resolve)); + })); + }; + await open("A"); + await pending.get("A").requested; + await close(); + await open("B"); + await page.locator("#restoreSolverHistoryEntry:not([disabled])").waitFor(); + const expected = await page.locator("#solverHistoryDialogOutput").innerText(); + await settle("A"); + assert.equal(await page.locator("#solverHistoryDialogOutput").innerText(), expected); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page.locator("#copySolverHistoryEntry").click(); + await page.locator("#solverHistoryDialogStatus:not(:empty)").waitFor(); + const copied = await page.evaluate(() => globalThis.navigator.clipboard.readText()); + assert.equal(copied.replaceAll("\r\n", "\n"), expected); + await page.locator("#restoreSolverHistoryEntry").click(); + assert.equal(Number(await page.locator("#configLiters").inputValue()), 20); + assert.equal(Number(await page.locator("#solverTargetsTable tbody input").first().inputValue()), 102); + for (const id of ["C", "D"]) { + await open(id); + await pending.get(id).requested; + await close(); + await settle(id); + assert.equal(await page.locator("#solverHistoryDialog").evaluate((dialog) => dialog.open), false); + assert.equal(await page.locator("#copySolverHistoryEntry").isDisabled(), true); + assert.equal(await page.locator("#restoreSolverHistoryEntry").isDisabled(), true); + } + await page.locator("#solverHistoryDialog").evaluate((dialog) => { + Object.defineProperty(dialog, "showModal", { value: undefined, configurable: true }); + Object.defineProperty(dialog, "open", { value: undefined, configurable: true }); + }); + await open("B"); + await page.locator("#restoreSolverHistoryEntry:not([disabled])").waitFor(); + assert.equal(await page.locator("#solverHistoryDialogOutput").innerText(), expected); + await close(); + await page.locator("#solverHistoryDialog").evaluate((dialog) => { + delete dialog.showModal; + delete dialog.open; + }); + await page.unroute(`${origin}/solver-history`); + await page.unroute(`${origin}/solver-history/*`); +} + +async function assertIgnoredObjectives(page, setConfig) { + setConfig({ solver_model: "mass_nnls", ignored_elements: ["Cu"] }); + await page.reload(); + await page.locator('#apiStatus[data-state="ready"]').waitFor(); + for (const model of ["mass_nnls", "nnls_tuning", "hierarchical"]) { + await page.locator('[data-shell-view="solver"]').click(); + await page.locator("#solverMode details.solver-advanced-config").evaluate((details) => { details.open = true; }); + await page.locator("#solverAutoApply").uncheck(); + await page.locator("#solverTargetsTable tbody input").first().fill("100"); + await page.locator("#solverTargetsTable tbody tr").filter({ hasText: /^Cu/ }).locator("input").fill("1"); + const persisted = page.waitForResponse((response) => response.url().endsWith("/preferences") + && response.request().method() === "PUT"); + await page.locator("#solverConfigModel").selectOption(model); + assert.deepEqual((await (await persisted).json()).solver_config.ignored_elements, ["Cu"]); + const solved = page.waitForResponse((response) => response.url().endsWith("/solve")); + await page.locator("#solveBtn").click(); + const response = await solved; + const config = response.request().postDataJSON().solver_config; + assert.deepEqual(config.ignored_elements, ["Cu"]); + assert.equal(Object.hasOwn(config.target_priorities, "Cu"), false); + assert.equal((await response.json()).objective_elements.includes("Cu"), false); + await page.locator("#includeSolverSetup").check(); + await page.locator("#profileName").fill(`Ignored objectives ${model}`); + const saved = page.waitForResponse((item) => item.url().endsWith("/nutrient-solutions") + && item.request().method() === "POST"); + await page.locator("#saveProfile").click(); + assert.deepEqual((await saved).request().postDataJSON().solver_config.ignored_elements, ["Cu"]); + await page.locator("#profileSelect option").filter({ hasText: `Ignored objectives ${model}` }) + .waitFor({ state: "attached" }); + await page.reload(); + await page.locator('#apiStatus[data-state="ready"]').waitFor(); + } + await page.locator('[data-shell-view="solver"]').click(); + const copper = page.locator("#solverTargetsTable tbody tr").filter({ hasText: /^Cu/ }); + await copper.locator("input").fill("1"); + const persisted = page.waitForResponse((response) => response.url().endsWith("/preferences") + && response.request().method() === "PUT"); + await copper.locator("select").first().selectOption("3"); + const config = (await (await persisted).json()).solver_config; + assert.deepEqual(config.ignored_elements, []); + assert.deepEqual(config.target_priorities.Cu, { under: 3, over: 0 }); + await page.locator("#solverMode details.solver-advanced-config").evaluate((details) => { details.open = true; }); + const reset = page.waitForResponse((response) => response.url().endsWith("/preferences") + && response.request().method() === "PUT"); + await page.locator("#solverConfigResetDefaults").click(); + assert.deepEqual((await (await reset).json()).solver_config, {}); + await page.locator("#includeSolverSetup").check(); + await page.locator("#profileSelect").selectOption({ label: "Ignored objectives mass_nnls" }); + await page.locator("#loadProfile").click(); + await page.waitForFunction(() => document.querySelector("#solverConfigModel").value === "mass_nnls"); + const solved = page.waitForResponse((response) => response.url().endsWith("/solve")); + await page.locator("#solveBtn").click(); + const response = await solved; + assert.deepEqual(response.request().postDataJSON().solver_config.ignored_elements, ["Cu"]); + assert.equal((await response.json()).objective_elements.includes("Cu"), false); +} + +async function assertPreferenceFailures(page, dialogs, failWrites) { + await page.locator(".rail-settings").evaluate((details) => { details.open = true; }); + failWrites(true); + const limit = page.locator("#solverHistoryLimit"); + await limit.fill("25"); + await limit.press("Tab"); + await page.waitForFunction(() => document.querySelector("#solverHistoryLimit").value === "1000" + && !document.querySelector("#solverHistoryLimit").disabled); + assert.equal(await page.locator("#solverHistorySettingsStatus").innerText(), ""); + assert.equal(dialogs.pop(), "disk unavailable"); + const themeFailure = page.waitForEvent("dialog"); + await page.locator("#themeSelect").selectOption("soil"); + await themeFailure; + assert.equal(await page.locator("body").getAttribute("data-theme"), "soil"); + assert.equal(dialogs.pop(), "disk unavailable"); + failWrites(false); + await limit.fill("25"); + await limit.press("Tab"); + await page.locator("#solverHistorySettingsStatus:not(:empty)").waitFor(); + assert.equal(await limit.inputValue(), "25"); + await page.locator('[data-shell-view="fertilizers"]').click(); + await page.locator("#profileSelect").selectOption({ index: 1 }); + const favorite = page.locator("#favoriteProfile"); + const previous = await favorite.getAttribute("aria-pressed"); + failWrites(true); + await favorite.click(); + await page.waitForFunction((value) => { + const button = document.querySelector("#favoriteProfile"); + return !button.disabled && button.getAttribute("aria-pressed") === value; + }, previous); + assert.equal(dialogs.pop(), "disk unavailable"); + failWrites(false); + await favorite.click(); + await page.waitForFunction((value) => { + const button = document.querySelector("#favoriteProfile"); + return !button.disabled && button.getAttribute("aria-pressed") !== value; + }, previous); +} + +(async () => { + assert.ok(executablePath, "Chrome or Chromium is required; set HORTICALC_BROWSER_PATH"); + const browser = await chromium.launch({ headless: true, executablePath }); + try { + const baseURL = process.env.HORTICALC_TEST_URL || "http://127.0.0.1:8765"; + const page = await browser.newPage({ baseURL, viewport: { width: 1280, height: 900 } }); + const errors = []; + const dialogs = []; + let preferences = {}; + let failPreferences = false; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("dialog", async (dialog) => { dialogs.push(dialog.message()); await dialog.dismiss(); }); + await page.addInitScript(({ key, fertilizer }) => { + if (globalThis.localStorage.getItem(key) === null) { + globalThis.localStorage.setItem(key, JSON.stringify([fertilizer, "Removed fertilizer"])); + } + }, { key: allowedStorageKey, fertilizer: selectedFertilizer }); + await page.route(`${new URL(baseURL).origin}/preferences`, async (route) => { + if (route.request().method() === "PUT") { + if (failPreferences) { + await route.fulfill({ status: 500, json: { detail: "disk unavailable" } }); + return; + } + preferences = { ...preferences, ...route.request().postDataJSON() }; + } + await route.fulfill({ json: preferences }); + }); + await page.goto(baseURL); + await page.locator('#apiStatus[data-state="ready"]').waitFor(); + await assertSolverSelection(page); + await assertCatalogPruning(page); + await assertTraceScaling(page); + await assertWaterEditing(page); + await assertWaterPhosphorus(page); + await assertSolverInvalidation(page); + await assertPreferenceFailures(page, dialogs, (value) => { failPreferences = value; }); + await assertIgnoredObjectives(page, (config) => { preferences.solver_config = config; }); + await assertHistoryDialogRequests(page); + await assertProfileLoadIdentity(page, () => preferences); + assert.equal(await page.locator("#apiStatus").getAttribute("data-state"), "ready"); + assert.deepEqual(dialogs, []); + assert.deepEqual(errors, []); + } finally { + await browser.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/src/horticalc/solver.py b/src/horticalc/solver.py index ee10bb4..6da6087 100644 --- a/src/horticalc/solver.py +++ b/src/horticalc/solver.py @@ -184,7 +184,9 @@ def _objective_keys( return keys -def _fertilizer_element_contrib_per_g(fert: Fertilizer, mm: Dict[str, float]) -> Dict[str, float]: +def _fertilizer_element_contrib_per_g( + fert: Fertilizer, mm: Dict[str, float], *, urea_as_nh4: bool = False +) -> Dict[str, float]: elements: Dict[str, float] = {} wf = float(fert.weight_factor or 1.0) @@ -195,7 +197,7 @@ def add(key: str, value: float) -> None: for form, frac in fert.comp.items(): mg_per_g = float(frac) * 1000.0 * wf - n_form_key = FERTILIZER_N_FORM_OUTPUT_KEYS.get(form) + n_form_key = FERTILIZER_N_FORM_OUTPUT_KEYS.get("NH4" if form == "UREA" and urea_as_nh4 else form) if n_form_key is not None: add("N_total", mg_per_g) add(n_form_key, mg_per_g) @@ -293,10 +295,12 @@ def _build_matrix( mm: Dict[str, float], keys: List[str], liters: float, + *, + urea_as_nh4: bool = False, ) -> np.ndarray: matrix = np.zeros((len(keys), len(fertilizers))) for col, fert in enumerate(fertilizers): - contrib = _fertilizer_element_contrib_per_g(fert, mm) + contrib = _fertilizer_element_contrib_per_g(fert, mm, urea_as_nh4=urea_as_nh4) for row, key in enumerate(keys): matrix[row, col] = contrib.get(key, 0.0) / liters return matrix @@ -380,8 +384,13 @@ def _score_percent_errors( objective_keys: List[str], targets_raw: Dict[str, float], achieved_elements: Dict[str, float], + *, + scale_eps_mg_per_l: float = 1.0, ) -> tuple[float, ...]: - return (max(_objective_percent_errors(objective_keys, targets_raw, achieved_elements), default=0.0),) + errors = _objective_percent_errors( + objective_keys, targets_raw, achieved_elements, scale_eps_mg_per_l=scale_eps_mg_per_l + ) + return (max(errors, default=0.0),) def _signed_percent_error(target: float, achieved: float) -> float: @@ -392,12 +401,17 @@ def _objective_percent_errors( objective_keys: List[str], targets_raw: Dict[str, float], achieved_elements: Dict[str, float], + *, + scale_eps_mg_per_l: float = 1.0, ) -> tuple[float, ...]: errors: list[float] = [] for key in objective_keys: target = float(targets_raw.get(key, 0.0)) achieved_val = float(achieved_elements.get(key, 0.0)) - errors.append(abs(_signed_percent_error(target, achieved_val))) + error = ( + achieved_val / scale_eps_mg_per_l * 100.0 if target == 0.0 else _signed_percent_error(target, achieved_val) + ) + errors.append(abs(error)) return tuple(errors) @@ -500,6 +514,7 @@ def _singleton_supplier_pass( mode: str = "overshoot", use_potential_share: bool = False, upper_bounds_full: np.ndarray | None = None, + scale_eps_mg_per_l: float = 1.0, ) -> tuple[np.ndarray, Dict[str, float]]: adjusted = x_full.copy() skip = skip_keys or set() @@ -551,8 +566,8 @@ def _singleton_supplier_pass( raise ValueError(f"Unknown singleton supplier mode: {mode}") achieved_new = recompute_achieved_fn(proposed) score_fn = _objective_percent_errors if mode == "overshoot" else _score_percent_errors - old_score = score_fn(objective_keys, targets_raw, achieved_elements) - new_score = score_fn(objective_keys, targets_raw, achieved_new) + old_score = score_fn(objective_keys, targets_raw, achieved_elements, scale_eps_mg_per_l=scale_eps_mg_per_l) + new_score = score_fn(objective_keys, targets_raw, achieved_new, scale_eps_mg_per_l=scale_eps_mg_per_l) improves = (mode == "overshoot" and achieved_new.get(key, 0.0) <= achieved_elements.get(key, 0.0)) or ( mode == "underfill" and achieved_new.get(key, 0.0) >= achieved_elements.get(key, 0.0) ) @@ -677,6 +692,7 @@ def _apply_singleton_pass( mode=mode, use_potential_share=use_potential_share, upper_bounds_full=problem.upper_bounds, + scale_eps_mg_per_l=problem.solver_config["scale_eps_mg_per_l"], ) changed = bool(np.any(np.abs(updated_weights - weights) > 1e-12)) return updated_weights, updated_elements, changed @@ -750,11 +766,13 @@ def solve(self, variant: _Variant) -> _VariantResult: self.problem.objective_keys, self.problem.targets, result[1], + scale_eps_mg_per_l=config["scale_eps_mg_per_l"], ) unweighted_score = _score_percent_errors( self.problem.objective_keys, self.problem.targets, unweighted[1], + scale_eps_mg_per_l=config["scale_eps_mg_per_l"], ) if unweighted_score < weighted_score: return unweighted @@ -887,7 +905,9 @@ def _prepare_solve_problem( [targets.get(key, 0.0) - water_only.elements_mg_l.get(key, 0.0) for key in objective_keys], dtype=float, ) - matrix = _build_matrix(allowed, molar_masses, objective_keys, liters) + matrix = _build_matrix( + allowed, molar_masses, objective_keys, liters, urea_as_nh4=bool(recipe.get("urea_as_nh4", False)) + ) return _PreparedProblem( recipe=recipe, diff --git a/tests/conftest.py b/tests/conftest.py index 789e482..e55b6f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,11 +2,13 @@ from collections.abc import Iterator from pathlib import Path +from shutil import copytree import pytest from fastapi.testclient import TestClient import api.app as api_app +from horticalc import paths from horticalc.activation import clear_activation_handler, configure_activation_token from horticalc.data_io import load_fertilizers, load_molar_masses from horticalc.paths import PortableLayout, shipped_fertilizers_path @@ -14,6 +16,16 @@ TEST_SESSION_TOKEN = "t" * 43 +@pytest.fixture(autouse=True) +def isolated_app_root(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + root = Path(__file__).resolve().parents[1] + sandbox = tmp_path / "app" + for name in ("data", "recipes"): + copytree(root / name, sandbox / name) + monkeypatch.setattr(paths, "app_root", lambda: sandbox) + return sandbox + + def _authenticate(client: TestClient) -> None: configure_activation_token(TEST_SESSION_TOKEN) response = client.get("/", params={"session": TEST_SESSION_TOKEN}, follow_redirects=False) @@ -21,22 +33,9 @@ def _authenticate(client: TestClient) -> None: @pytest.fixture -def isolated_api_layout(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> PortableLayout: - root = Path(__file__).resolve().parents[1] - sandbox = tmp_path / "api" - user = sandbox / "user" - layout = PortableLayout( - root=root, - user=user, - logs=sandbox / "logs", - fertilizers=shipped_fertilizers_path(root), - water_profiles=user / "water_profiles", - nutrient_solutions=user / "nutrient_solutions", - recipes=user / "recipes", - ) - for directory in (layout.logs, layout.water_profiles, layout.nutrient_solutions, layout.recipes): - directory.mkdir(parents=True, exist_ok=True) - monkeypatch.setattr(api_app, "FERTILIZERS", load_fertilizers(shipped_fertilizers_path(root))) +def isolated_api_layout(monkeypatch: pytest.MonkeyPatch, isolated_app_root: Path) -> PortableLayout: + layout = paths.ensure_portable_layout() + monkeypatch.setattr(api_app, "FERTILIZERS", load_fertilizers(shipped_fertilizers_path(isolated_app_root))) monkeypatch.setattr(api_app, "MOLAR_MASSES", load_molar_masses()) monkeypatch.setattr(api_app, "PORTABLE_LAYOUT", layout) return layout diff --git a/tests/frontend/api_errors.test.mjs b/tests/frontend/api_errors.test.mjs index a8e4854..11cdcac 100644 --- a/tests/frontend/api_errors.test.mjs +++ b/tests/frontend/api_errors.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { postJson } from "../../frontend/app/api.js"; +import { loadPreferences, persistPreferences, postJson } from "../../frontend/app/api.js"; test("API errors retain structured conflict metadata", async () => { const previousFetch = globalThis.fetch; @@ -35,3 +35,32 @@ test("API errors retain structured conflict metadata", async () => { globalThis.fetch = previousFetch; } }); + +test("preference writes reject failures and keep later writes queued", async () => { + const previousFetch = globalThis.fetch; + const requests = []; + let rejectFirst; + globalThis.fetch = async (_url, options) => { + if (!options) return { ok: true, json: async () => ({ theme: "soil" }) }; + requests.push(JSON.parse(options.body)); + if (requests.length === 1) { + await new Promise((resolve) => { rejectFirst = resolve; }); + return { ok: false, status: 500, json: async () => ({ detail: "disk unavailable" }) }; + } + return { ok: true, json: async () => ({ theme: "soil", ...JSON.parse(options.body) }) }; + }; + try { + assert.deepEqual(await loadPreferences(), { theme: "soil" }); + const failed = persistPreferences({ solver_history_limit: 0 }); + const rejected = assert.rejects(failed, /disk unavailable/); + const recovered = persistPreferences({ locale: "de" }); + await Promise.resolve(); + assert.deepEqual(requests, [{ solver_history_limit: 0 }]); + rejectFirst(); + await rejected; + assert.deepEqual(await recovered, { theme: "soil", locale: "de" }); + assert.deepEqual(requests, [{ solver_history_limit: 0 }, { locale: "de" }]); + } finally { + globalThis.fetch = previousFetch; + } +}); diff --git a/tests/frontend/formatting.test.mjs b/tests/frontend/formatting.test.mjs index 8e01f21..7a396b9 100644 --- a/tests/frontend/formatting.test.mjs +++ b/tests/frontend/formatting.test.mjs @@ -5,7 +5,6 @@ import { buildAlignedRows, formatNumber, parseDecimalInput, - roundScaledValue, } from "../../frontend/app/formatting.js"; test("decimal parsing accepts both supported separators and rejects invalid values", () => { @@ -16,7 +15,7 @@ test("decimal parsing accepts both supported separators and rejects invalid valu }); test("number and clipboard formatting are deterministic", () => { - assert.equal(roundScaledValue(1.23456), 1.235); + assert.equal(formatNumber(1.23456), "1.235"); assert.equal(formatNumber(Number.NaN), "-"); assert.deepEqual( buildAlignedRows(["name", "g"], [["A", "2"], ["long", "10"]], [1]), diff --git a/tests/frontend/scaling.test.mjs b/tests/frontend/scaling.test.mjs index 1d052d7..28d2bf0 100644 --- a/tests/frontend/scaling.test.mjs +++ b/tests/frontend/scaling.test.mjs @@ -8,7 +8,7 @@ import { scaledValues, } from "../../frontend/app/scaling.js"; -test("scaling rounds the factor and values once", () => { +test("scaling rounds the factor while preserving quantity precision", () => { const rows = [{ base: 1.111 }, { base: 2 }]; const factor = applyScaledValues( rows, @@ -17,11 +17,21 @@ test("scaling rounds the factor and values once", () => { (row, value) => { row.value = value; }, ); assert.equal(factor, 1.05); - assert.deepEqual(rows.map(({ value }) => value), [1.167, 2.1]); + assert.deepEqual(rows.map(({ value }) => value), [1.16655, 2.1]); assert.equal(roundScaleFactor(1.049), 1.05); assert.deepEqual(scaledValues(rows, -2, ({ base }) => base).values, [0, 0]); }); +test("trace doses survive factor and batch scaling", () => { + const scaled = scaledValues([{ dose: 0.0002 }], 1.05, ({ dose }) => dose); + assert.ok(Math.abs(scaled.values[0] / 0.00021 - 1) < 1e-14); + const doubled = scaleAmountsByVolume({ trace: 0.0002 }, 1, 2); + assert.equal(doubled.trace, 0.0004); + const restored = scaleAmountsByVolume(doubled, 2, 1); + // Allow floating-point error, well below any displayed dose precision. + assert.ok(Math.abs(restored.trace / 0.0002 - 1) < 1e-14); +}); + test("fixed Solver amounts scale proportionally with batch volume", () => { assert.deepEqual(scaleAmountsByVolume({ Fetrilon: 2, PeKacid: 6 }, 10, 20), { Fetrilon: 4, diff --git a/tests/frontend/solver_config.test.mjs b/tests/frontend/solver_config.test.mjs index 62e535b..2455284 100644 --- a/tests/frontend/solver_config.test.mjs +++ b/tests/frontend/solver_config.test.mjs @@ -40,7 +40,7 @@ test("solver configuration retains schema bounds and normalizes input", () => { }); }); -test("solver payload formatting removes zero entries without changing public keys", () => { +test("solver payload retains zero targets and omits inactive fixed amounts", () => { assert.equal(formatClipboardIonLabel("N_total"), "N"); assert.deepEqual(buildSolvePayload({ liters: 10, @@ -56,7 +56,7 @@ test("solver payload formatting removes zero entries without changing public key }, }), { liters: 10, - targets: { K: 100 }, + targets: { K: 100, Ca: 0 }, water_profile: { mg_per_l: { Ca: 20 }, osmosis_percent: 25 }, fertilizers_allowed: ["A"], fixed_grams: { B: 2 }, @@ -67,3 +67,17 @@ test("solver payload formatting removes zero entries without changing public key }, }); }); + +test("zero nitrogen form targets reach the domain objective selection", () => { + const payload = buildSolvePayload({ + liters: 1, + targetValues: { N_NO3: 100, N_NH4: 0, N_UREA: 0 }, + waterMgPerL: {}, + osmosisPercent: 0, + allowedFertilizers: ["Mixed"], + fixedGrams: {}, + ureaAsNh4: false, + solverConfig: { nitrogen_objective_mode: "n_forms_only" }, + }); + assert.deepEqual(payload.targets, { N_NO3: 100, N_NH4: 0, N_UREA: 0 }); +}); diff --git a/tests/test_api_request_validation.py b/tests/test_api_request_validation.py index 7ed2210..ca8e871 100644 --- a/tests/test_api_request_validation.py +++ b/tests/test_api_request_validation.py @@ -7,6 +7,22 @@ import api.app as api_app +@pytest.mark.parametrize( + "route, payload, invalid_input", + [ + ("/solve", '{"targets": [NaN]}', ["nan"]), + ("/solve", '{"targets": [Infinity, -Infinity]}', ["inf", "-inf"]), + ("/calculate", '{"liters": {"x": NaN}}', {"x": "nan"}), + ("/calculate", '{"liters": {"x": [Infinity]}}', {"x": ["inf"]}), + ], +) +def test_nested_non_finite_validation_inputs_return_422(api_client_no_raise, route, payload, invalid_input) -> None: + response = api_client_no_raise.post(route, content=payload, headers={"content-type": "application/json"}) + + assert response.status_code == 422 + assert response.json()["detail"][0]["input"] == invalid_input + + def test_yaml_save_rejects_malformed_or_non_object_payloads(api_client_no_raise: TestClient) -> None: malformed = api_client_no_raise.post( "/water-profiles", diff --git a/tests/test_frontend_browser_smoke.py b/tests/test_frontend_browser_smoke.py index 3b46dfd..03ee87a 100644 --- a/tests/test_frontend_browser_smoke.py +++ b/tests/test_frontend_browser_smoke.py @@ -11,7 +11,6 @@ import pytest ROOT = Path(__file__).resolve().parents[1] -SMOKE_SCRIPT = ROOT / "scripts" / "frontend_smoke.cjs" SESSION_TOKEN = "t" * 43 @@ -34,12 +33,14 @@ def _wait_for_server(url: str) -> None: @pytest.mark.skipif(shutil.which("node") is None, reason="Node.js is required") -def test_frontend_workflows_execute_without_browser_errors() -> None: +@pytest.mark.parametrize("smoke_script", ["frontend_smoke.cjs", "state_smoke.cjs"]) +def test_frontend_workflows_execute_without_browser_errors(isolated_app_root: Path, smoke_script: str) -> None: assert (ROOT / "node_modules" / "playwright").exists() or os.environ.get("NODE_PATH"), ( "Run npm ci to install the Playwright browser test dependency" ) port = _free_port() + shutil.copytree(ROOT / "frontend", isolated_app_root / "frontend") url = f"http://127.0.0.1:{port}" server_environment = { **os.environ, @@ -49,12 +50,11 @@ def test_frontend_workflows_execute_without_browser_errors() -> None: server = subprocess.Popen( [ os.fspath(Path(os.sys.executable)), - "-m", - "uvicorn", - "api.app:app", - "--host", - "127.0.0.1", - "--port", + "-c", + "import sys; from pathlib import Path; from horticalc import paths; " + "paths.app_root = lambda: Path(sys.argv[1]); import uvicorn; " + "uvicorn.run('api.app:app', host='127.0.0.1', port=int(sys.argv[2]))", + str(isolated_app_root), str(port), ], cwd=ROOT, @@ -65,7 +65,10 @@ def test_frontend_workflows_execute_without_browser_errors() -> None: try: _wait_for_server(url) environment = {**os.environ, "HORTICALC_TEST_URL": f"{url}/?session={SESSION_TOKEN}"} - subprocess.run(["node", os.fspath(SMOKE_SCRIPT)], cwd=ROOT, env=environment, check=True) + result = subprocess.run( + ["node", os.fspath(ROOT / "scripts" / smoke_script)], cwd=ROOT, env=environment, check=False, timeout=120 + ) + assert result.returncode == 0 finally: server.terminate() server.wait(timeout=5) diff --git a/tests/test_preferences.py b/tests/test_preferences.py index ccd26f0..f1bf62c 100644 --- a/tests/test_preferences.py +++ b/tests/test_preferences.py @@ -1,11 +1,14 @@ from __future__ import annotations import json +import threading +from concurrent.futures import ThreadPoolExecutor import pytest from fastapi.testclient import TestClient -from horticalc import paths +import api.app as api_app +from horticalc import data_io, paths from horticalc.data_io import load_user_preferences, save_user_preferences from tests.frontend_assets import frontend_path @@ -231,3 +234,58 @@ def test_preferences_save_requires_object(monkeypatch, tmp_path) -> None: save_user_preferences(["invalid"]) assert not paths.user_preferences_path(tmp_path).exists() + + +def test_concurrent_preference_updates_preserve_disjoint_fields(monkeypatch) -> None: + first_read = threading.Event() + second_read = threading.Event() + read_lock = threading.Lock() + reads = 0 + + def overlapping_read(): + nonlocal reads + preferences = load_user_preferences() + with read_lock: + reads += 1 + first = reads == 1 + if first: + first_read.set() + second_read.wait(timeout=0.5) + else: + second_read.set() + return preferences + + monkeypatch.setattr(api_app, "load_user_preferences", overlapping_read) + with ThreadPoolExecutor(max_workers=2) as workers: + theme = workers.submit(api_app.put_preferences, api_app.PreferencesPayload(theme="soil")) + assert first_read.wait(timeout=5) + locale = workers.submit(api_app.put_preferences, api_app.PreferencesPayload(locale="de")) + theme.result(timeout=5) + locale.result(timeout=5) + + assert load_user_preferences() == {"theme": "soil", "locale": "de"} + + +def test_preference_replace_failure_preserves_confirmed_data(api_client_no_raise, monkeypatch) -> None: + save_user_preferences({"theme": "soil"}) + + def fail_replace(*_args): + raise OSError("disk unavailable") + + monkeypatch.setattr(data_io.os, "replace", fail_replace) + response = api_client_no_raise.put("/preferences", json={"locale": "de"}) + + assert response.status_code == 500 + assert load_user_preferences() == {"theme": "soil"} + assert list(paths.user_preferences_path().parent.glob(".preferences.json.tmp-*")) == [] + + +def test_retention_failure_reports_failure_after_preferences_are_persisted(api_client_no_raise, monkeypatch) -> None: + def fail_trim(*_args): + raise OSError("history unavailable") + + monkeypatch.setattr(api_app, "trim_solver_history", fail_trim) + response = api_client_no_raise.put("/preferences", json={"solver_history_limit": 10}) + + assert response.status_code == 500 + assert load_user_preferences()["solver_history_limit"] == 10 diff --git a/tests/test_solver_validation.py b/tests/test_solver_validation.py index 6da56d8..c023984 100644 --- a/tests/test_solver_validation.py +++ b/tests/test_solver_validation.py @@ -210,3 +210,142 @@ def test_water_profile_overshoot_remains_visible_in_errors() -> None: assert result.fertilizers == [] assert result.errors_mg_l["Ca"] == pytest.approx(100.0, abs=1e-12) assert result.errors_percent["Ca"] == pytest.approx(100.0, abs=1e-12) + + +@pytest.mark.parametrize("model", ["mass_nnls", "hierarchical", "nnls_tuning"]) +@pytest.mark.parametrize("urea_as_nh4", [False, True]) +@pytest.mark.parametrize("fixed_dose", [0.0, 0.02]) +def test_solver_urea_mode_matches_delivered_nitrogen_with_water_and_fixed_doses( + model: str, urea_as_nh4: bool, fixed_dose: float +) -> None: + mm = load_molar_masses() + fertilizers = {name: Fertilizer(name, False, 1.0, {"UREA": 0.46}) for name in ("Variable", "Fixed")} + # One mmol/L of water ammonium supplies one mmol/L of elemental nitrogen. + water_nitrogen = mm["N"] + fertilizer_nitrogen = 46.0 + fixed_dose * 460.0 + # Hierarchical stages retain optima within 1e-7 mg/L; allow twice that for the final LP. + concentration_tolerance = 2e-7 + target_key = "N_NH4" if urea_as_nh4 else "N_UREA" + result = solve_recipe_data( + { + "liters": 1, + "fertilizers_allowed": list(fertilizers), + "fixed_grams": {"Fixed": fixed_dose}, + "targets": {target_key: fertilizer_nitrogen + (water_nitrogen if urea_as_nh4 else 0)}, + "urea_as_nh4": urea_as_nh4, + "solver_config": {"solver_model": model, "nitrogen_objective_mode": "n_forms_only"}, + }, + ferts=fertilizers, + mm=mm, + water_profile_data={"mg_per_l": {"NH4": mm["NH4"]}}, + ) + + doses = {entry["name"]: entry["grams"] for entry in result.fertilizers} + assert doses["Variable"] == pytest.approx(0.1, rel=0, abs=concentration_tolerance / 460) + assert doses.get("Fixed", 0) == pytest.approx(fixed_dose, rel=0, abs=1e-12) + assert result.achieved_elements_mg_l["N_total"] == pytest.approx( + fertilizer_nitrogen + water_nitrogen, rel=0, abs=concentration_tolerance + ) + assert result.errors_mg_l[target_key] == pytest.approx(0, rel=0, abs=concentration_tolerance) + assert result.achieved_elements_mg_l["N_UREA"] == pytest.approx( + 0 if urea_as_nh4 else fertilizer_nitrogen, rel=0, abs=concentration_tolerance + ) + + +@pytest.mark.parametrize("model", ["mass_nnls", "hierarchical", "nnls_tuning"]) +def test_ammonium_mode_does_not_claim_to_supply_urea(model: str) -> None: + result = solve_recipe_data( + { + "liters": 1, + "fertilizers_allowed": ["Urea"], + "targets": {"N_UREA": 46}, + "urea_as_nh4": True, + "solver_config": {"solver_model": model, "nitrogen_objective_mode": "n_forms_only"}, + }, + ferts={"Urea": Fertilizer("Urea", False, 1.0, {"UREA": 0.46})}, + mm=load_molar_masses(), + water_profile_data={"mg_per_l": {}}, + ) + + assert result.fertilizers == [] + assert result.errors_mg_l["N_UREA"] == -46 + if model == "hierarchical": + assert result.priority_stages[0]["max_error_mg_per_l"] == pytest.approx(46) + + +@pytest.mark.parametrize("include_zero, expected_dose", [(True, 0.5), (False, 1.0)]) +def test_zero_nitrogen_form_target_remains_an_objective(include_zero: bool, expected_dose: float) -> None: + targets = {"N_NO3": 100, "K": 0} + if include_zero: + targets["N_NH4"] = 0 + result = solve_recipe_data( + { + "liters": 1, + "fertilizers_allowed": ["Mixed"], + "targets": targets, + "solver_config": {"nitrogen_objective_mode": "n_forms_only", "singleton_underfill_enabled": False}, + }, + ferts={"Mixed": Fertilizer("Mixed", False, 1.0, {"NO3": 0.1, "NH4": 0.1})}, + mm=load_molar_masses(), + water_profile_data={"mg_per_l": {}}, + ) + + assert result.fertilizers[0]["grams"] == pytest.approx(expected_dose) + assert ("N_NH4" in result.objective_elements) is include_zero + assert "K" not in result.objective_elements + + +@pytest.mark.parametrize("form, target_key", [("NH4", "N_NH4"), ("UREA", "N_UREA")]) +@pytest.mark.parametrize( + "relative_weighting, scale, expected_dose", + [(False, 1.0, 0.5), (True, 1.0, 1 / 10001), (True, 2.0, 4 / 10004)], +) +def test_tuning_scores_zero_nitrogen_objectives( + form: str, target_key: str, relative_weighting: bool, scale: float, expected_dose: float +) -> None: + result = solve_recipe_data( + { + "liters": 1, + "fertilizers_allowed": ["Mixed"], + "targets": {"N_NO3": 100, target_key: 0, "K": 0}, + "solver_config": { + "nitrogen_objective_mode": "n_forms_only", + "relative_weighting": relative_weighting, + "overshoot_penalty": 0, + "scale_eps_mg_per_l": scale, + }, + }, + ferts={"Mixed": Fertilizer("Mixed", False, 1.0, {"NO3": 0.1, form: 0.1})}, + mm=load_molar_masses(), + water_profile_data={"mg_per_l": {}}, + ) + + # Minimize (100*x - 100)^2 + (100*x)^2, with row scaling when enabled. + assert result.fertilizers[0]["grams"] == pytest.approx(expected_dose, rel=0, abs=1e-10) + assert result.achieved_elements_mg_l[target_key] == pytest.approx(100 * expected_dose, rel=0, abs=1e-8) + assert target_key in result.objective_elements + assert "K" not in result.objective_elements + + +def test_zero_objective_tuning_accounts_for_fixed_doses_and_water() -> None: + mm = load_molar_masses() + result = solve_recipe_data( + { + "liters": 1, + "fertilizers_allowed": ["Mixed", "Fixed ammonium"], + "fixed_grams": {"Fixed ammonium": 0.1}, + "targets": {"N_NO3": 100, "N_NH4": 0}, + "solver_config": {"nitrogen_objective_mode": "n_forms_only"}, + }, + ferts={ + "Mixed": Fertilizer("Mixed", False, 1.0, {"NO3": 0.1, "NH4": 0.1}), + "Fixed ammonium": Fertilizer("Fixed ammonium", False, 1.0, {"NH4": 0.1}), + }, + mm=mm, + water_profile_data={"mg_per_l": {"NH4": 10 * mm["NH4"] / mm["N"]}}, + ) + + # Fixed dose and water each supply 10 mg/L N; the variable optimum is 0.4 g. + doses = {entry["name"]: entry["grams"] for entry in result.fertilizers} + assert doses == pytest.approx({"Mixed": 0.4, "Fixed ammonium": 0.1}, rel=0, abs=1e-10) + assert result.achieved_elements_mg_l["N_NH4"] == pytest.approx(60, rel=0, abs=1e-8) diff --git a/tests/test_validation_contracts.py b/tests/test_validation_contracts.py index b5c5a0b..f17ae8c 100644 --- a/tests/test_validation_contracts.py +++ b/tests/test_validation_contracts.py @@ -95,12 +95,20 @@ def test_empty_injected_fertilizer_mapping_is_not_replaced(monkeypatch: pytest.M ) -def test_cli_domain_errors_exit_with_code_two(tmp_path: Path) -> None: +def test_cli_domain_errors_exit_with_code_two(tmp_path: Path, isolated_app_root: Path) -> None: recipe = tmp_path / "invalid.yml" recipe.write_text("liters: 0\nfertilizers: []\n", encoding="utf-8") result = subprocess.run( - [sys.executable, "-m", "horticalc", str(recipe)], + [ + sys.executable, + "-c", + "import sys; from pathlib import Path; from horticalc import paths; " + "paths.app_root = lambda: Path(sys.argv[1]); " + "from horticalc.__main__ import main; main(sys.argv[2:])", + str(isolated_app_root), + str(recipe), + ], cwd=ROOT, capture_output=True, text=True, diff --git a/tests/test_windows_build.py b/tests/test_windows_build.py new file mode 100644 index 0000000..299cfe7 --- /dev/null +++ b/tests/test_windows_build.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +POWERSHELL = shutil.which("pwsh") or shutil.which("powershell") +BUILD_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "packaging" / "build_windows.ps1" +INSTALLER_SCRIPT = BUILD_SCRIPT.with_name("build_windows_installer.ps1") + + +@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell is required") +@pytest.mark.parametrize( + "failed_command, message", + [(1, "Version discovery failed"), (2, "Version resource generation failed"), (3, "PyInstaller failed")], +) +def test_windows_build_stops_before_touching_stale_output(tmp_path: Path, failed_command: int, message: str) -> None: + script = tmp_path / "scripts" / "packaging" / "build_windows.ps1" + script.parent.mkdir(parents=True) + shutil.copyfile(BUILD_SCRIPT, script) + output = tmp_path / "dist" / "Horticalc" + output.mkdir(parents=True) + binary = output / "Horticalc.exe" + binary.write_text("previous binary", encoding="utf-8") + for name in ("frontend", "data", "recipes"): + (tmp_path / name).mkdir() + (tmp_path / name / "sentinel").write_text("new asset", encoding="utf-8") + (output / name).mkdir() + (output / name / "sentinel").write_text("previous asset", encoding="utf-8") + (script.parent / "README.txt").write_text("readme", encoding="utf-8") + (tmp_path / "LICENSE").write_text("license", encoding="utf-8") + python_path = sys.executable.replace("'", "''") + script_path = str(script).replace("'", "''") + command = f""" +$ErrorActionPreference = 'Stop' +$global:buildProbeCalls = 0 +function python {{ + $global:buildProbeCalls++ + if ($global:buildProbeCalls -eq {failed_command}) {{ + & '{python_path}' -c "import sys; print('0.6.7'); sys.exit(7)" + }} else {{ + & '{python_path}' -c "print('0.6.7')" + }} +}} +& '{script_path}' +""" + environment = {key: value for key, value in os.environ.items() if key != "HORTICALC_VERSION"} + result = subprocess.run( + [POWERSHELL, "-NoProfile", "-NonInteractive", "-Command", command], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + + assert result.returncode != 0 + assert message in result.stderr + assert binary.read_text(encoding="utf-8") == "previous binary" + for name in ("frontend", "data", "recipes"): + assert (output / name / "sentinel").read_text(encoding="utf-8") == "previous asset" + + +@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell is required") +@pytest.mark.parametrize("version_output", ["0.6.7", ""]) +def test_installer_stops_before_compilation_when_version_discovery_fails(tmp_path: Path, version_output: str) -> None: + script = tmp_path / "scripts" / "packaging" / "build_windows_installer.ps1" + script.parent.mkdir(parents=True) + shutil.copyfile(INSTALLER_SCRIPT, script) + for name in ( + "dist/Horticalc/Horticalc.exe", + "dist/Horticalc/_internal/pythonnet/runtime/Python.Runtime.dll", + "dist/Horticalc/frontend/index.html", + "dist/Horticalc/LICENSE", + "assets/horticalc.ico", + "scripts/packaging/horticalc.iss", + "horticalc-v0.6.7-windows-setup.exe", + ): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("previous output", encoding="utf-8") + marker = tmp_path / "compiler-called" + compiler = tmp_path / "fake-iscc.ps1" + marker_path = str(marker).replace("'", "''") + compiler.write_text(f"Set-Content -LiteralPath '{marker_path}' -Value 'called'\n", encoding="utf-8") + python_path = sys.executable.replace("'", "''") + script_path = str(script).replace("'", "''") + compiler_path = str(compiler).replace("'", "''") + command = f""" +function python {{ + & '{python_path}' -c "import sys; sys.stdout.write('{version_output}'); sys.exit(7)" +}} +& '{script_path}' -IsccPath '{compiler_path}' +""" + environment = {key: value for key, value in os.environ.items() if key != "HORTICALC_VERSION"} + result = subprocess.run( + [POWERSHELL, "-NoProfile", "-NonInteractive", "-Command", command], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + check=False, + timeout=30, + ) + + assert result.returncode != 0 + assert "Version discovery failed with exit code 7" in result.stderr + assert not marker.exists() + assert (tmp_path / "dist/Horticalc/Horticalc.exe").read_text(encoding="utf-8") == "previous output" + assert (tmp_path / "horticalc-v0.6.7-windows-setup.exe").read_text(encoding="utf-8") == "previous output"