Compare commits

..

6 Commits

Author SHA1 Message Date
joungmin
d6ee62230e refactor(admin): #351 SSE 6곳 consumeSseStream으로 통일
VideosPanel:
- bulkTranscript/bulkExtract: 단일 SSE 핸들러 → consumeSseStream
- rebuildVectors: consumeSseStream
- remapCuisine / remapFoods: consumeSseStream

RestaurantsPanel:
- bulkTabling / bulkCatchtable: consumeSseStream

이전: 각 호출이 자체적으로 reader+decoder+buf.split+match 6곳 복제.
이제: lib/admin-utils.ts의 consumeSseStream(resp, onEvent)으로 일관 처리.

빌드 + npm test 13/13 통과. 회귀 없음.

Refs: #351
2026-06-15 17:15:35 +09:00
joungmin
cf1055bdf9 docs(changelog): v0.1.40 #343 테스트 인프라 기록 2026-06-15 16:29:22 +09:00
joungmin
2580414790 build(npm): #343 lock 재생성 (Jest 30 + @testing-library/* 동기화) 2026-06-15 16:26:52 +09:00
joungmin
730727a7a6 test(frontend): #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns
테스트 인프라:
- Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers
- next/jest로 SWC/Next.js 자동 통합
- jest.config.ts (setupFilesAfterEnv) + jest.setup.ts
- npm scripts: test, test:watch
- 샘플 테스트 3개, 13/13 통과:
  - i18n/config: isLocale + detectBrowserLocale (5 케이스)
  - Stars 컴포넌트: 별점/aria/clamp/showNumber (5 케이스)
  - admin-utils: getAdminToken + authHeaders (4 케이스)

ARIA Tabs (MyReviewsList):
- role=tablist + tab + aria-selected + aria-controls + tabIndex
- panel에 role=tabpanel + aria-labelledby

next/image:
- next.config.ts remotePatterns: lh3.googleusercontent.com / i.ytimg.com / yt3.ggpht.com
- ReviewSection의 user_avatar_url에 명시적 eslint-disable + 사유

후속(별도): 전체 컴포넌트 테스트 점진 추가, 백엔드 JUnit 인프라, E2E (Playwright), CI 통합

설계서: docs/design/343-frontend-test-infra/README.md

Refs: #343
2026-06-15 16:25:55 +09:00
joungmin
9ba905aad8 docs(design): #343 RTL/Jest 인프라 + next/image + ARIA Tabs 설계서 (Architect)
next/jest + RTL 도입, 샘플 테스트 3개(Stars + i18n config), remotePatterns,
MyReviewsList Tabs ARIA. 백엔드 JUnit/E2E/CI는 후속.

설계서: docs/design/343-frontend-test-infra/README.md (Approved)
Refs: #343 (Architect)
2026-06-15 16:17:23 +09:00
joungmin
8c4b0c3e9a docs(changelog): v0.1.38 #348 isNameSimilar 한국어 기록 2026-06-15 16:12:46 +09:00
14 changed files with 4673 additions and 210 deletions

View File

@@ -6,6 +6,25 @@
## 2026-06-15 ## 2026-06-15
### 🧪 #343 Jest+RTL 인프라 + ARIA Tabs + remotePatterns (v0.1.40)
- Jest 30 + jest-environment-jsdom + RTL + jest-dom matchers 도입
- next/jest 자동 SWC 통합, jest.config.ts + jest.setup.ts (setupFilesAfterEnv)
- npm scripts: test, test:watch
- 샘플 테스트 3개 13/13 통과: i18n/config(5), Stars(5), admin-utils(4)
- MyReviewsList: role=tablist/tab/aria-selected/aria-controls/tabIndex + tabpanel
- next.config.ts remotePatterns: Google avatar + YouTube thumbnail/avatar
- 후속: 전체 컴포넌트 테스트 확장, 백엔드 JUnit, E2E(Playwright), CI 통합
- 설계서: docs/design/343-frontend-test-infra/README.md
- Refs: #343 (close)
### 🔤 #348 isNameSimilar 한국어 자모 + Sørensen-Dice (v0.1.38)
- HangulSimilarity 유틸 신규 (Unicode NFD 분해 + bigram Sørensen-Dice)
- RestaurantController.isNameSimilar 교체, 임계값 0.45
- 짧은 한국어 이름 매칭 정확도 향상 (예: "스타벅스 강남" vs "스타벅스 강남점")
- 후속 분리: #357(DDG→정식 API), #358(DTO+@Valid), #359(UNIQUE+데이터 정리)
- 설계서: docs/design/348-name-similarity/README.md
- Refs: #348 (close)
### 🌐 #352 i18n 뼈대 ko/en/ja/es (v0.1.37) ### 🌐 #352 i18n 뼈대 ko/en/ja/es (v0.1.37)
- next-intl 5.x 도입 - next-intl 5.x 도입
- src/i18n/{config,LocaleProvider} + src/messages/{ko,en,ja,es}.json (30 키) - src/i18n/{config,LocaleProvider} + src/messages/{ko,en,ja,es}.json (30 키)

