From fc4b891af65545b04767ee3b2e27e69dc87cf71f Mon Sep 17 00:00:00 2001 From: gituser Date: Mon, 19 Oct 2026 13:34:25 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20ABANDONED=20=EC=83=81=ED=83=9C=EB=A5=BC=20?= =?UTF-8?q?"=EC=A4=91=EB=8B=A8=EB=90=A8"=EC=9C=BC=EB=A1=9C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수정계획 §2-4 4번. 종합 화면의 평가 축·빈 값 문구·음성 영역(§2-4 1~3번)은 후속 계획(종합피드백-평가축-섹션노출-구현계획)과 PR #169가 우선해 이 PR에서 제외한다. Co-Authored-By: Claude Opus 5.5 --- .../feedback-page/model/interviewDisplay.ts | 1 + tests/interview-display.test.mjs | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 tests/interview-display.test.mjs diff --git a/src/features/feedback-page/model/interviewDisplay.ts b/src/features/feedback-page/model/interviewDisplay.ts index 5a34a09..7578756 100644 --- a/src/features/feedback-page/model/interviewDisplay.ts +++ b/src/features/feedback-page/model/interviewDisplay.ts @@ -28,6 +28,7 @@ const STATUS_LABELS: Record = { READY: "대기중", PENDING: "분석중", FAILED: "분석실패", + ABANDONED: "중단됨", }; const getSafeDate = (value?: string) => { diff --git a/tests/interview-display.test.mjs b/tests/interview-display.test.mjs new file mode 100644 index 0000000..b9b09c7 --- /dev/null +++ b/tests/interview-display.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import ts from "typescript"; + +// interviewDisplay가 쓰는 직무 라벨 상수는 이 테스트와 무관해 같은 값을 돌려주는 함수로 바꾼다. +const source = readFileSync(new URL("../src/features/feedback-page/model/interviewDisplay.ts", import.meta.url), "utf8") + .replace(/^import \{ getJobRoleLabel \} from .*$/m, "const getJobRoleLabel = (value) => value;"); +const { outputText } = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext } }); +const display = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`); + +test("ABANDONED status is labeled", () => { + assert.equal(display.getInterviewStatusLabel("ABANDONED"), "중단됨"); +}); From 599db8eebdad8784e6dc2a431bb2b86cfe5a8bd2 Mon Sep 17 00:00:00 2001 From: gituser Date: Mon, 19 Oct 2026 13:34:38 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=82=9C=EC=9D=B4=EB=8F=84=EB=A5=BC=20lev?= =?UTF-8?q?el=EB=A1=9C=20=EC=9D=BD=EA=B3=A0=20SOLO=20=EB=A9=B4=EC=A0=91?= =?UTF-8?q?=EA=B4=80=20=EC=9D=B4=EB=A6=84=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 난이도: getCareerLabel(career) → getLevelLabel(feedback.level ?? interview.persona.level) - FeedbackData에 level 추가(mode는 PR #169가 같은 타입에 추가해 중복 선언을 피함) - InterviewResponse.persona는 옵셔널로 읽음(API PR 머지 전에도 깨지지 않음) - SOLO 면접관 이름은 저장값 끝의 -{uuid}를 떼고 표시, MULTI는 personaName을 쓰지 않고 "면접관 N명" 유지 Co-Authored-By: Claude Opus 5.5 --- .../feedback-list/api/getAllInterviews.ts | 1 + .../feedback-list/model/useFeedbackList.ts | 4 +- .../feedback-page/model/interviewDisplay.ts | 38 ++++++++++--------- tests/interview-display.test.mjs | 21 ++++++++++ 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/features/feedback-page/feedback-list/api/getAllInterviews.ts b/src/features/feedback-page/feedback-list/api/getAllInterviews.ts index 5820719..fa0740c 100644 --- a/src/features/feedback-page/feedback-list/api/getAllInterviews.ts +++ b/src/features/feedback-page/feedback-list/api/getAllInterviews.ts @@ -78,6 +78,7 @@ export interface FeedbackData { interviewId?: number; sessionId?: string; style?: string; + level?: string; status?: string; totalScore?: number; intentAlignmentScore?: number; diff --git a/src/features/feedback-page/feedback-list/model/useFeedbackList.ts b/src/features/feedback-page/feedback-list/model/useFeedbackList.ts index ac6b3d2..65e7f57 100644 --- a/src/features/feedback-page/feedback-list/model/useFeedbackList.ts +++ b/src/features/feedback-page/feedback-list/model/useFeedbackList.ts @@ -7,13 +7,13 @@ import { type FeedbackData, } from "@/features/feedback-page/feedback-list/api/getAllInterviews"; import { - getCareerLabel, getFormattedDate, getInterviewStatusLabel, getInterviewStyleLabel, getInterviewModeLabel, getInterviewId, getInterviewerName, + getLevelLabel, sortInterviewsByCreatedAt, } from "@/features/feedback-page/model/interviewDisplay"; import { extractErrorMessage } from "@/shared/api/errorMessage"; @@ -81,7 +81,7 @@ const getListItems = ( styleLabel: getInterviewStyleLabel( feedback?.style ?? interview.persona?.type, ), - levelLabel: getCareerLabel(interview.persona?.career), + levelLabel: getLevelLabel(feedback?.level ?? interview.persona?.level), interviewerName, statusLabel: feedback ? getInterviewStatusLabel(feedback.status) diff --git a/src/features/feedback-page/model/interviewDisplay.ts b/src/features/feedback-page/model/interviewDisplay.ts index 7578756..a4a5a4a 100644 --- a/src/features/feedback-page/model/interviewDisplay.ts +++ b/src/features/feedback-page/model/interviewDisplay.ts @@ -96,27 +96,31 @@ export const getInterviewStyleLabel = (type?: string) => { return PERSONA_TYPE_LABELS[type] ?? "성향 미정"; }; -export const getCareerLabel = (career?: number) => { - if (career === undefined || career === null) { - return "미제공"; - } +const LEVEL_LABELS: Record = { + EASY: "쉬움", + NORMAL: "보통", + HARD: "어려움", +}; - if (career <= 1) { - return "쉬움"; - } +export const getLevelLabel = (level?: string | null) => + (level ? LEVEL_LABELS[level] : undefined) ?? "미제공"; - if (career <= 3) { - return "보통"; - } +// SOLO 페르소나 이름은 유일성을 위해 끝에 -{uuid}가 붙어 저장된다. +const PERSONA_NAME_UUID_SUFFIX = + /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - return "어려움"; -}; +export const getPersonaDisplayName = (personaName?: string | null) => + personaName?.trim().replace(PERSONA_NAME_UUID_SUFFIX, "").trim() ?? ""; + +export const getInterviewerName = (interview: InterviewSummary) => { + const count = getInterviewerCount(interview); + const personaName = + interview.mode === "MULTI" + ? "" + : getPersonaDisplayName(interview.persona?.personaName); -export const getInterviewerName = (interview: InterviewSummary) => - interview.persona?.personaName?.trim() || - (getInterviewerCount(interview) > 0 - ? `면접관 ${getInterviewerCount(interview)}명` - : "면접관 정보 없음"); + return personaName || (count > 0 ? `면접관 ${count}명` : "면접관 정보 없음"); +}; export const getInterviewStatusLabel = (status?: string) => { if (!status) { diff --git a/tests/interview-display.test.mjs b/tests/interview-display.test.mjs index b9b09c7..39f5fd9 100644 --- a/tests/interview-display.test.mjs +++ b/tests/interview-display.test.mjs @@ -9,6 +9,27 @@ const source = readFileSync(new URL("../src/features/feedback-page/model/intervi const { outputText } = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext } }); const display = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`); +test("level label reads EASY/NORMAL/HARD", () => { + assert.equal(display.getLevelLabel("EASY"), "쉬움"); + assert.equal(display.getLevelLabel("NORMAL"), "보통"); + assert.equal(display.getLevelLabel("HARD"), "어려움"); + for (const value of [undefined, null, "", "UNKNOWN"]) assert.equal(display.getLevelLabel(value), "미제공"); +}); + +test("SOLO interviewer name drops the uuid suffix", () => { + const persona = { id: 56, personaName: "김도윤-4898e87d-1c2b-4d5e-8f90-a1b2c3d4e5f6", type: "FRIENDLY", level: "EASY" }; + assert.equal(display.getInterviewerName({ mode: "SOLO", personaId: 56, persona }), "김도윤"); + assert.equal(display.getInterviewerName({ personaId: 56, persona: { ...persona, personaName: "김도윤" } }), "김도윤"); + assert.equal(display.getInterviewerName({ mode: "SOLO", personaId: 56, persona: { ...persona, personaName: "이름-abc" } }), "이름-abc"); +}); + +test("interviewer name falls back without persona and MULTI keeps the count", () => { + assert.equal(display.getInterviewerName({ mode: "SOLO", personaId: 56 }), "면접관 1명"); + assert.equal(display.getInterviewerName({ mode: "SOLO", personaId: 56, persona: null }), "면접관 1명"); + assert.equal(display.getInterviewerName({ mode: "MULTI", personaIds: [1, 2, 3], persona: { id: 1, personaName: "김도윤-4898e87d-1c2b-4d5e-8f90-a1b2c3d4e5f6" } }), "면접관 3명"); + assert.equal(display.getInterviewerName({ mode: "SOLO" }), "면접관 정보 없음"); +}); + test("ABANDONED status is labeled", () => { assert.equal(display.getInterviewStatusLabel("ABANDONED"), "중단됨"); }); From d5f681e5cf291dd64df35ec53338b36cd6d441ff Mon Sep 17 00:00:00 2001 From: gituser Date: Mon, 19 Oct 2026 13:34:50 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EC=A4=80(?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=C2=B7=EC=A2=85=ED=95=A9=20=EC=A0=90=EC=88=98?= =?UTF-8?q?)=EA=B3=BC=20=EB=B0=A9=ED=96=A5=20=EC=84=A0=ED=83=9D,=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=B5=9C=EC=8B=A0=EC=88=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sortFeedbackList(items, { key: date|score, order: asc|desc })로 분리, createdAt 타임스탬프로 비교 - 점수 없는 항목은 두 방향 모두 맨 뒤(그 안·동점은 최신순) - 정렬 상태를 URL 쿼리 sort·order에 replace로 저장, 기본값이면 쿼리 생략, 잘못된 값은 기본값 - 기준 셀렉트(정렬 기준) + 방향 토글 버튼(정렬 방향), 기준을 바꾸면 내림차순으로 Co-Authored-By: Claude Opus 5.5 --- .../feedback-list/model/sortFeedbackList.ts | 108 ++++++++++++++++++ .../feedback-list/model/useFeedbackList.ts | 1 + .../feedback-page/feedback-list/index.tsx | 83 +++++++++----- .../feedback-page/feedback-list/style.ts | 22 ++++ .../feedback-page/feedback-list/type.ts | 1 + tests/sort-feedback-list.test.mjs | 60 ++++++++++ 6 files changed, 244 insertions(+), 31 deletions(-) create mode 100644 src/features/feedback-page/feedback-list/model/sortFeedbackList.ts create mode 100644 tests/sort-feedback-list.test.mjs diff --git a/src/features/feedback-page/feedback-list/model/sortFeedbackList.ts b/src/features/feedback-page/feedback-list/model/sortFeedbackList.ts new file mode 100644 index 0000000..ec5ae5b --- /dev/null +++ b/src/features/feedback-page/feedback-list/model/sortFeedbackList.ts @@ -0,0 +1,108 @@ +import type { FeedbackListItem } from "@/widgets/feedback-page/feedback-list/type"; + +export type FeedbackListSortKey = "date" | "score"; +export type FeedbackListSortOrder = "asc" | "desc"; + +export interface FeedbackListSort { + key: FeedbackListSortKey; + order: FeedbackListSortOrder; +} + +export const DEFAULT_FEEDBACK_LIST_SORT: FeedbackListSort = { + key: "date", + order: "desc", +}; + +export const FEEDBACK_LIST_SORT_KEY_LABELS: Record = { + date: "날짜", + score: "종합 점수", +}; + +const SORT_ORDER_LABELS: Record> = { + date: { desc: "최신순", asc: "오래된 순" }, + score: { desc: "높은 점수순", asc: "낮은 점수순" }, +}; + +export const getFeedbackListSortLabel = ({ key, order }: FeedbackListSort) => + SORT_ORDER_LABELS[key][order]; + +const isSortKey = (value: string | null): value is FeedbackListSortKey => + value === "date" || value === "score"; + +const isSortOrder = (value: string | null): value is FeedbackListSortOrder => + value === "asc" || value === "desc"; + +// 쿼리가 없거나 값이 틀리면 기본값(날짜 · 내림차순)을 쓴다. +export const readFeedbackListSort = ( + searchParams: URLSearchParams, +): FeedbackListSort => { + const key = searchParams.get("sort"); + const order = searchParams.get("order"); + + return isSortKey(key) && isSortOrder(order) + ? { key, order } + : DEFAULT_FEEDBACK_LIST_SORT; +}; + +// 기본값이면 sort·order 쿼리를 붙이지 않는다. +export const writeFeedbackListSort = ( + searchParams: URLSearchParams, + sort: FeedbackListSort, +) => { + const next = new URLSearchParams(searchParams); + + if ( + sort.key === DEFAULT_FEEDBACK_LIST_SORT.key && + sort.order === DEFAULT_FEEDBACK_LIST_SORT.order + ) { + next.delete("sort"); + next.delete("order"); + } else { + next.set("sort", sort.key); + next.set("order", sort.order); + } + + return next; +}; + +const getCreatedTime = (item: FeedbackListItem) => { + const time = item.createdAt ? Date.parse(item.createdAt) : Number.NaN; + + return Number.isNaN(time) ? 0 : time; +}; + +const getScore = (item: FeedbackListItem) => + typeof item.totalScore === "number" && Number.isFinite(item.totalScore) + ? item.totalScore + : null; + +export const sortFeedbackList = ( + items: readonly T[], + { key, order }: FeedbackListSort, +): T[] => { + const direction = order === "asc" ? 1 : -1; + const latestFirst = (left: T, right: T) => + getCreatedTime(right) - getCreatedTime(left); + + if (key === "date") { + return [...items].sort( + (left, right) => direction * (getCreatedTime(left) - getCreatedTime(right)), + ); + } + + // 점수가 없는 항목은 방향과 관계없이 맨 뒤, 그 안과 동점은 최신순. + return [...items].sort((left, right) => { + const leftScore = getScore(left); + const rightScore = getScore(right); + + if (leftScore === null || rightScore === null) { + if (leftScore !== rightScore) { + return leftScore === null ? 1 : -1; + } + + return latestFirst(left, right); + } + + return direction * (leftScore - rightScore) || latestFirst(left, right); + }); +}; diff --git a/src/features/feedback-page/feedback-list/model/useFeedbackList.ts b/src/features/feedback-page/feedback-list/model/useFeedbackList.ts index 65e7f57..40b9459 100644 --- a/src/features/feedback-page/feedback-list/model/useFeedbackList.ts +++ b/src/features/feedback-page/feedback-list/model/useFeedbackList.ts @@ -70,6 +70,7 @@ const getListItems = ( id: targetInterviewId, companyName: readCompanyBadge(interview.company)?.displayName, date: getFormattedDate(interview.createdAt), + createdAt: interview.createdAt, imageUrl: interview.persona?.imageUrl ?? getInterviewerImageUrl( diff --git a/src/widgets/feedback-page/feedback-list/index.tsx b/src/widgets/feedback-page/feedback-list/index.tsx index 1e9d607..e58f7ea 100644 --- a/src/widgets/feedback-page/feedback-list/index.tsx +++ b/src/widgets/feedback-page/feedback-list/index.tsx @@ -1,16 +1,17 @@ -import { useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { + FEEDBACK_LIST_SORT_KEY_LABELS, + getFeedbackListSortLabel, + readFeedbackListSort, + sortFeedbackList, + writeFeedbackListSort, + type FeedbackListSort, + type FeedbackListSortKey, +} from "@/features/feedback-page/feedback-list/model/sortFeedbackList"; import * as S from "./style"; import type { FeedbackListProps } from "./type"; -type SortOrder = "oldest" | "latest"; - -const getDateTime = (date: string) => { - const parsedDate = new Date(date.split("/").join("-")); - - return Number.isNaN(parsedDate.getTime()) ? 0 : parsedDate.getTime(); -}; - const getNormalizedStatusLabel = (statusLabel: string) => statusLabel.replace(/\s/g, ""); @@ -40,37 +41,57 @@ const FeedbackList = ({ onItemClick, }: InternalProps) => { const navigate = useNavigate(); - const [sortOrder, setSortOrder] = useState("oldest"); + const [searchParams, setSearchParams] = useSearchParams(); + const sort = readFeedbackListSort(searchParams); const sortedItems = useMemo( - () => - [...items].sort((left, right) => { - const leftTime = getDateTime(left.date); - const rightTime = getDateTime(right.date); - - return sortOrder === "oldest" - ? leftTime - rightTime - : rightTime - leftTime; - }), - [items, sortOrder], + () => sortFeedbackList(items, readFeedbackListSort(searchParams)), + [items, searchParams], ); + const updateSort = (nextSort: FeedbackListSort) => { + setSearchParams( + (current) => writeFeedbackListSort(current, nextSort), + { replace: true }, + ); + }; return ( {title} - - - setSortOrder(event.target.value as SortOrder) + + + + // 기준을 바꾸면 방향은 내림차순으로 되돌린다. + updateSort({ + key: event.target.value as FeedbackListSortKey, + order: "desc", + }) + } + > + {(Object.keys(FEEDBACK_LIST_SORT_KEY_LABELS) as FeedbackListSortKey[]).map((key) => ( + + ))} + + + + updateSort({ + key: sort.key, + order: sort.order === "desc" ? "asc" : "desc", + }) } > - - - - + {getFeedbackListSortLabel(sort)} + + {isLoading ? ( diff --git a/src/widgets/feedback-page/feedback-list/style.ts b/src/widgets/feedback-page/feedback-list/style.ts index 63db007..3c8bfc1 100644 --- a/src/widgets/feedback-page/feedback-list/style.ts +++ b/src/widgets/feedback-page/feedback-list/style.ts @@ -87,6 +87,28 @@ export const SortSelect = styled.select` ${focusRing} `; +export const SortControls = styled.div` + display: inline-flex; + align-items: center; + gap: 0.5rem; +`; + +export const SortDirectionButton = styled.button` + min-height: 2rem; + border: 0.0625rem solid rgba(26, 115, 232, 0.32); + border-radius: 0.35rem; + background: transparent; + color: #1a73e8; + cursor: pointer; + font-family: inherit; + font-size: 1rem; + font-weight: 800; + line-height: 1; + padding: 0.3rem 0.6rem; + + ${focusRing} +`; + export const List = styled.div` width: 100%; display: grid; diff --git a/src/widgets/feedback-page/feedback-list/type.ts b/src/widgets/feedback-page/feedback-list/type.ts index a001b51..c310585 100644 --- a/src/widgets/feedback-page/feedback-list/type.ts +++ b/src/widgets/feedback-page/feedback-list/type.ts @@ -2,6 +2,7 @@ export interface FeedbackListItem { companyName?: string; id: number; date: string; + createdAt?: string; imageUrl: string; title: string; styleLabel: string; diff --git a/tests/sort-feedback-list.test.mjs b/tests/sort-feedback-list.test.mjs new file mode 100644 index 0000000..b1fe68e --- /dev/null +++ b/tests/sort-feedback-list.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import ts from "typescript"; + +const source = readFileSync(new URL("../src/features/feedback-page/feedback-list/model/sortFeedbackList.ts", import.meta.url), "utf8"); +const { outputText } = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext } }); +const sorter = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`); +const { sortFeedbackList, readFeedbackListSort, writeFeedbackListSort, getFeedbackListSortLabel } = sorter; + +const item = (id, createdAt, totalScore) => ({ id, createdAt, totalScore, date: createdAt.slice(0, 10) }); +const items = [ + item(1, "2026-10-17T09:00:00Z", 70), + item(2, "2026-10-19T03:00:00Z", 89), + item(3, "2026-10-19T12:10:00Z", undefined), + item(4, "2026-10-18T05:00:00Z", 89), + item(5, "2026-10-16T05:00:00Z", undefined), + item(6, "2026-10-19T08:00:00Z", 55), +]; +const ids = (key, order) => sortFeedbackList(items, { key, order }).map((entry) => entry.id); + +test("four combinations", () => { + assert.deepEqual(ids("date", "desc"), [3, 6, 2, 4, 1, 5]); + assert.deepEqual(ids("date", "asc"), [5, 1, 4, 2, 6, 3]); + assert.deepEqual(ids("score", "desc"), [2, 4, 1, 6, 3, 5]); + assert.deepEqual(ids("score", "asc"), [6, 1, 2, 4, 3, 5]); +}); +test("same day at different times is ordered by timestamp", () => { + const sameDay = ids("date", "desc").filter((id) => [2, 3, 6].includes(id)); + assert.deepEqual(sameDay, [3, 6, 2]); +}); +test("missing scores stay last in both directions, latest first", () => { + for (const order of ["asc", "desc"]) assert.deepEqual(ids("score", order).slice(-2), [3, 5]); +}); +test("ties on score break by latest first", () => { + for (const order of ["asc", "desc"]) { + const tied = ids("score", order).filter((id) => id === 2 || id === 4); + assert.deepEqual(tied, [2, 4]); + } +}); +test("does not mutate input", () => { + const copy = [...items]; + sortFeedbackList(items, { key: "score", order: "asc" }); + assert.deepEqual(items, copy); +}); +test("query read/write: default omitted, invalid falls back", () => { + assert.deepEqual(readFeedbackListSort(new URLSearchParams("")), { key: "date", order: "desc" }); + assert.deepEqual(readFeedbackListSort(new URLSearchParams("sort=score&order=asc")), { key: "score", order: "asc" }); + for (const query of ["sort=name&order=asc", "sort=score", "sort=score&order=up"]) { + assert.deepEqual(readFeedbackListSort(new URLSearchParams(query)), { key: "date", order: "desc" }); + } + assert.equal(writeFeedbackListSort(new URLSearchParams("sort=score&order=asc&x=1"), { key: "date", order: "desc" }).toString(), "x=1"); + assert.equal(writeFeedbackListSort(new URLSearchParams(""), { key: "score", order: "desc" }).toString(), "sort=score&order=desc"); +}); +test("labels", () => { + assert.deepEqual( + [["date", "desc"], ["date", "asc"], ["score", "desc"], ["score", "asc"]].map(([key, order]) => getFeedbackListSortLabel({ key, order })), + ["최신순", "오래된 순", "높은 점수순", "낮은 점수순"], + ); +}); From dd7e3009510ed7c0b7d6f84eb29e737417b3ac69 Mon Sep 17 00:00:00 2001 From: gituser Date: Mon, 19 Oct 2026 13:34:59 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=91=9C=EC=8B=9C=C2=B7=EC=A0=95=EB=A0=AC?= =?UTF-8?q?=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=8B=9C=EB=82=98?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 성향/난이도(피드백 없는 면접 포함), SOLO 이름 접미사 제거, MULTI 인원 수, 중단됨 - 정렬 기본값·쿼리 생략, 점수 정렬·방향 토글, 종합 진입 후 뒤로 유지, 잘못된 쿼리 기본값 Co-Authored-By: Claude Opus 5.5 --- tests/feedback-list.browser.mjs | 107 ++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/feedback-list.browser.mjs diff --git a/tests/feedback-list.browser.mjs b/tests/feedback-list.browser.mjs new file mode 100644 index 0000000..31f2b92 --- /dev/null +++ b/tests/feedback-list.browser.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { pathToFileURL } from "node:url"; + +const modulePath = process.env.PLAYWRIGHT_MODULE_PATH; +const { chromium } = await import(modulePath ? pathToFileURL(modulePath).href : "playwright"); +const origin = process.env.INTERVIEW_TEST_ORIGIN ?? "http://127.0.0.1:5173"; +const browser = await chromium.launch({ + headless: true, + ...(process.env.BROWSER_EXECUTABLE_PATH ? { executablePath: process.env.BROWSER_EXECUTABLE_PATH } : {}), +}); +let passed = 0; +const report = (label) => { passed++; console.log(`PASS ${label}`); }; + +const solo56 = { + id: 56, mode: "SOLO", status: "COMPLETED", personaId: 56, createdAt: "2026-10-19T03:10:00Z", sessionId: "s56", + persona: { id: 56, personaName: "김도윤-4898e87d-1c2b-4d5e-8f90-a1b2c3d4e5f6", personaRole: "TECH", type: "FRIENDLY", level: "EASY", tone: "SOFT" }, +}; +const listInterviews = [ + solo56, + { id: 55, mode: "SOLO", status: "COMPLETED", personaId: 55, createdAt: "2026-10-19T01:00:00Z", sessionId: "s55", + persona: { id: 55, personaName: "박서연-0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0", type: "METICULOUS", level: "HARD" } }, + { id: 54, mode: "MULTI", status: "COMPLETED", personaIds: [1, 2, 3], createdAt: "2026-10-18T05:00:00Z", sessionId: "s54", + persona: { id: 1, personaName: "TECH-0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0", type: "REALISTIC", level: "NORMAL" } }, + { id: 53, mode: "SOLO", status: "ABANDONED", personaId: 53, createdAt: "2026-10-17T05:00:00Z" }, +]; +const listFeedbacks = [ + { interviewId: 56, sessionId: "s56", status: "SUCCEEDED", style: "FRIENDLY", level: "EASY", totalScore: 89 }, + { interviewId: 54, sessionId: "s54", status: "SUCCEEDED", style: "REALISTIC", level: "NORMAL", totalScore: 95 }, +]; + +async function open(path) { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await context.newPage(); + page.setDefaultTimeout(8_000); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/*", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (!pathname.startsWith("/api/")) return route.continue(); + const json = (body, code = 200) => route.fulfill({ status: code, contentType: "application/json", body: JSON.stringify(body) }); + if (pathname.endsWith("/refresh")) return json({ accessToken: "test-only-token" }); + if (pathname.endsWith("/me")) return json({ data: { id: 7, name: "테스트", nickname: "테스트", email: "test@example.com" } }); + if (pathname === "/api/interviews/getAll") return json({ success: true, data: listInterviews }); + if (pathname === "/api/feedbacks/getAll") return json({ success: true, data: listFeedbacks }); + if (pathname === "/api/answer/getAll") return json({ success: true, data: [] }); + if (pathname === "/api/feedbacks") return json({ success: true, data: { ...listFeedbacks[0], feedbacks: [] } }); + return json({ success: true, data: [] }); + }); + await page.goto(`${origin}${path}`); + return { page, close: async () => { assert.deepEqual(errors, []); await context.close(); } }; +} + +const cardOrder = async (page) => { + await page.locator("button img[alt$=' 이미지']").first().waitFor(); + return page.evaluate(() => [...document.querySelectorAll("button img[alt$=' 이미지']")].map((img) => img.alt.replace(/ 이미지$/, ""))); +}; + +// 2-5 + 2-6 목록 +{ + const f = await open("/main/feedback/list"); + assert.deepEqual(await cardOrder(f.page), ["김도윤", "박서연", "면접관 3명", "면접관 1명"]); + const card56 = f.page.locator("button", { hasText: "김도윤" }).first(); + for (const label of ["친근한", "쉬움", "89점"]) await card56.getByText(label, { exact: true }).first().waitFor(); + const card55 = f.page.locator("button", { hasText: "박서연" }).first(); + for (const label of ["꼼꼼한", "어려움"]) await card55.getByText(label, { exact: true }).first().waitFor(); + assert.equal(await f.page.locator("button", { hasText: "TECH-" }).count(), 0); + await f.page.getByText("중단됨", { exact: true }).waitFor(); + assert.equal(new URL(f.page.url()).search, ""); + assert.equal(await f.page.getByRole("button", { name: "정렬 방향" }).innerText(), "최신순"); + report("list: default latest, no query, 친근한/쉬움, 김도윤 without suffix, MULTI count, persona-less 면접관 1명, 중단됨"); + + const initialHistoryLength = await f.page.evaluate(() => history.length); + await f.page.getByRole("combobox", { name: "정렬 기준" }).selectOption("score"); + assert.equal(await f.page.getByRole("button", { name: "정렬 방향" }).innerText(), "높은 점수순"); + assert.equal(new URL(f.page.url()).search, "?sort=score&order=desc"); + assert.deepEqual(await cardOrder(f.page), ["면접관 3명", "김도윤", "박서연", "면접관 1명"]); + await f.page.getByRole("button", { name: "정렬 방향" }).click(); + assert.equal(await f.page.getByRole("button", { name: "정렬 방향" }).innerText(), "낮은 점수순"); + assert.deepEqual(await cardOrder(f.page), ["김도윤", "면접관 3명", "박서연", "면접관 1명"]); + const historyLength = await f.page.evaluate(() => history.length); + report("list: score sort, toggle to 낮은 점수순, missing scores last"); + + await f.page.locator("button", { hasText: "김도윤" }).first().click(); + await f.page.waitForURL("**/main/feedback/overall/56"); + await f.page.goBack(); + await f.page.waitForURL("**/main/feedback/list?sort=score&order=asc"); + assert.equal(await f.page.getByRole("button", { name: "정렬 방향" }).innerText(), "낮은 점수순"); + assert.equal(historyLength, initialHistoryLength); + report("list: sort kept after visiting overall and going back (replace, no extra history)"); + + await f.page.getByRole("combobox", { name: "정렬 기준" }).selectOption("date"); + assert.equal(await f.page.getByRole("button", { name: "정렬 방향" }).innerText(), "최신순"); + assert.equal(new URL(f.page.url()).search, ""); + await f.close(); + report("list: back to date resets to latest and drops query"); +} +{ + const f = await open("/main/feedback/list?sort=name&order=up"); + assert.deepEqual(await cardOrder(f.page), ["김도윤", "박서연", "면접관 3명", "면접관 1명"]); + assert.equal(await f.page.getByRole("button", { name: "정렬 방향" }).innerText(), "최신순"); + await f.close(); + report("list: invalid query falls back to default"); +} + +await browser.close(); +console.log(`${passed} passed`); From d818b61080bac79dcfdbc081c967900aa5c131f7 Mon Sep 17 00:00:00 2001 From: gituser Date: Mon, 19 Oct 2026 13:37:23 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EC=A0=95=EB=A0=AC=20=EB=B0=A9?= =?UTF-8?q?=ED=96=A5=20=EB=B2=84=ED=8A=BC=20=EC=A0=91=EA=B7=BC=EC=84=B1=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=97=90=20=ED=98=84=EC=9E=AC=20=EB=B0=A9?= =?UTF-8?q?=ED=96=A5=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aria-label을 고정 "정렬 방향"에서 "정렬 방향: {현재 조합 이름}"으로 변경(예: 정렬 방향: 최신순) - 브라우저 테스트 선택자·검증을 새 접근성 이름에 맞춤 Co-Authored-By: Claude Opus 5.5 --- .../feedback-page/feedback-list/index.tsx | 2 +- tests/feedback-list.browser.mjs | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/widgets/feedback-page/feedback-list/index.tsx b/src/widgets/feedback-page/feedback-list/index.tsx index e58f7ea..6b2bd2f 100644 --- a/src/widgets/feedback-page/feedback-list/index.tsx +++ b/src/widgets/feedback-page/feedback-list/index.tsx @@ -81,7 +81,7 @@ const FeedbackList = ({ updateSort({ key: sort.key, diff --git a/tests/feedback-list.browser.mjs b/tests/feedback-list.browser.mjs index 31f2b92..8f24eff 100644 --- a/tests/feedback-list.browser.mjs +++ b/tests/feedback-list.browser.mjs @@ -51,6 +51,14 @@ async function open(path) { return { page, close: async () => { assert.deepEqual(errors, []); await context.close(); } }; } +// 방향 토글의 접근성 이름은 "정렬 방향: {현재 조합}"이고 보이는 글자는 현재 조합 이름이다. +const expectDirection = async (page, label) => { + const button = page.getByRole("button", { name: `정렬 방향: ${label}`, exact: true }); + assert.equal(await button.innerText(), label); + assert.equal(await page.getByRole("button", { name: "정렬 방향", exact: true }).count(), 0); +}; +const directionButton = (page) => page.getByRole("button", { name: /^정렬 방향: / }); + const cardOrder = async (page) => { await page.locator("button img[alt$=' 이미지']").first().waitFor(); return page.evaluate(() => [...document.querySelectorAll("button img[alt$=' 이미지']")].map((img) => img.alt.replace(/ 이미지$/, ""))); @@ -67,16 +75,16 @@ const cardOrder = async (page) => { assert.equal(await f.page.locator("button", { hasText: "TECH-" }).count(), 0); await f.page.getByText("중단됨", { exact: true }).waitFor(); assert.equal(new URL(f.page.url()).search, ""); - assert.equal(await f.page.getByRole("button", { name: "정렬 방향" }).innerText(), "최신순"); + await expectDirection(f.page, "최신순"); report("list: default latest, no query, 친근한/쉬움, 김도윤 without suffix, MULTI count, persona-less 면접관 1명, 중단됨"); const initialHistoryLength = await f.page.evaluate(() => history.length); await f.page.getByRole("combobox", { name: "정렬 기준" }).selectOption("score"); - assert.equal(await f.page.getByRole("button", { name: "정렬 방향" }).innerText(), "높은 점수순"); + await expectDirection(f.page, "높은 점수순"); assert.equal(new URL(f.page.url()).search, "?sort=score&order=desc"); assert.deepEqual(await cardOrder(f.page), ["면접관 3명", "김도윤", "박서연", "면접관 1명"]); - await f.page.getByRole("button", { name: "정렬 방향" }).click(); - assert.equal(await f.page.getByRole("button", { name: "정렬 방향" }).innerText(), "낮은 점수순"); + await directionButton(f.page).click(); + await expectDirection(f.page, "낮은 점수순"); assert.deepEqual(await cardOrder(f.page), ["김도윤", "면접관 3명", "박서연", "면접관 1명"]); const historyLength = await f.page.evaluate(() => history.length); report("list: score sort, toggle to 낮은 점수순, missing scores last"); @@ -85,12 +93,12 @@ const cardOrder = async (page) => { await f.page.waitForURL("**/main/feedback/overall/56"); await f.page.goBack(); await f.page.waitForURL("**/main/feedback/list?sort=score&order=asc"); - assert.equal(await f.page.getByRole("button", { name: "정렬 방향" }).innerText(), "낮은 점수순"); + await expectDirection(f.page, "낮은 점수순"); assert.equal(historyLength, initialHistoryLength); report("list: sort kept after visiting overall and going back (replace, no extra history)"); await f.page.getByRole("combobox", { name: "정렬 기준" }).selectOption("date"); - assert.equal(await f.page.getByRole("button", { name: "정렬 방향" }).innerText(), "최신순"); + await expectDirection(f.page, "최신순"); assert.equal(new URL(f.page.url()).search, ""); await f.close(); report("list: back to date resets to latest and drops query"); @@ -98,7 +106,7 @@ const cardOrder = async (page) => { { const f = await open("/main/feedback/list?sort=name&order=up"); assert.deepEqual(await cardOrder(f.page), ["김도윤", "박서연", "면접관 3명", "면접관 1명"]); - assert.equal(await f.page.getByRole("button", { name: "정렬 방향" }).innerText(), "최신순"); + await expectDirection(f.page, "최신순"); await f.close(); report("list: invalid query falls back to default"); }