From 9e63d480e1443d91904ae396fa88385336da3d1f Mon Sep 17 00:00:00 2001 From: gituser Date: Mon, 19 Oct 2026 13:33:28 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=91=20=EB=AC=B8=ED=95=AD=20=EC=88=98=EB=A5=BC=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EC=A7=88=EB=AC=B8=20=EC=88=98=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SOLO 진행 화면이 면접 생성 시점 스냅샷의 질문 수와 폴백값 10을 써서 "Question 01 / 10"으로 보였다. 질문 준비 완료 때 세션에 들어오는 totalQuestionCount를 쓰고, 개수를 모르면(0) 분모를 "--"로 표시한다. Co-Authored-By: Claude Opus 5.5 --- .../interview/interview-content/index.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/widgets/interview-page/interview/interview-content/index.tsx b/src/widgets/interview-page/interview/interview-content/index.tsx index 480d4d92..edbd3b06 100644 --- a/src/widgets/interview-page/interview/interview-content/index.tsx +++ b/src/widgets/interview-page/interview/interview-content/index.tsx @@ -6,7 +6,6 @@ import QuestionAudioControl, { QuestionAudioError } from "../question-audio-cont const TEXT_ANSWER_MAX_LENGTH = 10000; const TEXT_ANSWER_PLACEHOLDER = "이곳에 답변을 입력해주세요. 자신의 경험과 성과를 구체적인 수치와 함께 작성하면 좋은 평가를 받을 수 있습니다."; -const FALLBACK_TOTAL_QUESTION_COUNT = 10; const formatQuestionLabel = ({ currentQuestionNumber, @@ -29,10 +28,10 @@ const formatQuestionLabel = ({ ? questionNumber : `${questionNumber}-${followUpQuestionNumber}`; - return `Question ${questionLabel} / ${String(totalQuestionCount).padStart( - 2, - "0", - )}`; + const totalLabel = + totalQuestionCount > 0 ? String(totalQuestionCount).padStart(2, "0") : "--"; + + return `Question ${questionLabel} / ${totalLabel}`; }; const InterviewContentView = () => { @@ -49,14 +48,13 @@ const InterviewContentView = () => { onAnswerTextChange, onSubmitText, preparedInterview, + totalQuestionCount, voiceLevel, } = useInterviewSessionContext(); const isVoiceMode = mode === "voice"; const isTextMode = mode === "text"; const visualizerLevel = isVoiceStarted ? voiceLevel : 0.22; const isQuestionLoading = currentQuestion === null; - const totalQuestionCount = - preparedInterview?.questions.length || FALLBACK_TOTAL_QUESTION_COUNT; const currentQuestionNumber = Math.max(displayQuestionNumber, 1); const activeInterviewer = preparedInterview?.interviewers?.find( (interviewer) => interviewer.personaId === currentQuestion?.personaId, From eccf5705c4833d2de89c8b8a69b5bb9c9cd05c51 Mon Sep 17 00:00:00 2001 From: gituser Date: Mon, 19 Oct 2026 13:33:28 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EB=A9=B4=EC=A0=91=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=ED=9B=84=20=ED=95=B4=EB=8B=B9=20=EB=A9=B4=EC=A0=91?= =?UTF-8?q?=EC=9D=98=20=EC=A2=85=ED=95=A9=20=ED=94=BC=EB=93=9C=EB=B0=B1?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 완료 이동 state에 interviewId를 넣고, "피드백 보러가기"가 /main/feedback/overall/{id}로 fromCompletion state와 함께 이동한다. state가 없으면(새로고침 등) 지금처럼 목록으로 간다. - 면접 COMPLETED는 Chat → API가 비동기로 반영하므로, fromCompletion으로 들어왔는데 아직 IN_PROGRESS면 GET /api/interviews/get을 3초 간격으로 최대 60초 다시 조회하고 그동안 "면접 기록을 저장하고 있어요"를 보여 준다. COMPLETED가 되면 기존 피드백 조회·폴링으로 넘어가고, 60초를 넘기면 기존 오류 문구를 보여 준다. - 재조회 요청마다 시간 제한을 두고, 재시도할 수 없는 오류(4xx)는 바로 멈추고 서버 오류 문구를 보여 준다. Co-Authored-By: Claude Opus 5.5 --- .../feedback-page/api/getInterviewStatus.ts | 30 ++++++ .../model/useFeedbackInterview.ts | 98 ++++++++++++++++++- .../interview/model/useInterviewSession.ts | 4 +- .../feedback-page/feedback-overall/index.tsx | 5 +- .../completed/completed-content/index.tsx | 27 ++++- 5 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 src/features/feedback-page/api/getInterviewStatus.ts diff --git a/src/features/feedback-page/api/getInterviewStatus.ts b/src/features/feedback-page/api/getInterviewStatus.ts new file mode 100644 index 00000000..ebd3f557 --- /dev/null +++ b/src/features/feedback-page/api/getInterviewStatus.ts @@ -0,0 +1,30 @@ +import { apiInstance } from "@/shared/api/axiosInstance"; + +const GET_INTERVIEW_URL = "/api/interviews/get"; + +const readStatus = (value: unknown) => { + if (!value || typeof value !== "object") { + return null; + } + + const { status } = value as { status?: unknown }; + return typeof status === "string" ? status : null; +}; + +export const getInterviewStatus = async ( + interviewId: number, + { signal, timeout }: { signal?: AbortSignal; timeout?: number } = {}, +) => { + if (!Number.isSafeInteger(interviewId) || interviewId <= 0) { + throw new Error("조회할 면접 정보가 없습니다."); + } + + const response = await apiInstance.get(GET_INTERVIEW_URL, { + params: { interviewId }, + signal, + timeout, + }); + const payload = response.data as { data?: unknown } | null; + + return readStatus(payload?.data) ?? readStatus(payload); +}; diff --git a/src/features/feedback-page/model/useFeedbackInterview.ts b/src/features/feedback-page/model/useFeedbackInterview.ts index 4b5de974..35d50d33 100644 --- a/src/features/feedback-page/model/useFeedbackInterview.ts +++ b/src/features/feedback-page/model/useFeedbackInterview.ts @@ -1,10 +1,12 @@ import axios from "axios"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; import { getInterviewAnswers, type InterviewAnswer, } from "@/features/feedback-page/api/getInterviewAnswers"; +import { getInterviewStatus } from "@/features/feedback-page/api/getInterviewStatus"; import { getAllFeedbacks, @@ -21,7 +23,69 @@ import { } from "@/features/feedback-page/model/interviewDisplay"; import { extractErrorMessage } from "@/shared/api/errorMessage"; +const INTERVIEW_SAVE_CHECK_INTERVAL_MS = 3_000; +const INTERVIEW_SAVE_CHECK_TIMEOUT_MS = 60_000; +const INTERVIEW_SAVE_REQUEST_TIMEOUT_MS = 15_000; + +const isFromCompletion = (state: unknown) => + !!state && typeof state === "object" && + (state as { fromCompletion?: unknown }).fromCompletion === true; + +const isRetryableError = (error: unknown) => { + if (!axios.isAxiosError(error) || axios.isCancel(error)) return false; + const status = error.response?.status; + return !error.response || status === 408 || status === 429 || + (status !== undefined && status >= 500 && status < 600); +}; + +const waitFor = (ms: number, signal: AbortSignal) => new Promise((resolve) => { + const handleAbort = () => { + window.clearTimeout(timeoutId); + resolve(); + }; + const timeoutId = window.setTimeout(() => { + signal.removeEventListener("abort", handleAbort); + resolve(); + }, ms); + signal.addEventListener("abort", handleAbort, { once: true }); +}); + +// 완료 직후에는 Chat → API 저장이 끝나기 전이라 IN_PROGRESS일 수 있어 정해진 시간만 다시 조회한다. +const waitForInterviewCompletion = async (interviewId: number, signal: AbortSignal) => { + const deadline = Date.now() + INTERVIEW_SAVE_CHECK_TIMEOUT_MS; + let status = "IN_PROGRESS"; + let error: unknown = null; + + while (!signal.aborted) { + const remainingTime = deadline - Date.now(); + if (remainingTime <= 0) break; + + await waitFor(Math.min(INTERVIEW_SAVE_CHECK_INTERVAL_MS, remainingTime), signal); + if (signal.aborted) break; + + try { + // 응답이 멈춰도 60초 기한을 크게 넘기지 않도록 요청마다 시간 제한을 둔다. + const requestTimeout = Math.min( + INTERVIEW_SAVE_REQUEST_TIMEOUT_MS, + Math.max(deadline - Date.now(), 1_000), + ); + status = (await getInterviewStatus(interviewId, { signal, timeout: requestTimeout })) ?? status; + if (status !== "IN_PROGRESS") break; + } catch (requestError) { + if (signal.aborted) break; + if (!isRetryableError(requestError)) { + error = requestError; + break; + } + } + } + + return { status, error }; +}; + export const useFeedbackInterview = (id?: string, includePreviousFeedback = false) => { + const location = useLocation(); + const fromCompletion = isFromCompletion(location.state); const [answers, setAnswers] = useState([]); const [answersErrorMessage, setAnswersErrorMessage] = useState(null); const [interview, setInterview] = useState(null); @@ -32,15 +96,20 @@ export const useFeedbackInterview = (id?: string, includePreviousFeedback = fals ); const [isLoading, setIsLoading] = useState(true); const [isRetryingFeedback, setIsRetryingFeedback] = useState(false); + const [isSavingInterview, setIsSavingInterview] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const requestIdRef = useRef(0); const isRetryingFeedbackRef = useRef(false); + const saveCheckControllerRef = useRef(null); const loadInterview = useCallback(async () => { const requestId = ++requestIdRef.current; const interviewId = Number(id); + saveCheckControllerRef.current?.abort(); + saveCheckControllerRef.current = null; isRetryingFeedbackRef.current = false; setIsRetryingFeedback(false); + setIsSavingInterview(false); setAnswers([]); setAnswersErrorMessage(null); setFeedback(null); @@ -78,11 +147,29 @@ export const useFeedbackInterview = (id?: string, includePreviousFeedback = fals if (interviewsResult.status === "fulfilled") { const sortedInterviews = sortInterviewsByCreatedAt(interviewsResult.value); - const nextInterview = + let nextInterview = sortedInterviews.find((item) => getInterviewId(item) === interviewId) ?? null; setInterview(nextInterview); + let saveCheckError: unknown = null; + + if (fromCompletion && nextInterview?.status === "IN_PROGRESS") { + const controller = new AbortController(); + saveCheckControllerRef.current = controller; + setIsSavingInterview(true); + const result = await waitForInterviewCompletion(interviewId, controller.signal); + + if (requestId !== requestIdRef.current) { + return; + } + + saveCheckControllerRef.current = null; + setIsSavingInterview(false); + nextInterview = { ...nextInterview, status: result.status }; + saveCheckError = result.error; + setInterview(nextInterview); + } if (nextInterview?.createdAt && historyResult.status === "fulfilled") { const currentTime = new Date(nextInterview.createdAt).getTime(); @@ -106,6 +193,10 @@ export const useFeedbackInterview = (id?: string, includePreviousFeedback = fals if (!nextInterview) { setErrorMessage("면접 정보를 찾을 수 없습니다."); + } else if (saveCheckError) { + setFeedbackErrorMessage( + extractErrorMessage(saveCheckError, "면접 상태를 확인하지 못했습니다."), + ); } else if (nextInterview.status !== "COMPLETED") { setFeedbackErrorMessage("아직 완료되지 않은 면접입니다. 면접을 마친 뒤 피드백을 확인해주세요."); } else { @@ -138,7 +229,7 @@ export const useFeedbackInterview = (id?: string, includePreviousFeedback = fals } setIsLoading(false); - }, [id, includePreviousFeedback]); + }, [fromCompletion, id, includePreviousFeedback]); const retryFeedback = useCallback(async () => { if (feedback?.status !== "FAILED") { @@ -200,6 +291,8 @@ export const useFeedbackInterview = (id?: string, includePreviousFeedback = fals return () => { window.clearTimeout(timeoutId); requestIdRef.current += 1; + saveCheckControllerRef.current?.abort(); + saveCheckControllerRef.current = null; }; }, [loadInterview]); @@ -281,6 +374,7 @@ export const useFeedbackInterview = (id?: string, includePreviousFeedback = fals previousFeedback, isLoading, isRetryingFeedback, + isSavingInterview, refetch: loadInterview, retryFeedback, }; diff --git a/src/features/interview-page/interview/model/useInterviewSession.ts b/src/features/interview-page/interview/model/useInterviewSession.ts index f921768d..5cdf1c93 100644 --- a/src/features/interview-page/interview/model/useInterviewSession.ts +++ b/src/features/interview-page/interview/model/useInterviewSession.ts @@ -504,7 +504,9 @@ export const useInterviewSession = ( if (shouldNavigate && closeReasonRef.current === reason && !hasNavigatedRef.current) { hasNavigatedRef.current = true; navigate(getInterviewExitPath(reason), { - state: reason === "completed" ? { answeredQuestionCount: displayQuestionNumberRef.current } : undefined, + state: reason === "completed" + ? { answeredQuestionCount: displayQuestionNumberRef.current, interviewId: preparedInterview?.interviewId ?? null } + : undefined, }); } return true; diff --git a/src/pages/feedback-page/feedback-overall/index.tsx b/src/pages/feedback-page/feedback-overall/index.tsx index 2697f73f..df187477 100644 --- a/src/pages/feedback-page/feedback-overall/index.tsx +++ b/src/pages/feedback-page/feedback-overall/index.tsx @@ -19,6 +19,7 @@ const FeedbackOverallPage = () => { previousFeedback, isLoading, isRetryingFeedback, + isSavingInterview, refetch, retryFeedback, } = useFeedbackInterview(id, true); @@ -55,7 +56,9 @@ const FeedbackOverallPage = () => { {isLoading ? ( - 면접 정보를 불러오는 중입니다. + + {isSavingInterview ? "면접 기록을 저장하고 있어요" : "면접 정보를 불러오는 중입니다."} + ) : errorMessage || !interview ? ( diff --git a/src/widgets/interview-page/completed/completed-content/index.tsx b/src/widgets/interview-page/completed/completed-content/index.tsx index ee7cc357..e0226023 100644 --- a/src/widgets/interview-page/completed/completed-content/index.tsx +++ b/src/widgets/interview-page/completed/completed-content/index.tsx @@ -15,10 +15,35 @@ const getAnsweredQuestionCount = (state: unknown) => { : null; }; +const getCompletedInterviewId = (state: unknown) => { + if (!state || typeof state !== "object") { + return null; + } + + const { interviewId } = state as { interviewId?: unknown }; + return typeof interviewId === "number" && + Number.isSafeInteger(interviewId) && + interviewId > 0 + ? interviewId + : null; +}; + const InterviewCompletedContent = () => { const navigate = useNavigate(); const location = useLocation(); const answeredQuestionCount = getAnsweredQuestionCount(location.state); + const interviewId = getCompletedInterviewId(location.state); + + const handleOpenFeedback = () => { + if (interviewId === null) { + navigate("/main/feedback/list"); + return; + } + + navigate(`/main/feedback/overall/${interviewId}`, { + state: { fromCompletion: true }, + }); + }; return ( @@ -36,7 +61,7 @@ const InterviewCompletedContent = () => { navigate("/main/feedback/list")} + onClick={handleOpenFeedback} > 피드백 보러가기 From 99f06f14719743060e272c086b83dc10474f3027 Mon Sep 17 00:00:00 2001 From: gituser Date: Mon, 19 Oct 2026 13:33:28 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=EB=AC=B8=ED=95=AD=20=EC=88=98=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EC=99=80=20=EB=A9=B4=EC=A0=91=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=ED=9B=84=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SOLO 텍스트·음성 문항 수, 개수 미상 "--", MULTI 표시 회귀 - 완료 → 종합 피드백 이동, state 없는 완료 화면의 목록 이동 - IN_PROGRESS → COMPLETED 전환 뒤 PENDING 폴링, 60초 초과, 재시도 가능/불가 오류, fromCompletion 없음·ABANDONED·이미 COMPLETED일 때 재조회 없음 Co-Authored-By: Claude Opus 5.5 --- tests/interview-browser-fixture.mjs | 4 +- tests/interview-complete-flow.browser.mjs | 187 ++++++++++++++++++++++ 2 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 tests/interview-complete-flow.browser.mjs diff --git a/tests/interview-browser-fixture.mjs b/tests/interview-browser-fixture.mjs index 5a7e5144..12936f1c 100644 --- a/tests/interview-browser-fixture.mjs +++ b/tests/interview-browser-fixture.mjs @@ -10,7 +10,7 @@ export const browser = await chromium.launch({ headless: true, args: [ ] }); export const question = (id) => ({ questionId: id, parentId: 0, type: "ORIGINAL", intention: "경험", content: `질문 ${id}: 프로젝트 경험을 설명해주세요.`, personaId: 1 }); -export async function fixture({ multi = false, answerMode = "voice", timeout = false, denied = false, fail = false, delayUploads = false, complete = false, mimeMode, socketOnAnswer = false, preparationFailed = false, recorderFails = false, slowStop = false, audioResponses = null, holdAudio = false, fastAudioTimers = false, audioDeadline = false, playback = "native", recoveredSessionId = null, quitStatuses = [200] } = {}) { +export async function fixture({ multi = false, answerMode = "voice", timeout = false, denied = false, fail = false, delayUploads = false, complete = false, mimeMode, socketOnAnswer = false, preparationFailed = false, recorderFails = false, slowStop = false, audioResponses = null, holdAudio = false, fastAudioTimers = false, audioDeadline = false, playback = "native", recoveredSessionId = null, quitStatuses = [200], preparationQuestions = null } = {}) { const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); const page = await context.newPage(); page.setDefaultTimeout(10_000); @@ -172,7 +172,7 @@ export async function fixture({ multi = false, answerMode = "voice", timeout = f if (recoveredSessionId && pathname === "/api/questions/tailor" && ++recovery.reads <= 3) { return json({ data: { status: "NOT_REQUESTED", preparationStatus: "NOT_REQUESTED", questions: [], interviewId: 42 } }); } - if (pathname === "/api/questions/tailor") return json({ data: { status: preparationFailed ? "FAILED" : "SUCCEEDED", preparationStatus: preparationFailed ? "FAILED" : "READY", errorMessage: preparationFailed ? "테스트 면접 준비 실패" : null, retryable: false, chatDelivered: !preparationFailed, questions: [current, question(102)], interviewId: 42 } }); + if (pathname === "/api/questions/tailor") return json({ data: { status: preparationFailed ? "FAILED" : "SUCCEEDED", preparationStatus: preparationFailed ? "FAILED" : "READY", errorMessage: preparationFailed ? "테스트 면접 준비 실패" : null, retryable: false, chatDelivered: !preparationFailed, questions: preparationQuestions ?? [current, question(102)], interviewId: 42 } }); if (pathname.endsWith("/me")) return json({ data: { id: 7, name: "테스트", nickname: "테스트" } }); if (pathname.endsWith("/audio")) { const id = Number(pathname.split("/").at(-2)); diff --git a/tests/interview-complete-flow.browser.mjs b/tests/interview-complete-flow.browser.mjs new file mode 100644 index 00000000..92b818fb --- /dev/null +++ b/tests/interview-complete-flow.browser.mjs @@ -0,0 +1,187 @@ +import assert from "node:assert/strict"; +import { browser, fixture, origin, waitUntil } from "./interview-browser-fixture.mjs"; + +let passed = 0; +const report = (label) => { passed++; console.log(`PASS ${label}`); }; +const savingMessage = "면접 기록을 저장하고 있어요"; +const notCompletedMessage = "아직 완료되지 않은 면접입니다. 면접을 마친 뒤 피드백을 확인해주세요."; +const pendingMessage = "피드백을 분석 중입니다. 완료되면 자동으로 표시됩니다."; +const completedFeedback = { + interviewId: 42, + status: "SUCCEEDED", + totalScore: 80, + summary: "완료 흐름 확인 코멘트", + feedbacks: [{ questionId: 101, questionContent: "완료 흐름 질문", comment: "완료 흐름 확인 코멘트" }], +}; + +// 종합 피드백 화면이 읽는 API. interviewStatuses는 GET /api/interviews/get 응답 순서다(숫자는 HTTP 오류 코드). +async function routeFeedbackApis(page, { listStatus = "IN_PROGRESS", interviewStatuses = ["COMPLETED"], feedbacks = [{ status: "PENDING" }] } = {}) { + const state = { gets: 0, feedbackReads: 0, posts: 0 }; + await page.route("**/api/**", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (!pathname.startsWith("/api/")) return route.fallback(); + const json = (body, code = 200) => route.fulfill({ status: code, contentType: "application/json", body: JSON.stringify(body) }); + if (pathname === "/api/interviews/getAll") return json({ success: true, data: [{ id: 42, mode: "SOLO", status: listStatus, createdAt: "2026-10-19T03:10:00Z" }] }); + if (pathname === "/api/interviews/get") { + assert.equal(new URL(request.url()).searchParams.get("interviewId"), "42"); + const status = interviewStatuses[Math.min(state.gets, interviewStatuses.length - 1)]; + state.gets++; + if (typeof status === "number") return json({ message: `테스트 면접 조회 오류 ${status}` }, status); + return json({ success: true, data: { interviewId: 42, status } }); + } + if (pathname === "/api/feedbacks/getAll") return json({ success: true, data: [] }); + if (pathname === "/api/answer/getAll") return json({ success: true, data: [] }); + if (pathname === "/api/feedbacks") { + if (request.method() !== "GET") { state.posts++; return json({ success: true, data: { status: "PENDING" } }); } + const feedback = feedbacks[Math.min(state.feedbackReads, feedbacks.length - 1)]; + state.feedbackReads++; + return json({ success: true, data: { interviewId: 42, ...feedback } }); + } + if (pathname.endsWith("/refresh")) return json({ accessToken: "test-only-token" }); + if (pathname.endsWith("/me")) return json({ data: { id: 7, name: "테스트", nickname: "테스트" } }); + return json({ success: true, data: [] }); + }); + return state; +} + +// 가짜 시계로 종합 피드백 화면에 들어간다. fromCompletion이면 완료 화면에서 넘긴 state를 붙인다. +async function openOverall({ fromCompletion = true, ...apiOptions } = {}) { + const context = await browser.newContext(); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + const state = await routeFeedbackApis(page, apiOptions); + await page.goto(origin); + await page.getByRole("button", { name: "면접 시작하기", exact: true }).first().waitFor(); + // 가짜 시계가 performance를 바꾸므로 앱이 쓰는 라우터 모듈 URL을 먼저 읽고 시계를 건다. + const routerUrl = await page.evaluate(() => performance.getEntriesByType("resource").map((entry) => entry.name).find((url) => new URL(url).pathname === "/src/app/router.tsx")); + await page.clock.install(); + await page.evaluate(async ({ routerUrl, fromCompletion }) => { + const { default: router } = await import(routerUrl); + await router.navigate("/main/feedback/overall/42", fromCompletion ? { state: { fromCompletion: true } } : undefined); + }, { routerUrl, fromCompletion }); + return { page, state, close: async () => { assert.deepEqual(errors, []); await context.close(); } }; +} + +const advance = async (f, ms, expectedGets) => { + await f.page.clock.runFor(ms); + await waitUntil(() => f.state.gets === expectedGets); +}; + +try { + // 2-1: SOLO는 텍스트·음성 모두 InterviewContentView를 쓴다. + for (const answerMode of ["text", "voice"]) { + const f = await fixture({ answerMode }); + await f.page.getByText("Question 01 / 02", { exact: true }).waitFor(); + assert.equal(await f.page.getByText(/^Question [\d-]+ \/ 10$/).count(), 0); + assert.deepEqual(f.errors, []); + await f.context.close(); + report(`SOLO ${answerMode}: label uses prepared question count`); + } + const empty = await fixture({ answerMode: "text", preparationQuestions: [] }); + // 준비 응답에 질문이 없으면 개수는 0으로 남고, 질문은 Chat 조회로 들어온다. + await empty.page.getByText("Question 01 / --", { exact: true }).waitFor(); + await empty.context.close(); + report("SOLO text: unknown total question count shows --"); + const multi = await fixture({ multi: true, answerMode: "voice" }); + await multi.page.getByText("Question 01 / 2", { exact: true }).waitFor(); + await multi.context.close(); + report("MULTI voice: dashboard label unchanged"); + + // 2-2: 완료 → 버튼 → 종합 피드백, IN_PROGRESS 재확인 뒤 PENDING 표시(실시간 3초 간격). + const done = await fixture({ answerMode: "text", complete: true }); + await done.page.getByText("Question 01 / 02", { exact: true }).waitFor(); + await done.page.getByPlaceholder(/이곳에 답변을 입력/).fill("완료 흐름 확인 답변입니다."); + await done.page.getByRole("button", { name: "제출하기", exact: true }).click(); + await done.page.waitForURL("**/main/interview/completed"); + assert.deepEqual(await done.page.evaluate(() => window.history.state?.usr), { answeredQuestionCount: 1, interviewId: 42 }); + const doneApi = await routeFeedbackApis(done.page, { interviewStatuses: ["IN_PROGRESS", "COMPLETED"] }); + await done.page.getByRole("button", { name: "피드백 보러가기", exact: true }).click(); + await done.page.waitForURL("**/main/feedback/overall/42"); + assert.deepEqual(await done.page.evaluate(() => window.history.state?.usr), { fromCompletion: true }); + await done.page.getByText(savingMessage, { exact: true }).waitFor(); + await done.page.getByText(pendingMessage, { exact: true }).waitFor({ timeout: 15_000 }); + assert.equal(doneApi.gets, 2); + assert.equal(await done.page.getByText(notCompletedMessage, { exact: true }).count(), 0); + assert.deepEqual(done.errors, []); + await done.context.close(); + report("completed button opens overall feedback and waits for COMPLETED"); + + const stateless = await fixture({ answerMode: "text" }); + await stateless.page.evaluate(() => window.__router.navigate("/main/interview/completed")); + await stateless.page.getByRole("button", { name: "피드백 보러가기", exact: true }).click(); + await stateless.page.waitForURL((url) => url.pathname === "/main/feedback/list"); + await stateless.context.close(); + report("completed screen without state opens feedback list"); + + const recovered = await openOverall({ + interviewStatuses: ["IN_PROGRESS", "COMPLETED"], + feedbacks: [{ status: "PENDING" }, completedFeedback], + }); + await recovered.page.getByText(savingMessage, { exact: true }).waitFor(); + await recovered.page.clock.pauseAt(await recovered.page.evaluate(() => Date.now() + 100)); + await advance(recovered, 3_000, 1); + await recovered.page.getByText(savingMessage, { exact: true }).waitFor(); + await advance(recovered, 3_000, 2); + await recovered.page.getByText(pendingMessage, { exact: true }).waitFor(); + assert.equal(recovered.state.posts, 0); + await recovered.page.clock.runFor(5_000); + await recovered.page.getByText("완료 흐름 확인 코멘트", { exact: true }).first().waitFor(); + await recovered.page.clock.runFor(60_000); + assert.equal(recovered.state.gets, 2); + await recovered.close(); + report("IN_PROGRESS then COMPLETED switches to feedback PENDING polling"); + + const transient = await openOverall({ interviewStatuses: [503, "COMPLETED"] }); + await transient.page.getByText(savingMessage, { exact: true }).waitFor(); + await transient.page.clock.pauseAt(await transient.page.evaluate(() => Date.now() + 100)); + await advance(transient, 3_000, 1); + await advance(transient, 3_000, 2); + await transient.page.getByText(pendingMessage, { exact: true }).waitFor(); + await transient.close(); + report("retryable re-check error keeps checking"); + + const rejected = await openOverall({ interviewStatuses: [403] }); + await rejected.page.getByText(savingMessage, { exact: true }).waitFor(); + await rejected.page.clock.pauseAt(await rejected.page.evaluate(() => Date.now() + 100)); + await advance(rejected, 3_000, 1); + await rejected.page.getByText("테스트 면접 조회 오류 403", { exact: true }).waitFor(); + await rejected.page.clock.runFor(60_000); + assert.equal(rejected.state.gets, 1); + assert.equal(await rejected.page.getByText(notCompletedMessage, { exact: true }).count(), 0); + await rejected.close(); + report("non-retryable re-check error stops with its message"); + + const timedOut = await openOverall({ interviewStatuses: ["IN_PROGRESS"] }); + await timedOut.page.getByText(savingMessage, { exact: true }).waitFor(); + await timedOut.page.clock.pauseAt(await timedOut.page.evaluate(() => Date.now() + 100)); + for (let check = 1; check <= 20; check++) await advance(timedOut, 3_000, check); + await timedOut.page.getByText(notCompletedMessage, { exact: true }).waitFor(); + await timedOut.page.clock.runFor(60_000); + assert.equal(timedOut.state.gets, 20); + assert.equal(timedOut.state.feedbackReads, 0); + await timedOut.close(); + report("still IN_PROGRESS after 60s shows existing message and stops"); + + for (const [label, options] of [ + ["entry without fromCompletion", { fromCompletion: false }], + ["ABANDONED interview", { listStatus: "ABANDONED" }], + ]) { + const f = await openOverall(options); + await f.page.getByText(notCompletedMessage, { exact: true }).waitFor(); + await f.page.clock.runFor(10_000); + assert.equal(f.state.gets, 0); + await f.close(); + report(`${label}: no re-check, existing message immediately`); + } + + const alreadyDone = await openOverall({ listStatus: "COMPLETED" }); + await alreadyDone.page.getByText(pendingMessage, { exact: true }).waitFor(); + assert.equal(alreadyDone.state.gets, 0); + await alreadyDone.close(); + report("already COMPLETED skips re-check"); + + console.log(`${passed} interview completion flow scenarios passed`); +} finally { await browser.close(); }