법률 사건관리 앱과 같은 패턴으로 의료 사건(진료기록) 관리 앱 추가 (routes-doctor-cases.ts + doctor-app.html + WOPI 라우트). Home Assistant를 iframe으로 띄우는 얇은 래퍼 앱 추가(ha.applecherry.net, 2026-08-01 통합 완료된 것 URL만 새로 노출). weather_openmeteo의 요청 성형/리포트 포맷팅 로직을 weather-openmeteo-format.ts로 분리(순수함수라 테스트 가능, 2026-07-29 있었던 타임존/롤링윈도우/daily-in-hourly 버그 3건 회귀 방지 테스트 포함) + geocode 재시도 테스트. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
84 lines
3.0 KiB
TypeScript
84 lines
3.0 KiB
TypeScript
/**
|
|
* geocode-retry.test.ts
|
|
*
|
|
* Pinned to the real 2026-07-29 failure: a `weather_openmeteo({"location":"Madrid"})`
|
|
* call in the middle of a 6-city burst returned "위치를 찾을 수 없습니다: Madrid"
|
|
* while the 5 surrounding calls and 20/20 direct retries succeeded. The geocoding
|
|
* API threw a one-off transient blip; geocodeCity had no retry, so that single
|
|
* blip hardened into a permanent "city not found" and the model dropped Madrid.
|
|
*
|
|
* These tests pin the retry contract: transient failures (throw / non-2xx) get
|
|
* retried, a genuine 200-with-empty is answered immediately without wasting retries.
|
|
*/
|
|
|
|
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { geocodeCity } from '../src/tools/weather.js';
|
|
|
|
const REAL = (name: string) => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ results: [{ name, country: '스페인', country_code: 'ES', latitude: 40.4165, longitude: -3.70256, population: 3255944 }] }),
|
|
});
|
|
|
|
describe('geocodeCity retry', () => {
|
|
let origFetch: typeof fetch;
|
|
let calls: number;
|
|
let plan: Array<() => Promise<any>>;
|
|
|
|
beforeEach(() => {
|
|
origFetch = globalThis.fetch;
|
|
calls = 0;
|
|
plan = [];
|
|
globalThis.fetch = (async () => {
|
|
const step = plan[calls++] ?? plan[plan.length - 1];
|
|
return step();
|
|
}) as any;
|
|
});
|
|
afterEach(() => { globalThis.fetch = origFetch; });
|
|
|
|
test('succeeds on first try, calls fetch once', async () => {
|
|
plan = [() => Promise.resolve(REAL('Madrid'))];
|
|
const r = await geocodeCity('Madrid');
|
|
assert.equal(calls, 1);
|
|
assert.equal(r?.lat, 40.4165);
|
|
assert.equal(r?.displayName, 'Madrid, 스페인');
|
|
});
|
|
|
|
test('retries past a transient 5xx then succeeds', async () => {
|
|
plan = [
|
|
() => Promise.resolve({ ok: false, status: 503, json: async () => ({}) }),
|
|
() => Promise.resolve({ ok: false, status: 502, json: async () => ({}) }),
|
|
() => Promise.resolve(REAL('Madrid')),
|
|
];
|
|
const r = await geocodeCity('Madrid');
|
|
assert.equal(calls, 3, `expected 3 fetches, got ${calls}`);
|
|
assert.ok(r, 'should resolve on the third attempt');
|
|
assert.equal(r?.lat, 40.4165);
|
|
});
|
|
|
|
test('retries past a network throw then succeeds', async () => {
|
|
plan = [
|
|
() => Promise.reject(new Error('ECONNRESET')),
|
|
() => Promise.resolve(REAL('Madrid')),
|
|
];
|
|
const r = await geocodeCity('Madrid');
|
|
assert.equal(calls, 2);
|
|
assert.ok(r);
|
|
assert.equal(r?.lat, 40.4165);
|
|
});
|
|
|
|
test('200 with no results = genuine not found, no retry', async () => {
|
|
plan = [() => Promise.resolve({ ok: true, status: 200, json: async () => ({ results: [] }) })];
|
|
const r = await geocodeCity('zzqxnotacity');
|
|
assert.equal(calls, 1, 'empty results must not be retried');
|
|
assert.equal(r, null);
|
|
});
|
|
|
|
test('three transient failures exhaust retries and return null', async () => {
|
|
plan = [() => Promise.reject(new Error('timeout'))];
|
|
const r = await geocodeCity('Madrid');
|
|
assert.equal(calls, 3, `expected 3 attempts, got ${calls}`);
|
|
assert.equal(r, null);
|
|
});
|
|
}); |