From 2997f7682e44b06599bb9e90609c42f71112dea1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 09:17:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(=EC=8B=9C=ED=97=98):=20test=5F302=5F2?= =?UTF-8?q?=20=EA=B0=80=EC=A7=9C=20=ED=92=80=20=E2=80=94=20=EC=98=88?= =?UTF-8?q?=EC=A0=95=EB=85=B8=EC=84=A0=20=EC=A7=80=EC=A0=95=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=97=90=20=EB=B9=88=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 까닭: 528 공유 판정이 require_project_access 에 plan_route_assignees 조회를 더함 · 가짜 풀이 모든 fetchone 에 회사 줄을 돌려 「지정받은 회사」로 읽힘 → 남의 회사 GET 200 - 코드 구멍 아님 — 진짜 서버(8001) 회사 3 계정: 남 회사 GET 403 · 미지정 예정노선 403 · 지정 예정노선 GET 200 · PUT 403 - 공유 판정 지킴 시험은 test_528 에 있음(지정 GET 200 · PUT 403 · 미지정 403) Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_302_2_print_drawings.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/resources/tester/test_302_2_print_drawings.py b/resources/tester/test_302_2_print_drawings.py index 2169aa975..ca8d22775 100644 --- a/resources/tester/test_302_2_print_drawings.py +++ b/resources/tester/test_302_2_print_drawings.py @@ -219,11 +219,12 @@ class _FakePool: async def __aexit__(self, *_): return False - async def execute(self, *_): - return None + async def execute(self, sql, *_): + self.sql = sql async def fetchone(self): - return pool.row + # 예정노선 지정(plan_route_assignees) 없음 — 회사 대조만 시험. + return None if "plan_route_assignees" in self.sql else pool.row class _Connection: async def __aenter__(self): From 945ed637f74bf72c3aeeec89484d0f8e3110b0dd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 09:19:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(A05=20=EA=B0=80=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98):=20=EC=9E=A5=EB=A9=B4=20account(sys=20?= =?UTF-8?q?=C2=B7=20company=5Fadmin=20=C2=B7=20user)=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=EB=B3=84=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=C2=B7=20cal?= =?UTF-8?q?louts=20sel=20=EB=AA=A9=EB=A1=9D=20=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=C2=B7=20=EB=B2=88=ED=98=B8=20=EC=9B=90=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=95=88=20=EB=B0=80=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=EB=8D=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- A05_EduDetail/A05_EduDetail_Guide_Capture.py | 96 +++++++++++++++----- resources/tester/test_539_guide_capture.py | 70 ++++++++++++++ 2 files changed, 145 insertions(+), 21 deletions(-) diff --git a/A05_EduDetail/A05_EduDetail_Guide_Capture.py b/A05_EduDetail/A05_EduDetail_Guide_Capture.py index 468b236d4..786d10319 100644 --- a/A05_EduDetail/A05_EduDetail_Guide_Capture.py +++ b/A05_EduDetail/A05_EduDetail_Guide_Capture.py @@ -9,7 +9,8 @@ - --force = `_manual.json` 그림도 찍음 · --headed = 창을 띄워 찍음(헤드리스에서 3D · 지도가 안 그려질 때) - 프로젝트 = A05_EduDetail/guide/_projects.json `{"road": "", "stab": "", "plan": ""}` · --project-id 를 주면 그 id 가 모든 프로젝트 장면에 우선(자기 검증 프로젝트로 시험할 때) - - 로그인 = tmp/aislo_cred.json(시스템 관리자) · 값은 어디에도 찍지 않음 + - 로그인 = 장면 칸 account 대로 — sys(기본) = tmp/aislo_cred.json · company_admin · user = tmp/verify_accounts.json + 의 「회사관리자」 · 「일반」 · 계정마다 컨텍스트를 따로 로그인(쓰는 것만 · 처음 쓸 때) · 값은 어디에도 찍지 않음 - 끝에 표(장면 · 크기 · kB · 걸린 시간 · 실패 까닭) · 실패가 하나라도 있으면 종료 코드 1 `_scenes.json` 꼴 = 장면(그림 하나) 목록 — 이 머리 설명이 기준 @@ -17,6 +18,7 @@ { "name": "B05_00_overview", 필수 · <페이지>_<두 자리>_<영문 소문자·숫자·_> · 그림 = .webp "route": "#/b05-profile", 필수 · 해시 주소("#/…") · vite 주소 뒤에 붙임 + "account": "sys", 선택 · sys(기본 · 시스템 관리자) | company_admin | user · 역할마다 화면이 다른 페이지용 "project": "road", road | stab | plan | none(기본) · none = 현재 프로젝트 비움 "viewport": [1600, 900], 선택 · 화면 크기(CSS px) · 기본 1600×900 · 그림은 늘 2배 "before": [ 단계, … ], 선택 · 찍기 전 조작(아래 단계 꼴) @@ -25,7 +27,8 @@ "clip_pad": 16, 선택 · clip 둘레 여백(CSS px · 기본 16) "callouts": [ 선택 · 노란 둥근 테두리 + 검은 원 흰 번호 · 번호 = 글 번호 표 {"n": 1, "sel": "", "pos": "tl"} pos = 번호 원 자리 tl(기본) · tr · bl · br(바깥 모서리) - ], + {"n": 2, "sel": ["", ""]} sel 목록 = 여러 요소의 둘레를 하나로 묶은 상자 하나 + 번호 하나 + ] 번호 원이 화면 밖으로 나가면 안쪽으로 밀어 넣음(넘칠 때만) · clip 은 원까지 넣어 자름 "after": [ 단계, … ], 선택 · 찍은 뒤 조작(확인창 [취소] · 모달 닫기) "allow_post": ["/api/b05/…"] 선택 · 허락할 POST 주소 조각(읽기용 POST 만 · 아래 지킴) } @@ -53,6 +56,7 @@ - 브라우저 확인창(window.confirm)은 늘 취소 · 새 컨텍스트라 사용자 브라우저 · 저장소와 안 섞임 - 장면마다 새 탭 · sessionStorage 비우고 시작(앞 장면 조작이 안 남음) · localStorage 는 프로젝트 · 테마 · 말만 세움 + - 번호 원은 늘 화면 안 · clip 은 원을 포함하도록 넓힘(원이 잘리지 않음) - clip · callouts 대상은 찍기 전 화면 안으로 스크롤(clip 먼저 · 번호 차례) 뒤 자리를 잼 - `_manual.json`(관리자가 손으로 바꾼 그림 · 이름 목록 또는 이름 열쇠 사전) 이름은 건너뜀(--force 면 찍음) @@ -85,11 +89,17 @@ STEP_TIMEOUT = 15000 SETTLE_MS = 500 CLIP_PAD = 16 PROJECT_KEYS = ("road", "stab", "plan", "none") +ACCOUNTS_FILE = ROOT / "tmp" / "verify_accounts.json" +ACCOUNT_KEYS = { + "company_admin": "회사관리자", + "user": "일반", +} # 장면 account → 파일 열쇠 (sys 는 CRED) +ACCOUNTS = ("sys", *ACCOUNT_KEYS) STEP_KINDS = ( "click", "contextmenu", "fill", "select", "hover", "press", "eval", "wait", "wait_ms", "scroll", ) # fmt: skip SCENE_KEYS = { - "name", "route", "project", "viewport", "before", "settle_ms", "clip", "clip_pad", + "name", "route", "account", "project", "viewport", "before", "settle_ms", "clip", "clip_pad", "callouts", "after", "allow_post", } # fmt: skip STEP_EXTRA = {"value", "confirm", "frame", "timeout", "sel"} @@ -182,6 +192,8 @@ def check_scene(scene: Any, page: str) -> list[str]: route = scene.get("route") if not isinstance(route, str) or not route.startswith("#/"): errs.append("route 는 '#/…' 해시 주소") + if scene.get("account", "sys") not in ACCOUNTS: + errs.append(f"account 는 {' · '.join(ACCOUNTS)}") if scene.get("project", "none") not in PROJECT_KEYS: errs.append(f"project 는 {' · '.join(PROJECT_KEYS)}") vp = scene.get("viewport") @@ -209,8 +221,8 @@ def check_scene(scene: Any, page: str) -> list[str]: callouts = [] seen: set[int] = set() for i, c in enumerate(callouts): - if not isinstance(c, dict) or not isinstance(c.get("sel"), str) or not c["sel"].strip(): - errs.append(f"callouts[{i}] 는 {{n, sel}}") + if not isinstance(c, dict) or not callout_sels(c.get("sel")): + errs.append(f"callouts[{i}] 는 {{n, sel(글 또는 글 목록)}}") continue n = c.get("n") if not isinstance(n, int) or isinstance(n, bool) or not 1 <= n <= 99: @@ -290,8 +302,36 @@ def save_webp(png: bytes, path: Path) -> tuple[int, int, float]: return img.width, img.height, round(path.stat().st_size / 1024, 1) +def callout_sels(sel: Any) -> list[str]: + """callouts sel(글 또는 글 목록) → 선택자 목록 · 꼴이 틀리면 빈 목록.""" + items = [sel] if isinstance(sel, str) else sel + if not isinstance(items, list) or not items: + return [] + return items if all(isinstance(x, str) and x.strip() for x in items) else [] + + +def union_box(boxes: list[dict[str, float]]) -> dict[str, float]: + """요소 상자들(x · y · width · height)의 둘레 상자 하나.""" + x0, y0 = min(b["x"] for b in boxes), min(b["y"] for b in boxes) + x1 = max(b["x"] + b["width"] for b in boxes) + y1 = max(b["y"] + b["height"] for b in boxes) + return {"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0} + + +def account_cred(account: str) -> dict[str, str]: + """장면 account → {email, password} · 파일 없음 · 칸 없음이면 SceneError(값은 안 찍음).""" + path = CRED if account == "sys" else ACCOUNTS_FILE + if not path.is_file(): + raise SceneError(f"계정 파일 없음: tmp/{path.name}") + data = json.loads(path.read_text(encoding="utf-8")) + cred = data if account == "sys" else data.get(ACCOUNT_KEYS[account]) + if not isinstance(cred, dict) or not cred.get("email") or not cred.get("password"): + raise SceneError(f"계정 '{account}' 의 email · password 없음") + return cred + + def callout_boxes(box: dict[str, float], pos: str, vw: int, vh: int) -> tuple[dict, dict]: - """요소 상자(CSS px) → (노란 테두리 상자, 번호 원 상자) · 원은 바깥 모서리 · 화면 안으로 당김.""" + """요소 상자(CSS px) → (노란 테두리 상자, 번호 원 상자) · 원은 바깥 모서리 · 화면 밖이면 안으로 밈.""" ring = { "x": box["x"] - RING_PAD, "y": box["y"] - RING_PAD, @@ -447,7 +487,11 @@ def shoot_scene( page.wait_for_timeout(scene.get("settle_ms", SETTLE_MS)) # 찍을 대상을 화면 안으로 — clip 먼저 · 번호 차례(어긋난 번호 · 잘린 그림 막음) - targets = [(c["sel"], c.get("frame")) for c in scene.get("callouts") or []] + targets = [ + (sel, c.get("frame")) + for c in scene.get("callouts") or [] + for sel in callout_sels(c["sel"]) + ] if scene.get("clip"): targets.insert(0, (scene["clip"], None)) for sel, frame in targets: @@ -457,8 +501,9 @@ def shoot_scene( pass # 안 보이면 아래 _box 가 까닭을 냄 items, boxes = [], [] for c in scene.get("callouts") or []: - ring, dot = callout_boxes(_box(page, c["sel"], c.get("frame"), f"번호 {c['n']}"), - c.get("pos", "tl"), vw, vh) # fmt: skip + frame = c.get("frame") + els = [_box(page, sel, frame, f"번호 {c['n']}") for sel in callout_sels(c["sel"])] + ring, dot = callout_boxes(union_box(els), c.get("pos", "tl"), vw, vh) items.append({"n": c["n"], "ring": ring, "dot": dot}) boxes += [ring, dot] clip = None @@ -475,14 +520,14 @@ def shoot_scene( page.close() -def login(context: Any, api: str) -> None: - cred = json.loads(CRED.read_text(encoding="utf-8")) +def login(context: Any, api: str, account: str = "sys") -> None: + cred = account_cred(account) res = context.request.post( api + "/api/auth/login/request", data={"email": cred["email"], "password": cred["password"]}, ) if not res.ok: - raise SceneError(f"로그인 실패 HTTP {res.status}") + raise SceneError(f"로그인 실패({account}) HTTP {res.status}") if res.json().get("status") == "otp_required": raise SceneError("로그인에 메일 인증 필요 — 개발 서버(AUTH_OTP_DISABLED)로") @@ -505,13 +550,20 @@ def capture(scenes: list[dict[str, Any]], out: Path, args: argparse.Namespace) - last = e if browser is None: raise SceneError(f"브라우저(Edge · Chrome)를 못 띄움: {str(last).splitlines()[0]}") - context = browser.new_context( - viewport={"width": VIEWPORT[0], "height": VIEWPORT[1]}, - device_scale_factor=SCALE, - locale="ko-KR", - color_scheme="light", - ) - login(context, args.api) + contexts: dict[str, Any] = {} # 계정별 로그인한 컨텍스트 — 처음 쓸 때 만듦 + + def context_for(account: str) -> Any: + if account not in contexts: + ctx = browser.new_context( + viewport={"width": VIEWPORT[0], "height": VIEWPORT[1]}, + device_scale_factor=SCALE, + locale="ko-KR", + color_scheme="light", + ) + login(ctx, args.api, account) + contexts[account] = ctx + return contexts[account] + for sc in scenes: row = {"name": sc["name"], "size": "", "kb": "", "sec": "", "why": ""} t0 = time.perf_counter() @@ -520,7 +572,8 @@ def capture(scenes: list[dict[str, Any]], out: Path, args: argparse.Namespace) - else: try: pid = resolve_project(sc.get("project", "none"), args.project_id, projects) - png, blocked = shoot_scene(context, args.vite, sc, pid) + ctx = context_for(sc.get("account", "sys")) + png, blocked = shoot_scene(ctx, args.vite, sc, pid) w, h, kb = save_webp(png, out / f"{sc['name']}.webp") row.update(size=f"{w}×{h}", kb=kb) if blocked: @@ -566,7 +619,8 @@ def main(argv: list[str] | None = None) -> int: skip = manual_names(out) for s in scenes: mark = " (건너뜀 _manual)" if s["name"] in skip and not args.force else "" - print(f"{s['name']} {s['route']} {s.get('project', 'none')}{mark}") + acct = s.get("account", "sys") + print(f"{s['name']} {s['route']} {s.get('project', 'none')} {acct}{mark}") return 0 rows = capture(scenes, out, args) except SceneError as e: diff --git a/resources/tester/test_539_guide_capture.py b/resources/tester/test_539_guide_capture.py index 32d704f7a..d94fee832 100644 --- a/resources/tester/test_539_guide_capture.py +++ b/resources/tester/test_539_guide_capture.py @@ -189,3 +189,73 @@ def test_callout_and_clip_boxes(): clip = G.union_clip([ring, dot], 16, 1600, 900) assert clip["x"] <= dot["x"] - 16 + 0.01 and clip["width"] > ring["w"] assert G.union_clip([{"x": -50, "y": 0, "w": 2000, "h": 10}], 16, 1600, 900)["width"] == 1600 + + +@pytest.mark.parametrize("acct", ["sys", "company_admin", "user"]) +def test_account_ok(acct): + assert G.check_scene(_scene(account=acct), "B05") == [] + + +def test_account_bad(): + assert G.check_scene(_scene(account="admin"), "B05") + + +def test_callout_sel_list(): + ok = _scene(callouts=[{"n": 1, "sel": [".a", ".b"]}, {"n": 2, "sel": ".c"}]) + assert G.check_scene(ok, "B05") == [] + for bad in ([], [""], [".a", 3], 5, None): + assert G.check_scene(_scene(callouts=[{"n": 1, "sel": bad}]), "B05") + + +def test_union_box_and_ring(): + u = G.union_box( + [ + {"x": 10, "y": 20, "width": 30, "height": 10}, + {"x": 100, "y": 5, "width": 20, "height": 10}, + ] + ) + assert u == {"x": 10, "y": 5, "width": 110, "height": 25} + ring, _ = G.callout_boxes(u, "tl", 1600, 900) + assert ring == {"x": 6, "y": 1, "w": 118, "h": 33} + + +@pytest.mark.parametrize("pos", ["tl", "tr", "bl", "br"]) +def test_dot_pushed_inside_only_when_overflow(pos): + vw, vh = 800, 600 + # 화면 모서리에 붙은 요소 → 원이 밖으로 나갈 자리 = 안으로 + for box in ( + {"x": 0, "y": 0, "width": 800, "height": 600}, + {"x": 790, "y": 590, "width": 10, "height": 10}, + ): + _, dot = G.callout_boxes(box, pos, vw, vh) + assert 0 <= dot["x"] and dot["x"] + dot["w"] <= vw + assert 0 <= dot["y"] and dot["y"] + dot["h"] <= vh + # 안쪽 요소 → 원 자리 그대로(바깥 모서리 20px 걸침) + ring, dot = G.callout_boxes({"x": 300, "y": 300, "width": 100, "height": 50}, pos, vw, vh) + assert dot["x"] == (ring["x"] - 20 if pos in ("tl", "bl") else ring["x"] + ring["w"] - 8) + assert dot["y"] == (ring["y"] - 20 if pos in ("tl", "tr") else ring["y"] + ring["h"] - 8) + + +def test_account_cred(tmp_path, monkeypatch): + f = tmp_path / "acc.json" + f.write_text( + json.dumps({"회사관리자": {"email": "a@b", "password": "x"}, "일반": {"email": "c@d"}}) + ) + sysf = tmp_path / "sys.json" + sysf.write_text(json.dumps({"email": "s@s", "password": "y"})) + monkeypatch.setattr(G, "ACCOUNTS_FILE", f) + monkeypatch.setattr(G, "CRED", sysf) + assert G.account_cred("company_admin")["email"] == "a@b" + assert G.account_cred("sys")["email"] == "s@s" + with pytest.raises(G.SceneError) as e: + G.account_cred("user") # password 없음 + assert "x" not in str(e.value).replace("password", "") + monkeypatch.setattr(G, "ACCOUNTS_FILE", tmp_path / "none.json") + with pytest.raises(G.SceneError): + G.account_cred("company_admin") + + +def test_list_shows_account(capsys): + d = _folder([_scene(account="user")]) + assert G.main(["--page", "B05", "--out", str(d), "--list"]) == 0 + assert "user" in capsys.readouterr().out