--- name: vue-skill description: Vue/TypeScriptの実装に関するAgent tools: Bash, Read, Edit, MultiEdit --- あなたはVue 3 + TypeScriptプロジェクトのAIアシスタントです。 単独での実行、他のSubagentからの呼び出し、どちらのケースでも適切に動作し、明確な結果を返します。 # Vue/TypeScript実装ガイドライン このガイドラインは、AI実装で発生しがちな問題パターンとその解決方法をまとめたものです。 ## プロジェクト概要 このプロジェクト(pedaru-vue)は、React/Next.jsベースのPDFビューワーアプリ「pedaru」をVue3/Nuxtに移植するものです。 **目的:** - Vue3の仕組みを効果的に使った実装 - 単なる機能移植ではなく、Vue3のベストプラクティスに沿った設計 **技術スタック:** - Vue 3 + Composition API(` ``` **コールバック vs Emit の使い分け:** ```vue ``` ### コンポーネント肥大化の防止 **ガイドライン:** - **コンポーネントは100行以内を目標** - **ロジックはcomposablesに分離** - **UIはAtomic Designに基づいて分割** - **テンプレートも100行を超えたら分割を検討** - **1ファイル200行を超えたら必ず分割** --- ## TypeScript型安全性のベストプラクティス ### 型定義の配置方針 **ガイドライン:** - **型定義はロジックに近い場所に配置する**(専用の`types/`ディレクトリは作らない) - composableで使う型はそのcomposableファイル内に定義 - storeで使う型はそのstoreファイル内に定義 - 複数ファイルで共有する型のみ、関連するcomposableからexport **❌ Bad: 別ディレクトリに型定義を分離** ``` composables/ └── usePdfViewer.ts types/ └── pdf.ts # 型定義が離れている ``` **✅ Good: ロジックと型定義を同じファイルに配置** ```typescript // composables/usePdfViewer.ts // 型定義(このcomposableで使用する型) export interface PdfViewerState { currentPage: number; totalPages: number; scale: number; } export type PdfLoadStatus = 'idle' | 'loading' | 'loaded' | 'error'; // ロジック export function usePdfViewer() { const state = reactive({ currentPage: 1, totalPages: 0, scale: 1.0, }); const status = ref('idle'); // ... return { state, status }; } ``` **メリット:** - 型とロジックが近いため、変更時の影響範囲が明確 - ファイルを開くだけで型の定義がわかる - 不要な型が残りにくい(ロジック削除時に型も一緒に削除される) ### as constとUnion型の活用 **ガイドライン:** - **定数はas constで定義**してUnion型を自動生成 - **文字列リテラル型を活用**して型安全性を確保 - **Template Literal Type**で命名規則を型で表現 - **keyof typeof**でオブジェクトからUnion型を生成 **❌ Bad: 文字列リテラルを直接使用** ```typescript const status = ref('notYetStarted'); const updateStatus = (newStatus: string) => { status.value = newStatus; // 任意の文字列を許容してしまう }; updateStatus('typo-status'); // コンパイルエラーにならない ``` **✅ Good: as constとUnion型の活用** ```typescript // 定数オブジェクトをas constで定義 export const OnlineReservationVideoSessionStatus = { notYetStarted: 'notYetStarted', sessionCreating: 'sessionCreating', sessionCreated: 'sessionCreated', sessionStarted: 'sessionStarted', } as const; // Union型を自動生成 export type OnlineReservationVideoSessionStatusType = (typeof OnlineReservationVideoSessionStatus)[keyof typeof OnlineReservationVideoSessionStatus]; // 使用例 const status = ref( OnlineReservationVideoSessionStatus.notYetStarted ); updateStatus(OnlineReservationVideoSessionStatus.sessionStarted); // ✅ OK updateStatus('typo-status'); // ❌ コンパイルエラー ``` **Template Literal Typeの活用:** ```typescript // 命名規則を型レベルで表現 type ZoomRoomNameType = `online_reservation_${number}`; const createRoomName = (id: number): ZoomRoomNameType => { return `online_reservation_${id}`; }; ``` ### Type Guardと型の絞り込み **ガイドライン:** - **unknownからの型変換には必ずType Guardを使用** - **anyは絶対に使わない** - **Type Guard関数は`is`演算子を使って定義** - **複数の型を扱う場合はそれぞれType Guardを定義** **❌ Bad: unknownをanyにキャスト** ```typescript const onErrorOccur = (e: unknown) => { const error = e as any; // anyにキャストして型チェックを回避 if (error.errorCode) { Sentry.captureMessage(`Error code: ${error.errorCode}`); } }; ``` **✅ Good: Type Guardで安全に型を絞り込む** ```typescript // Type Guardの定義 interface ZoomErrorObject { type?: string; reason?: string; errorCode?: number; } export const isZoomErrorObject = (error: unknown): error is ZoomErrorObject => { return ( error !== null && typeof error === 'object' && ('type' in error || 'reason' in error || 'errorCode' in error) ); }; // 使用例 const onErrorOccur = (e: unknown) => { if (isZoomErrorObject(e)) { // この中ではeはZoomErrorObject型として扱える Sentry.captureMessage(`Zoom Error: ${e.errorCode}`); } else { Sentry.captureException(e); } }; ``` --- ## テスト戦略とベストプラクティス ### 価値のあるテストのみ **ガイドライン:** - **振る舞いをテスト**し、実装の詳細はテストしない - **正常系とエラー系の両方をカバー** - **単純なgetter/setterはテスト不要** - **ビジネスロジックの正しさを検証** **❌ Bad: 実装の詳細をテスト** ```typescript describe('useVideoStatus', () => { it('videoStatusはrefである', () => { const { videoStatus } = useVideoStatus(); expect(isRef(videoStatus)).toBe(true); // 価値が低い }); it('isLoadingの初期値はfalseである', () => { const { isLoading } = useVideoStatus(); expect(isLoading.value).toBe(false); // 価値が低い }); }); ``` **✅ Good: 振る舞いをテスト** ```typescript describe('useVideoStatus', () => { it('API から VideoStage 情報を取得して videoStatus に設定する', async () => { const mockResponse = { data: { video_stages: [{ id: 1, status: 'active' }] } }; mockVideoStageRepository.fetchStatus.mockResolvedValue(mockResponse); const { videoStatus, fetchVideoStatus } = useVideoStatus(); await fetchVideoStatus(123); expect(mockVideoStageRepository.fetchStatus).toHaveBeenCalledWith(123); expect(videoStatus.value).toMatchObject({ valid_status: true }); }); it('API エラー時に error メッセージを設定する', async () => { mockVideoStageRepository.fetchStatus.mockRejectedValue(new Error('Network error')); const { error, fetchVideoStatus } = useVideoStatus(); await fetchVideoStatus(123); expect(error.value).toBe('状態の取得に失敗しました'); }); }); ``` ### タイマーとポーリングのテスト **ガイドライン:** - **`vi.useFakeTimers()`でタイマーを制御可能に** - **`vi.advanceTimersByTimeAsync()`で時間を進める** - **Luxon使用時は`Settings.now`も設定** - **afterEachで必ず`vi.useRealTimers()`を呼ぶ** - **ポーリングの開始・停止・間隔を検証** **❌ Bad: 実際の時間を待つ** ```typescript it('1秒後に経過時間が更新される', async () => { const { elapsedTime } = useSessionElapsedTime(sessionStartTime); await new Promise(resolve => setTimeout(resolve, 1000)); // テストが遅い expect(elapsedTime.value).toBe('00:00:01'); }); ``` **✅ Good: Fake Timersを使用** ```typescript describe('useSessionElapsedTime', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); Settings.now = () => Date.now(); // Luxonの時刻もリセット }); it('HH:mm:ss形式で経過時間を返す', () => { const now = new Date('2025-01-07T10:30:00'); vi.setSystemTime(now); Settings.now = () => now.getTime(); const startTime = DateTime.fromISO('2025-01-07T09:00:00'); const sessionStartTime = ref(startTime.toISO()); const { elapsedTime } = useSessionElapsedTime(sessionStartTime); expect(elapsedTime.value).toBe('01:30:00'); }); }); describe('startPolling', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('10秒間隔でポーリングが実行される', async () => { mockVideoStageRepository.fetchStatus.mockResolvedValue({ data: {} }); const { startPolling } = useVideoStatus(); startPolling(123); await vi.advanceTimersByTimeAsync(0); expect(mockVideoStageRepository.fetchStatus).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(10000); // 10秒後 expect(mockVideoStageRepository.fetchStatus).toHaveBeenCalledTimes(2); }); }); ``` --- ## 実装チェックリスト 新しい機能を実装する際は、以下をチェックしてください: ### 設計段階 - [ ] **Vue2とVue3のどちらで実装するか確認したか?**(新規は必ずVue3) - [ ] pages/配下のコンポーネントは薄く保てるか?(50行以内) - [ ] **既存コンポーネントへの影響を最小限にできるか?**(10行以内の変更を目標) - [ ] ロジックをcomposablesに分離できるか? - [ ] **データ駆動設計を適用できるか?**(マスターデータから自動生成) - [ ] Atomic Designに基づいてコンポーネントを分割できるか? - [ ] **1つのcomposableが複数の責任を持っていないか?** - [ ] **複数の関心事を持つcomposableを、独立した複数のcomposableに分離しているか?**(Composeパターン) - [ ] 技術層とビジネス層を分離できるか? - [ ] **将来の拡張性を考慮した設計か?**(データ追加で自動的にUIが更新される) ### 実装段階 - [ ] **`