hours=168처럼 도구 스펙상 정상 범위(최대 384)를 요청해도 시간별 표는 maxHourlyRows(기본 48)에서 조용히 잘렸는데, 헤더는 여전히 '지금부터 168시간'이라고 적어 잘렸다는 사실을 모델에게 숨겼다. 그 결과 모델이 표에 없는 뒷부분을 지어내거나(다른 경로로 API를 재호출해 우회) 반대로 '데이터 부족'이라 정직하게 오보고했다(2026-09-27 실측, gemini-3.5-flash-lite로 '한주간 예보' 질의). maxHourlyRows가 실제 병목(API엔 더 있는데 표만 자른 경우)일 때만 잘렸다고 명시하고 daily 변수로 재요청하라고 안내. API가 애초에 적게 준 경우(정상적 데이터 부족)는 기존 동작 유지 — 이 구분을 놓친 첫 시도는 기존 테스트가 실패로 잡아줬다. 회귀 테스트 2건 추가, 전체 736개 통과. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
310 lines
13 KiB
TypeScript
310 lines
13 KiB
TypeScript
/**
|
|
* weather-openmeteo-format.test.ts
|
|
*
|
|
* Pinned to the real 2026-07-29 failure rather than invented cases.
|
|
*
|
|
* Asked "오늘 유럽 주요 도시 최고 기온" twice, the gateway answered Berlin 28.2°C once and
|
|
* 34.4°C the next time. Open-Meteo's actual daily maxima for those coordinates were
|
|
* 29.8°C (07-29) and 36.7°C (07-30) in Europe/Berlin — so one answer was the current
|
|
* evening temperature and the other was tomorrow's peak. Neither was today's high, and
|
|
* neither call errored.
|
|
*
|
|
* Both wrong answers trace to this module's inputs: `temperature_2m_max` posted to the
|
|
* hourly block, and a rolling 24h window presented as a calendar day under Korean
|
|
* timestamps. The fixtures below are the API's own bytes for that request.
|
|
*/
|
|
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
splitOpenMeteoVariables,
|
|
isDailyOnlyVariable,
|
|
formatUtcOffset,
|
|
relativeDayLabel,
|
|
localTodayFromOffset,
|
|
formatOpenMeteoReport,
|
|
} from '../src/tools/weather-openmeteo-format';
|
|
|
|
const BERLIN_OFFSET = 7200; // Europe/Berlin, UTC+2 in July
|
|
|
|
describe('splitOpenMeteoVariables', () => {
|
|
test('temperature_2m_max를 daily로 보낸다 — hourly로 보내면 최고기온이 아닌 값이 돌아옴', () => {
|
|
const { hourly, daily } = splitOpenMeteoVariables('temperature_2m_max');
|
|
assert.deepEqual(daily, ['temperature_2m_max']);
|
|
assert.deepEqual(hourly, []);
|
|
});
|
|
|
|
test('일별·시간별을 섞어 보내면 각각의 블록으로 갈린다', () => {
|
|
const { hourly, daily } = splitOpenMeteoVariables(
|
|
'temperature_2m,temperature_2m_max,precipitation,precipitation_sum,sunset'
|
|
);
|
|
assert.deepEqual(hourly, ['temperature_2m', 'precipitation']);
|
|
assert.deepEqual(daily, ['temperature_2m_max', 'precipitation_sum', 'sunset']);
|
|
});
|
|
|
|
test('기본 변수 묶음은 전부 hourly로 남는다', () => {
|
|
const { hourly, daily } = splitOpenMeteoVariables(
|
|
'temperature_2m,precipitation,wind_speed_10m,wind_direction_10m,relative_humidity_2m,uv_index,weather_code'
|
|
);
|
|
assert.equal(daily.length, 0);
|
|
assert.equal(hourly.length, 7);
|
|
});
|
|
|
|
test('weather_code·uv_index는 양쪽에 존재하므로 hourly에 남긴다', () => {
|
|
// 이 둘을 daily로 옮기면 기본 호출의 시간별 표에서 열이 통째로 사라진다.
|
|
const { hourly, daily } = splitOpenMeteoVariables('weather_code,uv_index');
|
|
assert.deepEqual(hourly, ['weather_code', 'uv_index']);
|
|
assert.deepEqual(daily, []);
|
|
});
|
|
|
|
test('공백·빈 항목·중복을 정리한다', () => {
|
|
const { hourly, daily } = splitOpenMeteoVariables(' temperature_2m , ,temperature_2m,, temperature_2m_max ');
|
|
assert.deepEqual(hourly, ['temperature_2m']);
|
|
assert.deepEqual(daily, ['temperature_2m_max']);
|
|
});
|
|
|
|
test('배열로 줘도 동일하게 동작한다', () => {
|
|
const { daily } = splitOpenMeteoVariables(['temperature_2m_min', 'temperature_2m']);
|
|
assert.deepEqual(daily, ['temperature_2m_min']);
|
|
});
|
|
|
|
test('빈 입력은 빈 결과 — 호출부가 hourly 파라미터를 아예 생략할 수 있어야 함', () => {
|
|
assert.deepEqual(splitOpenMeteoVariables(''), { hourly: [], daily: [] });
|
|
});
|
|
|
|
test('isDailyOnlyVariable: 실제 오답을 만든 이름들', () => {
|
|
assert.ok(isDailyOnlyVariable('temperature_2m_max'));
|
|
assert.ok(isDailyOnlyVariable('precipitation_probability_max'));
|
|
assert.ok(!isDailyOnlyVariable('temperature_2m'));
|
|
assert.ok(!isDailyOnlyVariable('weather_code'));
|
|
});
|
|
});
|
|
|
|
describe('formatUtcOffset', () => {
|
|
test('베를린 좌표가 UTC+09:00으로 찍히면 눈에 띄어야 한다 (버그의 지문)', () => {
|
|
assert.equal(formatUtcOffset(32400), 'UTC+09:00');
|
|
assert.equal(formatUtcOffset(7200), 'UTC+02:00');
|
|
});
|
|
|
|
test('음수 오프셋과 30분 단위', () => {
|
|
assert.equal(formatUtcOffset(-18000), 'UTC-05:00');
|
|
assert.equal(formatUtcOffset(19800), 'UTC+05:30');
|
|
assert.equal(formatUtcOffset(0), 'UTC+00:00');
|
|
});
|
|
|
|
test('값이 없으면 UTC로 떨어진다', () => {
|
|
assert.equal(formatUtcOffset(NaN as any), 'UTC+00:00');
|
|
});
|
|
});
|
|
|
|
describe('localTodayFromOffset', () => {
|
|
test('현지 오프셋으로 "오늘"이 갈린다 — 서버 시간(KST)이 아니라', () => {
|
|
// 2026-07-29 22:00 KST = 2026-07-29 13:00 UTC = 2026-07-29 15:00 베를린
|
|
const now = new Date('2026-07-29T13:00:00Z');
|
|
assert.equal(localTodayFromOffset(BERLIN_OFFSET, now), '2026-07-29');
|
|
assert.equal(localTodayFromOffset(32400, now), '2026-07-29');
|
|
});
|
|
|
|
test('날짜 경계를 넘는 경우 서버와 현지의 오늘이 달라진다', () => {
|
|
// 2026-07-30 01:00 KST 는 베를린으로는 아직 07-29 저녁
|
|
const now = new Date('2026-07-29T16:00:00Z');
|
|
assert.equal(localTodayFromOffset(32400, now), '2026-07-30'); // 한국
|
|
assert.equal(localTodayFromOffset(BERLIN_OFFSET, now), '2026-07-29'); // 베를린
|
|
});
|
|
});
|
|
|
|
describe('relativeDayLabel', () => {
|
|
test('오늘/내일/모레', () => {
|
|
assert.equal(relativeDayLabel('2026-07-29', '2026-07-29'), '오늘');
|
|
assert.equal(relativeDayLabel('2026-07-30', '2026-07-29'), '내일');
|
|
assert.equal(relativeDayLabel('2026-07-31', '2026-07-29'), '모레');
|
|
assert.equal(relativeDayLabel('2026-07-28', '2026-07-29'), '어제');
|
|
});
|
|
|
|
test('월 경계를 넘어도 맞는다', () => {
|
|
assert.equal(relativeDayLabel('2026-08-01', '2026-07-31'), '내일');
|
|
});
|
|
|
|
test('범위 밖은 라벨 없음 — 억지 라벨보다 무라벨이 안전', () => {
|
|
assert.equal(relativeDayLabel('2026-08-05', '2026-07-29'), null);
|
|
assert.equal(relativeDayLabel('아무거나', '2026-07-29'), null);
|
|
});
|
|
});
|
|
|
|
describe('formatOpenMeteoReport — 베를린 실패 사례', () => {
|
|
const now = new Date('2026-07-29T13:00:00Z'); // 질문 시각(22:00 KST / 15:00 베를린)
|
|
|
|
test('일별 최고기온이 오늘/내일 라벨과 함께 나온다', () => {
|
|
// Open-Meteo 실제 응답: daily=temperature_2m_max, timezone=Europe/Berlin
|
|
const text = formatOpenMeteoReport({
|
|
displayName: '베를린, 독일',
|
|
timezone: 'Europe/Berlin',
|
|
utcOffsetSeconds: BERLIN_OFFSET,
|
|
hours: 24,
|
|
hourlyVars: [],
|
|
dailyVars: ['temperature_2m_max'],
|
|
dailyBlock: { time: ['2026-07-29', '2026-07-30'], temperature_2m_max: [29.8, 36.7] },
|
|
dailyUnits: { temperature_2m_max: '°C' },
|
|
now,
|
|
});
|
|
|
|
assert.match(text, /Europe\/Berlin \(UTC\+02:00\)/);
|
|
assert.match(text, /2026-07-29 \(오늘\)\t29\.8/);
|
|
assert.match(text, /2026-07-30 \(내일\)\t36\.7/);
|
|
// 실제로 답했던 두 오답이 이 표에는 등장하지 않아야 한다
|
|
assert.ok(!text.includes('28.2'), '오늘 저녁 기온이 최고기온으로 섞여 들어감');
|
|
assert.ok(!text.includes('34.4'), '롤링 창 최댓값이 섞여 들어감');
|
|
});
|
|
|
|
test('시간별 구간은 "달력상 하루가 아님"이 명시된다', () => {
|
|
const text = formatOpenMeteoReport({
|
|
displayName: '베를린, 독일',
|
|
timezone: 'Europe/Berlin',
|
|
utcOffsetSeconds: BERLIN_OFFSET,
|
|
hours: 24,
|
|
hourlyVars: ['temperature_2m'],
|
|
dailyVars: [],
|
|
hourlyBlock: {
|
|
time: ['2026-07-29T15:00', '2026-07-29T23:00', '2026-07-30T12:00'],
|
|
temperature_2m: [28.3, 22.1, 34.4],
|
|
},
|
|
hourlyUnits: { temperature_2m: '°C' },
|
|
now,
|
|
});
|
|
|
|
assert.match(text, /지금부터 24시간/);
|
|
assert.match(text, /달력상 하루가 아니라/);
|
|
// 34.4가 오늘이 아니라 내일 값임이 행 라벨로 드러나야 한다
|
|
assert.match(text, /2026-07-30 12:00 \(내일\)\t34\.4/);
|
|
});
|
|
|
|
test('일별과 시간별을 함께 요청하면 일별이 먼저 나온다', () => {
|
|
const text = formatOpenMeteoReport({
|
|
displayName: '베를린, 독일',
|
|
timezone: 'Europe/Berlin',
|
|
utcOffsetSeconds: BERLIN_OFFSET,
|
|
hours: 24,
|
|
hourlyVars: ['temperature_2m'],
|
|
dailyVars: ['temperature_2m_max'],
|
|
hourlyBlock: { time: ['2026-07-29T15:00'], temperature_2m: [28.3] },
|
|
dailyBlock: { time: ['2026-07-29'], temperature_2m_max: [29.8] },
|
|
hourlyUnits: { temperature_2m: '°C' },
|
|
dailyUnits: { temperature_2m_max: '°C' },
|
|
now,
|
|
});
|
|
assert.ok(text.indexOf('[일별]') < text.indexOf('[시간별]'), '직접적인 답이 뒤로 밀림');
|
|
});
|
|
|
|
test('같은 날짜가 이어지면 라벨을 반복하지 않는다', () => {
|
|
const text = formatOpenMeteoReport({
|
|
displayName: '베를린, 독일',
|
|
timezone: 'Europe/Berlin',
|
|
utcOffsetSeconds: BERLIN_OFFSET,
|
|
hours: 3,
|
|
hourlyVars: ['temperature_2m'],
|
|
dailyVars: [],
|
|
hourlyBlock: {
|
|
time: ['2026-07-29T15:00', '2026-07-29T16:00', '2026-07-29T17:00'],
|
|
temperature_2m: [28.3, 28.0, 27.4],
|
|
},
|
|
hourlyUnits: { temperature_2m: '°C' },
|
|
now,
|
|
});
|
|
assert.equal((text.match(/\(오늘\)/g) || []).length, 1);
|
|
});
|
|
|
|
test('renderValue로 WMO 코드·풍향이 사람 말로 바뀐다', () => {
|
|
const text = formatOpenMeteoReport({
|
|
displayName: '서울',
|
|
timezone: 'Asia/Seoul',
|
|
utcOffsetSeconds: 32400,
|
|
hours: 1,
|
|
hourlyVars: ['weather_code'],
|
|
dailyVars: [],
|
|
hourlyBlock: { time: ['2026-07-29T22:00'], weather_code: [61] },
|
|
hourlyUnits: { weather_code: 'wmo code' },
|
|
renderValue: (k, v) => (k === 'weather_code' && v === 61 ? '비(약)' : String(v)),
|
|
now,
|
|
});
|
|
assert.match(text, /비\(약\)/);
|
|
});
|
|
|
|
test('결측치는 -로 채우고 행을 건너뛰지 않는다', () => {
|
|
const text = formatOpenMeteoReport({
|
|
displayName: '베를린, 독일',
|
|
timezone: 'Europe/Berlin',
|
|
utcOffsetSeconds: BERLIN_OFFSET,
|
|
hours: 2,
|
|
hourlyVars: ['temperature_2m', 'uv_index'],
|
|
dailyVars: [],
|
|
hourlyBlock: {
|
|
time: ['2026-07-29T15:00', '2026-07-29T16:00'],
|
|
temperature_2m: [28.3, null],
|
|
uv_index: [null, 3.1],
|
|
},
|
|
now,
|
|
});
|
|
assert.match(text, /28\.3\t-/);
|
|
assert.match(text, /-\t3\.1/);
|
|
});
|
|
|
|
test('maxHourlyRows로 잘라도 헤더는 남는다', () => {
|
|
const times = Array.from({ length: 100 }, (_, i) => `2026-07-29T${String(i % 24).padStart(2, '0')}:00`);
|
|
const text = formatOpenMeteoReport({
|
|
displayName: '베를린, 독일',
|
|
timezone: 'Europe/Berlin',
|
|
utcOffsetSeconds: BERLIN_OFFSET,
|
|
hours: 100,
|
|
hourlyVars: ['temperature_2m'],
|
|
dailyVars: [],
|
|
hourlyBlock: { time: times, temperature_2m: times.map(() => 20) },
|
|
maxHourlyRows: 5,
|
|
now,
|
|
});
|
|
assert.match(text, /시각\ttemperature 2m/);
|
|
assert.equal(text.split('\n').filter(l => l.startsWith('2026-')).length, 5);
|
|
});
|
|
|
|
// 2026-09-27 실측 회귀: hours=168(일주일) 요청 + API가 168시간치를 실제로 다 줬는데도
|
|
// maxHourlyRows(기본 48)에 걸려 표가 2일치에서 끊겼다. 그런데 헤더는 여전히 "지금부터 168시간"
|
|
// 이라고 적어서, 모델이 표에 없는 뒷부분을 데이터 자체가 없는 것으로 오인했다(gemini-3.5-flash-lite
|
|
// 가 "데이터 부족"이라 정직하게 보고 — 모델 잘못이 아니라 이 함수가 잘라낸 걸 숨긴 게 원인).
|
|
test('maxHourlyRows가 API 데이터보다 작으면(진짜 병목) 잘렸다고 명시한다', () => {
|
|
const times = Array.from({ length: 168 }, (_, i) => {
|
|
const d = new Date(Date.UTC(2026, 8, 27) + i * 3600_000);
|
|
return d.toISOString().slice(0, 16);
|
|
});
|
|
const text = formatOpenMeteoReport({
|
|
displayName: '구미',
|
|
timezone: 'Asia/Seoul',
|
|
utcOffsetSeconds: 9 * 3600,
|
|
hours: 168,
|
|
hourlyVars: ['temperature_2m'],
|
|
dailyVars: [],
|
|
hourlyBlock: { time: times, temperature_2m: times.map(() => 20) },
|
|
now,
|
|
});
|
|
assert.match(text, /48시간만 표시/);
|
|
assert.match(text, /168시간치가 더 있으나/);
|
|
assert.match(text, /daily 변수/);
|
|
});
|
|
|
|
// 반대로 API가 요청보다 적게 줬을 뿐(진짜 병목이 아님)이면 기존 동작(헤더에 요청 hours 그대로)을
|
|
// 유지한다 — "시간별 구간은 '달력상 하루가 아님'이 명시된다" 테스트가 이 케이스를 이미 고정한다.
|
|
test('API 데이터가 hours보다 적을 뿐이면 잘렸다는 문구를 붙이지 않는다', () => {
|
|
const times = ['2026-07-29T15:00', '2026-07-29T23:00'];
|
|
const text = formatOpenMeteoReport({
|
|
displayName: '구미',
|
|
timezone: 'Asia/Seoul',
|
|
utcOffsetSeconds: 9 * 3600,
|
|
hours: 24,
|
|
hourlyVars: ['temperature_2m'],
|
|
dailyVars: [],
|
|
hourlyBlock: { time: times, temperature_2m: times.map(() => 20) },
|
|
now,
|
|
});
|
|
assert.doesNotMatch(text, /표가 길어지지 않게/);
|
|
assert.match(text, /지금부터 24시간 \(/);
|
|
});
|
|
});
|