test: add weather logic test suite
All checks were successful
CI / Lint, test, typecheck and build (push) Successful in 9m58s
All checks were successful
CI / Lint, test, typecheck and build (push) Successful in 9m58s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
1070
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
41
tests/forecast-utils.test.ts
Normal file
41
tests/forecast-utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
67
tests/warning-regions.test.ts
Normal file
67
tests/warning-regions.test.ts
Normal 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
117
tests/weather-brief.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
29
tests/weather-utils.test.ts
Normal file
29
tests/weather-utils.test.ts
Normal 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
14
vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user