test: add weather logic test suite
All checks were successful
CI / Lint, test, typecheck and build (push) Successful in 9m58s

This commit is contained in:
zv
2026-06-14 20:44:37 +02:00
parent ee55521803
commit bdba3c709c
11 changed files with 1357 additions and 10 deletions

View File

@@ -8,7 +8,7 @@ on:
jobs:
quality:
name: Lint, typecheck and build
name: Lint, test, typecheck and build
runs-on: ubuntu-latest
steps:
@@ -34,5 +34,8 @@ jobs:
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm run test
- name: Build
run: npm run build

View File

@@ -25,13 +25,15 @@ npm run dev
npm run lint
npm run format
npm run format:check
npm run test
npm run test:watch
npm run typecheck
npm run build
npm run start
npm run notifications:worker
```
Repozytorium nie ma obecnie skryptu testów. `npm run format` i `npm run format:check` używają Prettiera. `npm run typecheck` uruchamia `tsc --noEmit`, a `npm run build` uruchamia produkcyjny build Next.js. `npm run notifications:worker` uruchamia osobny proces Node do cyklicznego wywoływania endpointów Web Push na self-hostingu; wczytuje `.env` i `.env.local`, ale nadal wymaga równolegle działającego `npm run start` albo poprawnego `WTR_APP_URL`. Nie opisuj ani nie uruchamiaj nieistniejących komend jako standardowego workflow.
Testy jednostkowe uruchamia `npm run test` przez Vitest, a `npm run test:watch` działa w trybie obserwowania zmian. `npm run format` i `npm run format:check` używają Prettiera. `npm run typecheck` uruchamia `tsc --noEmit`, a `npm run build` uruchamia produkcyjny build Next.js. `npm run notifications:worker` uruchamia osobny proces Node do cyklicznego wywoływania endpointów Web Push na self-hostingu; wczytuje `.env` i `.env.local`, ale nadal wymaga równolegle działającego `npm run start` albo poprawnego `WTR_APP_URL`. Nie opisuj ani nie uruchamiaj nieistniejących komend jako standardowego workflow.
## Konwencje kodu
@@ -108,8 +110,8 @@ Przy zmianach UI:
- Dobieraj lokalną weryfikację proporcjonalnie do ryzyka zmiany. CI w Gitea Actions jest ostateczną bramką jakości po pushu.
- Zmiany w README, docs, tekstach i komentarzach: nie uruchamiaj `npm run lint`, `npm run typecheck` ani `npm run build`; wystarczy `git diff` i ewentualnie `git diff --check`.
- Małe zmiany wizualne, np. klasy Tailwind, border, radius, spacing, kolory albo copy w JSX: nie uruchamiaj rutynowo `npm run build`. Uruchom `npm run lint` tylko, gdy zmiana mogła naruszyć składnię albo reguły lintingu, a `npm run typecheck` tylko przy zmianach typów, propsów lub logiki.
- Zmiany w komponentach z logiką, hookach, typach, parserach i danych pogodowych: uruchom `npm run typecheck`; dodaj `npm run lint`, jeśli zmiana dotyka kodu TS/TSX.
- Zmiany wysokiego ryzyka, np. Next routing, config, dependencies, `package-lock`, PWA/service worker, API/server code, build config i obsługa env: uruchom pełny zestaw `npm run lint`, `npm run format:check`, `npm run typecheck` i `npm run build`.
- Zmiany w komponentach z logiką, hookach, typach, parserach i danych pogodowych: uruchom `npm run typecheck`; dodaj `npm run lint`, jeśli zmiana dotyka kodu TS/TSX, oraz `npm run test`, jeśli zmiana dotyka logiki objętej testami.
- Zmiany wysokiego ryzyka, np. Next routing, config, dependencies, `package-lock`, PWA/service worker, API/server code, build config i obsługa env: uruchom pełny zestaw `npm run lint`, `npm run format:check`, `npm run typecheck`, `npm run test` i `npm run build`.
- Przed większym pushem albo większym refaktorem możesz uruchomić pełną weryfikację.
- Sprawdź `git status` przed zakończeniem. Używaj `git diff --check` wtedy, gdy zmiana mogła wprowadzić problemy whitespace. Nie commituj wygenerowanego churnu w `next-env.d.ts`.
- Commituj zakończone zmiany w formacie Conventional Commits, np. `fix: correct warning filtering`, ale nie używaj eskalacji dla `git commit` bez realnej potrzeby.

View File

@@ -68,12 +68,14 @@ Przykład znajduje się w [.env.example](.env.example).
| `npm run lint` | Uruchamia ESLint. |
| `npm run format` | Formatuje obsługiwane pliki przez Prettier. |
| `npm run format:check` | Sprawdza formatowanie Prettier bez zapisywania zmian. |
| `npm run test` | Uruchamia testy jednostkowe logiki pogodowej przez Vitest. |
| `npm run test:watch` | Uruchamia Vitest w trybie obserwowania zmian. |
| `npm run typecheck` | Uruchamia `tsc --noEmit`. |
| `npm run build` | Buduje aplikację produkcyjnie i uruchamia kontrolę TypeScript wykonywaną przez Next.js. |
| `npm run start` | Uruchamia zbudowaną aplikację. |
| `npm run notifications:worker` | Uruchamia self-hostowany worker powiadomień. Wymaga działającej aplikacji Next.js. |
Repozytorium nie ma obecnie skryptu testów. Formatowanie jest obsługiwane przez Prettier.
Testy jednostkowe korzystają z Vitest i obejmują kluczową logikę pogodową, m.in. briefy, jednostki, ikony prognozy oraz dopasowanie ostrzeżeń do regionu. Formatowanie jest obsługiwane przez Prettier.
## CI
@@ -84,6 +86,7 @@ Pipeline działa na `ubuntu-latest`, instaluje zależności przez `npm ci`, a na
- `npm run lint`,
- `npm run format:check`,
- `npm run typecheck`,
- `npm run test`,
- `npm run build`.
Na instancji Gitea musi być włączony Actions runner kompatybilny z etykietą `ubuntu-latest`.

View File

@@ -76,10 +76,10 @@ Repozytorium używa Gitea Actions jako końcowej bramki jakości po pushu. Lokal
- dokumentacja, teksty i komentarze: przegląd diffu oraz opcjonalnie `git diff --check`,
- drobne zmiany wizualne: bez rutynowego builda; `npm run lint` tylko przy ryzyku błędu składni lub reguł lintingu,
- zmiany w logice, hookach, typach, parserach i danych pogodowych: `npm run typecheck`, a dla TS/TSX także `npm run lint`,
- routing Next.js, config, zależności, `package-lock`, PWA/service worker, API/server code, build config i obsługa env: `npm run lint`, `npm run format:check`, `npm run typecheck` oraz `npm run build`.
- zmiany w logice, hookach, typach, parserach i danych pogodowych: `npm run typecheck`, a dla TS/TSX także `npm run lint`; dodaj `npm run test`, jeśli zmiana dotyka logiki objętej testami,
- routing Next.js, config, zależności, `package-lock`, PWA/service worker, API/server code, build config i obsługa env: `npm run lint`, `npm run format:check`, `npm run typecheck`, `npm run test` oraz `npm run build`.
Repozytorium nie ma obecnie skryptu testów. Formatowanie jest obsługiwane przez Prettier (`npm run format` i `npm run format:check`).
Testy jednostkowe korzystają z Vitest (`npm run test`) i obejmują kluczową logikę pogodową. Formatowanie jest obsługiwane przez Prettier (`npm run format` i `npm run format:check`).
## Bezpieczeństwo Zależności

1070
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"notifications:worker": "node scripts/notification-worker.mjs"
},
@@ -39,6 +41,7 @@
"postcss": "^8.4.49",
"prettier": "^3.8.4",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
"typescript": "^5.7.2",
"vitest": "^4.1.8"
}
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { getEffectiveHourlyWeatherCode, getForecastConditionKey, isForecastNight } from "@/lib/forecast-utils";
describe("forecast utilities", () => {
it("promotes dry weather code to rain when precipitation probability is high", () => {
expect(
getEffectiveHourlyWeatherCode({
weatherCode: 0,
precipitation: 0,
precipitationProbability: 100,
}),
).toBe(61);
});
it("keeps clear weather code when there is no precipitation signal", () => {
expect(
getEffectiveHourlyWeatherCode({
weatherCode: 0,
precipitation: 0,
precipitationProbability: 10,
}),
).toBe(0);
});
it("recognizes night hours from sunrise and sunset", () => {
const day = {
sunrise: "2026-06-15T05:00",
sunset: "2026-06-15T21:00",
};
expect(isForecastNight("2026-06-15T03:00", day)).toBe(true);
expect(isForecastNight("2026-06-15T12:00", day)).toBe(false);
expect(isForecastNight("2026-06-15T22:00", day)).toBe(true);
});
it("maps storm and rain weather codes to condition keys", () => {
expect(getForecastConditionKey(61)).toBe("forecast.condition.rain");
expect(getForecastConditionKey(95)).toBe("forecast.condition.thunderstorm");
});
});

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { getCountyTerytForLocation, warningMatchesCounty, warningMatchesLocalSelection } from "@/lib/warning-regions";
import type { WeatherWarning } from "@/types/imgw";
import type { SelectedLocation } from "@/types/location";
function warning(overrides: Partial<WeatherWarning> = {}): WeatherWarning {
return {
id: "meteo-1",
kind: "meteo",
level: 1,
title: "Storm",
description: null,
comment: null,
validFrom: null,
validTo: null,
publishedAt: null,
probability: null,
areas: [],
terytCodes: [],
provinces: ["mazowieckie"],
office: null,
...overrides,
};
}
function location(overrides: Partial<SelectedLocation> = {}): SelectedLocation {
return {
name: "Warszawa",
province: "mazowieckie",
district: "Warszawa",
country: "Poland",
countryCode: "PL",
admin1: "Mazowieckie",
admin2: "Warszawa",
timezone: "Europe/Warsaw",
region: "PL",
countyTeryt: "1465",
latitude: 52.23,
longitude: 21.01,
stationId: null,
stationName: null,
distanceKm: null,
...overrides,
};
}
describe("warning region matching", () => {
it("resolves Mazowieckie county codes from district names", () => {
expect(getCountyTerytForLocation("mazowieckie", "Warszawa", null)).toBe("1465");
expect(getCountyTerytForLocation("mazowieckie", "radomski", null)).toBe("1425");
});
it("matches meteorological warnings by county TERYT prefix", () => {
const warsawWarning = warning({ terytCodes: ["146501"] });
expect(warningMatchesCounty(warsawWarning, "1465")).toBe(true);
expect(warningMatchesCounty(warsawWarning, "1425")).toBe(false);
});
it("uses county filtering before province fallback for selected locations", () => {
const radomWarning = warning({ terytCodes: ["142501"] });
expect(warningMatchesLocalSelection(radomWarning, "mazowieckie", location())).toBe(false);
expect(warningMatchesLocalSelection(radomWarning, "mazowieckie", location({ countyTeryt: null }))).toBe(true);
});
});

117
tests/weather-brief.test.ts Normal file
View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { buildTomorrowWeatherBrief, buildWeatherBrief } from "@/lib/weather-brief";
import type { WeatherForecast, HourlyForecast } from "@/types/forecast";
import type { WeatherWarning } from "@/types/imgw";
import { getWeatherCapabilities } from "@/types/weather-region";
function hour(overrides: Partial<HourlyForecast> = {}): HourlyForecast {
return {
time: "2026-06-14T08:00",
temperature: 10,
feelsLike: 9,
precipitationProbability: 20,
precipitation: 0,
weatherCode: 3,
windSpeed: 4,
source: "open-meteo",
...overrides,
};
}
function forecast(hours: HourlyForecast[]): WeatherForecast {
return {
latitude: 52.23,
longitude: 21.01,
region: "PL",
timezone: "Europe/Warsaw",
hourly: hours,
daily: [],
sources: ["open-meteo"],
capabilities: getWeatherCapabilities("PL"),
unavailableReasons: [],
};
}
function warning(overrides: Partial<WeatherWarning> = {}): WeatherWarning {
return {
id: "meteo-1",
kind: "meteo",
level: 2,
title: "Thunderstorms",
description: null,
comment: null,
validFrom: "2026-06-15T06:00:00.000Z",
validTo: "2026-06-15T18:00:00.000Z",
publishedAt: null,
probability: 80,
areas: [],
terytCodes: ["146501"],
provinces: ["mazowieckie"],
office: null,
...overrides,
};
}
describe("weather briefs", () => {
it("uses selected temperature and wind units in the morning brief", () => {
const brief = buildWeatherBrief({
forecast: forecast([
hour({ time: "2026-06-14T08:00", temperature: 10, windSpeed: 4, precipitationProbability: 80 }),
hour({ time: "2026-06-14T09:00", temperature: 12, windSpeed: 5, precipitationProbability: 30 }),
]),
warnings: [],
province: "mazowieckie",
countyTeryt: "1465",
locationName: "Warsaw",
language: "en",
temperatureUnit: "f",
windSpeedUnit: "mph",
now: new Date("2026-06-14T06:00:00.000Z"),
});
expect(brief).not.toBeNull();
expect(brief?.pushBody).toContain("50°F-54°F");
expect(brief?.pushBody).toContain("wind 11 mph");
expect(brief?.pushBody).not.toContain("km/h");
});
it("warns about tomorrow storms from model weather codes without IMGW warning", () => {
const brief = buildTomorrowWeatherBrief({
forecast: forecast([
hour({ time: "2026-06-15T08:00", temperature: 14, weatherCode: 3, precipitationProbability: 20 }),
hour({ time: "2026-06-15T15:00", temperature: 21, weatherCode: 95, precipitationProbability: 30 }),
]),
warnings: [],
province: "mazowieckie",
countyTeryt: "1465",
locationName: "Warsaw",
language: "en",
now: new Date("2026-06-14T16:00:00.000Z"),
});
expect(brief?.severity).toBe("warning");
expect(brief?.headline).toBe("Thunderstorms are possible tomorrow");
expect(brief?.pushBody).toContain("thunderstorms");
expect(brief?.pushBody).not.toContain("IMGW");
});
it("prefers relevant IMGW county warning over province-wide context", () => {
const brief = buildTomorrowWeatherBrief({
forecast: forecast([hour({ time: "2026-06-15T12:00", weatherCode: 3 })]),
warnings: [
warning({ title: "Radom storm", terytCodes: ["142501"] }),
warning({ title: "Warsaw storm", terytCodes: ["146501"] }),
],
province: "mazowieckie",
countyTeryt: "1465",
locationName: "Warsaw",
language: "en",
now: new Date("2026-06-14T16:00:00.000Z"),
});
expect(brief?.headline).toBe("Tomorrow: IMGW Warsaw storm");
expect(brief?.pushBody).toContain("IMGW: Warsaw storm");
expect(brief?.pushBody).not.toContain("Radom storm");
});
});

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_WIND_SPEED_UNIT,
formatTemperature,
formatWindSpeed,
isTemperatureUnit,
isWindSpeedUnit,
} from "@/lib/weather-utils";
describe("weather utilities", () => {
it("formats wind speed in km/h by default", () => {
expect(DEFAULT_WIND_SPEED_UNIT).toBe("kmh");
expect(formatWindSpeed(5, "en")).toBe("18 km/h");
});
it("formats selected temperature and wind units", () => {
expect(formatTemperature(10, "en", "f")).toBe("50°F");
expect(formatWindSpeed(4, "en", "mph")).toBe("9 mph");
expect(formatWindSpeed(4, "en", "ms")).toBe("4.0 m/s");
});
it("validates supported presentation units", () => {
expect(isTemperatureUnit("c")).toBe(true);
expect(isTemperatureUnit("kelvin")).toBe(false);
expect(isWindSpeedUnit("kmh")).toBe(true);
expect(isWindSpeedUnit("knots")).toBe(false);
});
});

14
vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
test: {
environment: "node",
globals: false,
},
});