Files
Aislo/resources/tester/test_343_3_browser_cache.py
T
eomsangdonandClaude Opus 5.5 e0051bff72 perf(전처리 화면): 343-3 B04 다시 열기 받는 양 — 보관함 저장 실패 고침 · 점군 고정 표본 이진 · 꺼진 층은 켤 때 · 남은 층 ETag · 등고선 주소 한 벌
- b_asset_cache — 쓰기 끝 = 트랜잭션 완료(용량 초과 취소 잡음) · 실패 한 줄 경고(까닭당 한 번) · onblocked · onversionchange · 열기 실패 null 안 기억 · 지우기는 색인 열쇠 · 앞머리 범위(값 안 읽음)
- 점군 미리보기 — 전처리 때 고정 표본 이진(preview_points_{필터}.bin · 원천 지문 같으면 건너뜀) · GET point-cloud/binary(ETag) · 화면은 fetchCachedBytes(IndexedDB) + decodePointPreview(py 짝)
- B04 지도 — 켜진 층만 처음에 받음 · 꺼진 층은 켤 때 · 같은 층 겹쳐 받기 막음
- geojson(지적도 · 행정구역 · 등고선) 파일 그대로 cached_file_response(ETag) · 등고선 단순화본은 원본이 새로우면 다시 만듦
- 등고선 주소 contourAssetUrl 하나(B04 · 미리 받기) — B05 두 곳은 sub5 몫
- 시험 test_343_3_browser_cache 13건 + helper_343_3_asset_cache.cjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 11:21:51 +09:00

198 lines
8.1 KiB
Python

