From fb53dcb6cc075e2e597ee0831c27eb8e7fac87bc Mon Sep 17 00:00:00 2001 From: maxihan1115-coder Date: Fri, 30 Oct 2026 22:00:50 +0900 Subject: [PATCH 1/9] =?UTF-8?q?chore:=20WEB-F7-19=20=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vw4EcW97tTnFJ9UDobtbJd From 0d2cc098ff36be36f79034835ca69df9cf6a6ca9 Mon Sep 17 00:00:00 2001 From: maxihan1115-coder Date: Fri, 30 Oct 2026 22:09:11 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[SPEC]=20=EC=8B=A4=ED=8C=A8=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=9D=91=EB=8B=B5=20=EB=94=94=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B9=B8=EC=97=90=20device(=ED=8F=B0=20=C2=B7=20?= =?UTF-8?q?=EC=95=B1=20=ED=8C=90)=20=E2=80=94=20=EC=A6=9D=EA=B1=B0=20?= =?UTF-8?q?=EC=B9=B8=20=E3=80=8C=EA=B8=B0=EA=B8=B0=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=E3=80=8D=20=ED=83=AD=20(WEB-F7-19=20=C2=B7=202026-10-30=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=8A=B9=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vw4EcW97tTnFJ9UDobtbJd --- docs/DESIGN.md | 2 +- docs/SPEC.md | 8 ++++---- .../\353\246\254\355\217\254\355\214\205.md" | 13 ++++++++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 5ab3ca34e..33bb6ee21 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -555,7 +555,7 @@ TC ID, 소요시간, 응답 코드가 세로로 정렬돼야 훑을 수 있다. - **시간축 증거** (같은 날) — 펼친 디바이스 칸의 사전조건 · 입력 아래. 위에 우묵한 띠(단계마다 걸린 시간만큼 폭 · 통과는 옅은 통과 칠 · 실패는 실패 바탕 · 고른 단계는 대표색 테두리 · 단추라 키보드로 고른다), 아래 두 단 — 왼쪽 단계 목록(확인 줄 그대로, 고른 단계는 고른 표시) · 오른쪽 340px 증거 칸(개발자 도구 탭 꼴 · 항목 상세 「네트워크 · 조작 · 콘솔」과 같은 탭). 칸 폭이 640px 보다 좁으면 증거 칸이 아래로 간다. 처음엔 처음 깨진 단계를 고른다. 단계를 고르면 실패 확인 줄이 한 번 깜박이고 증거 칸이 겹쳐 바뀐다(원칙 4 「증거 바꾸기」). 같은 실패로 묶인 디바이스는 단계를 한 번 그리고 화면 · logcat 을 증거 칸에서 디바이스 이름을 달아 나란히 둔다 -- **증거 칸은 종류마다 탭만 다르다** (같은 날) — 웹 `화면 · 네트워크 · 콘솔` · 앱 `화면 · logcat · 화면 영상`(팜이 받아 둔 것만 탭이 선다) · API `요청 · 응답 · 기대값 차이`(절차에 요청 · 응답 원문이 있으면 API 로 본다). +- **증거 칸은 종류마다 탭만 다르다** (같은 날) — 웹 `화면 · 네트워크 · 콘솔` · 앱 `화면 · logcat · 화면 영상 · 기기 정보`(팜이 받아 둔 것만 탭이 선다 · 기기 정보는 폰 · 앱 판을 읽은 항목만, 못 읽은 칸은 「모름」 — 2026-10-30 WEB-F7-19) · API `요청 · 응답 · 기대값 차이`(절차에 요청 · 응답 원문이 있으면 API 로 본다). 화면 · 요청 · 응답 · 기대값 차이 · 네트워크 · 콘솔은 고른 단계 것만, logcat · 영상은 실행 전체라 「단계를 바꿔도 같습니다」 한 줄을 단다. 네트워크 · 콘솔은 실패 카드 통로에 없어 그 탭을 처음 누를 때 항목 상세를 한 번 읽는다. 통과 단계에서 찍은 화면은 「실패 시점」이라 부르지 않는다. 게임 오브젝트 탭 · FPS 줄(시안)은 기능 테스트 항목에 그 값이 없어 안 그린다 — FPS 는 기기 성능에만, 게임 요소 대조값은 항목이 아니라 게임 지도에 쌓인다 - **통과 · 미실행 줄** — 지금의 결과 줄 모양 그대로에 `펼치기` 버튼. 펼친 자리는 줄 아래 들여 쓴 같은 절차 그림 diff --git a/docs/SPEC.md b/docs/SPEC.md index 7be82431d..cad747444 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -58,15 +58,15 @@ | **WS-A** 카탈로그 | `spec/공통/1-제품과-구조` · `spec/공통/2-명세선언` · `spec/도메인/카탈로그` · `spec/공통/4-데이터모델` | 3272줄 | | **WS-B** 실행 | `spec/공통/1-제품과-구조` · `spec/공통/3-공유계약` · `spec/공통/5-화면공통` · `spec/도메인/실행` · `spec/공통/4-데이터모델` | 5042줄 | | **WS-C** 러너 | `spec/공통/1-제품과-구조` · `spec/공통/2-명세선언` · `spec/공통/3-공유계약` · `spec/도메인/러너` | 2020줄 | -| **WS-D** 리포팅 | `spec/공통/1-제품과-구조` · `spec/공통/5-화면공통` · `spec/도메인/리포팅` · `spec/공통/4-데이터모델` | 3820줄 | -| **WS-E** 화면 | `spec/공통/1-제품과-구조` · `spec/공통/5-화면공통` · `spec/도메인/카탈로그` · `spec/도메인/실행` · `spec/도메인/리포팅` · `spec/도메인/인증` (§8.6·§8.8) · DESIGN.md | 4850줄 | +| **WS-D** 리포팅 | `spec/공통/1-제품과-구조` · `spec/공통/5-화면공통` · `spec/도메인/리포팅` · `spec/공통/4-데이터모델` | 3831줄 | +| **WS-E** 화면 | `spec/공통/1-제품과-구조` · `spec/공통/5-화면공통` · `spec/도메인/카탈로그` · `spec/도메인/실행` · `spec/도메인/리포팅` · `spec/도메인/인증` (§8.6·§8.8) · DESIGN.md | 4861줄 | | **WS-F** 인증 | `spec/공통/1-제품과-구조` · `spec/공통/5-화면공통` · `spec/도메인/인증` · `spec/공통/4-데이터모델` | 4175줄 | | **contracts** 계약 반영 | `spec/공통/1-제품과-구조` · `spec/공통/3-공유계약` · `spec/공통/4-데이터모델` · `spec/공통/6-인프라` · `spec/도메인/러너` | 4328줄 | | **WS-작성** 테스트 작성 | `spec/공통/1-제품과-구조` · `spec/공통/4-데이터모델` · `spec/도메인/작성` · `spec/도메인/인증` | 6457줄 | | **WS-시나리오** E2E 시나리오 (여러 갈래에 걸친다 — `docs/archive/WORKSTREAMS.md` 「📐 E2E 시나리오」) | `spec/공통/1-제품과-구조` · `spec/공통/3-공유계약` · `spec/공통/4-데이터모델` · `spec/도메인/시나리오` · `spec/도메인/러너` · `spec/도메인/실행` · `spec/도메인/인증` | 7400줄 | | **WS-성능** 성능 테스트 (여러 갈래에 걸친다 — 정본은 `spec/도메인/성능`) | `spec/공통/1-제품과-구조` · `spec/공통/3-공유계약` · `spec/공통/4-데이터모델` · `spec/공통/5-화면공통` · `spec/공통/6-인프라` · `spec/도메인/성능` · `spec/도메인/러너` · `spec/도메인/인증` | 6582줄 | | **WS-게임** 게임 테스트 (여러 갈래에 걸친다 — 정본은 `spec/도메인/게임`) | `spec/공통/1-제품과-구조` · `spec/공통/3-공유계약` · `spec/공통/4-데이터모델` · `spec/도메인/게임` · `spec/도메인/러너` · `spec/도메인/작성` | 6982줄 | -| **WS-0** 골격 · **integration** 통합·병합 | 17장 전부 | 14372줄 | +| **WS-0** 골격 · **integration** 통합·병합 | 17장 전부 | 14383줄 | `spec/공통/1-제품과-구조`는 어느 갈래든 읽는다. 용어를 다르게 쓰면 나머지가 전부 어긋난다. WS-0 과 integration 은 장을 따로 싣지 않는다 — 전 갈래를 건드리므로 색인을 보고 필요한 장을 직접 연다. @@ -187,7 +187,7 @@ WS-0 과 integration 은 장을 따로 싣지 않는다 — 전 갈래를 건드 |----|--------|-----| | [카탈로그](spec/도메인/카탈로그.md) | §3.1 · §7 Catalog · §8.1 케이스 목록(표머리 · 상세 칸 · 판정 흐름 · 설계 기법 · 맥락 · 엑셀로 내려받기 · 여러 건 고르기) | 554 | | [실행](spec/도메인/실행.md) | §3.2 · §3.11 빌드마다 돌리기 · §7 빌드·ParamSet·Execution·실패 분석 · §8.2 실행 설정 · §8.3 결과 화면(요약 띠 · 실패 카드 · 통과 줄) · §8.7 실행 기록 목록(A안 목록 + 오른쪽 결과 칸 · 표머리) · §8.9 진행과 완료 알림 · 남은 시간 · §8.10 실행 창 | 1344 | -| [리포팅](spec/도메인/리포팅.md) | §3.3 · §7 Reporting(실패 카드 통로 포함) · §8.4 항목 상세 · §8.5 대시보드(Grafana) · §8.12 대시보드(앱) | 837 | +| [리포팅](spec/도메인/리포팅.md) | §3.3 · §7 Reporting(실패 카드 통로 포함) · §8.4 항목 상세 · §8.5 대시보드(Grafana) · §8.12 대시보드(앱) | 848 | | [러너](spec/도메인/러너.md) | §3.4 · §5.2 Runner HTTP 계약 · 앱 실행(Android · Appium) · 러너 토큰 · 파일 내주기 | 647 | | [인증](spec/도메인/인증.md) | §3.5 · §7 Auth·설정 API·인증 적용 범위·Grafana 통로 · §8.6 로그인·회원가입·비밀번호 변경 화면 · §8.8 설정 화면 | 1192 | | [시나리오](spec/도메인/시나리오.md) | §3.7 · §7 Scenario — 조립·버전·시험 실행 · §8.11 E2E 시나리오 화면(마법사 포함) | 1113 | diff --git "a/docs/spec/\353\217\204\353\251\224\354\235\270/\353\246\254\355\217\254\355\214\205.md" "b/docs/spec/\353\217\204\353\251\224\354\235\270/\353\246\254\355\217\254\355\214\205.md" index 4632caf39..ea6a759f9 100644 --- "a/docs/spec/\353\217\204\353\251\224\354\235\270/\353\246\254\355\217\254\355\214\205.md" +++ "b/docs/spec/\353\217\204\353\251\224\354\235\270/\353\246\254\355\217\254\355\214\205.md" @@ -148,7 +148,7 @@ GET /api/runs/:runId/insights 직전 실행과 견준 결과 + 이번 차례는 지금 판 항목 차례, 판에 없는 번호는 그 뒤 번호 차례. tcIds 는 tc_id 차례. 미확정 실패도 든다. 지금 지도 기준이라 박제하지 않는다(위 「실패 요구사항」)~~ **2026-10-19 ⑫ — 실패요구들은 지운다**(사용자 결정 · 결과 화면의 「실패 요구사항」 칸을 빼서 읽는 곳이 없다). 카드의 reqs 는 그대로다 GET /api/runs/:runId/failures?page=[&platform=desktop|mobile|android] 실행 결과 화면의 실패 케이스 카드 재료 (2026-10-08 신설 · PR #175, 실행 §8.3) - → { items: [{ tcId, tcName, devices: [{ platform, change, streak, recent, attempts, failedAttempts, item, farm }] }], total, page, pageSize } + → { items: [{ tcId, tcName, devices: [{ platform, change, streak, recent, attempts, failedAttempts, item, farm, device }] }], total, page, pageSize } **기존 GET /api/runs/:runId · /insights · /items/:historyId 응답은 바꾸지 않는다** — insights 와 같은 까닭이다(2026-10-08 당시. insights 의 실패요구들은 2026-10-11 에, /items/:historyId 의 failure 는 2026-10-19 에, farm 은 2026-10-20 에 더했다 — 있던 칸은 안 바꾸고 칸을 더하는 쪽이다) 끝난 실행의 실패 항목을 미확정까지 싣는다(2026-10-11 PRD-F4-02 · 실행 §3.2). 통과 · 미실행은 안 든다(그 케이스의 통과 디바이스는 화면이 실행 응답에서 그린다) items 는 실패 **케이스**이고 `카드쪽크기`(reporting/failuresShape.ts)개씩 자른다. 한 케이스의 디바이스가 두 쪽으로 갈리지 않는다. @@ -159,6 +159,7 @@ GET /api/runs/:runId/failures?page=[&platform=desktop|mobile|android] 비밀값 가림(§4.1)이 두 통로에서 같아야 한다. 빈 칸을 박제 스키마 default 로 채우는 일은 그 통로처럼 화면이 한다(§3.3). 그래서 그 통로의 failure(2026-10-19 ⑫ — 실행 §7 항목 상세)가 item 에 같이 실린다. 카드 머리의 유형 칩 · 사람 말 한 줄이 그것을 읽는다 farm: { name, bytes }[] (2026-10-20 APP-F1-12) — 그 회차에 러너가 받아 둔 팜 화면 영상 · logcat. 항목 상세의 farm 과 같은 함수다(실행 §7). 없으면 빈 목록 + device: { model, osVersion, appVersion, appBuild? } | null (2026-10-30 WEB-F7-19) — 그 회차를 돌린 폰과 앱의 판(run_item.device · 공유계약 DeviceInfo). android 만 있고 웹 · API 는 null. 못 읽은 칸은 null 그대로다 — 실패 카드 증거 칸의 「기기 정보」 탭(DESIGN.md 「증거 칸은 종류마다 탭만 다르다」)이 읽는다 attempts · failedAttempts 는 이 실행에서 그 (케이스, 디바이스)의 회차 수와 그중 실패 수다 recent 는 최근 판정 흐름 — **같은 서비스 · 같은 대상 서버(env) · 같은 종류**의 끝난 실행(RUNNING 아님 · 중단 포함 · 스모크 아닌 실행이면 스모크는 뺀다 — 위 insights previous 와 같다, 2026-10-25 GAME-F2-14) 중 이 실행까지, 그 (케이스, 디바이스)가 나온 실행을 최대 `최근몇건`(execution/history.ts)개, **맨 앞이 이번 실행**이다. @@ -245,6 +246,16 @@ GET /api/evidence/:id 문서 다운로드. **이 주소는 상태: 반영 완료 (2026-10-11, reporting/failReqs.ts · reporting/routes.ts · reporting/failures.ts · db/migrations/20261011000001_run_case_bug.sql · auth/routeTable.ts · auth/scope.ts · web/RunFailedReqs.tsx · web/RunFailJudge.tsx — PR #212) ``` +``` +[계약 변경 필요] +대상: Admin API — GET /api/runs/:runId/failures 의 devices[].device +현재: 폰 · 앱 판은 run_item.device 에 저장되지만 실패 카드 응답에 없어 화면이 못 읽는다 +제안: 위 §7 줄 그대로 — 디바이스 칸에 device 를 더한다. 있던 칸은 안 바뀐다 +이유: 시안 11판 앱 · 게임 증거 칸의 「기기 정보」 탭(진행판 WEB-F7-19) +영향: WS-D(질의 한 줄) · WS-E(증거 칸 탭) +상태: 반영 완료 (2026-10-30, reporting/failures.ts · web/RunEvidence.tsx — 2026-10-30 사용자 승인 · WEB-F7-19) +``` + --- ### 8.4 항목 상세 — 명세 기반 포맷 From 688e212c4752b4cde42c1bdd36a56ef449dd7cda Mon Sep 17 00:00:00 2001 From: maxihan1115-coder Date: Fri, 30 Oct 2026 22:09:11 +0900 Subject: [PATCH 3/9] =?UTF-8?q?[WS-E]=20=EC=95=B1=20=C2=B7=20=EA=B2=8C?= =?UTF-8?q?=EC=9E=84=20=EC=A6=9D=EA=B1=B0=20=EC=B9=B8=20=E3=80=8C=EA=B8=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=95=EB=B3=B4=E3=80=8D=20=ED=83=AD=20=E2=80=94?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=EC=B9=B4=EB=93=9C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20device=20=EB=A5=BC=20=EC=8B=A3=EA=B3=A0=20=EB=AA=BB?= =?UTF-8?q?=20=EC=9D=BD=EC=9D=80=20=EC=B9=B8=EC=9D=80=20=E3=80=8C=EB=AA=A8?= =?UTF-8?q?=EB=A6=84=E3=80=8D=20(WEB-F7-19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vw4EcW97tTnFJ9UDobtbJd --- .../src/reporting/failures-routes.test.ts | 14 ++++++++++ apps/admin/src/reporting/failures.ts | 10 ++++++- apps/admin/src/web/RunEvidence.test.tsx | 21 ++++++++++++++ apps/admin/src/web/RunEvidence.tsx | 28 ++++++++++++++++++- apps/admin/src/web/api.ts | 4 ++- apps/admin/src/web/messages/runs.ts | 4 +++ apps/admin/src/web/runEvidence.css | 13 +++++++++ 7 files changed, 91 insertions(+), 3 deletions(-) diff --git a/apps/admin/src/reporting/failures-routes.test.ts b/apps/admin/src/reporting/failures-routes.test.ts index 0833e72be..f262c279a 100644 --- a/apps/admin/src/reporting/failures-routes.test.ts +++ b/apps/admin/src/reporting/failures-routes.test.ts @@ -100,6 +100,12 @@ describe.skipIf(연결 === undefined)('실패 카드 통로', () => { await 항목(끝남, 'XFCR-001', 'mobile', 'FAIL'); await 항목(끝남, 'XFCR-002', 'mobile', 'FAIL'); await 항목(끝남, 'XFCR-003', 'desktop', 'PASS'); + const 앱 = await 실행만들기('앱', 'FINISHED'); + await 항목(앱, 'XFCR-004', 'android', 'FAIL'); + await pool.query(`UPDATE run_item SET device = $2 WHERE run_id = $1`, [ + 앱, + JSON.stringify({ model: 'SM-S911N', osVersion: '14', appVersion: null, appBuild: '241' }), + ]); await 실행만들기('도는중', 'RUNNING'); await 실행만들기('시나리오', 'FINISHED', 시나리오번호); @@ -137,6 +143,14 @@ describe.skipIf(연결 === undefined)('실패 카드 통로', () => { expect(body.items.map((c: { tcId: string }) => c.tcId)).toEqual(['XFCR-001', 'XFCR-002']); }); + it('디바이스 칸에 그 회차를 돌린 폰과 앱의 판을 싣고 없으면 null 이다', async () => { + const 앱 = (await 라우트앱.inject({ method: 'GET', url: 주소(실행.앱!) })).json(); + expect(앱.items[0].devices[0].device).toEqual({ model: 'SM-S911N', osVersion: '14', appVersion: null, appBuild: '241' }); + + const 웹 = (await 라우트앱.inject({ method: 'GET', url: 주소(실행.끝남!) })).json(); + expect(웹.items[0].devices[0].device).toBeNull(); + }); + it('도는 실행은 409 RUN_NOT_FINISHED 다', async () => { const res = await 라우트앱.inject({ method: 'GET', url: 주소(실행.도는중!) }); diff --git a/apps/admin/src/reporting/failures.ts b/apps/admin/src/reporting/failures.ts index d505e09a8..994e49284 100644 --- a/apps/admin/src/reporting/failures.ts +++ b/apps/admin/src/reporting/failures.ts @@ -1,6 +1,6 @@ // 실행 결과 화면의 실패 케이스 카드 질의 — 실패 · 변화 · 최근 흐름 · 처음 실패한 회차의 항목 상세 (도메인/리포팅 §7) -import type { ItemStatus, Platform } from '@platform/kit'; +import type { DeviceInfo, ItemStatus, Platform } from '@platform/kit'; import { 팜증적목록, type 팜파일 } from '../execution/farmEvidence.js'; import { findItem } from '../execution/queries.js'; @@ -20,6 +20,8 @@ export interface FailureDevice extends Omit<실패디바이스, 'firstFailedHist farm: 팜파일[]; /** 한 건 실행이 덮은 디바이스면 그 한 건 · 원래 결과 (도메인/실행 §7 `GET /api/runs/:runId` items[].rerun) */ rerun: 다시실행칸 | null; + /** 그 회차를 돌린 폰과 앱의 판 — android 만 있다. 증거 칸 「기기 정보」 탭이 읽는다 (리포팅 §7) */ + device: DeviceInfo | null; } export interface FailureCase { @@ -62,6 +64,11 @@ const 실패행들SQL = ` SELECT i.history_id, i.tc_id, i.tc_name, i.platform, i.attempt, i.status, ${다시실행칸들} FROM ${지금항목('o.run_id = $1')} i`; +async function 기기(historyId: number): Promise { + const { rows } = await (await db()).query<{ device: DeviceInfo | null }>('SELECT device FROM run_item WHERE history_id = $1', [historyId]); + return rows[0]?.device ?? null; +} + export async function 실패카드(runId: number, page: number, platform?: Platform): Promise { const pool = await db(); const 비교 = await compareWithPrevious(runId); @@ -130,6 +137,7 @@ export async function 실패카드(runId: number, page: number, platform?: Platf item, farm: await 팜증적목록(실행번호, firstFailedHistoryId), rerun, + device: await 기기(firstFailedHistoryId), }; }), ), diff --git a/apps/admin/src/web/RunEvidence.test.tsx b/apps/admin/src/web/RunEvidence.test.tsx index 1a2970cef..83fef2d45 100644 --- a/apps/admin/src/web/RunEvidence.test.tsx +++ b/apps/admin/src/web/RunEvidence.test.tsx @@ -80,4 +80,25 @@ describe('실패 카드의 시간축 증거 (WEB-F7-13)', () => { fireEvent.click(screen.getByRole('button', { name: '1단계 토큰을 받는다' })); expect(screen.getByText('이 단계는 기대값과 다른 곳이 없습니다')).toBeTruthy(); }); + + it('앱 항목은 판을 읽었을 때만 「기기 정보」 탭이 서고 못 읽은 칸은 「모름」이다', async () => { + const 판 = { model: 'SM-S911N', osVersion: '14', appVersion: null, appBuild: '241' }; + 그리기(쪽([케이스('ZZI-0001', '주소 검색', [장치('android', 11, { device: 판 }, { steps: 화면단계들() })])]), [줄(11, 'ZZI-0001', 'android', 'FAIL')]); + + fireEvent.click(await screen.findByRole('tab', { name: '기기 정보' })); + expect(screen.getByRole('tabpanel').textContent).toBe('기기SM-S911NAndroid14앱 버전빌드 241'); + }); + + it('판을 못 읽은 앱 항목 · 웹 항목에는 「기기 정보」 탭이 없다', async () => { + 그리기( + 쪽([ + 케이스('ZZI-0001', '주소 검색', [장치('android', 11, { device: null }, { steps: 화면단계들() })]), + 케이스('ZZI-0002', '장바구니', [장치('desktop', 12, {}, { steps: 화면단계들() })]), + ]), + [줄(11, 'ZZI-0001', 'android', 'FAIL'), 줄(12, 'ZZI-0002', 'desktop', 'FAIL')], + ); + + await screen.findAllByRole('group', { name: '단계 고르기' }); + expect(screen.queryByRole('tab', { name: '기기 정보' })).toBeNull(); + }); }); diff --git a/apps/admin/src/web/RunEvidence.tsx b/apps/admin/src/web/RunEvidence.tsx index 3989fa418..90e416b32 100644 --- a/apps/admin/src/web/RunEvidence.tsx +++ b/apps/admin/src/web/RunEvidence.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; +import type { DeviceInfo } from '@platform/kit'; + import { api, type FailureDevice, type ItemTrace, type RunItemDetail, type StepResult } from './api.js'; import { 팜영상인가, 팜증적 } from './FarmEvidence.js'; import { use말, use언어 } from './i18n.js'; @@ -10,7 +12,7 @@ import { 기록없음, 네트워크표, 콘솔표 } from './ItemTrace.js'; import { Failed, Loading, PLATFORM_LABEL, seconds, useAsync } from './ui.js'; export type 증거종류 = 'web' | 'app' | 'api'; -type 탭 = 'shot' | 'net' | 'con' | 'log' | 'video' | 'req' | 'res' | 'diff'; +type 탭 = 'shot' | 'net' | 'con' | 'log' | 'video' | 'dev' | 'req' | 'res' | 'diff'; /** 애플리케이션 종류를 따로 받지 않고 항목으로 가른다 — API 케이스만 절차에 요청 · 응답 원문을 남긴다 */ export function 증거종류(item: RunItemDetail): 증거종류 { @@ -102,6 +104,8 @@ function 증거칸({ devices, seq }: { devices: FailureDevice[]; seq: number }) // 팜이 받아 둔 것만 탭이 선다 — 팜 기능 전 항목은 화면 하나다 ...(팜.some((f) => !팜영상인가(f)) ? [{ id: 'log' as const, 이름: 'logcat' }] : []), ...(팜.some(팜영상인가) ? [{ id: 'video' as const, 이름: t('화면 영상') }] : []), + // 폰 연결이 열려 판을 읽은 항목만 — 이 칸 전 기록에는 없다 + ...(devices.some((d) => d.device != null) ? [{ id: 'dev' as const, 이름: t('기기 정보') }] : []), ] : [ { id: 'shot', 이름: t('화면') }, @@ -168,6 +172,8 @@ function 증거칸({ devices, seq }: { devices: FailureDevice[]; seq: number }) ))} ); + } else if (탭 === 'dev') { + 본문 = devices.map((d) => <기기정보 key={d.platform} device={d.device ?? null} 이름={묶임 ? PLATFORM_LABEL[d.platform] : null} />); } else { 본문 = ; } @@ -199,6 +205,26 @@ function 증거칸({ devices, seq }: { devices: FailureDevice[]; seq: number }) ); } +/** 못 읽은 칸은 「모름」 — 지어내지 않는다. 판은 실행 전체라 단계를 바꿔도 같다 */ +function 기기정보({ device, 이름 }: { device: DeviceInfo | null; 이름: string | null }) { + const t = use말(); + const 모름 = t('모름'); + const 앱 = [device?.appVersion ?? null, device?.appBuild == null ? null : t('빌드 {번호}', { 번호: device.appBuild })].filter((x) => x !== null); + return ( + <> + {이름 === null ? null : {이름}} +
+
{t('기기')}
+
{device?.model ?? 모름}
+
Android
+
{device?.osVersion ?? 모름}
+
{t('앱 버전')}
+
{앱.length === 0 ? 모름 : 앱.join(' · ')}
+
+ + ); +} + /** 고른 단계의 줄만 — 준비 · 정리(seq 없음) 줄은 「상세」에서 본다 */ function 단계기록({ trace, seq, 탭, 앞말 }: { trace: ItemTrace | null; seq: number; 탭: 'net' | 'con'; 앞말: string | null }) { const t = use말(); diff --git a/apps/admin/src/web/api.ts b/apps/admin/src/web/api.ts index ed10a1b5a..dda89780d 100644 --- a/apps/admin/src/web/api.ts +++ b/apps/admin/src/web/api.ts @@ -1,7 +1,7 @@ // Admin API 호출 한 곳 (SPEC §7). 응답 모양은 WS-A·WS-B가 실제로 내보내는 것을 그대로 옮겼다 // 목 데이터는 두지 않는다 — 개발 서버도 /api를 진짜 admin으로 프록시한다 (vite.config.ts) -import type { ItemStatus, JsonSchema, Platform, RunningStep, StepResult } from '@platform/kit'; +import type { DeviceInfo, ItemStatus, JsonSchema, Platform, RunningStep, StepResult } from '@platform/kit'; import type { Technique } from '@platform/kit/types'; import type { 고칠것 } from '../authoring/edit.js'; @@ -393,6 +393,8 @@ export interface FailureDevice { farm: 팜파일[]; /** 한 건 실행이 덮은 디바이스 — item 은 그 한 건 항목이다 */ rerun?: 다시실행 | null; + /** 그 회차를 돌린 폰과 앱의 판 — android 만 온다. 선택으로 둔 것은 이 칸을 모르는 기존 가짜 응답을 안 고치려고다 */ + device?: DeviceInfo | null; } /** 실패 카드의 「실패 원인」 판정. SCREEN 이 BUG 를 이긴다 */ diff --git a/apps/admin/src/web/messages/runs.ts b/apps/admin/src/web/messages/runs.ts index 065834670..9554886b1 100644 --- a/apps/admin/src/web/messages/runs.ts +++ b/apps/admin/src/web/messages/runs.ts @@ -196,6 +196,10 @@ export const 실행말: Record = { '{디바이스} {절차} 실패 시점 화면': 'Screenshot of {디바이스} when {절차} failed', '화면 영상': 'Screen recording', '화면 영상 {번호}': 'Screen recording {번호}', + '기기 정보': 'Device info', + '기기': 'Device', + '앱 버전': 'App version', + '모름': 'Unknown', '영상 보기': 'Watch recording', '영상을 열지 못했습니다': 'Could not open the recording', '{파일} 받기 ({크기})': 'Download {파일} ({크기})', diff --git a/apps/admin/src/web/runEvidence.css b/apps/admin/src/web/runEvidence.css index 3136d5f45..c51b21952 100644 --- a/apps/admin/src/web/runEvidence.css +++ b/apps/admin/src/web/runEvidence.css @@ -224,6 +224,19 @@ color: var(--ink-faint); text-align: center; } +.ev-dev { + margin: 0; + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 6px 14px; + font-size: 12.5px; +} +.ev-dev dt { + color: var(--ink-faint); +} +.ev-dev dd { + margin: 0; +} .ev-json { margin: 0; padding: 10px 12px; From bd4316c3ec21e53029e9cb2c990fd9d98d107b9f Mon Sep 17 00:00:00 2001 From: maxihan1115-coder Date: Fri, 30 Oct 2026 22:11:21 +0900 Subject: [PATCH 4/9] =?UTF-8?q?[WS-E]=20=EC=8B=9C=EA=B0=84=EC=B6=95=20?= =?UTF-8?q?=E2=80=94=20=EA=B3=A0=EB=A5=B8=20=EB=8B=A8=EA=B3=84=20=EC=B9=B8?= =?UTF-8?q?=20=EA=B9=9C=EB=B0=95=EC=9E=84=20=C2=B7=20=EC=9B=B9=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=ED=8E=BC=EC=B9=98=EB=A9=B4=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EB=A7=88=EB=8B=A4=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EB=88=88=EA=B8=88(=EC=8B=A4=ED=8C=A8=ED=95=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=9D=80=20=EC=8B=A4=ED=8C=A8=20=EC=83=89)?= =?UTF-8?q?=20(WEB-F7-19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vw4EcW97tTnFJ9UDobtbJd --- apps/admin/src/web/RunEvidence.test.tsx | 48 +++++++++++++++ apps/admin/src/web/RunEvidence.tsx | 79 +++++++++++++++++++++---- apps/admin/src/web/messages/runs.ts | 2 + apps/admin/src/web/runEvidence.css | 64 ++++++++++++++++++++ docs/DESIGN.md | 7 ++- 5 files changed, 184 insertions(+), 16 deletions(-) diff --git a/apps/admin/src/web/RunEvidence.test.tsx b/apps/admin/src/web/RunEvidence.test.tsx index 83fef2d45..d31f904ac 100644 --- a/apps/admin/src/web/RunEvidence.test.tsx +++ b/apps/admin/src/web/RunEvidence.test.tsx @@ -101,4 +101,52 @@ describe('실패 카드의 시간축 증거 (WEB-F7-13)', () => { await screen.findAllByRole('group', { name: '단계 고르기' }); expect(screen.queryByRole('tab', { name: '기기 정보' })).toBeNull(); }); + + it('같은 단계를 다시 눌러도 단계 칸이 다시 깜박이고 포커스는 그 칸에 남는다', async () => { + 그리기(쪽([케이스('ZZI-0001', '장바구니', [장치('desktop', 11, {}, { steps: 화면단계들() })])]), [줄(11, 'ZZI-0001', 'desktop', 'FAIL')]); + + const 칸 = within(await screen.findByRole('group', { name: '단계 고르기' })).getByRole('button', { name: '1단계 장바구니 화면을 연다' }); + 칸.focus(); + fireEvent.click(칸); + expect(칸.className).toBe('ev-seg flash-a'); + fireEvent.click(칸); + expect(칸.className).toBe('ev-seg flash-b'); + expect(document.activeElement).toBe(칸); + }); + + it('카드를 펼치면 항목 상세를 한 번 읽어 단계 칸 아래 요청마다 눈금을 긋고 실패한 요청은 따로 표시한다', async () => { + const 기록들 = 기록(); + 기록들.network.push({ ...기록들.network[1]!, atMs: 1520, status: 500 }); + const 읽기 = vi.spyOn(api, 'item').mockResolvedValue({ ...상세(11, 'desktop', { steps: 화면단계들() }), trace: 기록들 }); + 그리기(쪽([케이스('ZZI-0001', '장바구니', [장치('desktop', 11, {}, { steps: 화면단계들() })])]), [줄(11, 'ZZI-0001', 'desktop', 'FAIL')]); + + const 접이 = (await screen.findByRole('group', { name: '단계 고르기' })).closest('details')!; + 접이.open = true; + fireEvent(접이, new Event('toggle')); + + expect(await screen.findByText('실패한 요청')).toBeTruthy(); + const 칸들 = [...document.querySelectorAll('.ev-ticks span')]; + expect(칸들.map((칸) => 칸.querySelectorAll('i').length)).toEqual([1, 2]); + const 둘째 = [...칸들[1]!.querySelectorAll('i')]; + expect(둘째.map((눈금) => [눈금.className, (눈금 as HTMLElement).style.left])).toEqual([ + ['', '0%'], + ['bad', '50%'], + ]); + + fireEvent.click(screen.getByRole('tab', { name: '네트워크' })); + expect(await screen.findAllByText('/api/cart/items/88')).toHaveLength(2); + expect(읽기).toHaveBeenCalledTimes(1); + }); + + it('API · 앱 케이스는 카드를 펼쳐도 항목 상세를 읽지 않고 눈금도 없다', async () => { + const 읽기 = vi.spyOn(api, 'item'); + 그리기(쪽([케이스('ZZI-0001', '주소 검색', [장치('android', 11, {}, { steps: 화면단계들() })])]), [줄(11, 'ZZI-0001', 'android', 'FAIL')]); + + const 접이 = (await screen.findByRole('group', { name: '단계 고르기' })).closest('details')!; + 접이.open = true; + fireEvent(접이, new Event('toggle')); + + expect(읽기).not.toHaveBeenCalled(); + expect(document.querySelector('.ev-ticks')).toBeNull(); + }); }); diff --git a/apps/admin/src/web/RunEvidence.tsx b/apps/admin/src/web/RunEvidence.tsx index 90e416b32..ed6ee9aa1 100644 --- a/apps/admin/src/web/RunEvidence.tsx +++ b/apps/admin/src/web/RunEvidence.tsx @@ -1,15 +1,15 @@ // 실패 카드 디바이스 칸의 시간축 증거 — 단계 띠 · 단계 목록 · 종류별 증거 칸이 고른 단계 하나를 같이 본다 (WEB-F7-13 · 시안 11판 「실행 결과」) -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { DeviceInfo } from '@platform/kit'; -import { api, type FailureDevice, type ItemTrace, type RunItemDetail, type StepResult } from './api.js'; +import { api, type FailureDevice, type ItemTrace, type RunItemDetail, type RunItemPage, type StepResult } from './api.js'; import { 팜영상인가, 팜증적 } from './FarmEvidence.js'; import { use말, use언어 } from './i18n.js'; import { show, Step } from './ItemSteps.js'; import { 기록없음, 네트워크표, 콘솔표 } from './ItemTrace.js'; -import { Failed, Loading, PLATFORM_LABEL, seconds, useAsync } from './ui.js'; +import { type Async, Failed, Loading, PLATFORM_LABEL, seconds, useAsync } from './ui.js'; export type 증거종류 = 'web' | 'app' | 'api'; type 탭 = 'shot' | 'net' | 'con' | 'log' | 'video' | 'dev' | 'req' | 'res' | 'diff'; @@ -31,6 +31,21 @@ export function 시간축증거({ devices }: { devices: FailureDevice[] }) { const [고른, set고른] = useState(() => 첫고름(대표.steps)); // 고를 때마다 늘려 그 단계 줄을 새로 그린다 — 실패 확인 줄 깜박임이 다시 돈다(DESIGN 원칙 4 「증거 바꾸기」) const [깜박, set깜박] = useState(0); + const 웹 = 증거종류(대표) === 'web'; + const 뿌리 = useRef(null); + // 항목 상세는 네트워크 눈금과 네트워크 · 콘솔 탭이 같이 쓴다. 카드를 펼칠 때 한 번 읽는다 — 접힌 카드까지 미리 읽으면 쪽 하나에 스무 번 부른다 + const [읽음, set읽음] = useState(false); + const 상세 = useAsync(() => (읽음 ? api.item(대표.runId, 대표.historyId) : Promise.resolve(null)), [읽음, 대표.runId, 대표.historyId]); + useEffect(() => { + const 접이 = 뿌리.current?.closest('details'); + if (!웹 || 접이 == null) return; + const 펼침 = (): void => { + if (접이.open) set읽음(true); + }; + 펼침(); + 접이.addEventListener('toggle', 펼침); + return () => 접이.removeEventListener('toggle', 펼침); + }, [웹]); if (대표.steps.length === 0 || 고른 === null) return {t('실행된 절차가 없습니다.')}; @@ -39,9 +54,14 @@ export function 시간축증거({ devices }: { devices: FailureDevice[] }) { set고른(seq); set깜박((n) => n + 1); }; + // 짧은 단계도 누를 자리는 남긴다 — 눈금 줄도 같은 폭을 받아 단계 칸과 줄이 맞는다 + const 폭 = (s: StepResult): number => Math.max(전체ms === 0 ? 1 : (s.durationMs / 전체ms) * 100, 2); + // 단추를 다시 그리면 포커스가 빠진다 — 이름이 다른 같은 깜박임 둘을 번갈아 걸어 다시 돌린다 + const 깜박이 = 깜박 === 0 ? '' : 깜박 % 2 === 1 ? ' flash-a' : ' flash-b'; + const 기록 = 상세.data?.trace ?? null; return ( -
+
{t('시간축 · 단계를 누르면 증거 칸이 그 단계로 바뀝니다')} @@ -52,9 +72,8 @@ export function 시간축증거({ devices }: { devices: FailureDevice[] }) {
+ {웹 ? <네트워크눈금 steps={대표.steps} trace={기록} 폭={폭} /> : null}
@@ -79,13 +99,48 @@ export function 시간축증거({ devices }: { devices: FailureDevice[] }) { ))} - <증거칸 devices={devices} seq={고른} /> + <증거칸 devices={devices} seq={고른} 상세={상세} 읽기={() => set읽음(true)} />
); } -function 증거칸({ devices, seq }: { devices: FailureDevice[]; seq: number }) { +/** + * 단계 칸 아래에 그 단계에서 보낸 요청마다 눈금 하나. 단계 안 자리는 그 단계의 첫 조작 · 요청부터 잰 시각이다 + * ponytail: 단계가 시작한 시각을 따로 남기지 않아 첫 기록을 시작으로 본다 — 단계가 기다림으로 시작하면 눈금이 앞당겨진다. 단계 시작 시각을 남기면 그것으로 바꾼다 + */ +function 네트워크눈금({ steps, trace, 폭 }: { steps: StepResult[]; trace: ItemTrace | null; 폭: (s: StepResult) => number }) { + const t = use말(); + if (trace === null || trace.status !== 'DONE' || !trace.network.some((n) => n.seq !== null)) return null; + const 실패 = (n: ItemTrace['network'][number]): boolean => n.failure !== null || (n.status ?? 0) >= 400; + return ( + <> + +
+ {t('네트워크 요청')} + {t('실패한 요청')} +
+ + ); +} + +function 증거칸({ devices, seq, 상세, 읽기 }: { devices: FailureDevice[]; seq: number; 상세: Async; 읽기: () => void }) { const t = use말(); const 대표 = devices[0]!.item; const 종류 = 증거종류(대표); @@ -113,9 +168,6 @@ function 증거칸({ devices, seq }: { devices: FailureDevice[]; seq: number }) { id: 'con', 이름: t('콘솔') }, ]; const [탭, set탭] = useState<탭>(탭들[0]!.id); - // 네트워크 · 콘솔은 실패 카드 통로에 없다 — 그 탭을 처음 누를 때 항목 상세를 한 번 읽는다. 카드마다 미리 읽으면 쪽 하나에 스무 번 부른다 - const [읽음, set읽음] = useState(false); - const 상세 = useAsync(() => (읽음 ? api.item(대표.runId, 대표.historyId) : Promise.resolve(null)), [읽음, 대표.runId, 대표.historyId]); const 단계 = (d: FailureDevice): StepResult | undefined => d.item.steps.find((s) => s.seq === seq); let 본문; @@ -190,7 +242,8 @@ function 증거칸({ devices, seq }: { devices: FailureDevice[]; seq: number }) className="trace-tab" onClick={() => { set탭(x.id); - if (x.id === 'net' || x.id === 'con') set읽음(true); + // 네트워크 · 콘솔은 실패 카드 통로에 없다 — 펼치지 않은 채 탭을 누르면 여기서 읽는다 + if (x.id === 'net' || x.id === 'con') 읽기(); }} > {x.이름} diff --git a/apps/admin/src/web/messages/runs.ts b/apps/admin/src/web/messages/runs.ts index 9554886b1..4ba437bb5 100644 --- a/apps/admin/src/web/messages/runs.ts +++ b/apps/admin/src/web/messages/runs.ts @@ -200,6 +200,8 @@ export const 실행말: Record = { '기기': 'Device', '앱 버전': 'App version', '모름': 'Unknown', + '네트워크 요청': 'Network request', + '실패한 요청': 'Failed request', '영상 보기': 'Watch recording', '영상을 열지 못했습니다': 'Could not open the recording', '{파일} 받기 ({크기})': 'Download {파일} ({크기})', diff --git a/apps/admin/src/web/runEvidence.css b/apps/admin/src/web/runEvidence.css index c51b21952..6480abf75 100644 --- a/apps/admin/src/web/runEvidence.css +++ b/apps/admin/src/web/runEvidence.css @@ -143,6 +143,70 @@ outline: 2px solid var(--accent-mark); outline-offset: 1px; } +/* 증거 바꾸기 — 고른 칸도 실패 확인 줄과 같이 한 번 진하게 깜박인다. 이름만 다른 둘을 번갈아 걸어 같은 칸을 다시 눌러도 돈다 */ +.ev-seg.flash-a { + animation: 띠깜박A var(--t3) ease-out; +} +.ev-seg.flash-b { + animation: 띠깜박B var(--t3) ease-out; +} +@keyframes 띠깜박A { + from { + filter: saturate(2.2) brightness(0.85); + } +} +@keyframes 띠깜박B { + from { + filter: saturate(2.2) brightness(0.85); + } +} +/* 네트워크 눈금 — 단계 칸과 같은 폭의 칸 안에 요청마다 하나. 실패한 요청은 실패 색으로 조금 길게 */ +.ev-ticks { + display: flex; + gap: 2px; + height: 14px; + margin-top: 4px; +} +.ev-ticks span { + position: relative; + min-width: 18px; +} +.ev-ticks i { + position: absolute; + top: 2px; + width: 2px; + height: 10px; + border-radius: 1px; + background: var(--ink-faint); +} +.ev-ticks i.bad { + top: 1px; + width: 3px; + height: 12px; + background: var(--fail); +} +.ev-tl-leg { + display: flex; + gap: 14px; + margin-top: 6px; + font-size: 11px; + color: var(--ink-faint); +} +.ev-tl-leg span { + display: inline-flex; + align-items: center; + gap: 5px; +} +.ev-tl-leg span::before { + content: ''; + width: 3px; + height: 10px; + border-radius: 1px; + background: var(--ink-faint); +} +.ev-tl-leg span.bad::before { + background: var(--fail); +} .ev-seg:focus-visible { outline: 2px solid var(--ink); outline-offset: 1px; diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 33bb6ee21..7c6861bbf 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -153,7 +153,7 @@ TC ID, 소요시간, 응답 코드가 세로로 정렬돼야 훑을 수 있다. | 휴대폰 깊이 이동 | 목록에서 상세로 들어가면 오른쪽에서 밀려 들어오고, 메뉴판 · 아래 판은 아래에서 올라온다 | `--t2` · `--t3` | - **창은 열 때만 떠오른다** — 닫을 때는 바로 사라진다(아래 「모달」). 토스트만 사라질 때도 `--t2` 로 흐려진다 -- 깜박임(증거 바꾸기)은 실패 확인 줄 하나에 한 번이다 — 되풀이가 아니라서 아래 「되풀이 움직임은 둘뿐」 밖이 아니다 +- 깜박임(증거 바꾸기)은 고른 시간축 칸과 실패 확인 줄에 한 번씩이다 — 되풀이가 아니라서 아래 「되풀이 움직임은 둘뿐」 밖이 아니다 **누르면 바로 보이는 반응 (2026-10-20 ⑬-7 · 사용자 시안 C)** — 눌렀는데 화면이 늦게 바뀌는 자리마다 하나씩 둔다. 코드는 `styles.css` 「⑬-7 움직임」 블록과 `motion.ts` 다. @@ -554,9 +554,10 @@ TC ID, 소요시간, 응답 코드가 세로로 정렬돼야 훑을 수 있다. 고른 줄은 고른 표시(`--accent-soft`), 다시 누르면 푼다 — 「전체」 칸은 없다. 휴대폰 폭에서는 번호 칸을 숨기고 이유가 유형 아래로 내려간다 - **시간축 증거** (같은 날) — 펼친 디바이스 칸의 사전조건 · 입력 아래. 위에 우묵한 띠(단계마다 걸린 시간만큼 폭 · 통과는 옅은 통과 칠 · 실패는 실패 바탕 · 고른 단계는 대표색 테두리 · 단추라 키보드로 고른다), 아래 두 단 — 왼쪽 단계 목록(확인 줄 그대로, 고른 단계는 고른 표시) · 오른쪽 340px 증거 칸(개발자 도구 탭 꼴 · 항목 상세 「네트워크 · 조작 · 콘솔」과 같은 탭). 칸 폭이 640px 보다 좁으면 증거 칸이 아래로 간다. - 처음엔 처음 깨진 단계를 고른다. 단계를 고르면 실패 확인 줄이 한 번 깜박이고 증거 칸이 겹쳐 바뀐다(원칙 4 「증거 바꾸기」). 같은 실패로 묶인 디바이스는 단계를 한 번 그리고 화면 · logcat 을 증거 칸에서 디바이스 이름을 달아 나란히 둔다 + 처음엔 처음 깨진 단계를 고른다. 단계를 고르면 고른 칸과 실패 확인 줄이 한 번 깜박이고 증거 칸이 겹쳐 바뀐다(원칙 4 「증거 바꾸기」 — 같은 칸을 다시 눌러도 깜박이고 포커스는 그 칸에 남는다). + 웹 항목은 띠 아래에 네트워크 눈금 줄(2026-10-30 WEB-F7-19 · 시안 11판) — 단계 칸과 같은 폭의 칸마다 그 단계에서 보낸 요청 하나에 잉크 옅은 눈금 하나, 실패한 요청(4xx · 5xx · 끊김)은 실패 색으로 조금 길게. 단계 안 자리는 그 단계의 첫 조작 · 요청부터 잰 시각이다. 아래에 범례 「네트워크 요청 · 실패한 요청」. 기록이 없거나 앱 · API 항목이면 줄이 없다. 같은 실패로 묶인 디바이스는 단계를 한 번 그리고 화면 · logcat 을 증거 칸에서 디바이스 이름을 달아 나란히 둔다 - **증거 칸은 종류마다 탭만 다르다** (같은 날) — 웹 `화면 · 네트워크 · 콘솔` · 앱 `화면 · logcat · 화면 영상 · 기기 정보`(팜이 받아 둔 것만 탭이 선다 · 기기 정보는 폰 · 앱 판을 읽은 항목만, 못 읽은 칸은 「모름」 — 2026-10-30 WEB-F7-19) · API `요청 · 응답 · 기대값 차이`(절차에 요청 · 응답 원문이 있으면 API 로 본다). - 화면 · 요청 · 응답 · 기대값 차이 · 네트워크 · 콘솔은 고른 단계 것만, logcat · 영상은 실행 전체라 「단계를 바꿔도 같습니다」 한 줄을 단다. 네트워크 · 콘솔은 실패 카드 통로에 없어 그 탭을 처음 누를 때 항목 상세를 한 번 읽는다. + 화면 · 요청 · 응답 · 기대값 차이 · 네트워크 · 콘솔은 고른 단계 것만, logcat · 영상은 실행 전체라 「단계를 바꿔도 같습니다」 한 줄을 단다. 네트워크 · 콘솔은 실패 카드 통로에 없어 웹 카드를 펼칠 때(펼치지 않고 탭을 누르면 그때) 항목 상세를 한 번 읽어 눈금 줄과 두 탭이 같이 쓴다. 통과 단계에서 찍은 화면은 「실패 시점」이라 부르지 않는다. 게임 오브젝트 탭 · FPS 줄(시안)은 기능 테스트 항목에 그 값이 없어 안 그린다 — FPS 는 기기 성능에만, 게임 요소 대조값은 항목이 아니라 게임 지도에 쌓인다 - **통과 · 미실행 줄** — 지금의 결과 줄 모양 그대로에 `펼치기` 버튼. 펼친 자리는 줄 아래 들여 쓴 같은 절차 그림 - **실행 기록의 결과 칸에서는 머리(제목 · 머리 한 줄 · 단추 · 증적 줄)만 고정**하고 요약 띠부터 전부 스크롤한다 From ae1fd4fb97d430fdc8b7e948ea1a14ba32359ee3 Mon Sep 17 00:00:00 2001 From: maxihan1115-coder Date: Fri, 30 Oct 2026 22:14:08 +0900 Subject: [PATCH 5/9] =?UTF-8?q?[WS-E]=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=E2=80=94=20=EC=83=88=EB=A1=9C=20=EB=81=9D=EB=82=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=C2=B7=20=EC=9E=91=EC=84=B1=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EB=A5=BC=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20=EC=95=84=EB=9E=98(?= =?UTF-8?q?=ED=9C=B4=EB=8C=80=ED=8F=B0=EC=9D=80=20=ED=83=AD=20=EC=9C=84)?= =?UTF-8?q?=EC=97=90=20=EB=9D=84=EC=9B=8C=203=EC=B4=88=20=EB=92=A4=20?= =?UTF-8?q?=ED=9D=90=EB=A0=A4=EC=A0=B8=20=EC=82=AC=EB=9D=BC=EC=A7=84?= =?UTF-8?q?=EB=8B=A4=20(WEB-F7-19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vw4EcW97tTnFJ9UDobtbJd --- apps/admin/src/web/Notice.test.tsx | 49 +++++++++++++++++++++++++++ apps/admin/src/web/Notice.tsx | 48 ++++++++++++++++++++++++-- apps/admin/src/web/noticeBar.css | 49 +++++++++++++++++++++++++++ apps/admin/src/web/noticeView.test.ts | 19 ++++++++++- apps/admin/src/web/noticeView.ts | 17 ++++++++++ docs/DESIGN.md | 1 + 6 files changed, 180 insertions(+), 3 deletions(-) diff --git a/apps/admin/src/web/Notice.test.tsx b/apps/admin/src/web/Notice.test.tsx index 9a590a4eb..cb5ba7024 100644 --- a/apps/admin/src/web/Notice.test.tsx +++ b/apps/admin/src/web/Notice.test.tsx @@ -251,6 +251,55 @@ describe('읽음 목록 정리', () => { }); }); +describe('토스트', () => { + async function 다시묻는다(답: NoticeResponse, 부름: ReturnType): Promise { + 부름.mockResolvedValue(답); + await act(async () => { + window.dispatchEvent(new Event(알림바뀜)); + }); + } + + it('화면을 연 뒤 새로 끝난 실행 하나를 아래에 띄우고 3초 뒤 흐려져 사라진다. 열 때 이미 있던 것은 안 띄운다', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const 부름 = 띄운다({ running: [], recent: [끝난실행(1)] }); + await act(async () => {}); + expect(screen.queryByRole('status')).toBeNull(); + + await 다시묻는다({ running: [], recent: [끝난실행(2), 끝난실행(1)] }, 부름); + const 토스트 = screen.getByRole('status'); + expect(토스트.className).toBe('toast'); + expect(within(토스트).getByText(/^RUN 2 실행이 끝났습니다/)).toBeTruthy(); + expect(within(토스트).getByRole('link', { name: '결과 보기' }).getAttribute('href')).toBe('#/runs/2'); + + await act(async () => { + vi.advanceTimersByTime(3000); + }); + expect(screen.getByRole('status').className).toBe('toast out'); + await act(async () => { + vi.advanceTimersByTime(250); + }); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('고리를 누르면 읽음이 되고 토스트가 닫힌다', async () => { + const 부름 = 띄운다({ running: [], recent: [] }); + await act(async () => {}); + await 다시묻는다({ running: [], recent: [끝난실행(7)] }, 부름); + + fireEvent.click(within(screen.getByRole('status')).getByRole('link', { name: '결과 보기' })); + expect(screen.queryByRole('status')).toBeNull(); + expect(읽음들().has('run:7')).toBe(true); + }); + + it('지금 결과를 보고 있는 실행이 끝나면 토스트를 안 띄운다', async () => { + 보는실행알림(9); + const 부름 = 띄운다({ running: [도는실행(9)], recent: [] }); + await act(async () => {}); + await 다시묻는다({ running: [], recent: [끝난실행(9)] }, 부름); + expect(screen.queryByRole('status')).toBeNull(); + }); +}); + describe('받아 오기', () => { it('서버가 403 을 주면(실행 칸을 볼 수 있는 애플리케이션이 없다) 버튼도 줄도 안 그리고 다시 묻지도 않는다', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); diff --git a/apps/admin/src/web/Notice.tsx b/apps/admin/src/web/Notice.tsx index 2381fff23..44e2440af 100644 --- a/apps/admin/src/web/Notice.tsx +++ b/apps/admin/src/web/Notice.tsx @@ -3,12 +3,13 @@ import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { api, ApiError, 알림바뀜, type NoticeResponse } from './api.js'; +import { api, ApiError, 알림바뀜, type NoticeRecent, type NoticeResponse } from './api.js'; import { use말, use언어 } from './i18n.js'; import { 아이콘 } from './icons.js'; import { NoticeDrawer } from './NoticeDrawer.js'; import { 읽음들, 읽음바뀜, 읽음으로적는다, 읽음정리 } from './noticeRead.js'; -import { 끝난글, 보일줄들, 안읽은수, 읽음열쇠, type 끝난줄, type 도는줄, type 줄 as 줄타입 } from './noticeView.js'; +import { 끝난글, 끝난줄만들기, 보일줄들, 새소식, 안읽은수, 읽음열쇠, type 끝난줄, type 도는줄, type 줄 as 줄타입 } from './noticeView.js'; +import { use지켜봄 } from './runWatchStore.js'; import { use보는실행 } from './viewedRun.js'; /** @@ -136,6 +137,37 @@ function 끝난줄그림({ 줄, on읽음, 바꿈 }: { 줄: 끝난줄; on읽음: ); } +/** + * 새로 끝난 실행 · 작성 하나를 아래에서 띄워 3초 뒤 흐려지며 치운다 (DESIGN.md 「창이 아닌 떠 있는 것」 「토스트」 · 원칙 4 「알림 도착」). + * 포커스를 가져가지 않는다 — 읽는 사람에게는 `role="status"` 로 한 번 읽힌다 + */ +function 토스트({ 줄, on닫음, 바꿈 }: { 줄: 끝난줄; on닫음: (값: null) => void; 바꿈: 서비스바꿈 }) { + const [나감, set나감] = useState(false); + useEffect(() => { + const 흐림 = setTimeout(() => set나감(true), 3000); + const 치움 = setTimeout(() => on닫음(null), 3200); + return () => { + clearTimeout(흐림); + clearTimeout(치움); + }; + }, [on닫음]); + return ( + + ); +} + function 한줄({ 줄, 바꿈 }: { 줄: 줄타입; 바꿈: 서비스바꿈 }) { return 줄.도는중 ? <도는줄그림 줄={줄} 바꿈={바꿈} /> : <끝난줄그림 줄={줄} on읽음={읽음으로적는다} 바꿈={바꿈} />; } @@ -153,6 +185,17 @@ export function Notice({ 줄숨김, 서비스바꿈, 버튼자리 }: { 줄숨김 // 결과 화면이 지금 그리는 실행 — 그 실행의 도는 줄을 줄에서 뺀다 (점검 2.23) const 보는실행 = use보는실행(); const 버튼 = useRef(null); + const 지켜봄 = use지켜봄(); + const 앞소식 = useRef(null); + const [토스트줄, set토스트줄] = useState<끝난줄 | null>(null); + + // 답이 올 때마다 앞 답과 견줘 새로 끝난 것 하나를 토스트로 — 완료 창(진행 알약)과 결과 화면이 알리는 실행은 겹쳐 띄우지 않는다 + useEffect(() => { + if (답 === null) return; + const 새 = 새소식(앞소식.current, 답.recent, 읽음들(), [보는실행, 지켜봄?.runId ?? null]); + 앞소식.current = 답.recent; + if (새 !== null) set토스트줄(끝난줄만들기(새, 언어)); + }, [답, 보는실행, 지켜봄, 언어]); // 같은 탭의 실행 결과 화면이 읽음으로 적어도(`runState.ts`), 다른 탭에서 적어도 바로 따라온다 useEffect(() => { @@ -216,6 +259,7 @@ export function Notice({ 줄숨김, 서비스바꿈, 버튼자리 }: { 줄숨김 {/* 자리가 아직 안 붙었으면(첫 그림) 다음 그림에 붙는다 — 그 한 번은 버튼이 없다 */} {버튼자리 === undefined ? 알림버튼 : 버튼자리 === null ? null : createPortal(알림버튼, 버튼자리)}
+ {토스트줄 === null ? null : <토스트 key={토스트줄.열쇠} 줄={토스트줄} on닫음={set토스트줄} 바꿈={서비스바꿈} />} {!열림 || 답 === null ? null : } ); diff --git a/apps/admin/src/web/noticeBar.css b/apps/admin/src/web/noticeBar.css index 750ba4acc..5495fd2d8 100644 --- a/apps/admin/src/web/noticeBar.css +++ b/apps/admin/src/web/noticeBar.css @@ -188,3 +188,52 @@ width: 100%; } } + +/* 토스트 — 본문 오른쪽 아래(진행 알약이 있으면 그 위), 휴대폰은 아래 탭 위. 뒤집은 색이라 고리는 --toast-link (DESIGN.md 「토스트」) */ +.toast { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 31; + display: flex; + align-items: center; + gap: 12px; + max-width: min(480px, calc(100vw - 40px)); + padding: 10px 14px; + border-radius: 8px; + background: var(--ink); + color: var(--bg); + box-shadow: var(--shadow); + font-size: 12.5px; + animation: 떠오름 var(--t2) var(--ease) backwards; + transition: opacity var(--t2), transform var(--t2); +} +.toast.out { + opacity: 0; + transform: translateY(6px); +} +.toast-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.toast a { + flex: none; + color: var(--toast-link); + font-weight: 600; +} +body:has(.run-pill) .toast { + bottom: 68px; +} +@media (max-width: 620px) { + .toast { + right: 12px; + left: 12px; + bottom: calc(var(--ph-tabs) + 12px); + max-width: none; + } + body:has(.run-pill) .toast { + bottom: calc(var(--ph-tabs) + 60px); + } +} diff --git a/apps/admin/src/web/noticeView.test.ts b/apps/admin/src/web/noticeView.test.ts index 7e58d4fa3..b9e6db6dd 100644 --- a/apps/admin/src/web/noticeView.test.ts +++ b/apps/admin/src/web/noticeView.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { 끝난글, 끝난줄만들기, 도는줄만들기, 보일줄들, 상대시각, 서랍목록, 안읽은수, 읽음열쇠, 줄최대, type 끝난줄, type 도는줄 } from './noticeView.js'; +import { 끝난글, 끝난줄만들기, 도는줄만들기, 보일줄들, 상대시각, 새소식, 서랍목록, 안읽은수, 읽음열쇠, 줄최대, type 끝난줄, type 도는줄 } from './noticeView.js'; import { 끝난실행, 끝난작성, 도는E2E, 도는실행, 도는작성, 전에 } from './notice.fixture.js'; const 지금 = Date.now(); @@ -179,6 +179,23 @@ describe('서랍', () => { }); }); +describe('토스트로 알릴 새 소식', () => { + it('화면을 막 열었으면(앞 답 없음) 없다', () => { + expect(새소식(null, [끝난실행(1)], new Set(), [])).toBeNull(); + }); + + it('앞 답에 없던 안 읽은 것 가운데 가장 늦게 끝난 것 하나다', () => { + const 새것들 = [끝난실행(2, {}, { finishedAt: 전에(5) }), 끝난작성(3, { finishedAt: 전에(1) })]; + expect(새소식([끝난실행(1)], [끝난실행(1), ...새것들], new Set(), [])).toEqual(새것들[1]); + }); + + it('이미 읽었거나 완료 창 · 결과 화면이 알리는 실행은 뺀다', () => { + const 앞 = [끝난실행(1)]; + expect(새소식(앞, [...앞, 끝난실행(2)], new Set(['run:2']), [])).toBeNull(); + expect(새소식(앞, [...앞, 끝난실행(2)], new Set(), [null, 2])).toBeNull(); + }); +}); + describe('상대 시각', () => { const 기준 = new Date(2026, 9, 11, 15, 0, 0).getTime(); const 이전 = (분: number) => new Date(기준 - 분 * 60_000).toISOString(); diff --git a/apps/admin/src/web/noticeView.ts b/apps/admin/src/web/noticeView.ts index f754bd6c3..ca51662cb 100644 --- a/apps/admin/src/web/noticeView.ts +++ b/apps/admin/src/web/noticeView.ts @@ -220,6 +220,23 @@ export function 안읽은수(recent: readonly NoticeRecent[], 읽음: ReadonlySe return recent.filter((n) => !읽음.has(읽음열쇠(n))).length; } +/** + * 토스트로 알릴 새 소식 하나 — 앞 답에 없던 안 읽은 것 가운데 가장 늦게 끝난 것. + * 앞 답이 없으면(화면을 막 열었다) 없다 — 열기 전에 끝난 것은 소식이 아니라 줄 · 배지가 알린다. + * `뺄실행` 은 완료 창(진행 알약)이나 결과 화면이 이미 알리는 실행이다 + */ +export function 새소식( + 앞: readonly NoticeRecent[] | null, + 지금: readonly NoticeRecent[], + 읽음: ReadonlySet, + 뺄실행: readonly (number | null)[], +): NoticeRecent | null { + if (앞 === null) return null; + const 있던 = new Set(앞.map(읽음열쇠)); + const 새것 = 지금.filter((n) => !있던.has(읽음열쇠(n)) && !읽음.has(읽음열쇠(n)) && !(n.kind === 'run' && 뺄실행.includes(n.runId))); + return 서랍목록(새것, 읽음, 'all')[0] ?? null; +} + export type 거르개 = 'all' | 'run' | 'authoring' | 'unread'; /** 서랍 목록 — 새것 먼저. 거르개는 종류나 안 읽음 하나 */ diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7c6861bbf..66f52f2cd 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -874,6 +874,7 @@ TC ID, 소요시간, 응답 코드가 세로로 정렬돼야 훑을 수 있다. 하나만 고르는 조건은 칩 고르기(체크 네모 없음), 여럿 고르는 조건만 체크 네모다. 고른 조건은 표 위 칩(`--accent-soft` 바탕 · ✕ 로 풀기)으로 남고, 단추에 고른 조건 수 배지(대표색)가 붙는다. `Esc` · 판의 `닫기` 로 닫으면 포커스가 `필터` 단추로 돌아온다. 바깥을 눌러 닫으면 포커스는 누른 자리에 남고(결과 탭 · 칩의 ✕ 를 누른 사람에게서 포커스를 뺏지 않는다), 탭으로 판 밖에 나가도 닫힌다(2026-10-30 WEB-F7-07). 휴대폰에서는 아래 판이고 맨 아래에 `건 보기` 주 단추가 있다(누르면 닫힌다) - **토스트** — 끝난 일을 알린다. PC 는 본문 오른쪽 아래(진행 알약이 있으면 그 위), 휴대폰은 아래 탭 위다. `--ink` 바탕 · `--bg` 글자, 아래에서 떠오르고 3초 뒤 흐려져 사라진다. 고리 하나(`결과 보기` 등)는 `--toast-link` 글자다(`--accent` 는 어둡게 토스트 위 2.23 이라 못 쓴다) + 지금 띄우는 것은 알림 하나다(2026-10-30 WEB-F7-19) — 알림을 받아 올 때마다 앞 답과 견줘 새로 끝난 실행 · 작성 가운데 가장 늦게 끝난 것 하나를 알림 줄과 같은 글 · 고리로 띄운다. 화면을 막 열었을 때 이미 있던 것 · 읽은 것 · 완료 창(진행 알약)이나 지금 보는 결과 화면이 알리는 실행은 안 띄운다. 고리를 누르면 읽음이 되고 닫힌다. 포커스를 가져가지 않는다(`role="status"`) #### 휴대폰의 창 (2026-10-30 · 시안 11판 — 창 620px 미만) From 769e44ad7963e3a54eb3e588641d16b6eb059a84 Mon Sep 17 00:00:00 2001 From: maxihan1115-coder Date: Fri, 30 Oct 2026 22:15:02 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[WEB]=20=EC=8B=A4=ED=96=89=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A4=84=20=EA=B2=B0=EA=B3=BC=20=EB=9D=A0=20?= =?UTF-8?q?=EC=9E=90=EB=9D=BC=EA=B8=B0=20=C2=B7=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=20=EC=84=A0=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20(WEB-F7-19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vw4EcW97tTnFJ9UDobtbJd --- apps/admin/src/web/PerfChart.tsx | 8 +++++--- apps/admin/src/web/perf.css | 14 ++++++++++++++ apps/admin/src/web/runList.css | 3 +++ docs/DESIGN.md | 2 +- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/admin/src/web/PerfChart.tsx b/apps/admin/src/web/PerfChart.tsx index 2d415b86f..a6ec2dadf 100644 --- a/apps/admin/src/web/PerfChart.tsx +++ b/apps/admin/src/web/PerfChart.tsx @@ -66,9 +66,11 @@ export function PerfChart({
{목표y === null ? null : } - {선들.map((s) => ( - - ))} + + {선들.map((s) => ( + + ))} + {세로선 === undefined ? null : ( )} diff --git a/apps/admin/src/web/perf.css b/apps/admin/src/web/perf.css index 18ed16793..26d1a1b0f 100644 --- a/apps/admin/src/web/perf.css +++ b/apps/admin/src/web/perf.css @@ -245,6 +245,20 @@ border-radius: 4px; } +/* 선이 왼쪽부터 그려진다 — 점선 길이로 그리지 않고 왼쪽부터 오려 보인다. 선이 non-scaling-stroke 라 점선 길이가 화면 폭과 어긋난다. + 가장자리 4px 는 굵은 선 끝이 잘리지 않게. 실행 중 값이 바뀌어도 같은 요소라 다시 돌지 않는다 (DESIGN 원칙 4 「숫자 · 막대 · 선」) */ +.perf-lines { + animation: 선그림 var(--t3) var(--ease) 0.1s backwards; +} +@keyframes 선그림 { + from { + clip-path: inset(-4px 100% -4px -4px); + } + to { + clip-path: inset(-4px -4px -4px -4px); + } +} + .perf-graph polyline { fill: none; stroke: var(--chart-1); diff --git a/apps/admin/src/web/runList.css b/apps/admin/src/web/runList.css index 5b5840146..326db6227 100644 --- a/apps/admin/src/web/runList.css +++ b/apps/admin/src/web/runList.css @@ -75,6 +75,9 @@ .run-verdict .stripe { margin: 0; border-radius: 3px; + /* 화면을 열면 결과 띠가 왼쪽부터 자란다. 줄은 다시 읽어도 같은 요소라 한 번만 돈다 (DESIGN 원칙 4 「숫자 · 막대 · 선」) */ + transform-origin: left; + animation: 자람가로 var(--t3) var(--ease) 0.1s backwards; } .run-verdict .tally { diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 66f52f2cd..554b71095 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -140,7 +140,7 @@ TC ID, 소요시간, 응답 코드가 세로로 정렬돼야 훑을 수 있다. | 움직임 | 언제 · 어떻게 | 시간 | |---|---|---| | 화면 열기 | 메뉴를 누르면 판이 차례로 떠오른다(위 「처음 열 때만」) | `--t2` · 합 0.5초 | -| 숫자 · 막대 · 선 | 숫자가 0에서 올라가고 막대가 왼쪽부터 자라고 선 그래프가 그려진다. 대시보드 · 실행 결과 · 성능 숫자 칸 | 0.4초 · `--t3` | +| 숫자 · 막대 · 선 | 숫자가 0에서 올라가고 막대가 왼쪽부터 자라고 선 그래프가 그려진다. 대시보드 · 실행 결과 · 실행 기록 줄의 결과 띠 · 성능 숫자 칸 · 성능 그래프(2026-10-30 WEB-F7-19) | 0.4초 · `--t3` | | 불러오는 중 | 줄 모양 회색 뼈대 위로 빛이 훑는다. 다 오면 뼈대째 사라진다 | 1.2초 되풀이 | | 상세 서랍 | PC 는 오른쪽에서, 휴대폰은 아래에서 미끄러져 들어온다 | `--t2` · `--t3` | | 애플리케이션 바꾸기 | 왼쪽 줄의 대표색 막대가 고른 칸으로 미끄러져 옮겨 간다. 메뉴 묶음을 펼치면 하위가 떠오른다 | `--t2` | From 7a32a8b6f28ccf878ecaeded0bee54beb8d5d0ca Mon Sep 17 00:00:00 2001 From: maxihan1115-coder Date: Fri, 30 Oct 2026 22:24:13 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[WS-E]=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20=EC=A6=9D=EA=B1=B0=20=EB=B6=80=ED=92=88?= =?UTF-8?q?=EC=9D=84=20RunEvidenceParts=20=EB=A1=9C=20=C2=B7=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=9A=94=EC=B2=AD=20=ED=8C=90=EB=8B=A8=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=A1=9C=20=C2=B7=20=EA=B8=B0=EA=B8=B0=20=EC=B9=B8=20?= =?UTF-8?q?=ED=95=9C=20=EC=A7=88=EC=9D=98=20=C2=B7=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=8A=94=20=EB=8A=98=20=EC=9E=88=EB=8A=94=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=9E=90=EB=A6=AC=EC=99=80=20=EC=98=AC=EB=A0=A4=20?= =?UTF-8?q?=EB=91=90=EB=A9=B4=20=EB=A9=88=EC=B6=A4=20=C2=B7=20=EB=88=88?= =?UTF-8?q?=EA=B8=88=20=EC=B9=B8=20=EA=B2=BD=EA=B3=84=20=EB=A7=9E=EC=B6=A4?= =?UTF-8?q?=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EC=8B=A4=ED=96=89=20=ED=8C=90?= =?UTF-8?q?=20=EC=9C=84=EB=A1=9C=20(WEB-F7-19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vw4EcW97tTnFJ9UDobtbJd --- apps/admin/src/reporting/failures.ts | 15 +++--- apps/admin/src/web/ItemTrace.tsx | 3 +- apps/admin/src/web/Notice.test.tsx | 42 +++++++++++++---- apps/admin/src/web/Notice.tsx | 19 ++++++-- apps/admin/src/web/RunEvidence.tsx | 58 +---------------------- apps/admin/src/web/RunEvidenceParts.tsx | 61 +++++++++++++++++++++++++ apps/admin/src/web/noticeBar.css | 7 +++ apps/admin/src/web/runEvidence.css | 3 ++ 8 files changed, 131 insertions(+), 77 deletions(-) create mode 100644 apps/admin/src/web/RunEvidenceParts.tsx diff --git a/apps/admin/src/reporting/failures.ts b/apps/admin/src/reporting/failures.ts index 994e49284..f95e35b4d 100644 --- a/apps/admin/src/reporting/failures.ts +++ b/apps/admin/src/reporting/failures.ts @@ -64,11 +64,6 @@ const 실패행들SQL = ` SELECT i.history_id, i.tc_id, i.tc_name, i.platform, i.attempt, i.status, ${다시실행칸들} FROM ${지금항목('o.run_id = $1')} i`; -async function 기기(historyId: number): Promise { - const { rows } = await (await db()).query<{ device: DeviceInfo | null }>('SELECT device FROM run_item WHERE history_id = $1', [historyId]); - return rows[0]?.device ?? null; -} - export async function 실패카드(runId: number, page: number, platform?: Platform): Promise { const pool = await db(); const 비교 = await compareWithPrevious(runId); @@ -116,6 +111,14 @@ export async function 실패카드(runId: number, page: number, platform?: Platf const tcIds = 쪽.items.map((c) => c.tcId); const [요구표, 판정표] = await Promise.all([카드요구들(runId, tcIds), 판정들(runId, tcIds)]); + // 폰 · 앱 판은 쪽의 회차를 한 번에 읽는다 — history_id 는 실행을 넘어 하나라 한 건 실행이 덮은 회차도 그대로 찾는다 + const 회차들 = 쪽.items.flatMap((c) => c.devices.map((d) => d.firstFailedHistoryId)); + const 기기들 = new Map( + (await pool.query<{ history_id: string; device: DeviceInfo | null }>('SELECT history_id, device FROM run_item WHERE history_id = ANY($1)', [회차들])).rows.map( + (r) => [Number(r.history_id), r.device], + ), + ); + // ponytail: 쪽 크기만큼 묻는다 — 쪽이 커지면 한 질의로 모은다 const items = await Promise.all( 쪽.items.map(async (c): Promise => ({ @@ -137,7 +140,7 @@ export async function 실패카드(runId: number, page: number, platform?: Platf item, farm: await 팜증적목록(실행번호, firstFailedHistoryId), rerun, - device: await 기기(firstFailedHistoryId), + device: 기기들.get(firstFailedHistoryId) ?? null, }; }), ), diff --git a/apps/admin/src/web/ItemTrace.tsx b/apps/admin/src/web/ItemTrace.tsx index 2441ed515..a040fa0c6 100644 --- a/apps/admin/src/web/ItemTrace.tsx +++ b/apps/admin/src/web/ItemTrace.tsx @@ -11,7 +11,8 @@ import { use말 } from './i18n.js'; type 탭 = 'network' | 'actions' | 'console'; type 네트워크 = ItemTrace['network'][number]; -const 실패요청 = (n: 네트워크) => n.failure !== null || (n.status !== null && n.status >= 400); +/** 끊겼거나 4xx · 5xx 인 요청 — 네트워크 표 · 실패만 거르개 · 시간축 눈금이 같이 쓴다 */ +export const 실패요청 = (n: 네트워크) => n.failure !== null || (n.status !== null && n.status >= 400); const 실패조작 = (a: ItemTrace['actions'][number]) => a.error !== null; // 잡히지 않은 오류는 pageerror 로 온다 (traceParse.ts) const 실패콘솔 = (c: ItemTrace['console'][number]) => c.level === 'error' || c.level === 'pageerror'; diff --git a/apps/admin/src/web/Notice.test.tsx b/apps/admin/src/web/Notice.test.tsx index cb5ba7024..1ca0ae30f 100644 --- a/apps/admin/src/web/Notice.test.tsx +++ b/apps/admin/src/web/Notice.test.tsx @@ -252,6 +252,7 @@ describe('읽음 목록 정리', () => { }); describe('토스트', () => { + const 토스트 = () => document.querySelector('.toast'); async function 다시묻는다(답: NoticeResponse, 부름: ReturnType): Promise { 부름.mockResolvedValue(답); await act(async () => { @@ -263,22 +264,43 @@ describe('토스트', () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const 부름 = 띄운다({ running: [], recent: [끝난실행(1)] }); await act(async () => {}); - expect(screen.queryByRole('status')).toBeNull(); + expect(토스트()).toBeNull(); + expect(screen.getByRole('status').textContent).toBe(''); await 다시묻는다({ running: [], recent: [끝난실행(2), 끝난실행(1)] }, 부름); - const 토스트 = screen.getByRole('status'); - expect(토스트.className).toBe('toast'); - expect(within(토스트).getByText(/^RUN 2 실행이 끝났습니다/)).toBeTruthy(); - expect(within(토스트).getByRole('link', { name: '결과 보기' }).getAttribute('href')).toBe('#/runs/2'); + expect(screen.getByRole('status').textContent).toMatch(/^RUN 2 실행이 끝났습니다/); + const 뜬것 = 토스트()!; + expect(뜬것.className).toBe('toast'); + expect(within(뜬것).getByText(/^RUN 2 실행이 끝났습니다/)).toBeTruthy(); + expect(within(뜬것).getByRole('link', { name: '결과 보기' }).getAttribute('href')).toBe('#/runs/2'); await act(async () => { vi.advanceTimersByTime(3000); }); - expect(screen.getByRole('status').className).toBe('toast out'); + expect(토스트()!.className).toBe('toast out'); await act(async () => { vi.advanceTimersByTime(250); }); - expect(screen.queryByRole('status')).toBeNull(); + expect(토스트()).toBeNull(); + }); + + it('마우스를 올린 동안은 사라지지 않고 떼면 다시 3초를 센다', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const 부름 = 띄운다({ running: [], recent: [] }); + await act(async () => {}); + await 다시묻는다({ running: [], recent: [끝난실행(3)] }, 부름); + + fireEvent.mouseEnter(토스트()!); + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + expect(토스트()!.className).toBe('toast'); + + fireEvent.mouseLeave(토스트()!); + await act(async () => { + vi.advanceTimersByTime(3250); + }); + expect(토스트()).toBeNull(); }); it('고리를 누르면 읽음이 되고 토스트가 닫힌다', async () => { @@ -286,8 +308,8 @@ describe('토스트', () => { await act(async () => {}); await 다시묻는다({ running: [], recent: [끝난실행(7)] }, 부름); - fireEvent.click(within(screen.getByRole('status')).getByRole('link', { name: '결과 보기' })); - expect(screen.queryByRole('status')).toBeNull(); + fireEvent.click(within(토스트()!).getByRole('link', { name: '결과 보기' })); + expect(토스트()).toBeNull(); expect(읽음들().has('run:7')).toBe(true); }); @@ -296,7 +318,7 @@ describe('토스트', () => { const 부름 = 띄운다({ running: [도는실행(9)], recent: [] }); await act(async () => {}); await 다시묻는다({ running: [], recent: [끝난실행(9)] }, 부름); - expect(screen.queryByRole('status')).toBeNull(); + expect(토스트()).toBeNull(); }); }); diff --git a/apps/admin/src/web/Notice.tsx b/apps/admin/src/web/Notice.tsx index 44e2440af..e9c130192 100644 --- a/apps/admin/src/web/Notice.tsx +++ b/apps/admin/src/web/Notice.tsx @@ -139,20 +139,29 @@ function 끝난줄그림({ 줄, on읽음, 바꿈 }: { 줄: 끝난줄; on읽음: /** * 새로 끝난 실행 · 작성 하나를 아래에서 띄워 3초 뒤 흐려지며 치운다 (DESIGN.md 「창이 아닌 떠 있는 것」 「토스트」 · 원칙 4 「알림 도착」). - * 포커스를 가져가지 않는다 — 읽는 사람에게는 `role="status"` 로 한 번 읽힌다 + * 포커스를 가져가지 않는다. 마우스를 올리거나 고리에 포커스가 있는 동안은 멈춘다 — 누르려는 사이에 사라지지 않게 */ function 토스트({ 줄, on닫음, 바꿈 }: { 줄: 끝난줄; on닫음: (값: null) => void; 바꿈: 서비스바꿈 }) { const [나감, set나감] = useState(false); + const [멈춤, set멈춤] = useState(false); useEffect(() => { + if (멈춤) return; + set나감(false); const 흐림 = setTimeout(() => set나감(true), 3000); const 치움 = setTimeout(() => on닫음(null), 3200); return () => { clearTimeout(흐림); clearTimeout(치움); }; - }, [on닫음]); + }, [on닫음, 멈춤]); return ( -
+
set멈춤(true)} + onMouseLeave={() => set멈춤(false)} + onFocus={() => set멈춤(true)} + onBlur={() => set멈춤(false)} + > {끝난글(줄)} + {/* 읽어 주는 자리는 늘 두고 글만 바꾼다 — 글을 품은 채 새로 생긴 자리는 화면 읽기 프로그램이 흔히 안 읽는다 */} +

+ {토스트줄 === null ? '' : 끝난글(토스트줄)} +

{토스트줄 === null ? null : <토스트 key={토스트줄.열쇠} 줄={토스트줄} on닫음={set토스트줄} 바꿈={서비스바꿈} />} {!열림 || 답 === null ? null : } diff --git a/apps/admin/src/web/RunEvidence.tsx b/apps/admin/src/web/RunEvidence.tsx index ed6ee9aa1..591343225 100644 --- a/apps/admin/src/web/RunEvidence.tsx +++ b/apps/admin/src/web/RunEvidence.tsx @@ -2,13 +2,12 @@ import { useEffect, useRef, useState } from 'react'; -import type { DeviceInfo } from '@platform/kit'; - import { api, type FailureDevice, type ItemTrace, type RunItemDetail, type RunItemPage, type StepResult } from './api.js'; import { 팜영상인가, 팜증적 } from './FarmEvidence.js'; import { use말, use언어 } from './i18n.js'; import { show, Step } from './ItemSteps.js'; import { 기록없음, 네트워크표, 콘솔표 } from './ItemTrace.js'; +import { 기기정보, 네트워크눈금 } from './RunEvidenceParts.js'; import { type Async, Failed, Loading, PLATFORM_LABEL, seconds, useAsync } from './ui.js'; export type 증거종류 = 'web' | 'app' | 'api'; @@ -105,41 +104,6 @@ export function 시간축증거({ devices }: { devices: FailureDevice[] }) { ); } -/** - * 단계 칸 아래에 그 단계에서 보낸 요청마다 눈금 하나. 단계 안 자리는 그 단계의 첫 조작 · 요청부터 잰 시각이다 - * ponytail: 단계가 시작한 시각을 따로 남기지 않아 첫 기록을 시작으로 본다 — 단계가 기다림으로 시작하면 눈금이 앞당겨진다. 단계 시작 시각을 남기면 그것으로 바꾼다 - */ -function 네트워크눈금({ steps, trace, 폭 }: { steps: StepResult[]; trace: ItemTrace | null; 폭: (s: StepResult) => number }) { - const t = use말(); - if (trace === null || trace.status !== 'DONE' || !trace.network.some((n) => n.seq !== null)) return null; - const 실패 = (n: ItemTrace['network'][number]): boolean => n.failure !== null || (n.status ?? 0) >= 400; - return ( - <> - -
- {t('네트워크 요청')} - {t('실패한 요청')} -
- - ); -} - function 증거칸({ devices, seq, 상세, 읽기 }: { devices: FailureDevice[]; seq: number; 상세: Async; 읽기: () => void }) { const t = use말(); const 대표 = devices[0]!.item; @@ -258,26 +222,6 @@ function 증거칸({ devices, seq, 상세, 읽기 }: { devices: FailureDevice[]; ); } -/** 못 읽은 칸은 「모름」 — 지어내지 않는다. 판은 실행 전체라 단계를 바꿔도 같다 */ -function 기기정보({ device, 이름 }: { device: DeviceInfo | null; 이름: string | null }) { - const t = use말(); - const 모름 = t('모름'); - const 앱 = [device?.appVersion ?? null, device?.appBuild == null ? null : t('빌드 {번호}', { 번호: device.appBuild })].filter((x) => x !== null); - return ( - <> - {이름 === null ? null : {이름}} -
-
{t('기기')}
-
{device?.model ?? 모름}
-
Android
-
{device?.osVersion ?? 모름}
-
{t('앱 버전')}
-
{앱.length === 0 ? 모름 : 앱.join(' · ')}
-
- - ); -} - /** 고른 단계의 줄만 — 준비 · 정리(seq 없음) 줄은 「상세」에서 본다 */ function 단계기록({ trace, seq, 탭, 앞말 }: { trace: ItemTrace | null; seq: number; 탭: 'net' | 'con'; 앞말: string | null }) { const t = use말(); diff --git a/apps/admin/src/web/RunEvidenceParts.tsx b/apps/admin/src/web/RunEvidenceParts.tsx new file mode 100644 index 000000000..069753b40 --- /dev/null +++ b/apps/admin/src/web/RunEvidenceParts.tsx @@ -0,0 +1,61 @@ +// 실패 카드 시간축 증거의 작은 부품 — 네트워크 눈금 줄 · 「기기 정보」 탭 본문 (WEB-F7-19 · DESIGN.md 「시간축 증거」) + +import type { DeviceInfo, StepResult } from '@platform/kit'; + +import type { ItemTrace } from './api.js'; +import { use말 } from './i18n.js'; +import { 실패요청 } from './ItemTrace.js'; + +/** + * 단계 칸 아래에 그 단계에서 보낸 요청마다 눈금 하나. 단계 안 자리는 그 단계의 첫 조작 · 요청부터 잰 시각이다 + * ponytail: 단계가 시작한 시각을 따로 남기지 않아 첫 기록을 시작으로 본다 — 단계가 기다림으로 시작하면 눈금이 앞당겨진다. 단계 시작 시각을 남기면 그것으로 바꾼다 + */ +export function 네트워크눈금({ steps, trace, 폭 }: { steps: StepResult[]; trace: ItemTrace | null; 폭: (s: StepResult) => number }) { + const t = use말(); + if (trace === null || trace.status !== 'DONE' || !trace.network.some((n) => n.seq !== null)) return null; + return ( + <> + +
+ {t('네트워크 요청')} + {t('실패한 요청')} +
+ + ); +} + +/** 못 읽은 칸은 「모름」 — 지어내지 않는다. 판은 실행 전체라 단계를 바꿔도 같다 */ +export function 기기정보({ device, 이름 }: { device: DeviceInfo | null; 이름: string | null }) { + const t = use말(); + const 모름 = t('모름'); + const 앱 = [device?.appVersion ?? null, device?.appBuild == null ? null : t('빌드 {번호}', { 번호: device.appBuild })].filter((x) => x !== null); + return ( + <> + {이름 === null ? null : {이름}} +
+
{t('기기')}
+
{device?.model ?? 모름}
+
Android
+
{device?.osVersion ?? 모름}
+
{t('앱 버전')}
+
{앱.length === 0 ? 모름 : 앱.join(' · ')}
+
+ + ); +} diff --git a/apps/admin/src/web/noticeBar.css b/apps/admin/src/web/noticeBar.css index 5495fd2d8..43343678c 100644 --- a/apps/admin/src/web/noticeBar.css +++ b/apps/admin/src/web/noticeBar.css @@ -236,4 +236,11 @@ body:has(.run-pill) .toast { body:has(.run-pill) .toast { bottom: calc(var(--ph-tabs) + 60px); } + /* 「실패 N건 다시 실행」 판(72px)이 있으면 그 위, 알약까지 있으면 알약 위다 (runList.css 의 알약 규칙과 같은 자리) */ + body:has(.rerun-dock) .toast { + bottom: calc(var(--ph-tabs) + 84px); + } + body:has(.rerun-dock):has(.run-pill) .toast { + bottom: calc(var(--ph-tabs) + 132px); + } } diff --git a/apps/admin/src/web/runEvidence.css b/apps/admin/src/web/runEvidence.css index 6480abf75..01f6b7486 100644 --- a/apps/admin/src/web/runEvidence.css +++ b/apps/admin/src/web/runEvidence.css @@ -170,6 +170,9 @@ .ev-ticks span { position: relative; min-width: 18px; + /* 단계 칸(.ev-seg)과 같은 안쪽 여백 · 테두리 — flex 몫 밖에 붙는 폭이 같아야 칸 경계가 맞는다 */ + padding: 0 8px; + border: 1px solid transparent; } .ev-ticks i { position: absolute; From 8871aa98027b1b294a10796c42b1f08ff1d4a4b5 Mon Sep 17 00:00:00 2001 From: maxihan1115-coder Date: Fri, 30 Oct 2026 22:24:19 +0900 Subject: [PATCH 8/9] =?UTF-8?q?[SPEC]=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20=EC=8B=A4=ED=96=89=20=C2=A78.9=20?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EA=B3=B5=ED=86=B5/5=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=EC=97=90=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=C2=B7=20=EC=95=B1=20=EC=A6=9D=EA=B1=B0=EB=8A=94=20=ED=83=AD(DE?= =?UTF-8?q?SIGN=20=EA=B0=80=EB=A6=AC=ED=82=B4)=20=C2=B7=20=ED=86=A0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B8=80=20=EB=B2=94=EC=9C=84=20=C2=B7=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=B8=94=EB=A1=9D=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20(WEB-F7-19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vw4EcW97tTnFJ9UDobtbJd --- docs/DESIGN.md | 2 +- docs/SPEC.md | 14 +++++++------- ...231\224\353\251\264\352\263\265\355\206\265.md" | 1 + .../\353\246\254\355\217\254\355\214\205.md" | 2 +- .../\354\213\244\355\226\211.md" | 4 ++-- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 554b71095..d9ff519af 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -874,7 +874,7 @@ TC ID, 소요시간, 응답 코드가 세로로 정렬돼야 훑을 수 있다. 하나만 고르는 조건은 칩 고르기(체크 네모 없음), 여럿 고르는 조건만 체크 네모다. 고른 조건은 표 위 칩(`--accent-soft` 바탕 · ✕ 로 풀기)으로 남고, 단추에 고른 조건 수 배지(대표색)가 붙는다. `Esc` · 판의 `닫기` 로 닫으면 포커스가 `필터` 단추로 돌아온다. 바깥을 눌러 닫으면 포커스는 누른 자리에 남고(결과 탭 · 칩의 ✕ 를 누른 사람에게서 포커스를 뺏지 않는다), 탭으로 판 밖에 나가도 닫힌다(2026-10-30 WEB-F7-07). 휴대폰에서는 아래 판이고 맨 아래에 `건 보기` 주 단추가 있다(누르면 닫힌다) - **토스트** — 끝난 일을 알린다. PC 는 본문 오른쪽 아래(진행 알약이 있으면 그 위), 휴대폰은 아래 탭 위다. `--ink` 바탕 · `--bg` 글자, 아래에서 떠오르고 3초 뒤 흐려져 사라진다. 고리 하나(`결과 보기` 등)는 `--toast-link` 글자다(`--accent` 는 어둡게 토스트 위 2.23 이라 못 쓴다) - 지금 띄우는 것은 알림 하나다(2026-10-30 WEB-F7-19) — 알림을 받아 올 때마다 앞 답과 견줘 새로 끝난 실행 · 작성 가운데 가장 늦게 끝난 것 하나를 알림 줄과 같은 글 · 고리로 띄운다. 화면을 막 열었을 때 이미 있던 것 · 읽은 것 · 완료 창(진행 알약)이나 지금 보는 결과 화면이 알리는 실행은 안 띄운다. 고리를 누르면 읽음이 되고 닫힌다. 포커스를 가져가지 않는다(`role="status"`) + 지금 띄우는 것은 알림 하나다(2026-10-30 WEB-F7-19) — 알림을 받아 올 때마다 앞 답과 견줘 새로 끝난 실행 · 작성 가운데 가장 늦게 끝난 것 하나를 알림 줄의 머리 · 집계 글과 여는 고리 하나로 띄운다(「결정 필요」 고리는 줄 · 서랍에만). 화면을 막 열었을 때 이미 있던 것 · 읽은 것 · 완료 창(진행 알약)이나 지금 보는 결과 화면이 알리는 실행은 안 띄운다. 고리를 누르면 읽음이 되고 닫힌다. 마우스를 올리거나 고리에 포커스가 있는 동안은 멈춘다. 포커스를 가져가지 않고, 화면 읽기 프로그램에는 늘 있는 `role="status"` 자리의 글을 바꿔 한 번 읽힌다 #### 휴대폰의 창 (2026-10-30 · 시안 11판 — 창 620px 미만) diff --git a/docs/SPEC.md b/docs/SPEC.md index cad747444..794836c9a 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -56,17 +56,17 @@ | 갈래 | 읽을 장 | 분량 | |------|--------|------| | **WS-A** 카탈로그 | `spec/공통/1-제품과-구조` · `spec/공통/2-명세선언` · `spec/도메인/카탈로그` · `spec/공통/4-데이터모델` | 3272줄 | -| **WS-B** 실행 | `spec/공통/1-제품과-구조` · `spec/공통/3-공유계약` · `spec/공통/5-화면공통` · `spec/도메인/실행` · `spec/공통/4-데이터모델` | 5042줄 | +| **WS-B** 실행 | `spec/공통/1-제품과-구조` · `spec/공통/3-공유계약` · `spec/공통/5-화면공통` · `spec/도메인/실행` · `spec/공통/4-데이터모델` | 5043줄 | | **WS-C** 러너 | `spec/공통/1-제품과-구조` · `spec/공통/2-명세선언` · `spec/공통/3-공유계약` · `spec/도메인/러너` | 2020줄 | -| **WS-D** 리포팅 | `spec/공통/1-제품과-구조` · `spec/공통/5-화면공통` · `spec/도메인/리포팅` · `spec/공통/4-데이터모델` | 3831줄 | -| **WS-E** 화면 | `spec/공통/1-제품과-구조` · `spec/공통/5-화면공통` · `spec/도메인/카탈로그` · `spec/도메인/실행` · `spec/도메인/리포팅` · `spec/도메인/인증` (§8.6·§8.8) · DESIGN.md | 4861줄 | -| **WS-F** 인증 | `spec/공통/1-제품과-구조` · `spec/공통/5-화면공통` · `spec/도메인/인증` · `spec/공통/4-데이터모델` | 4175줄 | +| **WS-D** 리포팅 | `spec/공통/1-제품과-구조` · `spec/공통/5-화면공통` · `spec/도메인/리포팅` · `spec/공통/4-데이터모델` | 3832줄 | +| **WS-E** 화면 | `spec/공통/1-제품과-구조` · `spec/공통/5-화면공통` · `spec/도메인/카탈로그` · `spec/도메인/실행` · `spec/도메인/리포팅` · `spec/도메인/인증` (§8.6·§8.8) · DESIGN.md | 4862줄 | +| **WS-F** 인증 | `spec/공통/1-제품과-구조` · `spec/공통/5-화면공통` · `spec/도메인/인증` · `spec/공통/4-데이터모델` | 4176줄 | | **contracts** 계약 반영 | `spec/공통/1-제품과-구조` · `spec/공통/3-공유계약` · `spec/공통/4-데이터모델` · `spec/공통/6-인프라` · `spec/도메인/러너` | 4328줄 | | **WS-작성** 테스트 작성 | `spec/공통/1-제품과-구조` · `spec/공통/4-데이터모델` · `spec/도메인/작성` · `spec/도메인/인증` | 6457줄 | | **WS-시나리오** E2E 시나리오 (여러 갈래에 걸친다 — `docs/archive/WORKSTREAMS.md` 「📐 E2E 시나리오」) | `spec/공통/1-제품과-구조` · `spec/공통/3-공유계약` · `spec/공통/4-데이터모델` · `spec/도메인/시나리오` · `spec/도메인/러너` · `spec/도메인/실행` · `spec/도메인/인증` | 7400줄 | -| **WS-성능** 성능 테스트 (여러 갈래에 걸친다 — 정본은 `spec/도메인/성능`) | `spec/공통/1-제품과-구조` · `spec/공통/3-공유계약` · `spec/공통/4-데이터모델` · `spec/공통/5-화면공통` · `spec/공통/6-인프라` · `spec/도메인/성능` · `spec/도메인/러너` · `spec/도메인/인증` | 6582줄 | +| **WS-성능** 성능 테스트 (여러 갈래에 걸친다 — 정본은 `spec/도메인/성능`) | `spec/공통/1-제품과-구조` · `spec/공통/3-공유계약` · `spec/공통/4-데이터모델` · `spec/공통/5-화면공통` · `spec/공통/6-인프라` · `spec/도메인/성능` · `spec/도메인/러너` · `spec/도메인/인증` | 6583줄 | | **WS-게임** 게임 테스트 (여러 갈래에 걸친다 — 정본은 `spec/도메인/게임`) | `spec/공통/1-제품과-구조` · `spec/공통/3-공유계약` · `spec/공통/4-데이터모델` · `spec/도메인/게임` · `spec/도메인/러너` · `spec/도메인/작성` | 6982줄 | -| **WS-0** 골격 · **integration** 통합·병합 | 17장 전부 | 14383줄 | +| **WS-0** 골격 · **integration** 통합·병합 | 17장 전부 | 14384줄 | `spec/공통/1-제품과-구조`는 어느 갈래든 읽는다. 용어를 다르게 쓰면 나머지가 전부 어긋난다. WS-0 과 integration 은 장을 따로 싣지 않는다 — 전 갈래를 건드리므로 색인을 보고 필요한 장을 직접 연다. @@ -177,7 +177,7 @@ WS-0 과 integration 은 장을 따로 싣지 않는다 — 전 갈래를 건드 | [2-명세선언](spec/공통/2-명세선언.md) | §4 명세 선언 방식 · §4.1 비밀값 꼬리표 | 329 | | [3-공유계약](spec/공통/3-공유계약.md) | §5 Shared Kernel 타입 · §5.3 이음새 · §7.1 Execution·검색·인증 결정 | 715 | | [4-데이터모델](spec/공통/4-데이터모델.md) | §6 전체 스키마 · 박제 원칙 · 마이그레이션 | 2060 | -| [5-화면공통](spec/공통/5-화면공통.md) | §8 머리말 — 껍데기(**왼쪽 줄**(애플리케이션 칸 · 알림 · 설정 · 내 메뉴) · 사이드바(머리 · 찾기 · 메뉴 · 바닥) · 푸터 — 맨 위 헤더는 2026-10-30 걷었다) · **화면 머리** · 자리 목록 · 대표색 · 껍데기 색 · 등급 · **알림**(본문 위 줄 · 서랍) · **휴대폰 화면** · **프로젝트 고르기** · **화면 낱말** · **화면 오가기**(주소에 남는 조건 · 떠나기 확인 · 서버 기억) · **다국어** | 594 | +| [5-화면공통](spec/공통/5-화면공통.md) | §8 머리말 — 껍데기(**왼쪽 줄**(애플리케이션 칸 · 알림 · 설정 · 내 메뉴) · 사이드바(머리 · 찾기 · 메뉴 · 바닥) · 푸터 — 맨 위 헤더는 2026-10-30 걷었다) · **화면 머리** · 자리 목록 · 대표색 · 껍데기 색 · 등급 · **알림**(본문 위 줄 · 서랍) · **휴대폰 화면** · **프로젝트 고르기** · **화면 낱말** · **화면 오가기**(주소에 남는 조건 · 떠나기 확인 · 서버 기억) · **다국어** | 595 | | [6-인프라](spec/공통/6-인프라.md) | §9 인프라 · §9.1 기술 스택 · §9.2 운영 | 577 | | [7-데모와-완료](spec/공통/7-데모와-완료.md) | §10 데모 자산 · §11 완료 기준 | 161 | diff --git "a/docs/spec/\352\263\265\355\206\265/5-\355\231\224\353\251\264\352\263\265\355\206\265.md" "b/docs/spec/\352\263\265\355\206\265/5-\355\231\224\353\251\264\352\263\265\355\206\265.md" index 16f5cdcd1..c65303101 100644 --- "a/docs/spec/\352\263\265\355\206\265/5-\355\231\224\353\251\264\352\263\265\355\206\265.md" +++ "b/docs/spec/\352\263\265\355\206\265/5-\355\231\224\353\251\264\352\263\265\355\206\265.md" @@ -354,6 +354,7 @@ - **끝나면 같은 줄이 결과를 말한다** — `RUN 2113 실행이 끝났습니다 · 기능 전체 382건 · 기능 테스트 · 대상 서버 qa · 36 통과 · 4 실패`, `#56 작성 요청이 끝났습니다 · 케이스 파일 24개 · PRD 확인 필요 3건`(2026-10-19 ⑫ · 점검 5.8 — 앞 판 「스크립트 파일」. 수는 `caseFiles` 라 파일 수다 — 「케이스 24건」으로 쓰면 틀린 수가 된다)(확인 필요가 있으면 테스트 작성 「결정 필요」 고리를 같이 단다 — 작성이 PRD 에 새 요구를 「확인 필요」로 올리고 그것을 고르는 자리가 결정 목록이라서다, 도메인/작성 §3.6 「★ 결정 목록」. 앞 판 고리는 「PRD 관리」 — 2026-10-26 WEB-F6-05 에 바꿨다). 누르거나 닫으면 읽음이 되어 줄에서 빠진다. 끝난 지 2시간이 지나면 줄에서는 빠지고 서랍에만 남는다. + 화면을 연 뒤 새로 끝난 것은 토스트로도 한 번 뜬다 — 언제 띄우고 무엇을 빼는지는 DESIGN.md 「토스트」가 정본이다(2026-10-30 WEB-F7-19). **그 실행의 결과 화면을 보고 있는 사람에게는 줄 대신 모달이 뜬다** (도메인/실행 §8.9) — 모달은 하던 일을 가로막는 장치라서 그 실행을 보고 있을 때만 막을 자격이 있다. 모달로 알린 실행은 읽음이 된다 - **알림 서랍** — 볼 수 있는 서비스 전체에서 최근 7일 안에 끝난 실행 · 작성 요청. 거르기는 전체 · 실행 · 작성 · 안 읽음, 「모두 읽음으로」 하나. diff --git "a/docs/spec/\353\217\204\353\251\224\354\235\270/\353\246\254\355\217\254\355\214\205.md" "b/docs/spec/\353\217\204\353\251\224\354\235\270/\353\246\254\355\217\254\355\214\205.md" index ea6a759f9..1950c069d 100644 --- "a/docs/spec/\353\217\204\353\251\224\354\235\270/\353\246\254\355\217\254\355\214\205.md" +++ "b/docs/spec/\353\217\204\353\251\224\354\235\270/\353\246\254\355\217\254\355\214\205.md" @@ -253,7 +253,7 @@ GET /api/evidence/:id 문서 다운로드. **이 주소는 제안: 위 §7 줄 그대로 — 디바이스 칸에 device 를 더한다. 있던 칸은 안 바뀐다 이유: 시안 11판 앱 · 게임 증거 칸의 「기기 정보」 탭(진행판 WEB-F7-19) 영향: WS-D(질의 한 줄) · WS-E(증거 칸 탭) -상태: 반영 완료 (2026-10-30, reporting/failures.ts · web/RunEvidence.tsx — 2026-10-30 사용자 승인 · WEB-F7-19) +상태: 반영 완료 (2026-10-30, reporting/failures.ts · web/api.ts · web/RunEvidence.tsx · web/RunEvidenceParts.tsx — 2026-10-30 사용자 승인 · WEB-F7-19) ``` --- diff --git "a/docs/spec/\353\217\204\353\251\224\354\235\270/\354\213\244\355\226\211.md" "b/docs/spec/\353\217\204\353\251\224\354\235\270/\354\213\244\355\226\211.md" index e7580b8ad..56d46308b 100644 --- "a/docs/spec/\353\217\204\353\251\224\354\235\270/\354\213\244\355\226\211.md" +++ "b/docs/spec/\353\217\204\353\251\224\354\235\270/\354\213\244\355\226\211.md" @@ -838,7 +838,7 @@ RUN 머리에 실행 제목·실행 시각·실행자·대상 서버와 주소 판정별 보기 「실패」는 카드만, 「통과」 · 「미실행」은 줄만, 「전체」는 카드 다음 줄이다 - **같은 실패는 한 칸으로 묶는다** — 한 카드 안에서 **입력과 모든 절차 · 확인(문장 · 판정 · 실제 값)이 다 같은** 디바이스들은 절차를 한 번만 그리고 머리에 「PC · 모바일」로 적는다(2026-10-08 게이트 2 — 「같은 확인 · 같은 실제 값」보다 좁혔다. 하나라도 다르면 따로 그린다). 소요 · 최근 흐름 · 「상세」 링크는 디바이스마다 나란히, **실패 시점 화면도 디바이스마다** 이름을 달아 나란히 둔다 — 화면은 디바이스마다 다른 증거다. 실패한 절차에서 찍은 것만 「실패 시점 화면」이다(통과 절차에서 `capture` 로 찍은 것은 안 넣는다). 펼친 카드 안에서 같은 내용을 두 번 그리지 않는다 - **앱 항목은 그 아래에 팜 「화면 영상」 재생 칸과 「logcat.txt 받기 (n MB)」 링크를 단다** (2026-10-20 APP-F1-12) — 재료는 디바이스 칸의 `farm`(리포팅 §7). 영상은 한 조각이 100MB 를 넘어 누르기 전에는 받지 않는다(`preload="none"`). 항목 상세도 절차 아래에 같은 칸을 둔다. 없으면 칸도 없다 + **앱 항목은 그 아래에 팜 「화면 영상」 재생 칸과 「logcat.txt 받기 (n MB)」 링크를 단다** (2026-10-20 APP-F1-12 — 실패 카드에서는 2026-10-30 WEB-F7-13 부터 증거 칸의 탭이다. 탭 목록은 DESIGN.md 「증거 칸은 종류마다 탭만 다르다」) — 재료는 디바이스 칸의 `farm`(리포팅 §7). 영상은 한 조각이 100MB 를 넘어 누르기 전에는 받지 않는다(`preload="none"`). 항목 상세도 절차 아래에 같은 칸을 둔다. 없으면 칸도 없다 - **「상세」 링크는 디바이스 머리마다 둔다**(2026-10-08 게이트 2) — 카드 머리 하나로는 둘째 디바이스 · 카드 안 통과 디바이스의 상세(코드 뷰 · 다른 회차)로 갈 길이 없다. 카드 안 접힌 통과 · 미실행 줄에도 둔다 - **반복 회차는 처음 실패한 회차의 절차를 보이고** 「5회 중 2회 실패」를 단다. 나머지 회차는 상세에서 본다 — 위 「회차 요약」의 「어느 회차가 깨졌는지는 상세에서」와 같다 - **확인 문장 없이 깨진 항목은 오류 첫 줄만** 카드에 적는다(리포팅 §7 insights 의 대표 문장과 같은 자르기). 원문은 상세에만 둔다 — 아래 「러너에 닿지 못했을 때」의 「원문 오류는 목록에 쓰지 않는다」와 같은 까닭이고, 원문에 비밀값이 섞일 수 있다. @@ -1096,7 +1096,7 @@ API 애플리케이션은 `기능 테스트` · `E2E` 둘만 선다(UI 테스트 | 어디에 있나 | 무엇이 받나 | 어디에 적혔나 | |------------|-----------|-------------| | 그 실행 결과 화면을 보고 있다 · 그 실행의 진행 알약이 떠 있다(2026-10-30) | **완료 모달** | 아래 · 위 「진행 알약」 | -| 이 플랫폼의 다른 화면에 있다 | 본문 위 **알림 줄**이 「끝났습니다」로 바뀌고 **알림 서랍**에 7일 남는다 | 공통/5-화면공통 §8 「알림」 | +| 이 플랫폼의 다른 화면에 있다 | 본문 위 **알림 줄**이 「끝났습니다」로 바뀌고 **알림 서랍**에 7일 남는다. 화면을 연 뒤 끝난 것이면 **토스트**가 3초 뜬다(2026-10-30 WEB-F7-19) | 공통/5-화면공통 §8 「알림」 · 토스트는 DESIGN.md 「토스트」 | | 플랫폼을 떠나 있다 | **Slack 알림** | 아래 | #### 진행 중에 — 같은 상자가 진행을 그린다 (2026-09-21 신설) From 2c341948737f002c40a0ebf79ba8437aeeeec93c Mon Sep 17 00:00:00 2001 From: maxihan1115-coder Date: Fri, 30 Oct 2026 22:27:56 +0900 Subject: [PATCH 9/9] =?UTF-8?q?[DOC]=20=EC=A7=84=ED=96=89=ED=8C=90=20WEB-F?= =?UTF-8?q?7-19=20=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vw4EcW97tTnFJ9UDobtbJd --- docs/wbs.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/wbs.md b/docs/wbs.md index 6fc958d80..95dc4d6c1 100644 --- a/docs/wbs.md +++ b/docs/wbs.md @@ -660,7 +660,8 @@ - 근거 PR #534 · 2026-10-30 — 요구사항은 기능 묶음 그대로(요구에 화면 정보가 없어 화면별로 묶으면 미작성 요구가 「화면 모름」으로 빠진다 — 2026-10-30 사용자) · 케이스 묶음 꼬리표는 게임만 「창」 · 「창 조각」 · API 케이스를 엔드포인트별로 묶는 것은 서버가 묶음을 새로 줘야 해 뺐다 · 페이지 성능 칸은 표와 같은 말(LCP 평균 · FCP 평균 · 목표 넘은 페이지) · 기기 성능 칸은 시안 「화면 멈춤」을 재는 값이 없어 최대 기기 온도로 바꿨고 기기가 여럿이면 가장 나쁜 기기 값과 그 이름 - [x] `WEB-F7-18` [WEB] 시안 대조 ① 화면 손질 — 성능 숫자 칸 숫자 올라가기 · 접수 창 단추 차례(「닫기」 → 주 단추 「상세 페이지로」) · 「원래 결과 보기」→「지난 결과 보기」(「실행 중단」은 그대로, 2026-10-30 사용자) · 페이지 성능 「다시 측정」 · 「전체 로드 · 요청 수」 칸 · API 케이스 목록 디바이스 칩 걷기(도메인/API) · E2E API 디바이스 칩 「API」 하나 · 실행 기록 API 줄 대상 서버 주소 · 성능 실행 기록 종류 꼬리표 · 알림 점 커짐 시안 배율 · 가이드 종류별 예시 글 · API 문서 「바뀐 API 만 다시 작성」 단추 - 근거 PR #535 · 2026-10-30 — **E2E API 디바이스 칩은 안 바꿨다**: API 를 첫 애플리케이션으로 고른 시나리오에 웹 단계를 섞으면 그 디바이스가 웹 단계에도 걸려, 「API」 하나로 줄이면 모바일 웹 단계를 못 만든다(사용자에게 물음) · 디바이스 이름은 앱 · 게임 케이스 줄에서도 빠졌다(같은 「디바이스 칸 없음」) · 「바뀐 API 만 다시 작성」은 결정 필요 목록으로 보내는 고리이고 버전이 둘 이상일 때만 선다 · 전체 로드 · 요청 수는 리소스 100개 상한 안에서 센다(「100+」) · 「다시 측정」은 잰 결과가 있으면 · 숫자 칸은 마지막 장면만 반올림 없는 값 -- [ ] `WEB-F7-19` [WEB] 시안 대조 ② 토스트 · 증거 칸 — 토스트(실행 끝 · 알림 도착, 3초 뒤 사라짐) · 증거 바꿀 때 시간축 칸 깜박임 · 시간축 네트워크 눈금 · 앱 · 게임 증거 「기기 정보」 탭 · 실행 결과 막대 자라기 · 성능 그래프 선 그리기 +- [x] `WEB-F7-19` [WEB] 시안 대조 ② 토스트 · 증거 칸 — 토스트(실행 끝 · 알림 도착, 3초 뒤 사라짐) · 증거 바꿀 때 시간축 칸 깜박임 · 시간축 네트워크 눈금 · 앱 · 게임 증거 「기기 정보」 탭 · 실행 결과 막대 자라기 · 성능 그래프 선 그리기 + - 근거 PR #536 · 2026-10-30 — 「기기 정보」는 실패 카드 응답 디바이스 칸에 `device` 를 더했다(계약 — 2026-10-30 사용자 승인 · 리포팅 §7) · 토스트는 알림을 받아 올 때 앞 답에 없던 끝난 실행 · 작성 하나(열 때 있던 것 · 완료 창 · 보는 결과 화면의 실행은 뺌, 올려 두면 멈춤) · 네트워크 눈금은 웹 카드를 펼칠 때 항목 상세를 한 번 읽고, 단계 시작 시각이 없어 그 단계의 첫 조작 · 요청부터 잰다(ponytail) · 실패한 요청은 실패 색 · 「실행 결과 막대」는 실행 기록 줄의 결과 띠(결과 화면 머리는 이미 도넛이 그려진다) · 성능 선은 왼쪽부터 오려 보인다 - [ ] `WEB-F7-20` [ADMIN · WEB] 실행 기록 줄 — 직전 대비 「해결」 수 · 실행 위치(웹 PC Chrome · 모바일 Chrome / 앱 · 게임 디바이스 팜 · 기기 이름). `GET /api/runs` 응답에 칸이 는다(계약 — 2026-10-30 사용자 승인) - [ ] `WEB-F7-21` [ADMIN · WEB] 성능 실행 기록 핵심 수치 — 페이지 성능(첫 화면 표시 평균) · 앱 성능(CPU · 메모리) · 게임 성능(FPS · 프레임 드랍) 줄에도 수치(계약 — 2026-10-30 사용자 승인) - [ ] `WEB-F7-22` [ADMIN · WEB] API 케이스 메서드 · 경로 · 엔드포인트별 묶음 — 케이스 목록 줄마다 메서드 · 경로, 묶음 머리는 엔드포인트(계약 — 2026-10-30 사용자 승인)