/** * asos-station.test.ts * * ASOS(종관기상관측) 지점 매칭과 rn 인코딩 정규화. * * 지점 목록은 지점정보 API가 없어서(NO_OPENAPI_SERVICE_ERROR) 일자료 엔드포인트로 * stnIds 90~300을 전수조사해 뽑았다 — 97개. * * 좌표 기반 최근접 지점 선택은 만들다 폐기했다: 지점명을 지오코딩했더니 홍성·보령·밀양이 * 북한 동명 지역으로, 남원이 제주도로 나왔다. 좌표를 검증하려고 ASOS 관측기온과 * Open-Meteo 기온을 대조했지만 좌표가 정확한 서울조차 Δ3.1°C라(도시열섬·모델편차) * 판별자가 못 됐다. 그래서 이름으로만 맞히고, 못 맞히면 Open-Meteo로 명시적으로 물러난다. */ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { findAsosStation, nearestAsosStation, normalizeAsosHour } from '../src/tools/weather'; describe('findAsosStation — 지점명 매칭', () => { test('정확히 일치하는 지점', () => { assert.deepEqual(findAsosStation('서울'), { id: 108, name: '서울' }); assert.deepEqual(findAsosStation('구미'), { id: 279, name: '구미' }); }); test('행정 접미사를 벗겨서 맞힌다', () => { assert.deepEqual(findAsosStation('부산광역시'), { id: 159, name: '부산' }); // 반대 방향: 입력은 맨이름인데 지점명에 "시"가 붙어 있는 경우 assert.deepEqual(findAsosStation('김해'), { id: 253, name: '김해시' }); }); test('방위 접두사가 붙은 지점명도 맨이름으로 찾는다', () => { // 지점명은 "북창원"인데 사용자는 "창원"이라고 쓴다. assert.deepEqual(findAsosStation('창원'), { id: 155, name: '창원' }); assert.deepEqual(findAsosStation('춘천'), { id: 101, name: '춘천' }); }); test('정확 일치가 접두사 제거보다 우선한다', () => { // "부산"은 159(부산)과 296(북부산) 둘 다에 걸릴 수 있다 — 정확 일치가 이겨야 한다. assert.deepEqual(findAsosStation('부산'), { id: 159, name: '부산' }); }); test('ASOS 지점이 없는 입력은 null (호출부가 Open-Meteo로 물러남)', () => { assert.equal(findAsosStation('우리집'), null); assert.equal(findAsosStation('37.5665,126.978'), null); assert.equal(findAsosStation(''), null); assert.equal(findAsosStation(' '), null); }); }); describe('nearestAsosStation — 좌표에서 최근접 지점', () => { // 좌표는 2026-08-16에 기상청 API허브 stn_inf.php(공식)로 교체했다. 그 전엔 지점명을 // 지오코딩해 만들려다 홍성·보령·밀양이 북한, 남원이 제주도로 나와서 폐기했었다. test('지점이 바로 있는 도시는 몇 km 안에 잡힌다', () => { const seoul = nearestAsosStation(37.5665, 126.978); assert.equal(seoul?.name, '서울'); assert.ok(seoul!.km < 5, `서울 ${seoul!.km}km — 너무 멀다`); const gumi = nearestAsosStation(36.1195, 128.3446); assert.equal(gumi?.name, '구미'); assert.ok(gumi!.km < 5); }); test('지점이 없는 도시는 이웃 지점으로 넘어간다', () => { // 시흥·용인엔 ASOS가 없다 — 각각 인천·수원이 가장 가깝다. assert.equal(nearestAsosStation(37.3800, 126.8029)?.name, '인천'); assert.equal(nearestAsosStation(37.2411, 127.1776)?.name, '수원'); }); test('공식 좌표라서 지오코더가 틀렸던 지점도 제자리에 있다', () => { // 예전 지오코딩은 남해를 36.44,127.41(충청)로 봤다. 공식은 34.82,127.93. assert.equal(nearestAsosStation(34.8376, 127.8925)?.name, '남해'); // 홍성/보령/밀양은 북한 동명 지역으로 튀었던 것들. assert.equal(nearestAsosStation(36.6576, 126.6877)?.name, '홍성'); assert.equal(nearestAsosStation(35.4915, 128.7441)?.name, '밀양'); }); test('maxKm 밖이면 null — 먼 관측소를 "관측값"으로 빌려 쓰지 않는다', () => { assert.equal(nearestAsosStation(37.2429, 131.8664), null); // 독도 assert.equal(nearestAsosStation(34.3, 129.3), null); // 대마도 근해 }); test('maxKm를 넓히면 같은 좌표도 잡힌다', () => { const far = nearestAsosStation(37.2429, 131.8664, 200); assert.equal(far?.name, '울릉도'); }); test('좌표가 숫자가 아니면 null', () => { assert.equal(nearestAsosStation(NaN, 127), null); assert.equal(nearestAsosStation(37, NaN), null); }); }); describe('normalizeAsosHour — rn 인코딩', () => { test('""는 무강수 (결측 아님)', () => { assert.deepEqual(normalizeAsosHour('2026-08-15 03:00', ''), { time: '2026-08-15T03:00', mm: 0, wet: false }); }); test('"0.0"은 미량 강수 — 0mm지만 비는 온 시각', () => { // 이걸 무강수로 뭉개면 이슬비로 이어지던 비가 소강으로 잡혀 사상이 끊긴다. assert.deepEqual(normalizeAsosHour('2026-08-15 06:00', '0.0'), { time: '2026-08-15T06:00', mm: 0, wet: true }); }); test('실제 강수량', () => { assert.deepEqual(normalizeAsosHour('2026-08-15 16:00', '1.0'), { time: '2026-08-15T16:00', mm: 1, wet: true }); }); test('null/undefined는 무강수로 처리', () => { assert.equal(normalizeAsosHour('2026-08-15 01:00', null).wet, false); assert.equal(normalizeAsosHour('2026-08-15 01:00', undefined).wet, false); }); test('tm의 공백을 T로 바꿔 사상 판정에서 정렬 가능하게 만든다', () => { // 초단기실황 쪽 시각(2026-08-16T02:00)과 한 배열에 섞여 시간순 정렬되어야 한다. assert.equal(normalizeAsosHour('2026-08-15 23:00', '0.5').time, '2026-08-15T23:00'); }); });