mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-09-14 16:37:18 +08:00
9ef274c3b7
The fine-dust helper still broke the documented station-name path when the station lookup API returned no rows, and it guessed a KHAI label from PM10 whenever the upstream API omitted khaiGrade. This change locks both behaviors with regressions, resolves station selection to a direct measurement lookup when only the station name is usable, and reports 정보없음 when the source omits the KHAI field. The docs now match the implemented fallback and missing-field behavior. Constraint: Keep the fix compatible with existing fetch_station_payload callers and PR #21 scope Rejected: Recompute KHAI from other pollutant fields | official formula/data inputs are not implemented in this helper Rejected: Fail hard when station lookup is empty despite --station-name | contradicts the documented direct station-name fallback Confidence: high Scope-risk: narrow Reversibility: clean Directive: Do not synthesize 통합대기등급 from PM10/PM2.5 surrogates unless the official KHAI formula and inputs are implemented end-to-end Tested: python3 scripts/test_fine_dust.py; npm run ci; python3 scripts/fine_dust.py report --station-file scripts/fixtures/fine-dust-stations.json --measurement-file scripts/fixtures/fine-dust-measurements.json --lat 37.5665 --lon 126.9780; python3 scripts/fine_dust.py report --station-file scripts/fixtures/fine-dust-stations.json --measurement-file scripts/fixtures/fine-dust-measurements.json --region-hint '서울 강남구'; python3 scripts/fine_dust.py --help; env -u AIR_KOREA_OPEN_API_KEY python3 scripts/fine_dust.py report --lat 37.5665 --lon 126.9780; python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py Not-tested: Live Air Korea API responses with a real service key
258 lines
10 KiB
Python
258 lines
10 KiB
Python
import io
|
|
import json
|
|
import pathlib
|
|
import unittest
|
|
from contextlib import redirect_stdout
|
|
from unittest import mock
|
|
|
|
import fine_dust
|
|
|
|
|
|
FIXTURES = pathlib.Path(__file__).with_name("fixtures")
|
|
|
|
|
|
def load_fixture(name):
|
|
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
|
|
|
|
|
class FineDustTests(unittest.TestCase):
|
|
def test_wgs84_coordinates_are_converted_to_air_korea_tm(self):
|
|
tm_x, tm_y = fine_dust.wgs84_to_air_korea_tm(37.5665, 126.9780)
|
|
|
|
self.assertAlmostEqual(tm_x, 198245.053, places=3)
|
|
self.assertAlmostEqual(tm_y, 451586.838, places=3)
|
|
|
|
def test_pick_station_prefers_nearest_station_for_coordinates(self):
|
|
stations = load_fixture("fine-dust-stations.json")
|
|
|
|
station = fine_dust.pick_station(
|
|
fine_dust.extract_items(stations),
|
|
lat=37.5665,
|
|
lon=126.9780,
|
|
)
|
|
|
|
self.assertEqual(station["stationName"], "중구")
|
|
|
|
def test_pick_station_prefers_specific_region_token_over_generic_city_token(self):
|
|
stations = load_fixture("fine-dust-stations.json")
|
|
|
|
station = fine_dust.pick_station(
|
|
fine_dust.extract_items(stations),
|
|
region_hint="서울 강남구",
|
|
)
|
|
|
|
self.assertEqual(station["stationName"], "강남구")
|
|
|
|
def test_pick_station_falls_back_to_region_hint_without_coordinates(self):
|
|
stations = load_fixture("fine-dust-stations.json")
|
|
|
|
station = fine_dust.pick_station(
|
|
fine_dust.extract_items(stations),
|
|
region_hint="강남",
|
|
)
|
|
|
|
self.assertEqual(station["stationName"], "강남구")
|
|
|
|
def test_build_report_combines_station_and_measurement_summary(self):
|
|
stations = load_fixture("fine-dust-stations.json")
|
|
measurements = load_fixture("fine-dust-measurements.json")
|
|
|
|
report = fine_dust.build_report(
|
|
station_items=fine_dust.extract_items(stations),
|
|
measurement_items=fine_dust.extract_items(measurements),
|
|
lat=37.5665,
|
|
lon=126.9780,
|
|
)
|
|
|
|
self.assertEqual(report["station_name"], "중구")
|
|
self.assertEqual(report["pm10"], {"value": "42", "grade": "보통"})
|
|
self.assertEqual(report["pm25"], {"value": "19", "grade": "보통"})
|
|
self.assertEqual(report["measured_at"], "2026-03-27 21:00")
|
|
|
|
def test_build_report_marks_khai_grade_unknown_when_api_omits_it(self):
|
|
report = fine_dust.build_report(
|
|
station_items=[{"stationName": "중구", "addr": "서울 중구 서소문로 124"}],
|
|
measurement_items=[
|
|
{
|
|
"stationName": "중구",
|
|
"dataTime": "2026-03-27 21:00",
|
|
"pm10Value": "42",
|
|
"pm10Grade": "2",
|
|
"pm25Value": "19",
|
|
"pm25Grade": "2",
|
|
"khaiGrade": None,
|
|
}
|
|
],
|
|
station_name="중구",
|
|
)
|
|
|
|
self.assertEqual(report["khai_grade"], "정보없음")
|
|
|
|
def test_cli_report_supports_fixture_inputs(self):
|
|
station_path = FIXTURES / "fine-dust-stations.json"
|
|
measurement_path = FIXTURES / "fine-dust-measurements.json"
|
|
stdout = io.StringIO()
|
|
|
|
with redirect_stdout(stdout):
|
|
fine_dust.main([
|
|
"report",
|
|
"--station-file",
|
|
str(station_path),
|
|
"--measurement-file",
|
|
str(measurement_path),
|
|
"--lat",
|
|
"37.5665",
|
|
"--lon",
|
|
"126.9780",
|
|
])
|
|
|
|
rendered = stdout.getvalue()
|
|
self.assertIn("측정소: 중구", rendered)
|
|
self.assertIn("PM10: 42 (보통)", rendered)
|
|
self.assertIn("PM2.5: 19 (보통)", rendered)
|
|
|
|
def test_live_station_lookup_converts_lat_lon_before_nearby_request(self):
|
|
args = fine_dust.parse_args(["report", "--lat", "37.5665", "--lon", "126.9780"])
|
|
recorded_calls = []
|
|
|
|
def fake_fetch_json(url, params):
|
|
recorded_calls.append((url, params))
|
|
return {"response": {"body": {"items": [{"stationName": "중구", "addr": "서울 중구 서소문로 124"}]}}}
|
|
|
|
with (
|
|
mock.patch.object(fine_dust, "get_required_secret", return_value="test-secret"),
|
|
mock.patch.object(fine_dust, "fetch_json", side_effect=fake_fetch_json),
|
|
):
|
|
payload = fine_dust.fetch_station_payload(args)
|
|
|
|
items = fine_dust.extract_items(payload)
|
|
self.assertEqual(items[0]["stationName"], "중구")
|
|
self.assertEqual(len(recorded_calls), 1)
|
|
|
|
request_url, request_params = recorded_calls[0]
|
|
self.assertTrue(request_url.endswith("/getNearbyMsrstnList"))
|
|
self.assertAlmostEqual(request_params["tmX"], 198245.053, places=3)
|
|
self.assertAlmostEqual(request_params["tmY"], 451586.838, places=3)
|
|
self.assertNotIn("dmX", request_params)
|
|
self.assertNotIn("dmY", request_params)
|
|
|
|
def test_live_station_lookup_falls_back_to_region_search_after_empty_nearby_result(self):
|
|
args = fine_dust.parse_args(["report", "--lat", "37.5665", "--lon", "126.9780", "--region-hint", "서울 강남구"])
|
|
recorded_calls = []
|
|
|
|
def fake_fetch_json(url, params):
|
|
recorded_calls.append((url, params))
|
|
if url.endswith("/getNearbyMsrstnList"):
|
|
return {"response": {"body": {"items": []}}}
|
|
if url.endswith("/getMsrstnList"):
|
|
return {"response": {"body": {"items": [{"stationName": "강남구", "addr": "서울 강남구 학동로 426"}]}}}
|
|
raise AssertionError(f"unexpected URL: {url}")
|
|
|
|
with (
|
|
mock.patch.object(fine_dust, "get_required_secret", return_value="test-secret"),
|
|
mock.patch.object(fine_dust, "fetch_json", side_effect=fake_fetch_json),
|
|
):
|
|
payload = fine_dust.fetch_station_payload(args)
|
|
|
|
items = fine_dust.extract_items(payload)
|
|
self.assertEqual(items[0]["stationName"], "강남구")
|
|
self.assertEqual([url.rsplit("/", 1)[-1] for url, _ in recorded_calls], ["getNearbyMsrstnList", "getMsrstnList"])
|
|
fallback_params = recorded_calls[1][1]
|
|
self.assertEqual(fallback_params["addr"], "서울 강남구")
|
|
|
|
def test_cli_json_report_marks_region_fallback_when_nearby_lookup_is_empty(self):
|
|
stdout = io.StringIO()
|
|
|
|
def fake_fetch_json(url, params):
|
|
if url.endswith("/getNearbyMsrstnList"):
|
|
return {"response": {"body": {"items": []}}}
|
|
if url.endswith("/getMsrstnList"):
|
|
return {"response": {"body": {"items": [{"stationName": "강남구", "addr": "서울 강남구 학동로 426"}]}}}
|
|
if url.endswith("/getMsrstnAcctoRltmMesureDnsty"):
|
|
return {
|
|
"response": {
|
|
"body": {
|
|
"items": [
|
|
{
|
|
"stationName": "강남구",
|
|
"dataTime": "2026-03-27 21:00",
|
|
"pm10Value": "42",
|
|
"pm10Grade": "2",
|
|
"pm25Value": "19",
|
|
"pm25Grade": "2",
|
|
"khaiGrade": "2",
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
raise AssertionError(f"unexpected URL: {url}")
|
|
|
|
with (
|
|
redirect_stdout(stdout),
|
|
mock.patch.object(fine_dust, "get_required_secret", return_value="test-secret"),
|
|
mock.patch.object(fine_dust, "fetch_json", side_effect=fake_fetch_json),
|
|
):
|
|
fine_dust.main(
|
|
[
|
|
"report",
|
|
"--lat",
|
|
"37.5665",
|
|
"--lon",
|
|
"126.9780",
|
|
"--region-hint",
|
|
"서울 강남구",
|
|
"--json",
|
|
]
|
|
)
|
|
|
|
rendered = json.loads(stdout.getvalue())
|
|
self.assertEqual(rendered["station_name"], "강남구")
|
|
self.assertEqual(rendered["lookup_mode"], "fallback")
|
|
|
|
def test_cli_json_report_uses_station_name_directly_when_station_lookup_is_empty(self):
|
|
stdout = io.StringIO()
|
|
recorded_calls = []
|
|
|
|
def fake_fetch_json(url, params):
|
|
recorded_calls.append((url, params))
|
|
if url.endswith("/getMsrstnList"):
|
|
return {"response": {"body": {"items": []}}}
|
|
if url.endswith("/getMsrstnAcctoRltmMesureDnsty"):
|
|
return {
|
|
"response": {
|
|
"body": {
|
|
"items": [
|
|
{
|
|
"stationName": "중구",
|
|
"dataTime": "2026-03-27 21:00",
|
|
"pm10Value": "42",
|
|
"pm10Grade": "2",
|
|
"pm25Value": "19",
|
|
"pm25Grade": "2",
|
|
"khaiGrade": "2",
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
raise AssertionError(f"unexpected URL: {url}")
|
|
|
|
with (
|
|
redirect_stdout(stdout),
|
|
mock.patch.object(fine_dust, "get_required_secret", return_value="test-secret"),
|
|
mock.patch.object(fine_dust, "fetch_json", side_effect=fake_fetch_json),
|
|
):
|
|
fine_dust.main(["report", "--station-name", "중구", "--json"])
|
|
|
|
rendered = json.loads(stdout.getvalue())
|
|
self.assertEqual(rendered["station_name"], "중구")
|
|
self.assertIsNone(rendered["station_address"])
|
|
self.assertEqual(rendered["lookup_mode"], "fallback")
|
|
self.assertEqual([url.rsplit("/", 1)[-1] for url, _ in recorded_calls], ["getMsrstnList", "getMsrstnAcctoRltmMesureDnsty"])
|
|
self.assertEqual(recorded_calls[1][1]["stationName"], "중구")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|