# 작업 보고: task-3060

- **팀**: dev4-team (비슈누)
- **레벨**: Lv.1 · 회장 승인 완료
- **저장소**: `Jeon-Jonghyuk/InsuRo` · base `98cc880` → head `46affef`
- **PR**: [#273 (draft)](https://github.com/Jeon-Jonghyuk/InsuRo/pull/273) — ★ 머지·배포는 ANU 판단, 자동 머지 안 함
- **명세 sha256(앞16)**: `413e87f0955b73ab` (일치 확인)

---

## 요약 (SCQA)

**S**: 네이버 상위노출 분석의 "근거 글 목록"은 10건을 보여주지만, 본문 수집은 Top 3 표본에서만 이뤄진다. 그래서 나머지 행은 글자수·이미지·본문 키워드가 전부 `—` 로 비어 나왔다.

**C**: 회장이 화면에서 이를 확인하고 "Top4~10 은 글자수·이미지 갯수 등을 아예 안 보여줘도 될 거 같아. 네이버 카페탭도 마찬가지"라고 지시했다. 다만 수치를 무조건 숨기면 **Top 3 인데 수집에 실패한 행의 실패까지 감춰지는** 반대 위험이 생긴다.

**Q**: 어떤 기준으로 수치 줄을 숨겨야 배지와 어긋나지 않고, 실패도 감추지 않는가?

**A**: `rank <= 3` 로 새로 판정하지 않고 **Top 3 배지가 이미 쓰는 서버 산출값 `s.in_stats_sample`** 을 재사용해 수치 줄 `<p>` 자체를 미렌더했다. 배지와 수치 줄이 항상 같은 조건이 되고, 표본이면서 값이 null 인 행은 `—` 로 계속 노출된다. 실 브라우저에서 수치 줄이 뜬 행이 rank **1·2·4** 임을 확인해 `rank` 판정이 아님을 실증했다. 변경은 파일 1개·조건 1개이며 블로그·카페 공용 컴포넌트라 한 곳 수정으로 양쪽이 충족된다.

## 수정 파일별 검증 상태

| 파일 | 변경 | 검증 |
|---|---|---|
| src/components/keyword/NaverBenchmarkPanel.tsx | +13/-7 | 변이 2종 KILL(6/10 failed)·실브라우저 DOM 4조합·회귀 1697 GREEN |
| src/components/keyword/__tests__/NaverBenchmarkPanel.sources-metrics.test.tsx | +330/-0 | 신규 10 tests GREEN·변이 미적용시 no-op 아님 증명 |
| src/components/keyword/__tests__/NaverBenchmarkPanel.test.tsx | +10/-1 | 기존 12 tests GREEN·반대계약 봉인 해소(의도 보존) |

---

## 1. 회장 지시 · 무엇을 했는지

> "네이버 블로그탭 키워드분석 결과인데, **Top4~10 은 글자수·이미지 갯수 등을 아예 안 보여줘도 될 거 같아. 네이버 카페탭도 마찬가지!!**"

Top 3 만 본문을 수집하므로 4~10위는 수치가 전부 `—` 로 비어 나와 화면만 지저분했다.

`SourcesList` 안 수치 줄 `<p>` 를 `{s.in_stats_sample && ( ... )}` 로 감쌌다. **파일 1개, 조건 1개.**

```tsx
{s.in_stats_sample && (
  <p className="text-xs text-muted-foreground mt-0.5">
    {s.char_count != null ? ... : COPY.noValue} · 이미지 ... · 제목 키워드 ... · 본문 키워드 ...
  </p>
)}
```

- ★ `rank <= 3` 로 새로 판정하지 않고 **같은 파일 `:188` Top 3 배지와 동일한 `s.in_stats_sample`** 재사용 → 배지와 수치 줄이 항상 같은 조건
- ★ 빈 `<p>`·빈 문자열·`—` 를 남기지 않고 **요소 자체를 미렌더** (여백 0)
- ★ **Top 3 인데 본문 수집 실패로 값이 `—` 인 행은 수치 줄을 계속 노출** (실패 은폐 금지)
- 채널 분기 없음 — 블로그·카페 공용 컴포넌트라 한 곳 수정으로 양쪽 충족
- 제목·순위·"네이버 노출 N위"·Top 3 배지 그대로 유지

## 2. 생성/수정 파일 (PR 실측 3건, 전부 allowed_resources 내)

- `src/components/keyword/NaverBenchmarkPanel.tsx` (modified, +13/-7)
- `src/components/keyword/__tests__/NaverBenchmarkPanel.sources-metrics.test.tsx` (added, +330/-0)
- `src/components/keyword/__tests__/NaverBenchmarkPanel.test.tsx` (modified, +10/-1)

금지 경로(`server/**`·`src/pages/**`·`extension/**`·`.github/workflows/**`) 무접촉.
PR #271(task-3058) 의 `src/pages/CrmPipeline.tsx` 무접촉.
원격 ref sha `46affefc84cc1338edbb0e4f8c4848ae299c3973` = 로컬 HEAD 일치 (gh api 로 확인).

## 3. ★ 명세 전제 반증 1건 (ANU 확인 요망)

**"대상 파일 딱 1개" 전제는 성립하지 않았다.** 기존 `NaverBenchmarkPanel.test.tsx` 테스트 11번이 **정확히 반대 계약을 봉인**하고 있었다.

```
within(nullRow).getByText("— · 이미지 — · 제목 키워드 0회 · 본문 키워드 —")
   nullRow = rank 10, in_stats_sample: false
```

즉 "Top3 아닌 행에 `—` 수치 줄이 보여야 한다" 를 봉인 중이었고, 회장 지시를 구현하면 필연적으로 FAIL 한다 (구현 직후 실측 `1 failed | 11 passed`).

임의로 완화하지 않고 **원래 의도("null 이 0 으로 둔갑 금지")를 보존**하는 최소 조치를 했다. `SOURCES` 는 손대지 않고(테스트 10의 배지 계약 보존), `rank 10` 만 `in_stats_sample: true` 로 승격한 별도 응답 상수 `NULL_SOURCE_IN_SAMPLE_RESPONSE` 를 추가해 테스트 11이 그것을 쓰게 했다. 검증 강도는 동일하며, 오히려 신규 (D) 케이스와 정합한다.

## 4. 테스트 결과

### 회귀 (base·head **같은 환경**에서 재측정)

```
base 98cc880   113 files / 1687 tests / 0 failed / exit 0
head 46affef   114 files / 1697 tests / 0 failed / exit 0
델타           +1 file / +10 tests   (신규 봉인 파일 그만큼, 기존 감소 0)
```

### 봉인 변이 2종 — ★ 팀장이 독립 재현

| 변이 | diff 증명 | 결과 |
|---|---|---|
| ① 조건 제거 `{true && (` | `-{s.in_stats_sample && (` / `+{true && (` | **6 failed / 4 passed** |
| ② `{s.rank <= 3 && (` 치환 | `+{s.rank <= 3 && (` | **10 failed / 0 passed** |

각 변이는 `git diff` 로 실제 적용을 먼저 확인(no-op 아님 증명), 원복 후 **10/10 GREEN** 복귀·워킹트리 clean.

★ 변이②가 전건을 잡는 이유: 픽스처가 **`in_stats_sample` 과 `rank<=3` 이 어긋나는 행**(rank 3 → `false`, rank 4 → `true`)을 의도적으로 포함한다. 이 설계가 없으면 봉인은 무력하다.

★ 주의: `in_stats_sample && (` 는 파일 내 **2곳**(`:188` 배지, `:208` 수치 줄)에 있어 변이는 라인 지정(`sed 208s`)으로만 적용했다.

### 신규 봉인 테스트 (10 tests = 5 케이스 × blog/cafe `describe.each`)

`src/components/keyword/__tests__/NaverBenchmarkPanel.sources-metrics.test.tsx`

- (A) `in_stats_sample:true` 행에 수치 줄이 렌더된다
- (B) `false` 행에는 수치 줄 요소가 **DOM 에 아예 없다** — 텍스트가 아닌 `row.querySelectorAll("p").length === 0` 으로 판정해 빈 `<p>` 잔존까지 잡는다
- (C) 수치 줄 행 수 == Top 3 배지 행 수
- (D) `true` + 값 null 인 행은 수치 줄이 여전히 보이고 `—` 로 표시 (실패 은폐 금지)
- (E) rank 와 표본이 어긋난 행 교차 검증 (`rank<=3` 판정 아님)
- cafe 는 실제 요청 body 의 `channel === "cafe"` 까지 확인

## 5. 실 브라우저 실증 (Chromium · ★ 픽스처 주입)

실계정 경로는 **착수 불가**였다: worktree/상위 repo 에 `.env`·`.env.local` 부재 → dev 모드에서 `VITE_SUPABASE_URL` undefined → `createClient()` 가 앱 부팅 자체를 못 한다(`.env.production` 은 dev 에서 미로드). 실 Supabase 자격증명·유료 플랜 계정은 이 작업 scope 밖.

→ Playwright `route()` 픽스처 주입으로 검증. 스크린샷에 **"픽스처(FIXTURE) 데이터 · 실데이터 아님"** 배너를 이미지 안에 박아 넣었다. 게이팅 통과는 `LockedFeatureOverlay` 노출 **0건**으로, 채널은 실제 POST body 값으로 실증했다(로그인 월 아님을 팀장이 이미지로 직접 확인).

### DOM 실측 — blog/cafe × desktop(1280) / mobile(375), **4개 조합 전부 동일**

| 항목 | 값 |
|---|---|
| 전체 행 | 10 |
| 수치 줄 있는 행 | **3** |
| `Top 3` 배지 행 | **3** (일치) |
| 그 3행의 rank | **1 · 2 · 4** |
| 비표본 7행의 `<p>` 개수 | `[0,0,0,0,0,0,0]` |
| 빈 `<p></p>` 태그 | **0건** |

★ 수치 줄이 뜬 행이 rank **1·2·4** 라는 것이 `rank<=3` 이 아니라 `in_stats_sample` 로 판정됨의 실증이다 (rank 3 은 배지도 수치 줄도 없다).

### 행 높이 (잔여 여백 0 확인)

- 데스크톱: 표본 **88.63 / 89.63 / 89.63px** vs 비표본 **70.5px** (7행 전부 동일) → 차이 ≈18~19px = 수치 줄(`text-xs` + `mt-0.5`) 정확히 한 줄분
- 모바일: 표본 105.63~108.88px vs 비표본 70.5px (rank 3 만 72.75px — 제목이 2줄로 감긴 탓이지 빈 요소 때문이 아님)

### Top 3 + 값 null 행 (실패 은폐 금지) — PASS

rank 4 (`in_stats_sample:true`, 값 전부 null) 행의 수치 줄이 계속 렌더되며 텍스트는
`— · 이미지 — · 제목 키워드 0회 · 본문 키워드 —`. 4개 스크린샷 전부에서 육안 확인.
`title_keyword_count: 0` 은 null 이 아니므로 `0회` 로 정상 표기 — 0 을 `—` 로 삼키지도 않는다.

### 모바일 375px 소견

어색함 없음. 비표본 행은 순위+제목 2요소만 남아 70.5px 로 일정하게 떨어지고, 표본 행만 배지+수치 줄로 눌러앉아 **표본/비표본 시각 구분이 오히려 또렷**해진다. 수치 줄이 2줄로 감기지만 잘림·오버플로 없음. 좌측 `w-14` 순위 컬럼 고정폭이라 제목 시작선이 어긋나지 않는다.

스크린샷: `/tmp/task-3060/{blog,cafe}-{desktop,mobile}.png` + 근거 글 목록 확대컷 4장.

## L1 스모크테스트

실 Chromium 으로 앱을 띄워 근거 글 목록을 직접 렌더해 확인했다. 결과 **PASS**.

| 항목 | 결과 |
|---|---|
| 블로그탭 데스크톱 1280px | 스크린샷 `/tmp/task-3060/blog-desktop.png` — 근거 글 목록 (10건) 카드 정상 노출, 로그인 월 아님. 성공 |
| 블로그탭 모바일 375px | 스크린샷 `/tmp/task-3060/blog-mobile.png` — 동일 동작 확인. 성공 |
| 카페탭 데스크톱 1280px | 스크린샷 `/tmp/task-3060/cafe-desktop.png` — 제목 전부 `[카페] …`, 요청 channel=cafe 실증. 성공 |
| 카페탭 모바일 375px | 스크린샷 `/tmp/task-3060/cafe-mobile.png` — 동일 동작 확인. 성공 |
| 근거 글 목록 확대컷 4장 | `/tmp/task-3060/{blog,cafe}-{desktop,mobile}-sources.png` — 팀장이 이미지를 직접 열어 육안 확인 |
| 수치 줄 == Top 3 배지 개수 | 3 == 3 (4개 조합 전부). 확인 |
| 수치 줄이 뜬 행의 rank | 1 · 2 · 4 → `rank<=3` 판정 아님이 실증됨. 확인 |
| 비표본 7행의 `<p>` 개수 | `[0,0,0,0,0,0,0]`, 빈 `<p></p>` 0건. 확인 |
| Top 3 + 값 null 행 | rank 4 가 `— · 이미지 — · 제목 키워드 0회 · 본문 키워드 —` 계속 노출. 성공 |
| 유닛/회귀 | 1697 tests passed (base 1687 passed 대비 +10, 실패 0) |
| 봉인 변이 2종 | ① 6 failed ② 10 failed → 원복 후 10 passed. 확인 |

★ 실계정 자격증명 부재로 Playwright `route()` **픽스처 주입** 경로로 렌더했다. 스크린샷 이미지 안에 "픽스처(FIXTURE) 데이터 · 실데이터 아님" 배너를 박아 넣었다. 게이팅 통과는 `LockedFeatureOverlay` 노출 0건으로, 채널은 실제 POST body 값으로 실증했다.

## 6. 비고 (ANU 판단 대기)

1. **★ 명세 전제 반증** — 위 3절. 기존 테스트 11 수정이 불가피했다. 이견 있으면 해당 hunk 만 되돌리면 된다.
2. **CI 적색은 선재** — base `98cc880` 에서도 적색이다 (`ci` = 러너 디스크 `Errno 28`, `e2e-test` = task-2998 선재 실패). 이 PR 이 만진 3개 파일과 교집합 0.
3. **범위 밖 참고 1건** — 375px 에서 상단 탭 스트립이 가로 스크롤되어 "상위노출 분석" 탭이 화면 밖으로 잘린다. **이번 변경과 무관한 기존 동작**이며 손대지 않았다.
4. **환경 함정** — `BOT_GITHUB_TOKEN` 이 만료된 GitHub App 설치 토큰이었다. `scripts/refresh_bot_token.py` 로 재발급 후에도 `AUTHORIZATION: bearer` 헤더로는 `invalid credentials` 였고, **`AUTHORIZATION: basic base64(x-access-token:TOKEN)`** 형식이라야 원격 반영에 성공했다.
5. **★ scope-guard FAIL = 스냅샷 부재 오탐 (자가해소 안 함, ANU override 필요)** —
   `memory/capabilities/task-3060.json` 스냅샷이 **없다**. 이번 실행이 `dispatch.py` 를 경유하지 않은
   스케줄 실행이라 dispatch 시점 immutable 스냅샷이 만들어지지 않았기 때문이며, task 파일에는
   `allowed_resources` 블록이 **존재한다**. 실제 diff 는 3건 전부 allowed glob 안이다.

   ```
   src/components/keyword/NaverBenchmarkPanel.tsx                            ← paths 명시 항목
   src/components/keyword/__tests__/NaverBenchmarkPanel.sources-metrics.test.tsx  ← src/**/__tests__/**
   src/components/keyword/__tests__/NaverBenchmarkPanel.test.tsx                  ← src/**/__tests__/**
   ```

   즉 **범위 위반이 아니다.** 규율대로 자가해소하지 않고 근거만 박제한다.
   `memory/events/task-3060.escalate` (`reason: scope_guard_violation`) 생성됨.
   차단 대상은 머지인데 머지는 어차피 ANU 몫이라 이 PR 산출에는 영향 없다.
6. QC 게이트 최종 = **7 PASS / 15 SKIP / 1 WARN** (overall WARN, `.qc-done` 생성).
   1차 실행은 `l1_smoketest_check` FAIL 이었고, 보고서에 `## L1 스모크테스트` 섹션과
   증거(스크린샷·테스트 결과)를 추가해 해소했다.
7. 실계정 브라우저 검증은 `.env` 부재로 불가했다. 실데이터 확인이 필요하면 배포 후 ANU 가 프로덕션에서 재확인하는 편이 확실하다.