View File

@@ -0,0 +1,117 @@
# 설계서: RTL/Jest 인프라 + next/image + ARIA Tabs (#343)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #343 · 부모: #281 (현행화 frontend-review-memo, 09-Done)
> · 구현 파일: `frontend/package.json`, `frontend/jest.config.ts` (신규), `frontend/jest.setup.ts` (신규), `frontend/next.config.ts`, `frontend/__tests__/*` (신규), `frontend/src/components/MyReviewsList.tsx`, `frontend/src/components/ReviewSection.tsx`
> · 테스트: 본 이슈가 테스트 인프라 자체를 도입
## 1. 목적 (Why)
본 프로젝트는 지금까지 자동화된 단위 테스트가 0건. 누적된 "후속 테스트" 항목이 12+개(StatsService/CacheService/SearchService/Stars/HangulSimilarity 등)이며, 그동안 분리된 후속 이슈를 처리할 인프라가 없어 모두 보류 상태. 본 이슈에서 Jest + RTL 인프라 도입 + next/image 적용 + ARIA Tabs 보강.
## 2. 범위 (Scope)
- **포함**
- Jest 30 + `next/jest` 자동 설정 + `@testing-library/react` + `@testing-library/jest-dom`.
- `jest.config.ts`, `jest.setup.ts`, `package.json scripts: test, test:watch`.
- 샘플 테스트 3개 — 가장 안전한 순수 함수/단순 컴포넌트로 인프라 검증:
- `Stars` 컴포넌트 렌더 + 별점 표시
- 기존 `HangulSimilarity` (#348) — 자모/유사도
- `BotDetector` (#337) — 봇 UA 패턴
- `next.config.ts` `images.remotePatterns`에 Google avatar 도메인(`lh3.googleusercontent.com`) + YouTube thumbnail(`i.ytimg.com`).
- `ReviewSection`/`MyReviewsList``<img>` 일부를 `next/image` 또는 명시적 eslint-disable로 정리.
- `MyReviewsList` 탭에 WAI-ARIA Tabs 패턴(role=tablist/tab/aria-selected/aria-controls).
- **제외 (후속)**
- 백엔드 JUnit 테스트 인프라 (별도 큰 작업).
- E2E (Playwright) 도입.
- CI 통합 (GitHub Actions 또는 OCI DevOps).
- 모든 컴포넌트 테스트 — 점진적으로 추가.
- 모든 `<img>``next/image` 전수 교체 — 점진적.
## 3. 인수조건
- [ ] `npm test`가 단일 명령으로 동작 (0건 → 샘플 3개 통과).
- [ ] `npm run build`가 회귀 없이 통과.
- [ ] `next.config.ts``remotePatterns` 설정.
- [ ] `ReviewSection`의 user_avatar_url `<img>``next/image` 또는 eslint-disable 주석.
- [ ] `MyReviewsList` 탭이 `role="tablist"`/`role="tab"`/`aria-selected`/`aria-controls`/`tabIndex` 설정.
## 4. 컨텍스트 & 제약
- Next.js 16.1.6 + Turbopack.
- `next/jest`는 SWC/Babel 자동 통합. Turbopack 빌드와는 분리(테스트만 Jest 별도).
- Pretendard/Geist 폰트는 `next/font/local` 사용 → 테스트에선 mock 불필요.
- 패널 분리(#329)로 admin 영역은 단위 테스트 도입 더 쉬워짐.
## 5. 아키텍처 개요
```
frontend/
├── package.json
│ └── scripts: test, test:watch
├── jest.config.ts (next/jest createNextJestConfig 사용)
├── jest.setup.ts (@testing-library/jest-dom 확장 matchers)
├── __tests__/
│ ├── Stars.test.tsx
│ ├── HangulSimilarity.test.ts (자체 구현은 backend Java, TS 포팅은 미적용 → 다른 순수 함수로 대체)
│ └── BotDetector.test.ts (마찬가지 — backend → TS 동등 포팅 불가)
└── (대안) 프론트 측 순수 함수:
├── lib/cuisine-icons.ts 의 getPhosphorCuisineIcon
├── components/Stars 의 0.5 단위 렌더
└── i18n/config.ts 의 isLocale/detectBrowserLocale
```
→ 백엔드 Java 코드는 TS 테스트로 검증 불가. 프론트 측 순수 함수 3개로 대체:
- `Stars` 렌더 (RTL component test)
- `i18n/config.ts` `isLocale` (pure)
- `i18n/config.ts` `detectBrowserLocale` (navigator mock)
## 6. 데이터 모델
`__tests__/*.test.{tsx,ts}` — Jest 표준 컨벤션.
## 7. 함수 명세
| 단위 | 책임 | 비고 |
|------|------|------|
| `jest.config.ts` | `createJestConfig(customConfig)` + moduleNameMapper `@/*` | next/jest |
| `jest.setup.ts` | `import "@testing-library/jest-dom"` | 확장 matchers |
| `Stars.test.tsx` | 별점 0/2.5/5 렌더, aria-label 확인 | RTL |
| `i18n/config.test.ts` | isLocale/detectBrowserLocale | navigator mock |
| `MyReviewsList` Tabs 패치 | tablist/tab/aria-selected | role + aria |
| `ReviewSection` img → eslint-disable | 최소 변경 | next/image는 후속 |
## 8. 흐름
1. `npm i -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event @types/jest`.
2. `jest.config.ts` + `jest.setup.ts` 작성.
3. `package.json``"test": "jest"` + `"test:watch": "jest --watch"` 추가.
4. `__tests__/`에 3개 샘플 테스트.
5. `next.config.ts``remotePatterns` 추가.
6. `MyReviewsList` Tabs ARIA 보강.
7. `ReviewSection``<img>` 라인에 `// eslint-disable-next-line @next/next/no-img-element` (next/image 전환은 후속).
8. `npm test` 통과 → `npm run build` 통과.
## 9. 엣지케이스
- **Turbopack vs Jest**: 무관 (테스트는 별도 SWC 컴파일).
- **CSS modules / globals.css import**: jest.config.ts의 moduleNameMapper로 `\\.(css|scss)$``identity-obj-proxy` 대신 next/jest가 자동 처리.
- **Next.js Server Components**: 본 프로젝트는 모두 `"use client"` 컴포넌트라 RTL이 통상 동작.
## 10. 테스트
자기 자신 — `npm test`가 통과해야 본 이슈 완료.
## 11. 리스크 & 대안
- **선택**: `next/jest` + RTL. Next.js 공식 권장.
- **대안 A**: Vitest — 더 빠르지만 Next.js 공식 가이드 부재, 본 프로젝트 규모에서 차이 작음.
- **대안 B**: Playwright Component Testing — 더 무겁고 E2E 통합 안 됨.
- **트레이드오프**: Jest 30 + RTL은 React 19에 호환. 의존성 부담은 dev-only.
## 12. 미해결 질문
- CI(테스트 자동 실행) — 본 이슈 범위 밖. OCI DevOps Build Pipeline은 ARM64 미지원 → GitHub Actions 또는 Gitea Actions 후속.
- 백엔드 JUnit 테스트 인프라 — 별도 큰 이슈.
- E2E (Playwright) — 별도.

View File

@@ -0,0 +1,36 @@
/**
* #343 — Stars 컴포넌트 렌더 테스트.
*/
import { render, screen } from "@testing-library/react";
import Stars from "@/components/Stars";
describe("Stars", () => {
it("renders 5 star slots", () => {
const { container } = render(<Stars rating={3} />);
// 빈 별 5개 (text-gray-300 클래스 갖는 span)
const emptyStars = container.querySelectorAll("span.text-gray-300");
expect(emptyStars.length).toBe(5);
});
it("shows aria-label with rating", () => {
render(<Stars rating={4.5} />);
expect(screen.getByLabelText("4.5점")).toBeInTheDocument();
});
it("clamps rating to 0~5", () => {
render(<Stars rating={-1} />);
expect(screen.getByLabelText("0점")).toBeInTheDocument();
render(<Stars rating={10} />);
expect(screen.getByLabelText("5점")).toBeInTheDocument();
});
it("shows number when showNumber + rating > 0", () => {
const { container } = render(<Stars rating={3.5} showNumber />);
expect(container.textContent).toContain("3.5");
});
it("does not show number when rating is 0 even with showNumber", () => {
const { container } = render(<Stars rating={0} showNumber />);
expect(container.textContent).not.toContain("0");
});
});

View File

@@ -0,0 +1,28 @@
/**
* #343 — admin-utils 순수 함수 단위 테스트.
*/
import { getAdminToken, authHeaders } from "@/lib/admin-utils";
describe("admin-utils", () => {
beforeEach(() => {
localStorage.clear();
});
it("getAdminToken returns null when not set", () => {
expect(getAdminToken()).toBeNull();
});
it("getAdminToken returns stored token", () => {
localStorage.setItem("tasteby_token", "abc123");
expect(getAdminToken()).toBe("abc123");
});
it("authHeaders is empty when no token", () => {
expect(authHeaders()).toEqual({});
});
it("authHeaders includes Bearer when token set", () => {
localStorage.setItem("tasteby_token", "xyz");
expect(authHeaders()).toEqual({ Authorization: "Bearer xyz" });
});
});

View File

@@ -0,0 +1,42 @@
/**
* #343 — i18n/config 순수 함수 단위 테스트.
*/
import { isLocale, detectBrowserLocale, DEFAULT_LOCALE } from "@/i18n/config";
describe("i18n/config.isLocale", () => {
it("returns true for supported locales", () => {
expect(isLocale("ko")).toBe(true);
expect(isLocale("en")).toBe(true);
expect(isLocale("ja")).toBe(true);
expect(isLocale("es")).toBe(true);
});
it("returns false for unsupported / null / undefined", () => {
expect(isLocale("fr")).toBe(false);
expect(isLocale("zh")).toBe(false);
expect(isLocale(null)).toBe(false);
expect(isLocale(undefined)).toBe(false);
expect(isLocale("")).toBe(false);
});
});
describe("i18n/config.detectBrowserLocale", () => {
// jsdom의 navigator.language는 기본 'en-US'
it("returns supported locale from navigator.language", () => {
Object.defineProperty(navigator, "language", { value: "en-US", configurable: true });
expect(detectBrowserLocale()).toBe("en");
Object.defineProperty(navigator, "language", { value: "ko-KR", configurable: true });
expect(detectBrowserLocale()).toBe("ko");
Object.defineProperty(navigator, "language", { value: "ja", configurable: true });
expect(detectBrowserLocale()).toBe("ja");
Object.defineProperty(navigator, "language", { value: "es-MX", configurable: true });
expect(detectBrowserLocale()).toBe("es");
});
it("falls back to DEFAULT_LOCALE for unsupported", () => {
Object.defineProperty(navigator, "language", { value: "fr-FR", configurable: true });
expect(detectBrowserLocale()).toBe(DEFAULT_LOCALE);
Object.defineProperty(navigator, "language", { value: "zh-CN", configurable: true });
expect(detectBrowserLocale()).toBe(DEFAULT_LOCALE);
});
});

21
frontend/jest.config.ts Normal file
View File

@@ -0,0 +1,21 @@
// #343 — Jest 설정. next/jest로 SWC 자동 통합.
import type { Config } from "jest";
import nextJest from "next/jest.js";
const createJestConfig = nextJest({
// 테스트 환경의 Next.js 앱 루트
dir: "./",
});
const customConfig: Config = {
// jest-dom matchers는 setupFilesAfterEnv로 등록 (Jest framework 로드 후)
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
testEnvironment: "jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
testPathIgnorePatterns: ["<rootDir>/.next/", "<rootDir>/node_modules/"],
};
export default createJestConfig(customConfig);

2
frontend/jest.setup.ts Normal file
View File

@@ -0,0 +1,2 @@
// #343 — Jest setup. @testing-library/jest-dom matchers 확장.
import "@testing-library/jest-dom";

View File

@@ -2,6 +2,14 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
// #343 — 외부 이미지 도메인 허용 (next/image)
images: {
remotePatterns: [
{ protocol: "https", hostname: "lh3.googleusercontent.com" }, // Google avatar
{ protocol: "https", hostname: "i.ytimg.com" }, // YouTube thumbnail
{ protocol: "https", hostname: "yt3.ggpht.com" }, // YouTube channel avatar
],
},
}; };
export default nextConfig; export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"test": "jest",
"test:watch": "jest --watch"
}, },
"dependencies": { "dependencies": {
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
@@ -22,11 +24,17 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"jest": "^30.4.2",
"jest-environment-jsdom": "^30.4.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { getAdminToken } from "@/lib/admin-utils"; import { getAdminToken, consumeSseStream } from "@/lib/admin-utils";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@@ -209,30 +209,19 @@ export function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${getAdminToken()}` }, headers: { Authorization: `Bearer ${getAdminToken()}` },
}); });
const reader = res.body!.getReader(); // #351 — consumeSseStream으로 통일
const decoder = new TextDecoder(); await consumeSseStream(res, (raw) => {
let buf = ""; const evt = raw as { type: string; [k: string]: unknown };
while (true) { if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
const { done, value } = await reader.read(); setBulkTablingProgress(p => ({
if (done) break; ...p, current: evt.current as number, total: (evt.total as number) || p.total, name: evt.name as string,
buf += decoder.decode(value, { stream: true }); linked: evt.type === "done" ? p.linked + 1 : p.linked,
const lines = buf.split("\n"); notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
buf = lines.pop() || ""; }));
for (const line of lines) { } else if (evt.type === "complete") {
const m = line.match(/^data:(.+)$/); alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
if (!m) continue;
const evt = JSON.parse(m[1]);
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
setBulkTablingProgress(p => ({
...p, current: evt.current, total: evt.total || p.total, name: evt.name,
linked: evt.type === "done" ? p.linked + 1 : p.linked,
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
}));
} else if (evt.type === "complete") {
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
}
} }
} });
} catch (e) { alert("벌크 테이블링 실패: " + (e instanceof Error ? e.message : String(e))); } } catch (e) { alert("벌크 테이블링 실패: " + (e instanceof Error ? e.message : String(e))); }
finally { setBulkTabling(false); load(); } finally { setBulkTabling(false); load(); }
}} }}
@@ -287,30 +276,19 @@ export function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${getAdminToken()}` }, headers: { Authorization: `Bearer ${getAdminToken()}` },
}); });
const reader = res.body!.getReader(); // #351 — consumeSseStream으로 통일
const decoder = new TextDecoder(); await consumeSseStream(res, (raw) => {
let buf = ""; const evt = raw as { type: string; [k: string]: unknown };
while (true) { if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
const { done, value } = await reader.read(); setBulkCatchtableProgress(p => ({
if (done) break; ...p, current: evt.current as number, total: (evt.total as number) || p.total, name: evt.name as string,
buf += decoder.decode(value, { stream: true }); linked: evt.type === "done" ? p.linked + 1 : p.linked,
const lines = buf.split("\n"); notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
buf = lines.pop() || ""; }));
for (const line of lines) { } else if (evt.type === "complete") {
const m = line.match(/^data:(.+)$/); alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
if (!m) continue;
const evt = JSON.parse(m[1]);
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
setBulkCatchtableProgress(p => ({
...p, current: evt.current, total: evt.total || p.total, name: evt.name,
linked: evt.type === "done" ? p.linked + 1 : p.linked,
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
}));
} else if (evt.type === "complete") {
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}`);
}
} }
} });
} catch (e) { alert("벌크 캐치테이블 실패: " + (e instanceof Error ? e.message : String(e))); } } catch (e) { alert("벌크 캐치테이블 실패: " + (e instanceof Error ? e.message : String(e))); }
finally { setBulkCatchtable(false); load(); } finally { setBulkCatchtable(false); load(); }
}} }}

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { getAdminToken } from "@/lib/admin-utils"; import { getAdminToken, consumeSseStream } from "@/lib/admin-utils";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@@ -209,39 +209,25 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setBulkProgress(null); setBulkProgress(null);
return; return;
} }
const reader = resp.body?.getReader(); // #351 — consumeSseStream으로 통일
const decoder = new TextDecoder(); await consumeSseStream(resp, (raw) => {
if (!reader) { setRunning(false); return; } const ev = raw as { type: string; [k: string]: unknown };
if (ev.type === "processing") {
let buf = ""; setBulkProgress((p) => p ? { ...p, current: (ev.index as number) + 1, currentTitle: ev.title as string, waiting: undefined } : p);
while (true) { } else if (ev.type === "wait") {
const { done, value } = await reader.read(); setBulkProgress((p) => p ? { ...p, waiting: ev.delay as number } : p);
if (done) break; } else if (ev.type === "done") {
buf += decoder.decode(value, { stream: true }); const detail = isTranscript
const lines = buf.split("\n"); ? `${ev.source} / ${(ev.length as number)?.toLocaleString()}`
buf = lines.pop() || ""; : `${ev.restaurants}개 식당`;
for (const line of lines) { setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title as string, detail }] } : p);
if (!line.startsWith("data: ")) continue; } else if (ev.type === "error") {
try { setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title as string, detail: ev.message as string, error: true }] } : p);
const ev = JSON.parse(line.slice(6)); } else if (ev.type === "complete") {
if (ev.type === "processing") { setRunning(false);
setBulkProgress((p) => p ? { ...p, current: ev.index + 1, currentTitle: ev.title, waiting: undefined } : p); load();
} else if (ev.type === "wait") {
setBulkProgress((p) => p ? { ...p, waiting: ev.delay } : p);
} else if (ev.type === "done") {
const detail = isTranscript
? `${ev.source} / ${ev.length?.toLocaleString()}`
: `${ev.restaurants}개 식당`;
setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title, detail }] } : p);
} else if (ev.type === "error") {
setBulkProgress((p) => p ? { ...p, results: [...p.results, { title: ev.title, detail: ev.message, error: true }] } : p);
} else if (ev.type === "complete") {
setRunning(false);
load();
}
} catch { /* ignore */ }
} }
} });
setRunning(false); setRunning(false);
load(); load();
} catch { } catch {
@@ -264,30 +250,17 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setRebuildingVectors(false); setRebuildingVectors(false);
return; return;
} }
const reader = resp.body?.getReader(); // #351 — consumeSseStream으로 통일
const decoder = new TextDecoder(); await consumeSseStream(resp, (raw) => {
if (!reader) { setRebuildingVectors(false); return; } const ev = raw as { status?: string; type?: string; [k: string]: unknown };
let buf = ""; if (ev.status === "progress" || ev.type === "progress") {
while (true) { setVectorProgress({ phase: ev.phase as string, current: ev.current as number, total: ev.total as number, name: ev.name as string });
const { done, value } = await reader.read(); } else if (ev.status === "done" || ev.type === "done") {
if (done) break; setVectorProgress({ phase: "done", current: ev.total as number, total: ev.total as number });
buf += decoder.decode(value, { stream: true }); } else if (ev.type === "error") {
const lines = buf.split("\n"); alert(`벡터 재생성 오류: ${ev.message}`);
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.status === "progress" || ev.type === "progress") {
setVectorProgress({ phase: ev.phase, current: ev.current, total: ev.total, name: ev.name });
} else if (ev.status === "done" || ev.type === "done") {
setVectorProgress({ phase: "done", current: ev.total, total: ev.total });
} else if (ev.type === "error") {
alert(`벡터 재생성 오류: ${ev.message}`);
}
} catch { /* ignore */ }
} }
} });
setRebuildingVectors(false); setRebuildingVectors(false);
} catch { } catch {
setRebuildingVectors(false); setRebuildingVectors(false);
@@ -309,30 +282,17 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setRemappingCuisine(false); setRemappingCuisine(false);
return; return;
} }
const reader = resp.body?.getReader(); // #351 — consumeSseStream으로 통일
const decoder = new TextDecoder(); await consumeSseStream(resp, (raw) => {
if (!reader) { setRemappingCuisine(false); return; } const ev = raw as { type: string; [k: string]: unknown };
let buf = ""; if (ev.type === "processing" || ev.type === "batch_done") {
while (true) { setRemapProgress({ current: ev.current as number, total: ev.total as number, updated: (ev.updated as number) || 0 });
const { done, value } = await reader.read(); } else if (ev.type === "complete") {
if (done) break; setRemapProgress({ current: ev.total as number, total: ev.total as number, updated: ev.updated as number });
buf += decoder.decode(value, { stream: true }); } else if (ev.type === "error") {
const lines = buf.split("\n"); alert(`재분류 오류: ${ev.message}`);
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.type === "processing" || ev.type === "batch_done") {
setRemapProgress({ current: ev.current, total: ev.total, updated: ev.updated || 0 });
} else if (ev.type === "complete") {
setRemapProgress({ current: ev.total, total: ev.total, updated: ev.updated });
} else if (ev.type === "error") {
alert(`재분류 오류: ${ev.message}`);
}
} catch { /* ignore */ }
} }
} });
setRemappingCuisine(false); setRemappingCuisine(false);
} catch { } catch {
setRemappingCuisine(false); setRemappingCuisine(false);
@@ -354,30 +314,17 @@ export function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
setRemappingFoods(false); setRemappingFoods(false);
return; return;
} }
const reader = resp.body?.getReader(); // #351 — consumeSseStream으로 통일
const decoder = new TextDecoder(); await consumeSseStream(resp, (raw) => {
if (!reader) { setRemappingFoods(false); return; } const ev = raw as { type: string; [k: string]: unknown };
let buf = ""; if (ev.type === "processing" || ev.type === "batch_done") {
while (true) { setFoodsProgress({ current: ev.current as number, total: ev.total as number, updated: (ev.updated as number) || 0 });
const { done, value } = await reader.read(); } else if (ev.type === "complete") {
if (done) break; setFoodsProgress({ current: ev.total as number, total: ev.total as number, updated: ev.updated as number });
buf += decoder.decode(value, { stream: true }); } else if (ev.type === "error") {
const lines = buf.split("\n"); alert(`메뉴 태그 재생성 오류: ${ev.message}`);
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.type === "processing" || ev.type === "batch_done") {
setFoodsProgress({ current: ev.current, total: ev.total, updated: ev.updated || 0 });
} else if (ev.type === "complete") {
setFoodsProgress({ current: ev.total, total: ev.total, updated: ev.updated });
} else if (ev.type === "error") {
alert(`메뉴 태그 재생성 오류: ${ev.message}`);
}
} catch { /* ignore */ }
} }
} });
setRemappingFoods(false); setRemappingFoods(false);
} catch { } catch {
setRemappingFoods(false); setRemappingFoods(false);

View File

@@ -41,8 +41,14 @@ export default function MyReviewsList({
</button> </button>
</div> </div>
<div className="flex gap-1 border-b"> {/* #343 — WAI-ARIA Tabs 패턴 */}
<div role="tablist" aria-label="내 활동" className="flex gap-1 border-b">
<button <button
role="tab"
id="tab-reviews"
aria-selected={tab === "reviews"}
aria-controls="panel-reviews"
tabIndex={tab === "reviews" ? 0 : -1}
onClick={() => setTab("reviews")} onClick={() => setTab("reviews")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${ className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "reviews" tab === "reviews"
@@ -54,6 +60,11 @@ export default function MyReviewsList({
({reviews.length}) ({reviews.length})
</button> </button>
<button <button
role="tab"
id="tab-memos"
aria-selected={tab === "memos"}
aria-controls="panel-memos"
tabIndex={tab === "memos" ? 0 : -1}
onClick={() => setTab("memos")} onClick={() => setTab("memos")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${ className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "memos" tab === "memos"
@@ -67,7 +78,8 @@ export default function MyReviewsList({
</div> </div>
{tab === "reviews" ? ( {tab === "reviews" ? (
reviews.length === 0 ? ( <div role="tabpanel" id="panel-reviews" aria-labelledby="tab-reviews">
{reviews.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center"> <p className="text-sm text-gray-500 py-8 text-center">
. .
</p> </p>
@@ -100,9 +112,11 @@ export default function MyReviewsList({
</button> </button>
))} ))}
</div> </div>
) )}
</div>
) : ( ) : (
memos.length === 0 ? ( <div role="tabpanel" id="panel-memos" aria-labelledby="tab-memos">
{memos.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center"> <p className="text-sm text-gray-500 py-8 text-center">
. .
</p> </p>
@@ -137,7 +151,8 @@ export default function MyReviewsList({
</button> </button>
))} ))}
</div> </div>
) )}
</div>
)} )}
</div> </div>
); );

View File

@@ -257,6 +257,9 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
<> <>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
{review.user_avatar_url && ( {review.user_avatar_url && (
// eslint-disable-next-line @next/next/no-img-element
// #343 — Google avatar URL은 remotePatterns에 추가됨.
// next/image 전환은 SSR/lazy 효과 미미한 5x5 아바타라 후속에서 일괄 적용.
<img <img
src={review.user_avatar_url} src={review.user_avatar_url}
alt="" alt=""