feat: 의료 어시스턴트 앱 + Home Assistant 임베드 앱 추가

법률 사건관리 앱과 같은 패턴으로 의료 사건(진료기록) 관리 앱 추가
(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>
This commit is contained in:
kim
2026-08-07 13:16:01 +09:00
co-authored by Claude Sonnet 5
parent 02247e4ee3
commit d51c40484a
7 changed files with 2331 additions and 1 deletions
+266
View File
@@ -0,0 +1,266 @@
/**
* routes-doctor-cases.ts
* Doctor app: personal health record (진료 기록) list + per-record attached
* documents, mirroring routes-lawyer-cases.ts (case-storage.ts holds the
* shared logic — see that file's header). Identical CRUD/upload/download/edit
* behavior to lawyer's routes; only APP_TYPE differs, since case-storage.ts is
* already parameterized by appType.
*
* Out of scope (see doctor-app.html's SKILL_CONTENT): diagnosis/prescription
* generation — this file only stores whatever the user records, same as the
* legal/detective case stores just store what's typed in.
*/
import express from 'express';
import fs from 'fs';
import path from 'path';
import archiver from 'archiver';
import { execFile } from 'child_process';
import { getConfig } from '../../config/config';
import {
casesPath, caseFilesDir, resolveUploadPath, walkCaseFiles, fileCategory, mintWopiToken,
} from './case-storage';
const APP_TYPE = 'doctor';
export function registerDoctorCaseRoutes(
app: express.Application,
getSessionUser: (req: express.Request) => any,
isPathInsideDir: (base: string, target: string) => boolean,
): void {
app.get('/api/doctor/cases', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const p = casesPath(session.username, APP_TYPE);
if (!fs.existsSync(p)) return res.json({ cases: [] });
try { res.json(JSON.parse(fs.readFileSync(p, 'utf-8'))); } catch { res.json({ cases: [] }); }
});
app.put('/api/doctor/cases', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const { cases } = req.body || {};
if (!Array.isArray(cases)) return res.status(400).json({ error: 'cases array required' });
const p = casesPath(session.username, APP_TYPE);
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmp = `${p}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ cases }, null, 2), 'utf-8');
fs.renameSync(tmp, p);
res.json({ success: true });
});
app.get('/api/doctor/files', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const caseId = String(req.query.caseId || '');
if (!/^[a-zA-Z0-9_-]+$/.test(caseId)) return res.status(400).json({ error: 'invalid caseId' });
const dir = caseFilesDir(session.username, APP_TYPE, caseId);
const files = walkCaseFiles(dir).map(e => {
const segs = e.relPath.split('/');
return {
name: e.name,
relPath: e.relPath,
folder: segs.length > 1 ? segs[0] : '',
size: e.size,
mtime: e.mtime,
url: `/api/files/${APP_TYPE}/${caseId}/${segs.map(encodeURIComponent).join('/')}`,
category: fileCategory(e.name),
};
});
res.json({ files });
});
app.post('/api/doctor/upload', (req, res) => {
const session = getSessionUser(req);
if (!session) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; }
const caseId = String(req.query.caseId || '');
if (!/^[a-zA-Z0-9_-]+$/.test(caseId)) { res.status(400).json({ success: false, error: 'invalid caseId' }); return; }
const contentType = String(req.headers['content-type'] || '');
if (!contentType.includes('multipart/form-data')) {
res.status(400).json({ success: false, error: 'Content-Type must be multipart/form-data' }); return;
}
const boundary = contentType.split('boundary=')[1];
if (!boundary) { res.status(400).json({ success: false, error: 'Missing boundary' }); return; }
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('binary');
const boundaryDelim = '--' + boundary;
let filename = 'upload.bin';
let filetype = 'application/octet-stream';
let fileData: Buffer | null = null;
const parts = raw.split(boundaryDelim);
for (const part of parts) {
if (!part || part.trim() === '--' || part.trim() === '') continue;
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const header = part.substring(0, headerEnd);
if (!header.includes('name="file"')) continue;
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
?? header.match(/filename="([^"]+)"/);
if (fnMatch) {
const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1]
? Buffer.from(fnMatch[1], 'binary').toString('utf-8')
: decodeURIComponent(fnMatch[1]);
filename = raw8;
}
const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i);
if (ctMatch) filetype = ctMatch[1].trim();
const bodyStart = headerEnd + 4;
const bodyEnd = part.lastIndexOf('\r\n');
if (bodyEnd <= bodyStart) continue;
fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary');
break;
}
if (!fileData) { res.status(400).json({ success: false, error: 'No file found in upload' }); return; }
if (fileData.length > 50 * 1024 * 1024) { res.status(400).json({ success: false, error: 'File too large (max 50MB)' }); return; }
const dir = caseFilesDir(session.username, APP_TYPE, caseId);
fs.mkdirSync(dir, { recursive: true });
const { relPath, absPath } = resolveUploadPath(dir, filename);
fs.writeFileSync(absPath, fileData);
res.json({
success: true,
name: path.basename(relPath),
relPath,
size: fileData.length,
type: filetype,
url: `/api/files/${APP_TYPE}/${caseId}/${relPath.split('/').map(encodeURIComponent).join('/')}`,
category: fileCategory(relPath),
});
});
req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); });
});
app.delete('/api/doctor/files', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const caseId = String(req.body?.caseId || '');
const relPath = String(req.body?.relPath || req.body?.name || '');
const segments = relPath.split('/').filter(Boolean);
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.')) {
return res.status(400).json({ error: 'invalid' });
}
const dir = caseFilesDir(session.username, APP_TYPE, caseId);
const targetPath = path.join(dir, ...segments);
if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' });
if (!fs.existsSync(targetPath)) { res.json({ success: true, alreadyGone: true }); return; }
try {
const stat = fs.statSync(targetPath);
if (stat.isDirectory()) fs.rmSync(targetPath, { recursive: true, force: true });
else fs.unlinkSync(targetPath);
} catch (e: any) {
res.status(500).json({ success: false, error: String(e?.message || e) }); return;
}
res.json({ success: true });
});
app.get('/api/doctor/files/download-folder', (req, res) => {
const session = getSessionUser(req);
if (!session) { res.status(401).json({ error: 'Unauthorized' }); return; }
const caseId = String(req.query.caseId || '');
const folder = String(req.query.folder || '');
const segments = folder.split('/').filter(Boolean);
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.')) {
res.status(400).json({ error: 'invalid' }); return;
}
const dir = caseFilesDir(session.username, APP_TYPE, caseId);
const targetPath = path.join(dir, ...segments);
if (!isPathInsideDir(dir, targetPath)) { res.status(403).json({ error: 'Forbidden' }); return; }
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
res.status(404).json({ error: 'Folder not found' }); return;
}
const zipName = `${segments[segments.length - 1]}.zip`;
const encodedName = encodeURIComponent(zipName);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
const archive = archiver('zip', { zlib: { level: 9 } });
archive.on('error', (err: Error) => {
if (!res.headersSent) res.status(500).json({ error: String(err.message || err) });
});
archive.pipe(res);
archive.directory(targetPath, false);
archive.finalize();
});
// Overwrites a text document (.txt/.md/.csv) with UTF-8 content — same
// encoding-normalization use case as lawyer's version.
app.put('/api/doctor/files/content', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const caseId = String(req.body?.caseId || '');
const relPath = String(req.body?.relPath || '');
const content = req.body?.content;
const segments = relPath.split('/').filter(Boolean);
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.') || typeof content !== 'string') {
return res.status(400).json({ error: 'invalid' });
}
const dir = caseFilesDir(session.username, APP_TYPE, caseId);
const targetPath = path.join(dir, ...segments);
if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' });
if (!fs.existsSync(targetPath)) return res.status(404).json({ error: 'File not found' });
try {
fs.writeFileSync(targetPath, content, 'utf-8');
const stat = fs.statSync(targetPath);
res.json({ success: true, size: stat.size, mtime: stat.mtime.toISOString() });
} catch (e: any) {
res.status(500).json({ error: String(e?.message || e) });
}
});
// Opens a record file in Collabora directly (no NextCloud) — same WOPI-host
// approach as lawyer's collabora-open, sharing the same token map so any
// case-backed app's callback prefix works. .hwp is converted to .odt once
// (Collabora has no HWP filter); .odt opens as-is.
app.get('/api/doctor/collabora-open', async (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).send('Unauthorized');
const caseId = String(req.query.caseId || '');
const relPath = String(req.query.relPath || '');
const segments = relPath.split('/').filter(Boolean);
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.')) {
return res.status(400).send('invalid');
}
const dir = caseFilesDir(session.username, APP_TYPE, caseId);
const srcPath = path.join(dir, ...segments);
if (!isPathInsideDir(dir, srcPath) || !fs.existsSync(srcPath)) return res.status(404).send('File not found');
const collaboraCfg = (getConfig().getConfig() as any).collabora || {};
if (!collaboraCfg.baseUrl || !collaboraCfg.publicUrl) {
return res.status(500).send('Collabora is not configured (collabora.baseUrl/publicUrl)');
}
try {
let editPath = srcPath;
if (/\.hwp$/i.test(srcPath)) {
const outDir = path.dirname(srcPath);
const base = path.basename(srcPath, path.extname(srcPath));
const odtPath = path.join(outDir, `${base}.odt`);
if (!fs.existsSync(odtPath)) {
await new Promise<void>((resolve, reject) => {
execFile('soffice', ['--headless', '--convert-to', 'odt', '--outdir', outDir, srcPath], { timeout: 60_000 }, (err) => {
if (err) reject(err); else resolve();
});
});
if (!fs.existsSync(odtPath)) throw new Error('hwp → odt 변환 결과물이 없습니다');
}
editPath = odtPath;
}
const token = mintWopiToken(session.username, editPath);
const wopiSrc = `${collaboraCfg.publicUrl.replace(/\/+$/, '')}/api/doctor/wopi/files/${token}`;
const editorUrl = `${collaboraCfg.baseUrl.replace(/\/+$/, '')}/browser/dist/cool.html?WOPISrc=${encodeURIComponent(wopiSrc)}&access_token=${token}`;
res.redirect(editorUrl);
} catch (e: any) {
res.status(502).send('Collabora open failed: ' + String(e?.message || e));
}
});
}
+4 -1
View File
@@ -93,6 +93,7 @@ import { registerWriterRoutes } from './routes/routes-writer';
import { registerDentalRoutes } from './routes/routes-dental';
import { registerDetectiveDateRoutes } from './routes/routes-detective-dates';
import { registerLawyerCaseRoutes } from './routes/routes-lawyer-cases';
import { registerDoctorCaseRoutes } from './routes/routes-doctor-cases';
import { caseFilesDir, resolveUploadPath, registerWopiRoutes } from './routes/case-storage';
import {
getConfig,
@@ -4443,6 +4444,8 @@ registerUserRoutes(app, {
registerDetectiveRoutes(app, getSessionUser, isPathInsideDir, CONFIG_DIR_PATH);
registerLawyerCaseRoutes(app, getSessionUser, isPathInsideDir);
registerWopiRoutes(app, '/api/lawyer');
registerDoctorCaseRoutes(app, getSessionUser, isPathInsideDir);
registerWopiRoutes(app, '/api/doctor');
// Redirect legacy top-level HTML routes into /html/ after web-ui reorganization.
@@ -4452,7 +4455,7 @@ const LEGACY_HTML_REDIRECTS = [
'/mind-app.html','/lawyer-app.html','/investor-app.html','/accountant-app.html',
'/detective-app.html','/studio-app.html','/pptx-wizard.html','/traffic-app.html',
'/android-emulator.html','/arduino-emulator.html','/esp32-flasher.html',
'/python-runner.html','/xlsx-viewer.html','/dental-agent.html'
'/python-runner.html','/xlsx-viewer.html','/dental-agent.html','/doctor-app.html'
];
for (const p of LEGACY_HTML_REDIRECTS) {
// req.url retains the original query string (e.g. xlsx-viewer.html?file=...);
+206
View File
@@ -0,0 +1,206 @@
/**
* weather-openmeteo-format.ts
*
* Pure request-shaping and report-formatting logic for `weather_openmeteo`.
*
* Split out of weather.ts because three silent failures on 2026-07-29 all lived in this
* logic rather than in the HTTP call, and none of them produced an error:
*
* 1. The request pinned `timezone=Asia/Seoul` for every coordinate on earth. A Berlin
* forecast came back stamped in KST (utc_offset 32400), so "today" in the output was
* a Korean day. Nothing downstream could recover the city's own day boundary.
* 2. `forecast_hours` is a rolling window from *now*, not a calendar day. Asked at 22:00
* KST (15:00 in Berlin), the 24h window reached into the next European afternoon, and
* the max over that window — 34.4°C — was reported as "today's high". The real values
* were 29.8°C today and 36.7°C tomorrow: the answer was tomorrow's weather.
* 3. `temperature_2m_max` is a *daily* variable, but it was passed straight into
* `hourly=`. Open-Meteo accepts it there and returns an hourly series, so a plausible
* name produced plausible numbers that simply were not daily maxima.
*
* The fix for all three is to decide, before the fetch, which block each variable belongs
* to and to label every timestamp with the location's own calendar day. That decision is
* pure, so it is testable — unlike the fetch it feeds.
*/
/**
* Open-Meteo variables that exist ONLY in the `daily=` block.
*
* `weather_code`, `temperature_2m` and friends are deliberately absent: they are valid
* hourly variables, and a few (weather_code, uv_index) exist in both blocks. Only names
* that are meaningless as an hourly series belong here — those are the ones that silently
* return wrong-shaped data when misrouted.
*/
const DAILY_ONLY_VARIABLES = new Set([
'temperature_2m_max', 'temperature_2m_min', 'temperature_2m_mean',
'apparent_temperature_max', 'apparent_temperature_min', 'apparent_temperature_mean',
'precipitation_sum', 'rain_sum', 'showers_sum', 'snowfall_sum', 'precipitation_hours',
'precipitation_probability_max', 'precipitation_probability_min', 'precipitation_probability_mean',
'wind_speed_10m_max', 'wind_gusts_10m_max', 'wind_direction_10m_dominant',
'shortwave_radiation_sum', 'et0_fao_evapotranspiration',
'uv_index_max', 'uv_index_clear_sky_max',
'sunrise', 'sunset', 'daylight_duration', 'sunshine_duration',
]);
export interface SplitVariables {
hourly: string[];
daily: string[];
}
/**
* Route each requested variable to the Open-Meteo block that actually serves it.
*
* Duplicates are dropped and order is preserved so the output columns match what the
* caller asked for.
*/
export function splitOpenMeteoVariables(variables: string | string[]): SplitVariables {
const list = (Array.isArray(variables) ? variables : String(variables || '').split(','))
.map(v => v.trim())
.filter(Boolean);
const hourly: string[] = [];
const daily: string[] = [];
for (const v of list) {
const bucket = DAILY_ONLY_VARIABLES.has(v) ? daily : hourly;
if (!bucket.includes(v)) bucket.push(v);
}
return { hourly, daily };
}
export function isDailyOnlyVariable(name: string): boolean {
return DAILY_ONLY_VARIABLES.has(String(name || '').trim());
}
/**
* Format a UTC offset in seconds as `UTC+02:00`.
*
* Printed next to the timezone name because the offset is the part that makes a
* mislabelled day obvious at a glance — a Berlin forecast reading UTC+09:00 is the exact
* bug this module exists to prevent.
*/
export function formatUtcOffset(seconds: number): string {
const s = Number(seconds);
if (!Number.isFinite(s)) return 'UTC+00:00';
const sign = s < 0 ? '-' : '+';
const abs = Math.abs(s);
const hh = String(Math.floor(abs / 3600)).padStart(2, '0');
const mm = String(Math.floor((abs % 3600) / 60)).padStart(2, '0');
return `UTC${sign}${hh}:${mm}`;
}
/**
* Relative label for a local calendar date, as seen from the location's own "today".
*
* Returns null past 모레 rather than inventing labels — an unlabelled date is honest,
* while "3일 후" invites the same off-by-one reading the labels exist to prevent.
*/
export function relativeDayLabel(date: string, todayLocal: string): string | null {
const d = Date.parse(`${date}T00:00:00Z`);
const t = Date.parse(`${todayLocal}T00:00:00Z`);
if (!Number.isFinite(d) || !Number.isFinite(t)) return null;
const diff = Math.round((d - t) / 86_400_000);
if (diff === 0) return '오늘';
if (diff === 1) return '내일';
if (diff === 2) return '모레';
if (diff === -1) return '어제';
return null;
}
/**
* The location's current calendar date, derived from the UTC offset Open-Meteo reports.
*
* Deliberately does not use the server's own timezone: the server sits in KST and the
* question is about the city's day, which is the whole point.
*/
export function localTodayFromOffset(utcOffsetSeconds: number, now: Date = new Date()): string {
const shifted = new Date(now.getTime() + (Number(utcOffsetSeconds) || 0) * 1000);
return shifted.toISOString().slice(0, 10);
}
export interface OpenMeteoReportInput {
displayName: string;
timezone: string;
utcOffsetSeconds: number;
/** Rolling-hour window that was requested, for the hourly section heading. */
hours: number;
hourlyVars: string[];
dailyVars: string[];
/** Raw Open-Meteo response blocks. */
hourlyBlock?: { time?: string[]; [k: string]: any };
dailyBlock?: { time?: string[]; [k: string]: any };
hourlyUnits?: Record<string, string>;
dailyUnits?: Record<string, string>;
/** Max hourly rows to print. */
maxHourlyRows?: number;
/** Per-variable value renderer (WMO code names, wind direction, …). */
renderValue?: (key: string, value: any) => string;
now?: Date;
}
/**
* Build the text report.
*
* Every timestamp is labelled with the location's local day and, where applicable,
* 오늘/내일/모레. The daily table comes first when present because that is the direct
* answer to "today's high" — leaving the model to derive it from an hourly series is
* exactly how a rolling window got reported as a calendar day.
*/
export function formatOpenMeteoReport(input: OpenMeteoReportInput): string {
const {
displayName, timezone, utcOffsetSeconds, hours,
hourlyVars, dailyVars, hourlyBlock, dailyBlock,
hourlyUnits = {}, dailyUnits = {}, maxHourlyRows = 48,
renderValue = (_k, v) => String(v),
now,
} = input;
const todayLocal = localTodayFromOffset(utcOffsetSeconds, now);
const out: string[] = [
`Open-Meteo 예보: ${displayName}`,
`현지 시간대: ${timezone} (${formatUtcOffset(utcOffsetSeconds)}) — 아래 모든 시각·날짜는 현지 기준`,
];
const dailyTimes: string[] = dailyBlock?.time || [];
if (dailyVars.length && dailyTimes.length) {
out.push('', `[일별] ${dailyVars.length}개 변수 / ${dailyTimes.length}일`);
out.push(`단위: ${dailyVars.map(k => `${k}=${dailyUnits[k] || '-'}`).join(', ')}`);
out.push(['날짜', ...dailyVars.map(k => k.replace(/_/g, ' '))].join('\t'));
for (let i = 0; i < dailyTimes.length; i++) {
const label = relativeDayLabel(dailyTimes[i], todayLocal);
const dateCell = label ? `${dailyTimes[i]} (${label})` : dailyTimes[i];
const vals = dailyVars.map(k => {
const v = dailyBlock?.[k]?.[i];
return v == null ? '-' : renderValue(k, v);
});
out.push([dateCell, ...vals].join('\t'));
}
}
const hourlyTimes: string[] = hourlyBlock?.time || [];
if (hourlyVars.length && hourlyTimes.length) {
const shown = Math.min(hours, maxHourlyRows, hourlyTimes.length);
// Say plainly that this window starts now, so its max is never mistaken for a daily max.
out.push('', `[시간별] 지금부터 ${hours}시간 (달력상 하루가 아니라 현재 시각 기준 연속 구간)`);
out.push(`단위: ${hourlyVars.map(k => `${k}=${hourlyUnits[k] || '-'}`).join(', ')}`);
out.push(['시각', ...hourlyVars.map(k => k.replace(/_/g, ' '))].join('\t'));
let lastDate = '';
for (let i = 0; i < shown; i++) {
const raw = hourlyTimes[i];
const [date, clock = ''] = raw.split('T');
let timeCell: string;
if (date !== lastDate) {
const label = relativeDayLabel(date, todayLocal);
timeCell = label ? `${date} ${clock} (${label})` : `${date} ${clock}`;
lastDate = date;
} else {
timeCell = `${date} ${clock}`;
}
const vals = hourlyVars.map(k => {
const v = hourlyBlock?.[k]?.[i];
return v == null ? '-' : renderValue(k, v);
});
out.push([timeCell, ...vals].join('\t'));
}
}
return out.join('\n');
}
+84
View File
@@ -0,0 +1,84 @@
/**
* 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);
});
});
+267
View File
@@ -0,0 +1,267 @@
/**
* 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);
});
});
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🏠 Home Assistant</title>
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
:root{--brand:#18bcf2;}
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
.hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
.hdr h1{font-size:13px;font-weight:700;margin:0;}
.hdr-sp{flex:1;}
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;text-decoration:none;display:inline-flex;align-items:center;}
.hdr-btn:hover{border-color:var(--brand);color:var(--brand);}
.frame-wrap{flex:1;min-height:0;position:relative;}
.frame-wrap iframe{width:100%;height:100%;border:none;display:block;}
.err{display:none;position:absolute;inset:0;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:var(--bg);color:var(--muted);font-size:13px;text-align:center;padding:20px;}
.err.show{display:flex;}
.err a{color:var(--brand);}
</style>
</head>
<body>
<header class="hdr">
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
<h1>🏠 Home Assistant</h1>
<span class="hdr-sp"></span>
<button class="hdr-btn" onclick="reloadFrame()">↺ 새로고침</button>
<a class="hdr-btn" href="https://ha.applecherry.net/" target="_blank">↗ 새 탭</a>
<button class="hdr-btn" onclick="toggleTheme()">🌙</button>
</header>
<div class="frame-wrap">
<iframe id="ha-frame" src="https://ha.applecherry.net/" title="Home Assistant"></iframe>
<div class="err" id="ha-err">
<div>Home Assistant를 불러오지 못했습니다.</div>
<div><a href="https://ha.applecherry.net/" target="_blank">새 탭에서 직접 열기 ↗</a></div>
</div>
</div>
<script>
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
function reloadFrame(){const f=document.getElementById('ha-frame');f.src=f.src;}
// iframe onerror doesn't fire for cross-origin load failures — fall back to a
// timeout check, since a blocked/unreachable HA instance otherwise just shows
// a blank white rectangle with no clue why.
let loaded=false;
document.getElementById('ha-frame').addEventListener('load',()=>{loaded=true;});
setTimeout(()=>{ if(!loaded) document.getElementById('ha-err').classList.add('show'); },8000);
</script>
</body>
</html>