"""593 시간 표시 — Z 글 → 보는 PC 시간대(TS) · Z 없는 옛 글 = 한국 시각 · 서버가 내는 글 = UTC Z / 파일 글 = 한국 시각(Python).""" import json import os import re import subprocess from datetime import datetime from pathlib import Path import pytest from common_util import common_util_time as srv ROOT = Path(__file__).resolve().parents[2] TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" SRC = ROOT / "common_util" / "common_util_time.ts" _RUNNER = """ import { writeFileSync } from "node:fs"; import * as m from "./common_util_time.js"; const now = Date.parse("2026-10-30T20:00:00Z"); const out = { tz: Intl.DateTimeFormat().resolvedOptions().timeZone, date: ["2026-10-30 10:02:03", "2026-10-30T10:02:03", "2026-10-30T01:02:03Z", "2026-10-30T10:02:03+09:00", null, "", "x"].map((v) => m.dateTimeText(v)), day: [m.dateText("2026-10-30T20:30:00Z"), m.dateText("2026-10-30"), m.dateText(null), m.dateText("")], time: [m.timeText("2026-10-30T20:30:00Z"), m.timeText(null)], ago: ["2026-10-30T20:00:00Z", "2026-10-30T10:00:00Z", "2026-10-29T10:00:00Z", "2026-10-26T10:00:00Z", "2026-10-01T10:00:00Z", null].map((v) => m.agoText(v, "-", now)), kst: m.kstText("2026-10-30 10:02:03"), }; writeFileSync(process.argv[2], JSON.stringify(out)); """ @pytest.fixture(scope="module") def runner(tmp_path_factory): if not TSC.is_file(): pytest.skip("프론트엔드 의존성 미설치") tmp = tmp_path_factory.mktemp("time") out = tmp / "js" args = ["--outDir", str(out), "--module", "esnext", "--target", "es2022", "--ignoreConfig"] subprocess.run( ["node", str(TSC), str(SRC), *args], cwd=str(ROOT), check=True, capture_output=True ) # noqa: S603, S607 for emitted in out.glob("*.js"): text = emitted.read_text(encoding="utf-8") emitted.write_text(re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', text), encoding="utf-8") (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") def run(tz: str) -> dict: dst = tmp / f"out_{tz.replace('/', '_')}.json" env = {**os.environ, "TZ": tz} subprocess.run( ["node", str(out / "runner.mjs"), str(dst)], check=True, capture_output=True, env=env ) # noqa: S603, S607 return json.loads(dst.read_text(encoding="utf-8")) return run def test_seoul(runner): r = runner("Asia/Seoul") assert r["tz"] == "Asia/Seoul" # Z 없는 옛 글(한국 시각) · ISO · Z · +09:00 모두 같은 한국 시각 assert r["date"][:4] == ["2026-10-30 10:02"] * 4 assert r["date"][4:] == ["—", "—", "—"] assert r["day"] == ["2026-10-31", "2026-10-30", "-", "-"] # 20:30Z = 한국 다음 날 05:30 assert r["time"] == ["05:30", ""] # 20:30Z assert r["kst"] == "2026-10-30 10:02" # 지금 = 한국 10-31 05:00 → 한국 달력 날짜로 센다 assert r["ago"] == ["오늘", "어제", "2 일 전", "5 일 전", "2026-10-01", "-"] def test_other_zone_follows_viewer(runner): r = runner("America/New_York") # 10-30 = 서머타임(UTC-4) assert r["date"][:4] == ["2026-10-29 21:02"] * 4 assert r["day"][0] == "2026-10-30" assert r["day"][1] == "2026-10-30" # 날짜만 있는 값은 안 바뀜 def test_utc_zone_old_text_is_kst(runner): r = runner("UTC") # 옛 로컬 글 10:02(한국) = UTC 01:02 assert r["date"][:4] == ["2026-10-30 01:02"] * 4 assert r["kst"] == "2026-10-30 01:02" def test_server_utc_text(): assert srv.utc_text(datetime(2026, 10, 30, 1, 2, 3, 999)) == "2026-10-30T01:02:03Z" assert srv.utc_text(datetime(2026, 10, 30, 10, 2, 3, tzinfo=srv.KST)) == "2026-10-30T01:02:03Z" assert srv.utc_text(None) is None assert srv.utc_text("x") == "x" and srv.utc_text("") is None assert srv.utc_now_text().endswith("Z") assert srv.utc_from_timestamp(0) == "1970-01-01T00:00:00Z" def test_server_kst(): assert srv.kst_from_timestamp(0).strftime("%Y-%m-%d %H:%M") == "1970-01-01 09:00" assert srv.kst_now().utcoffset().total_seconds() == 9 * 3600 assert srv.kst_today() == srv.kst_now().date() def test_응답_시각_끝_Z(): from fastapi.encoders import jsonable_encoder srv.install_json_utc() body = jsonable_encoder({"at": datetime(2026, 10, 30, 1, 2, 3), "n": 1}) assert body == {"at": "2026-10-30T01:02:03Z", "n": 1} def test_kst_text_파일_글(): assert srv.kst_text("2026-10-30T01:02:03Z") == "2026-10-30 10:02:03" assert srv.kst_text("2026-10-30T10:02:03") == "2026-10-30 10:02:03" # 옛 글 = 한국 시각 assert srv.kst_text("2026-10-30 10:02:03") == "2026-10-30 10:02:03" assert srv.kst_text(datetime(2026, 10, 30, 10, 2, 3, tzinfo=srv.KST)) == "2026-10-30 10:02:03" assert srv.kst_text(None) == "" and srv.kst_text("x") == "x" def test_D04_확정_시각_끝_Z(): from D04_PlanConfirm.D04_PlanConfirm_Router import unit_summary, year_summary at = datetime(2026, 10, 30, 1, 2, 3) assert year_summary({"year": 2026, "route": {}, "confirmed_at": at})["confirmed_at"] == ( "2026-10-30T01:02:03Z" ) unit = { "piece_id": None, "line": 1, "line_name": "본선", "year": 2026, "length_m": 0, "planned": [], "confirmed_at": at, } # fmt: skip assert unit_summary(unit)["confirmed_at"] == "2026-10-30T01:02:03Z" def _server_sources(): skip = {"venv", "node_modules", "resources", "tmp", "docs", ".claude", "config", "storage"} for dirpath, dirs, files in os.walk(ROOT): dirs[:] = [d for d in dirs if d not in skip and not d.startswith(".")] for name in files: if name.endswith(".py"): path = Path(dirpath) / name yield path, path.read_text(encoding="utf-8") def test_서버_시각_글_Z_없는_길_0(): # DB 시각을 글로 뽑는 SQL = 끝 Z · 시각 칸을 str() 로 감싸 내보내지 않음 bad_sql = re.compile(r"%i:%%?s(?!Z)") bad_str = re.compile(r"""\bstr\([^)\n]*(_at|timestamp|_until)["']\]\)""") found = [] for path, text in _server_sources(): for no, line in enumerate(text.splitlines(), 1): if bad_sql.search(line) or bad_str.search(line): found.append(f"{path.name}:{no}") assert found == []