"""343-3 B04 다시 열기 받는 양 — 보관함 저장 실패 고침 · 점군 고정 표본 이진 · 켤 때 받기 · ETag.
`helper_343_3_asset_cache.cjs` = 진짜 TS(b_asset_cache · decodePointPreview) + 가짜 IndexedDB.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import numpy as np
import pytest
import B04_PreProcess.B04_PreProcess_Engine_PointPreview as preview
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
def _npz(path: Path, count: int, rgb: bool = True) -> tuple[np.ndarray, np.ndarray]:
rng = np.random.default_rng(3)
xyz = np.column_stack(
(
rng.uniform(200000, 200100, count),
rng.uniform(500000, 500100, count),
rng.uniform(90, 120, count),
)
).astype(np.float32)
colors = rng.integers(0, 65535, (count, 3)).astype(np.uint16)
bounds = np.array([[200000, 200100], [500000, 500100], [90, 120]], dtype=np.float64)
arrays = {"xyz": xyz, "bounds": bounds}
if rgb:
arrays["rgb"] = colors
np.savez_compressed(path, **arrays)
return xyz, colors
def _decode(path: Path) -> tuple[dict, np.ndarray, np.ndarray | None]:
raw = path.read_bytes()
length = int.from_bytes(raw[:4], "little")
head = json.loads(raw[4 : 4 + length])
offset = -(-(4 + length) // 4) * 4
count = head["sampled_count"]
xyz = np.frombuffer(raw, "<f4", count * 3, offset).reshape(-1, 3)
rgb = None
if head["has_rgb"]:
rgb = np.frombuffer(raw, "<u2", count * 3, offset + count * 12).reshape(-1, 3)
return head, xyz, rgb
def test_고정_표본은_JSON_길과_같은_점(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(preview, "POINT_CLOUD_SAMPLE_LIMIT", 50)
source = tmp_path / "structured.npz"
xyz, colors = _npz(source, 200)
head, sample, rgb = _decode(preview.ensure_point_preview(source, None))
# 옛 JSON 길과 같은 규칙(같은 씨앗 · 같은 float32)
indexes = np.random.default_rng(20260710).choice(200, 50, replace=False)
assert head["point_count"] == 200 and head["sampled_count"] == 50
assert np.array_equal(sample, xyz[indexes]) and np.array_equal(rgb, colors[indexes])
assert head["bounds"]["x_min"] == 200000 and head["bounds"]["z_max"] == 120
def test_원천이_그대로면_다시_안_만듦(tmp_path: Path) -> None:
source = tmp_path / "ground_points_csf.npz"
_npz(source, 30, rgb=False)
target = preview.ensure_point_preview(source, "csf")
assert target.name == "preview_points_csf.bin"
stamp = target.stat().st_mtime_ns
assert preview.ensure_point_preview(source, "csf").stat().st_mtime_ns == stamp
_npz(source, 40, rgb=False)
head, _, rgb = _decode(preview.ensure_point_preview(source, "csf"))
assert head["sampled_count"] == 40 and rgb is None
@pytest.fixture(scope="module")
def out(tmp_path_factory: pytest.TempPathFactory) -> dict:
folder = tmp_path_factory.mktemp("p343")
source = folder / "structured.npz"
xyz, colors = _npz(source, 25)
target = preview.ensure_point_preview(source, None)
proc = subprocess.run(
["node", str(HERE / "helper_343_3_asset_cache.cjs"), str(target)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
data = json.loads(proc.stdout)
data["expected"] = {
"first": xyz[0].tolist(),
"last": xyz[-1].tolist(),
"rgb": colors[0].tolist(),
}
return data
def test_보관함_저장_뒤_두번째는_보관본(out: dict) -> None:
store = out["store"]
assert store["stored"] and store["first"] == store["second"] == "body:http://x/a"
assert store["warnings"] == []
def test_용량_초과면_한_줄_경고_다음엔_다시_받음(out: dict) -> None:
quota = out["quota"]
assert quota["stored"] == 0 and quota["refetched"] == 1
assert len(quota["warnings"]) == 1 # 같은 까닭은 한 번만
assert "저장" in quota["warnings"][0] and "QuotaExceededError" in quota["warnings"][0]
def test_열기_실패를_끝까지_기억하지_않음(out: dict) -> None:
retry = out["retry"]
assert retry["missed"] is None and retry["hit"] == "v" and retry["opens"] == 2
assert any("열기" in line for line in retry["warnings"])
def test_다른_탭이_막아도_멈추지_않음(out: dict) -> None:
blocked = out["blocked"]
assert blocked["body"] == "body:http://x/a"
assert any("막음" in line for line in blocked["warnings"])
def test_지우기는_색인_열쇠만(out: dict) -> None:
purge = out["purge"]
assert purge["keys"] == ["P|http://x/keep"] and purge["valueReads"] == 0
def test_등고선_주소_한_벌(out: dict) -> None:
assert out["contour"] == [
"http://x/api/projects/P/surface/models/7/contour?interval=1&smooth=false&recalculate=false",
"http://x/api/projects/P/surface/models/7/contour?interval=2&smooth=true&recalculate=true",
]
source = (ROOT / "B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts").read_text(
encoding="utf-8"
)
assert "contourAssetUrl(" in source and "/contour?interval" not in source
def test_TS_가_파이썬_이진을_같은_값으로_풂(out: dict) -> None:
decoded, expected = out["preview"], out["expected"]
assert decoded["point_count"] == decoded["sampled_count"] == 25
assert decoded["first"] == expected["first"] and decoded["last"] == expected["last"]
assert decoded["rgbFirst"] == expected["rgb"]
assert decoded["bounds"]["y_max"] == 500100
def test_꺼진_층은_켤_때_받음() -> None:
viewer = (ROOT / "B04_PreProcess/B04_PreProcess_UI_MapViewer.ts").read_text(encoding="utf-8")
assert "GIS_LAYERS.filter((layer) => activeGisLayers.has(layer))" in viewer
assert "activeGisLayers.forEach((layer) => void loadLayerOnDemand(layer))" in viewer
assert len(viewer.splitlines()) <= 700
def test_남은_층은_파일_그대로_ETag() -> None:
source = (ROOT / "B04_PreProcess/B04_PreProcess_Router_GIS.py").read_text(encoding="utf-8")
start = source.index("async def get_project_geojson")
body = source[start : source.index("@tiles_router", start)]
assert "json.loads(filepath" not in body and "json.loads(simplified" not in body
assert body.rstrip().count("cached_file_response(") >= 2
def test_화면은_이진_길로_점군을_받음() -> None:
api = (ROOT / "B04_PreProcess/B04_PreProcess_Api_Fetch.ts").read_text(encoding="utf-8")
assert "/surface/point-cloud/binary" in api and "fetchCachedBytes(" in api
def test_전처리가_미리보기_이진을_한_번_만듦(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import laspy
import B04_PreProcess.B04_PreProcess_Engine as engine
import B04_PreProcess.B04_PreProcess_Engine_SheetSurface as sheet
rng = np.random.default_rng(1)
header = laspy.LasHeader(point_format=3, version="1.2")
header.offsets = [200000, 500000, 0]
header.scales = [0.01, 0.01, 0.01]
las_data = laspy.LasData(header)
las_data.x = rng.uniform(200000, 200060, 3000)
las_data.y = rng.uniform(500000, 500060, 3000)
las_data.z = rng.uniform(100, 101, 3000)
las_data.classification = np.full(3000, 2, np.uint8)
las_path = tmp_path / "B03_FileInput" / "input" / "t.las"
las_path.parent.mkdir(parents=True)
las_data.write(las_path)
monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: None)
monkeypatch.setattr(sheet, "build_sheet_surface_from_route", lambda *a, **k: [])
result = engine.run_surface_analysis(tmp_path, las_path, source_filters=None, methods=["dtm"])
processed = tmp_path / "B04_PreProcess" / "processed"
made = {path.name: path.stat().st_mtime_ns for path in processed.glob("preview_points_*.bin")}
assert "preview_points_raw.bin" in made
assert {f"preview_points_{key}.bin" for key in result["source_filters"]} <= set(made)
engine.run_surface_analysis(tmp_path, las_path, source_filters=None, methods=["dtm"])
again = {path.name: path.stat().st_mtime_ns for path in processed.glob("preview_points_*.bin")}
assert again == made