diff --git a/CHANGELOG.md b/CHANGELOG.md index 2344592..b96344e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,8 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). mode say so, with the profile's boil-off. Werb's volumes still follow your profile. - **"My water" in Settings.** Set the water you brew with once: type the six - ions or load them from moneaudebrassage.fr (desktop app). New recipes use - it, and each recipe's water chemistry lists it as *My water* next to the + ions or load them from moneaudebrassage.fr by INSEE commune code, in the + desktop app and in the browser. New recipes use it, and each recipe's water chemistry lists it as *My water* next to the city profiles, plus *This recipe's water* when the file carries one. The recipe's *Save as default* and loader buttons are replaced by a link to Settings. diff --git a/apps/desktop/src/components/water/MabLoader.tsx b/apps/desktop/src/components/water/MabLoader.tsx index 72703a6..c295a6a 100644 --- a/apps/desktop/src/components/water/MabLoader.tsx +++ b/apps/desktop/src/components/water/MabLoader.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { useT } from "../../data/preferences.tsx"; import { translateError } from "../../data/errors.ts"; -import { isTauri } from "../../data/runtime.ts"; import { fetchCommuneAnalyses, isValidInsee, @@ -13,12 +12,10 @@ import { usePersistedJson } from "../../storage/index.ts"; /** * Fill the ions from the moneaudebrassage.fr public analysis for a French - * commune. Desktop only: the API is cross-origin and Origin-gated, which a - * browser fetch can't satisfy. The last pull is cached for offline use. + * commune. The last pull is cached for offline use. */ export function MabLoader({ onLoad }: { onLoad: (ions: IonProfile) => void }) { const t = useT(); - const desktop = isTauri(); const [open, setOpen] = useState(false); const [code, setCode] = useState(""); const [loading, setLoading] = useState(false); @@ -60,45 +57,39 @@ export function MabLoader({ onLoad }: { onLoad: (ions: IonProfile) => void }) { data-testid="water-mab-panel" className="rounded-lg bg-surface-raised border border-border p-3" > - {desktop ? ( - <> -
- - -
- {error &&

{error}

} - {cache && cache.networks.length > 0 && ( - onLoad(analysisToIons(a))} - /> - )} - - ) : ( -

{t("recipe.water.mab_desktop_only")}

+
+ + +
+ {error &&

{error}

} + {cache && cache.networks.length > 0 && ( + onLoad(analysisToIons(a))} + /> )}

{t("recipe.water.mab_attribution")} diff --git a/apps/desktop/src/data/i18n.ts b/apps/desktop/src/data/i18n.ts index 4e24392..175df19 100644 --- a/apps/desktop/src/data/i18n.ts +++ b/apps/desktop/src/data/i18n.ts @@ -736,10 +736,6 @@ const STRINGS: Record = { "recipe.water.mab_insee": { en: "INSEE code", fr: "Code INSEE" }, "recipe.water.mab_load": { en: "Load", fr: "Charger" }, "recipe.water.mab_loading": { en: "Loading…", fr: "Chargement…" }, - "recipe.water.mab_desktop_only": { - en: "Live commune lookup is available in the desktop app only. In the browser, enter the ions manually.", - fr: "La recherche par commune n'est disponible que dans l'application de bureau. Dans le navigateur, saisissez les ions manuellement.", - }, "recipe.water.mab_networks": { en: "Networks for {insee} — pick one", fr: "Réseaux pour {insee} — choisissez-en un" }, "recipe.water.mab_attribution": { en: "Water data © moneaudebrassage.fr", @@ -1166,10 +1162,6 @@ const STRINGS: Record = { en: "Enter a valid 5-digit INSEE commune code (e.g. 73008).", fr: "Saisissez un code INSEE de commune à 5 chiffres (ex. 73008).", }, - "error.water.mab.web_unsupported": { - en: "Commune lookup needs the desktop app — enter the ions manually here.", - fr: "La recherche par commune nécessite l'application de bureau — saisissez les ions manuellement ici.", - }, "error.water.mab.not_found": { en: "No published water analysis found for that commune.", fr: "Aucune analyse d'eau publiée pour cette commune.", diff --git a/apps/desktop/src/data/moneaudebrassage.ts b/apps/desktop/src/data/moneaudebrassage.ts index 16f1e84..dae781a 100644 --- a/apps/desktop/src/data/moneaudebrassage.ts +++ b/apps/desktop/src/data/moneaudebrassage.ts @@ -7,13 +7,9 @@ * to pre-fill the water-chemistry source ions so FR brewers don't have * to copy six numbers by hand. * - * The request is cross-origin and the API gates on the `Origin` header - * — neither works from a browser `fetch` (CORS rejects it, and `Origin` - * is a forbidden header the browser won't let JS set). So the fetch - * goes through the Tauri HTTP plugin (a native request, no CORS, the - * `Origin` header passes through). In the web build the feature is - * unavailable and the caller falls back to manual entry / the last - * cached pull. + * The API answers CORS requests from any origin, so the web build uses + * the browser `fetch`. The desktop app goes through the Tauri HTTP + * plugin, scoped to the API host in `capabilities/default.json`. * * Data © moneaudebrassage.fr — attribute the source in the UI. */ @@ -22,7 +18,6 @@ import { isTauri } from "./runtime.ts"; import { WerbError } from "./errors.ts"; const API_BASE = "https://apidb.moneaudebrassage.fr/lastanalyses"; -const ORIGIN = "https://www.moneaudebrassage.fr"; /** One distribution network's latest ion analysis (ppm). */ export interface WaterAnalysis { @@ -95,28 +90,23 @@ function toAnalysis(q: RawQuartier): WaterAnalysis { * Fetch and parse the latest analyses for an INSEE commune code. * * Throws a {@link WerbError} the UI can translate: - * - `water.mab.web_unsupported` — not running under Tauri. - * - `water.mab.bad_code` — code isn't a valid INSEE code. - * - `water.mab.not_found` — commune has no published analysis. - * - `water.mab.network` — request failed (offline, 5xx, …). + * - `water.mab.bad_code` — code isn't a valid INSEE code. + * - `water.mab.not_found` — commune has no published analysis. + * - `water.mab.network` — request failed (offline, 5xx, …). */ export async function fetchCommuneAnalyses(rawCode: string): Promise { const code = rawCode.trim().toUpperCase(); if (!isValidInsee(code)) { throw new WerbError("water.mab.bad_code"); } - if (!isTauri()) { - throw new WerbError("water.mab.web_unsupported"); - } - // Dynamic import keeps the Tauri HTTP plugin out of the web bundle. - const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http"); + const request = isTauri() ? (await import("@tauri-apps/plugin-http")).fetch : fetch; let res: Response; try { - res = await tauriFetch(`${API_BASE}/${encodeURIComponent(code)}`, { + res = await request(`${API_BASE}/${encodeURIComponent(code)}`, { method: "GET", - headers: { Accept: "application/json", Origin: ORIGIN }, + headers: { Accept: "application/json" }, }); } catch (err) { const detail = err instanceof Error ? err.message : String(err); diff --git a/apps/desktop/test/e2e/smoke.spec.ts b/apps/desktop/test/e2e/smoke.spec.ts index 561b0cb..57632cc 100644 --- a/apps/desktop/test/e2e/smoke.spec.ts +++ b/apps/desktop/test/e2e/smoke.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { App } from "./pages.js"; /** @@ -202,6 +202,59 @@ test.describe("Water chemistry — source = target reports no delta", () => { await page.locator('[data-testid="home-water"]').waitFor(); }); + test.describe("My water from moneaudebrassage.fr", () => { + // page.route doesn't see requests that go through a service worker. + test.use({ serviceWorkers: "block" }); + + const network = (name: string, ca: number) => ({ + nomreseau: name, + ca, + mg: 6, + na: 6, + cl: 11, + so4: 12, + hco3: 182, + ph: 7.8, + date_ca: "2026-09-22", + }); + + const serveCommune = async (page: Page, quartiers: unknown[]) => { + await page.route("https://apidb.moneaudebrassage.fr/lastanalyses/*", (route) => + route.fulfill({ + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": "*" }, + body: JSON.stringify({ departement: "73", inseecommune: "73008", quartiers }), + }), + ); + }; + + const lookUp = async (page: Page, code: string) => { + await page.getByRole("button", { name: /moneaudebrassage\.fr/i }).click(); + const panel = page.getByTestId("water-mab-panel"); + await panel.getByRole("textbox").fill(code); + await panel.getByRole("button", { name: /^(Load|Charger)$/ }).click(); + return panel; + }; + + test("a commune with one network fills My water in the browser", async ({ page }) => { + await serveCommune(page, [network("AIX LES BAINS PORTS", 53)]); + const app = new App(page); + await app.go("Settings"); + await lookUp(page, "73008"); + await expect(page.getByTestId("home-water-ca_ppm")).toHaveValue("53"); + await expect(page.getByTestId("home-water-hco3_ppm")).toHaveValue("182"); + }); + + test("a commune with several networks lets the brewer pick one", async ({ page }) => { + await serveCommune(page, [network("NORTH", 40), network("SOUTH", 90)]); + const app = new App(page); + await app.go("Settings"); + const panel = await lookUp(page, "73008"); + await panel.getByRole("button", { name: /SOUTH/ }).click(); + await expect(page.getByTestId("home-water-ca_ppm")).toHaveValue("90"); + }); + }); + test("real mismatch still flags deltas (Burton vs Pilsner)", async ({ page }) => { // Guard against the city-targets fix over-collapsing comparisons. const app = new App(page); diff --git a/apps/desktop/test/moneaudebrassage.test.ts b/apps/desktop/test/moneaudebrassage.test.ts index 029d04d..344bdce 100644 --- a/apps/desktop/test/moneaudebrassage.test.ts +++ b/apps/desktop/test/moneaudebrassage.test.ts @@ -2,10 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fetchCommuneAnalyses, isValidInsee } from "../src/data/moneaudebrassage.ts"; import { isWerbError } from "../src/data/errors.ts"; -// The module fetches through the Tauri HTTP plugin (dynamic import). -// Mock the plugin so we can drive responses without a real network / -// Tauri runtime. +// Under Tauri the module fetches through the HTTP plugin (dynamic import), +// elsewhere through the global fetch. Both are mocked. const tauriFetch = vi.fn(); +const browserFetch = vi.fn(); vi.mock("@tauri-apps/plugin-http", () => ({ fetch: (...args: unknown[]) => tauriFetch(...args) })); const TAURI_KEY = "__TAURI_INTERNALS__"; @@ -70,12 +70,15 @@ describe("isValidInsee", () => { describe("fetchCommuneAnalyses", () => { beforeEach(() => { tauriFetch.mockReset(); + browserFetch.mockReset(); + vi.stubGlobal("fetch", browserFetch); (globalThis as Record).window = globalThis as unknown as Window; (window as unknown as Record)[TAURI_KEY] = {}; }); afterEach(() => { delete (window as unknown as Record)[TAURI_KEY]; + vi.unstubAllGlobals(); }); it("rejects a bad code before touching the network", async () => { @@ -83,11 +86,12 @@ describe("fetchCommuneAnalyses", () => { expect(tauriFetch).not.toHaveBeenCalled(); }); - it("refuses to run outside Tauri", async () => { + it("uses the browser fetch outside Tauri", async () => { delete (window as unknown as Record)[TAURI_KEY]; - await expect(fetchCommuneAnalyses("73008")).rejects.toMatchObject({ - code: "water.mab.web_unsupported", - }); + browserFetch.mockResolvedValue(jsonResponse(SAMPLE)); + const res = await fetchCommuneAnalyses("73008"); + expect(res.networks).toHaveLength(1); + expect(browserFetch).toHaveBeenCalledOnce(); expect(tauriFetch).not.toHaveBeenCalled(); }); @@ -111,14 +115,14 @@ describe("fetchCommuneAnalyses", () => { }); }); - it("sends the Origin header the API gates on", async () => { + it("asks the API for the commune as JSON", async () => { tauriFetch.mockResolvedValue(jsonResponse(SAMPLE)); await fetchCommuneAnalyses("73008"); - const [url, opts] = tauriFetch.mock.calls[0]; - expect(url).toContain("/lastanalyses/73008"); - expect((opts as { headers: Record }).headers.Origin).toBe( - "https://www.moneaudebrassage.fr", + expect(tauriFetch).toHaveBeenCalledWith( + "https://apidb.moneaudebrassage.fr/lastanalyses/73008", + { method: "GET", headers: { Accept: "application/json" } }, ); + expect(browserFetch).not.toHaveBeenCalled(); }); it("maps a 404 to not_found", async () => {