diff --git a/.claude/rules/frontend/architecture.md b/.claude/rules/frontend/architecture.md index 34381a90..8eab3f7c 100644 --- a/.claude/rules/frontend/architecture.md +++ b/.claude/rules/frontend/architecture.md @@ -17,8 +17,8 @@ frontend/src/ │ └── index.ts ├── pages/ # ルートのエントリーポイント(薄いラッパー) │ ├── LoginPage.tsx / GitHubCallbackPage.tsx -│ ├── CareerPage.tsx / CareerAnalysisPage.tsx -│ ├── BlogPage.tsx / GitHubLinkPage.tsx +│ ├── CareerPage.tsx / BlogPage.tsx +│ ├── GitHubLinkPage.tsx │ └── NotFoundPage.tsx ├── components/ │ ├── AuthenticatedLayout.tsx # サイドバー + (フッターに NotificationBell を配置) @@ -26,36 +26,37 @@ frontend/src/ │ ├── NotificationBell.tsx # 通知ベル(未読バッジ・ドロップダウンパネル) │ ├── ConfirmDialog.tsx # 確認モーダル │ ├── ErrorBoundary.tsx # 例外境界 -│ ├── TaskProgressStepper.tsx # 非同期タスク進捗ステッパー │ ├── UserMenu.tsx │ ├── forms/ # BasicInfoForm, CareerResumeForm, ResumeForm 等 │ ├── github-link/ # GitHubLinkDashboard, LanguageBar 等 -│ ├── career-analysis/ # CareerAnalysisPage + 結果表示 │ ├── auth/ # LoginForm, RegisterForm │ ├── blog/ # BlogPage │ ├── icons/ # アイコンコンポーネント(Bell, Eye, Qiita, Zenn 等) -│ └── ui/ # 汎用 UI(ErrorToast, InlineSpinner, Skeleton) +│ └── ui/ # 汎用 UI(ErrorToast, InlineSpinner, Skeleton, AsyncTaskLoading) ├── hooks/ │ ├── useDocumentForm.ts # フォーム CRUD の共通フック(loading / saving / error 管理) │ ├── useMasterData.ts # マスタデータのモジュールレベルキャッシュ │ ├── useNotifications.ts # 通知ベル用フック(30秒ポーリング・パネル開閉・既読処理) │ ├── usePdfActions.ts # PDF ダウンロード/プレビュー -│ ├── useTaskPolling.ts # 非同期タスクの進捗ポーリング -│ ├── useBlogAccountManager.ts / useBlogSummaryPolling.ts -│ ├── useCareerAnalysisPage.ts / useCareerExperienceMutators.ts -│ ├── usePhotoUpload.ts / useProjectModalState.ts +│ ├── useTaskPolling.ts # 非同期タスクの進捗ポーリング(指数バックオフ対応) +│ ├── useAsyncTaskPage.ts # 「キャッシュ→入力→ポーリング→結果」の phase 管理(useTaskPolling を内包) +│ ├── useAuthSession.ts # 認証セッション状態 │ ├── useTheme.ts -│ └── analysis/ # useAsyncAnalysisPage(非同期分析共通) +│ ├── blog/ # useBlogAccountManager +│ └── career/ # useCareerDirty / useCareerExperienceMutators / useProjectModalForm / useProjectModalState / useProjectFormDirty / usePhotoUpload / usePdfPanelLayout / useResumeImportAssist ├── api/ │ ├── client.ts # fetch ラッパー(Cookie 認証、401 ハンドリング) -│ └── *.ts # ドメイン別 API モジュール(auth, blog, resumes, career-analysis, intelligence, master-data, notifications, download, ai-resume) +│ └── *.ts # ドメイン別 API モジュール(auth, blog, resumes, master-data, notifications, download, ai-resume, githubLink) ├── store/ # Redux Toolkit + redux-persist │ ├── index.ts # store 構成 │ ├── persistConfig.ts │ └── formCacheSlice.ts # フォームキャッシュ ├── utils/ │ ├── appError.ts -│ └── errorId.ts +│ ├── errorId.ts +│ ├── deepEqual.ts # フォーム dirty 判定用の再帰等価比較(useCareerDirty / useProjectFormDirty で共用) +│ ├── pdfjs.ts +│ └── taskStatus.ts # 非同期タスクのステータス契約(in-progress 判定) ├── constants/ + constants.ts # 定数定義 ├── types.ts # 共通型 ├── formTypes.ts / formMappers.ts / payloadBuilders.ts # フォーム入出力変換 @@ -69,4 +70,4 @@ frontend/src/ **状態管理**: Redux Toolkit + redux-persist。`store/formCacheSlice` でフォームの一時保持を行う。サーバ状態は各 API モジュール経由で取得し、コンポーネントローカルもしくはフックでキャッシュする方針。 -**非同期タスクの進捗**: `useTaskPolling` / `useAsyncAnalysisPage` でバックエンドの `dead_letter` / `processing` / `completed` 状態をポーリングし、`TaskProgressStepper` で可視化する。 +**非同期タスクの進捗**: `useTaskPolling` / `useAsyncTaskPage` がバックエンドのタスクステータス(`pending` / `processing` / `retrying` / `completed` / `dead_letter`)をポーリングし、`AsyncTaskLoading`(`components/ui/`)で可視化する。ステータス文字列の正本(`TaskStatus` 型・進行中判定 `isInProgressStatus`)は `utils/taskStatus.ts` に集約してあるので、状態を増減する際はまずそこを更新する。 diff --git a/backend/requirements.txt b/backend/requirements.txt index 188cfe31..59953e86 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,4 @@ -fastapi==0.136.3 +fastapi==0.136.1 # starlette は fastapi の推移的依存だが、PYSEC-2026-161(Host ヘッダ未検証による認証バイパス)対策で >=1.0.1 を明示固定する starlette==1.1.0 uvicorn[standard]==0.34.0 diff --git a/frontend/src/hooks/career/useCareerDirty.ts b/frontend/src/hooks/career/useCareerDirty.ts index 553a2003..80c3bd1a 100644 --- a/frontend/src/hooks/career/useCareerDirty.ts +++ b/frontend/src/hooks/career/useCareerDirty.ts @@ -7,6 +7,7 @@ import type { CareerProjectForm, } from "../../payloadBuilders"; import type { ResumeQualification } from "../../types"; +import { isDeepEqual } from "../../utils/deepEqual"; /** プロジェクト単位の dirty 情報。`any` は配下含めた未保存有無。 */ export type ProjectDirty = { @@ -65,36 +66,6 @@ export type CareerDirtyMap = { qualificationsAny: boolean; }; -/** - * 値が等しいかを判定する。プリミティブと配列・プレーンオブジェクトを再帰比較する。 - * フォーム値はプリミティブ/配列/プレーンオブジェクトのみで構成されている前提。 - */ -function isDeepEqual(a: unknown, b: unknown): boolean { - if (Object.is(a, b)) return true; - if (a === null || b === null) return false; - if (typeof a !== "object" || typeof b !== "object") return false; - - if (Array.isArray(a) || Array.isArray(b)) { - if (!Array.isArray(a) || !Array.isArray(b)) return false; - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (!isDeepEqual(a[i], b[i])) return false; - } - return true; - } - - const objA = a as Record; - const objB = b as Record; - const keysA = Object.keys(objA); - const keysB = Object.keys(objB); - if (keysA.length !== keysB.length) return false; - for (const k of keysA) { - if (!Object.prototype.hasOwnProperty.call(objB, k)) return false; - if (!isDeepEqual(objA[k], objB[k])) return false; - } - return true; -} - /** dirty なしの経歴 1 件分のテンプレート。配下クライアントとプロジェクトは form の shape を踏襲する。 */ function buildCleanExperience( experience: { clients: { projects: unknown[] }[] }, diff --git a/frontend/src/hooks/career/useCareerExperienceMutators.test.ts b/frontend/src/hooks/career/useCareerExperienceMutators.test.ts new file mode 100644 index 00000000..99ca1a7c --- /dev/null +++ b/frontend/src/hooks/career/useCareerExperienceMutators.test.ts @@ -0,0 +1,205 @@ +import { renderHook, act } from "@testing-library/react"; +import { useState } from "react"; +import { describe, it, expect } from "vitest"; + +import { + blankCareerClient, + blankCareerExperience, + blankCareerProject, +} from "../../constants"; +import type { CareerFormState } from "../../payloadBuilders"; +import { useCareerExperienceMutators } from "./useCareerExperienceMutators"; + +/** + * 経歴1件・取引先1件・プロジェクト1件を持つ標準フォームを作る。 + * 各テストはこの初期状態に対してミューテーターを適用し、結果の form state を検証する。 + */ +function buildForm(overrides: Partial = {}): CareerFormState { + return { + full_name: "山田 太郎", + career_summary: "", + self_pr: "", + experiences: [ + { + ...blankCareerExperience, + company: "株式会社A", + end_date: "2023-03", + clients: [ + { + ...blankCareerClient, + name: "取引先A", + has_client: true, + projects: [{ ...blankCareerProject, name: "プロジェクトX" }], + }, + ], + }, + ], + qualifications: [], + ...overrides, + }; +} + +/** + * 実 React state(useState)越しにミューテーターを駆動するヘルパ。 + * 各ミューテーターは setForm の updater を介して state を更新するため、 + * act() 後の `form` で「画面操作の結果フォームがどう変わるか」を検証できる。 + */ +function setup(initialForm: CareerFormState) { + return renderHook(() => { + const [form, setForm] = useState(initialForm); + const mutators = useCareerExperienceMutators(form.experiences, setForm); + return { form, mutators }; + }); +} + +describe("useCareerExperienceMutators", () => { + describe("updateExperienceField", () => { + it("is_current を true にすると end_date がクリアされる(在職中=退職日なし)", () => { + const { result } = setup(buildForm()); + act(() => { + result.current.mutators.updateExperienceField(0, "is_current", true); + }); + expect(result.current.form.experiences[0].is_current).toBe(true); + expect(result.current.form.experiences[0].end_date).toBe(""); + }); + + it("is_current を false にしても end_date は保持される", () => { + const { result } = setup(buildForm()); + act(() => { + result.current.mutators.updateExperienceField(0, "is_current", false); + }); + expect(result.current.form.experiences[0].is_current).toBe(false); + expect(result.current.form.experiences[0].end_date).toBe("2023-03"); + }); + + it("通常フィールドは end_date を巻き込まずに更新される", () => { + const { result } = setup(buildForm()); + act(() => { + result.current.mutators.updateExperienceField(0, "company", "株式会社B"); + }); + expect(result.current.form.experiences[0].company).toBe("株式会社B"); + expect(result.current.form.experiences[0].end_date).toBe("2023-03"); + }); + }); + + describe("updateClientHasClient", () => { + it("has_client を false にすると name がクリアされる", () => { + const { result } = setup(buildForm()); + act(() => { + result.current.mutators.updateClientHasClient(0, 0, false); + }); + expect(result.current.form.experiences[0].clients[0].has_client).toBe(false); + expect(result.current.form.experiences[0].clients[0].name).toBe(""); + }); + + it("has_client を true にしたときは name を保持する", () => { + const { result } = setup(buildForm()); + act(() => { + result.current.mutators.updateClientHasClient(0, 0, true); + }); + expect(result.current.form.experiences[0].clients[0].has_client).toBe(true); + expect(result.current.form.experiences[0].clients[0].name).toBe("取引先A"); + }); + }); + + describe("removeExperience", () => { + it("最後の1件は削除せず blank で置換する", () => { + const { result } = setup(buildForm()); + act(() => { + result.current.mutators.removeExperience(0); + }); + expect(result.current.form.experiences).toHaveLength(1); + expect(result.current.form.experiences[0].company).toBe(""); + }); + + it("複数あるときは該当 index を削除する", () => { + const form = buildForm(); + form.experiences = [ + ...form.experiences, + { ...blankCareerExperience, company: "株式会社B" }, + ]; + const { result } = setup(form); + act(() => { + result.current.mutators.removeExperience(0); + }); + expect(result.current.form.experiences).toHaveLength(1); + expect(result.current.form.experiences[0].company).toBe("株式会社B"); + }); + }); + + describe("removeClient", () => { + it("最後の1件は削除せず blank で置換する", () => { + const { result } = setup(buildForm()); + act(() => { + result.current.mutators.removeClient(0, 0); + }); + expect(result.current.form.experiences[0].clients).toHaveLength(1); + expect(result.current.form.experiences[0].clients[0].name).toBe(""); + }); + + it("複数あるときは該当 index を削除する", () => { + const form = buildForm(); + form.experiences[0].clients = [ + ...form.experiences[0].clients, + { ...blankCareerClient, name: "取引先B" }, + ]; + const { result } = setup(form); + act(() => { + result.current.mutators.removeClient(0, 0); + }); + expect(result.current.form.experiences[0].clients).toHaveLength(1); + expect(result.current.form.experiences[0].clients[0].name).toBe("取引先B"); + }); + }); + + describe("removeProject", () => { + it("最後の1件は削除せず blank で置換する", () => { + const { result } = setup(buildForm()); + act(() => { + result.current.mutators.removeProject(0, 0, 0); + }); + const projects = result.current.form.experiences[0].clients[0].projects; + expect(projects).toHaveLength(1); + expect(projects[0].name).toBe(""); + }); + + it("複数あるときは該当 index を削除する", () => { + const form = buildForm(); + form.experiences[0].clients[0].projects = [ + ...form.experiences[0].clients[0].projects, + { ...blankCareerProject, name: "プロジェクトY" }, + ]; + const { result } = setup(form); + act(() => { + result.current.mutators.removeProject(0, 0, 0); + }); + const projects = result.current.form.experiences[0].clients[0].projects; + expect(projects).toHaveLength(1); + expect(projects[0].name).toBe("プロジェクトY"); + }); + }); + + describe("onProjectSave", () => { + it("projIndex が null のときは末尾に追加する", () => { + const { result } = setup(buildForm()); + const newProject = { ...blankCareerProject, name: "新規プロジェクト" }; + act(() => { + result.current.mutators.onProjectSave(0, 0, null, newProject); + }); + const projects = result.current.form.experiences[0].clients[0].projects; + expect(projects).toHaveLength(2); + expect(projects[1].name).toBe("新規プロジェクト"); + }); + + it("projIndex を指定したときは該当プロジェクトを置換する", () => { + const { result } = setup(buildForm()); + const edited = { ...blankCareerProject, name: "編集済プロジェクト" }; + act(() => { + result.current.mutators.onProjectSave(0, 0, 0, edited); + }); + const projects = result.current.form.experiences[0].clients[0].projects; + expect(projects).toHaveLength(1); + expect(projects[0].name).toBe("編集済プロジェクト"); + }); + }); +}); diff --git a/frontend/src/hooks/career/useProjectFormDirty.ts b/frontend/src/hooks/career/useProjectFormDirty.ts index 93f5cbd3..b0c88dbf 100644 --- a/frontend/src/hooks/career/useProjectFormDirty.ts +++ b/frontend/src/hooks/career/useProjectFormDirty.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { blankCareerProject } from "../../constants"; import type { CareerProjectForm } from "../../payloadBuilders"; +import { isDeepEqual } from "../../utils/deepEqual"; /** ProjectModal 内で表示する dirty マップ */ export type ProjectFormDirty = { @@ -26,33 +27,6 @@ export type ProjectFormDirty = { phases: boolean; }; -/** 値が等しいかを判定する(プリミティブ・配列・プレーンオブジェクトを再帰比較)。 */ -function isDeepEqual(a: unknown, b: unknown): boolean { - if (Object.is(a, b)) return true; - if (a === null || b === null) return false; - if (typeof a !== "object" || typeof b !== "object") return false; - - if (Array.isArray(a) || Array.isArray(b)) { - if (!Array.isArray(a) || !Array.isArray(b)) return false; - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (!isDeepEqual(a[i], b[i])) return false; - } - return true; - } - - const objA = a as Record; - const objB = b as Record; - const keysA = Object.keys(objA); - const keysB = Object.keys(objB); - if (keysA.length !== keysB.length) return false; - for (const k of keysA) { - if (!Object.prototype.hasOwnProperty.call(objB, k)) return false; - if (!isDeepEqual(objA[k], objB[k])) return false; - } - return true; -} - /** * ProjectModal の編集中フォーム (local) と元データ (original) を比較し、 * フィールド単位の未保存マップを返すフック。 diff --git a/frontend/src/hooks/useAsyncTaskPage.ts b/frontend/src/hooks/useAsyncTaskPage.ts index 9115907f..b7a8cdf4 100644 --- a/frontend/src/hooks/useAsyncTaskPage.ts +++ b/frontend/src/hooks/useAsyncTaskPage.ts @@ -18,12 +18,6 @@ const POLLING_CONFIG = { multiplier: 1.5, } as const; -const getNextInterval = (current: number): number => - Math.min(current * POLLING_CONFIG.multiplier, POLLING_CONFIG.maxInterval); - -// getNextInterval は将来の拡張用に export しておく -export { getNextInterval }; - /** useAsyncTaskPage のオプション型 */ export type UseAsyncTaskPageOptions = { /** diff --git a/frontend/src/hooks/useTaskPolling.test.ts b/frontend/src/hooks/useTaskPolling.test.ts index f2156bf5..844001be 100644 --- a/frontend/src/hooks/useTaskPolling.test.ts +++ b/frontend/src/hooks/useTaskPolling.test.ts @@ -108,24 +108,33 @@ describe("useTaskPolling", () => { }); it("アンマウント時にポーリングが停止する", async () => { - const checkStatus = vi.fn().mockResolvedValue({ status: "pending" }); - const { result, unmount } = setup(checkStatus); - - act(() => { - result.current.startPolling(); - }); - - // 初回ポーリングを待つ - await waitFor(() => { - expect(checkStatus).toHaveBeenCalled(); - }); - - const callCount = checkStatus.mock.calls.length; - unmount(); - - // アンマウント後は呼び出し回数が増えないことを確認 - await new Promise((r) => setTimeout(r, FAST_INTERVAL * 3)); - expect(checkStatus.mock.calls.length).toBe(callCount); + // fake timers で仮想時間を進め、実時間待ち(フレーキー要因)を排除する + vi.useFakeTimers(); + try { + const checkStatus = vi.fn().mockResolvedValue({ status: "pending" }); + const { result, unmount } = setup(checkStatus); + + act(() => { + result.current.startPolling(); + }); + + // マウント中は 1 インターバル進めるとポーリングが反復する(初回即時 + 次回) + await act(async () => { + await vi.advanceTimersByTimeAsync(FAST_INTERVAL); + }); + const callCountWhileMounted = checkStatus.mock.calls.length; + expect(callCountWhileMounted).toBeGreaterThanOrEqual(2); + + unmount(); + + // アンマウント後は次回タイマーが破棄され、何インターバル進めても呼び出しは増えない + await act(async () => { + await vi.advanceTimersByTimeAsync(FAST_INTERVAL * 3); + }); + expect(checkStatus.mock.calls.length).toBe(callCountWhileMounted); + } finally { + vi.useRealTimers(); + } }); it("ネットワークエラー時はポーリングが継続する", async () => { diff --git a/frontend/src/utils/deepEqual.ts b/frontend/src/utils/deepEqual.ts new file mode 100644 index 00000000..2f0eabc2 --- /dev/null +++ b/frontend/src/utils/deepEqual.ts @@ -0,0 +1,32 @@ +/** + * 値が等しいかを判定する。プリミティブと配列・プレーンオブジェクトを再帰比較する。 + * フォーム値はプリミティブ/配列/プレーンオブジェクトのみで構成されている前提。 + * + * dirty 判定(未保存マーク)で local / baseline の差分検出に用いる純関数。 + * 等価判定ロジックを 1 か所に集約し、各 hook での乖離を防ぐ。 + */ +export function isDeepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (a === null || b === null) return false; + if (typeof a !== "object" || typeof b !== "object") return false; + + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!isDeepEqual(a[i], b[i])) return false; + } + return true; + } + + const objA = a as Record; + const objB = b as Record; + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + if (keysA.length !== keysB.length) return false; + for (const k of keysA) { + if (!Object.prototype.hasOwnProperty.call(objB, k)) return false; + if (!isDeepEqual(objA[k], objB[k])) return false; + } + return true; +}