CtrlK
BlogDocsLog inGet started
Tessl Logo

vue-skill

Vue/TypeScriptの実装に関するAgent

46

Quality

32%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Optimize this skill with Tessl

npx tessl skill review --optimize ./.claude/skills/vue-skill/SKILL.md
SKILL.md
Quality
Evals
Security

あなたはVue 3 + TypeScriptプロジェクトのAIアシスタントです。

単独での実行、他のSubagentからの呼び出し、どちらのケースでも適切に動作し、明確な結果を返します。

Vue/TypeScript実装ガイドライン

このガイドラインは、AI実装で発生しがちな問題パターンとその解決方法をまとめたものです。

プロジェクト概要

このプロジェクト(pedaru-vue)は、React/Next.jsベースのPDFビューワーアプリ「pedaru」をVue3/Nuxtに移植するものです。

目的:

  • Vue3の仕組みを効果的に使った実装
  • 単なる機能移植ではなく、Vue3のベストプラクティスに沿った設計

技術スタック:

  • Vue 3 + Composition API(<script setup>
  • Nuxt 3
  • TypeScript
  • Pinia(状態管理)

React から Vue3 への変換ガイド

React Hooks → Vue3 Composition API マッピング

ReactVue 3備考
useStateref / reactiveプリミティブはref、オブジェクトはreactive
useEffectwatch / watchEffect / onMounted依存配列の有無で使い分け
useCallback通常の関数Vueでは不要(必要に応じてcomputed)
useMemocomputed
useRefref / useTemplateRefDOM参照はuseTemplateRef
useContextprovide / inject または PiniaグローバルはPinia推奨
useReducerPinia store

変換例

React (useState + useEffect):

const [count, setCount] = useState(0);
const [doubled, setDoubled] = useState(0);

useEffect(() => {
  setDoubled(count * 2);
}, [count]);

Vue3 (ref + computed):

const count = ref(0);
const doubled = computed(() => count.value * 2);

useEffectの変換パターン

依存配列なし(マウント時のみ):

// React
useEffect(() => {
  console.log('mounted');
}, []);
// Vue3
onMounted(() => {
  console.log('mounted');
});

依存配列あり(値の変更を監視):

// React
useEffect(() => {
  fetchData(id);
}, [id]);
// Vue3
watch(() => id.value, (newId) => {
  fetchData(newId);
}, { immediate: true });

クリーンアップあり:

// React
useEffect(() => {
  const timer = setInterval(() => {}, 1000);
  return () => clearInterval(timer);
}, []);
// Vue3
onMounted(() => {
  const timer = setInterval(() => {}, 1000);
  onUnmounted(() => clearInterval(timer));
});

移植しない機能

以下に該当するものは移植対象外とする:

  • Electron/Tauri固有の処理: デスクトップアプリ固有のAPI呼び出し
  • 不要な互換性維持コード: 後方互換のためだけのコード
  • 過剰なエラーハンドリング: 発生し得ないケースの処理

注意!!!

  • 当ファイルを読む際は必ず全文読み込んでください。断片的に読んでも良い作業はできません。

目次

  1. プロジェクト概要
  2. React から Vue3 への変換ガイド
  3. Composables設計のベストプラクティス
  4. 既存コンポーネントへの影響を最小化する設計
  5. コンポーネント設計のベストプラクティス
  6. TypeScript型安全性のベストプラクティス
  7. テスト戦略とベストプラクティス
  8. 実装チェックリスト
  9. まとめ

pages/配下のコンポーネント肥大化防止

原則: pages/配下のコンポーネントを修正する際は、Atomic Designの考えに基づき、新規コンポーネントを作成してロジックを分離する。

pedaru-vue/
├── components/
│   ├── atoms/      # 基本的なUI要素
│   ├── molecules/  # 複合コンポーネント
│   └── organisms/  # 複雑な機能
├── composables/    # ビジネスロジック(型定義も同じファイルに配置)
├── pages/          # ルートコンポーネント(薄く保つ)
└── stores/         # Pinia stores

Composables設計のベストプラクティス

単一責任の原則

ガイドライン:

  • 1つのcomposableは1つの関心事のみを扱う
  • ファイル名はその責任を明確に表す(use*で始まる)
  • 50〜100行を目安とし、それを超える場合は分割を検討
  • 複数のcomposableを組み合わせて使用する設計を推奨(Compose)

❌ Bad: 複数の責任を持つ巨大なComposable

// useVideoManagement.ts(悪い例)
export function useVideoManagement() {
  // ポーリング、時間計算、セッション記録、Zoom SDK操作が混在
  // 100行以上の複雑なロジック...
}

✅ Good: 責任を分離

// useVideoStatus.ts - ビデオステータスのポーリング専用
export function useVideoStatus() {
  const videoStatus = ref<VideoStageStatusResponse | null>(null);
  const fetchVideoStatus = async (id: number) => { /* ... */ };
  const startPolling = (id: number) => { /* ... */ };
  const stopPolling = () => { /* ... */ };
  return { videoStatus, fetchVideoStatus, startPolling, stopPolling };
}

// useSessionElapsedTime.ts - 時間計算専用
export function useSessionElapsedTime(sessionStartTime: Ref<string | null>) {
  const elapsedTime = computed(() => { /* ... */ });
  return { elapsedTime };
}

✅ Good: 複数のComposableを組み合わせる(Compose)

重要な判断基準:責任範囲の正しい分離

例:症状選択機能で「選択」と「送信履歴管理」を1つのcomposableに混在させてはいけません。

// ❌ Bad: 責任範囲が混在
// useSymptomSelection.ts
export function useSymptomSelection() {
  // 症状選択の責任
  const toggleSymptomSelection = (key: SymptomItemKeyType) => { /* ... */ };

  // 送信履歴管理の責任(別の関心事!)
  const markAsSent = (key: SymptomItemKeyType) => { /* ... */ };
  const onSendSuccess = () => { /* 混在している */ };

  return { toggleSymptomSelection, markAsSent, onSendSuccess };
}

正しい設計:各関心事を独立したcomposableに分離し、組み合わせて使用

// useSymptomSelection.ts - 症状選択のみに集中
export function useSymptomSelection() {
  const selectedSymptomItems = ref<Set<SymptomItemKeyType>>(new Set());
  const toggleSymptomSelection = (key: SymptomItemKeyType) => { /* ... */ };
  const resetSelectedSymptoms = () => { /* ... */ };
  const selectedSymptoms = computed(() => { /* ... */ });

  return { selectedSymptomItems, selectedSymptoms, toggleSymptomSelection, resetSelectedSymptoms };
}

// useSymptomSendHistory.ts - 送信履歴管理専用(新規ファイル)
export function useSymptomSendHistory() {
  const sentSymptomItems = ref<Set<SymptomItemKeyType>>(new Set());
  const markAsSent = (keys: SymptomItemKeyType[]) => {
    keys.forEach(key => sentSymptomItems.value.add(key));
  };
  const isSent = (key: SymptomItemKeyType): boolean => {
    return sentSymptomItems.value.has(key);
  };

  return { sentSymptomItems, markAsSent, isSent };
}

// useChatMessageGenerator.ts - メッセージ生成専用(新規ファイル)
export function useChatMessageGenerator() {
  const generateSymptomMessage = (symptoms: SymptomItem[]): string => {
    const mainMessage = '症状に合わせたホームケアのPDFをお送りします。';
    const contents = symptoms.map((s) => `・${s.title}\n${s.url}\n`).join('\n');
    return `${mainMessage}\n${contents}`;
  };

  return { generateSymptomMessage };
}

// ChatAttachedPdfSelect.vue - 複数のcomposableを組み合わせる(Compose)
const { selectedSymptoms, resetSelectedSymptoms } = useSymptomSelection();
const { markAsSent } = useSymptomSendHistory();
const { generateSymptomMessage } = useChatMessageGenerator();

const handleSendChat = async () => {
  const message = generateSymptomMessage(selectedSymptoms.value);
  const success = await props.onSendChat(message);

  if (success) {
    markAsSent(selectedSymptoms.value.map(s => s.key));
    resetSelectedSymptoms();
    await showToast();
  }
};

メリット:

  • 単一責任の原則: 各composableが1つの関心事のみ
  • Composeの原則: 複数のcomposableを組み合わせて使用
  • テスト容易性: 各関心事を独立してテスト可能
  • 再利用性: 送信履歴管理は他の機能でも再利用可能
  • 明確な責任範囲: ファイル名から役割が明確

レイヤー分離(技術層とビジネス層)

ガイドライン:

  • 技術層: 外部ライブラリ(Zoom SDK、Twilio)の操作のみ
  • アプリケーション層: ビジネスロジック、Pinia操作、DB記録
  • 依存方向は常に「アプリケーション層 → 技術層」
  • コメントで設計意図を明示(// NOTE: Piniaにアクセスしないなど)

❌ Bad: 技術的な詳細とビジネスロジックが混在

// useZoomVideoSession.ts(悪い例)
export function useZoomVideoSession() {
  const startSession = async () => {
    // Zoom SDKの初期化
    zoomClient.value = ZoomVideo.createClient();
    await zoomClient.value.init('ja-JP', 'Global');

    // ビジネスロジック(DB記録)が混在
    await videoStageRepository.create({ status: 'active' });

    // Piniaへのアクションも混在
    pdfStore.updateStatus('active');
  };
}

✅ Good: レイヤーを明確に分離

// useZoomVideo.ts - 技術層(Zoom SDK操作のみ)
export function useZoomVideo() {
  const createSession = async (sessionId: string) => { /* Zoom SDKのみ */ };
  const joinSession = async (sessionId: string) => { /* Zoom SDKのみ */ };
  // NOTE: Piniaにアクセスしない
  return { createSession, joinSession };
}

// useOnlineReservationVideoSession.ts - アプリケーション層
export function useOnlineReservationVideoSession() {
  const { createSession, joinSession } = useZoomVideo();

  const startTalking = async (sessionId: string, id: number) => {
    await createSession(sessionId);              // 1. Zoom SDK
    await videoStageRepository.create({ id });   // 2. DB記録
    pdfStore.updateStatus({ ... });               // 3. Pinia
    await joinSession(sessionId);                // 4. Zoom SDK
  };

  return { startTalking };
}

状態管理とスコープ

ガイドライン:

  • 個別インスタンスが必要: composable内でstateを定義
  • 親子間で共有が必要: Provide/Injectパターン
  • アプリ全体で共有が必要: Pinia
  • テストでは常に独立したインスタンスを使用できるようにする

❌ Bad: グローバルなステート共有(シングルトン)

// composable外でstateを定義
const isVideoSessionActive = ref(false);

export function useVideoStatus() {
  // 複数のコンポーネントで同じインスタンスを共有
  return { isVideoSessionActive };
}

✅ Good: composable内でstateを定義(個別インスタンス)

export function useVideoStatus() {
  // composable内でstateを定義(呼び出しごとに新しいインスタンス)
  const isVideoSessionActive = ref(false);
  const preparationState = reactive({
    onlineReservationId: null as number | null,
  });
  return { isVideoSessionActive, preparationState };
}

クリーンアップ処理

ガイドライン:

  • タイマー、イベントリスナー、WebSocketなどは必ずクリーンアップ
  • getCurrentInstance()でコンポーネント外での使用を考慮
  • onUnmountedでリソース解放を保証
  • 手動停止メソッドも提供して柔軟性を確保

❌ Bad: クリーンアップ処理の欠如

export function useVideoStatus() {
  let pollingTimer: number | null = null;

  const startPolling = (id: number) => {
    pollingTimer = window.setInterval(() => { /* ... */ }, 10000);
  };

  // クリーンアップ処理がない!
  return { startPolling };
}

✅ Good: 適切なクリーンアップ処理

export function useVideoStatus() {
  let pollingTimer: number | null = null;

  const startPolling = (id: number) => {
    if (pollingTimer !== null) return; // 重複防止
    pollingTimer = window.setInterval(() => { /* ... */ }, 10000);
  };

  const stopPolling = () => {
    if (pollingTimer !== null) {
      clearInterval(pollingTimer);
      pollingTimer = null;
    }
  };

  // コンポーネント外で使用される可能性を考慮
  const instance = getCurrentInstance();
  if (instance) {
    onUnmounted(() => stopPolling());
  }

  return { startPolling, stopPolling };
}

データ駆動設計

ガイドライン:

  • マスターデータはas constで定義し、型推論を活用
  • UIはデータから自動生成する(map/filterを使用)
  • ビジネスロジックは汎用的に設計(特定の値に依存しない)
  • 新規追加はデータ定義のみで完結するようにする
  • URLなどの派生データは関数で生成

❌ Bad: UIとロジックが密結合

export function useBadSymptomSelection() {
  const selectedSymptoms = ref<string[]>([]);

  // 症状ごとにメソッドを追加する必要がある
  const addCough = () => { selectedSymptoms.value.push('咳'); };
  const addFever = () => { selectedSymptoms.value.push('発熱'); };

  return { selectedSymptoms, addCough, addFever };
}

✅ Good: マスターデータから自動生成

// 1. マスターデータの定義(as constで型推論)
const symptomItems = {
  seki: { title: '咳', category: '咳' },
  netsu_jyunyu: { title: '発熱(授乳期)', category: '発熱' },
  hanamizu: { title: '鼻水', category: '鼻水' },
} as const;

// 2. 型の自動生成
export type SymptomItemKeyType = keyof typeof symptomItems;

// 3. データからUI構造を自動生成
const symptoms = categories.map((category) => ({
  category,
  items: Object.entries(symptomItems)
    .filter(([, item]) => item.category === category)
    .map(([key, { title }]) => ({
      key: key as SymptomItemKeyType,
      title,
      url: generateUrl(key as SymptomItemKeyType),
    })),
}));

// 4. 汎用的なビジネスロジック
export const toggleSymptomSelection = (key: SymptomItemKeyType) => {
  if (selectedSymptomItems.value.has(key)) {
    selectedSymptomItems.value.delete(key);
  } else {
    selectedSymptomItems.value.add(key);
  }
};

拡張性の実例:

// ✅ 新しい症状を追加(データ定義のみ、1箇所の変更)
const symptomItems = {
  // ... 既存の定義
  atopy: { title: 'アトピー性皮膚炎', category: '皮膚トラブル' }, // 追加
} as const;
// → UIは自動的に更新される

既存コンポーネントへの影響を最小化する設計

ガイドライン:

  • 新機能は新規コンポーネントに隔離する
  • 親コンポーネントへの変更は最小限に(10行以内を目標)
  • 既存のメソッドを再利用できる場合はコールバック関数Propsを使う
  • Props/Emitsはシンプルに保つ(2〜3個まで)
  • ビジネスロジックはComposableに委譲する
  • UIロジックは新規コンポーネント内で完結させる

影響範囲の比較

項目Bad PatternGood Pattern
親コンポーネントの変更行数300行以上10行以内
新規Importなし(全て親に実装)1行のみ
既存メソッドの変更複数のメソッド修正変更なし(再利用)
新規dataの追加5個以上0個
テスト対象親コンポーネント全体新規コンポーネントのみ

シンプルなPropsインターフェース

interface Props {
  onSendChat: (message: string) => void; // コールバック関数
  isDoctorPage: boolean;                 // 表示制御フラグ
}

なぜEmitではなくコールバック関数を使うのか:

// ❌ Bad: Emitを使う場合(親側の変更が必要)
// 親コンポーネント(新規メソッドが必要)
<ChatAttachedPdfSelect @send-chat="handleSymptomChatSend" />
methods: {
  handleSymptomChatSend(message) {
    this.handleChatSend(message); // 既存メソッドを呼ぶだけ
  }
}

// ✅ Good: コールバック関数を使う場合(親側の変更不要)
// 親コンポーネント(既存メソッドをそのまま渡す)
<ChatAttachedPdfSelect :onSendChat="handleChatSend" />
// 新規メソッド不要!

コンポーネント設計のベストプラクティス

Atomic Design

ガイドライン:

  • pages/: ルーティングとメタ情報のみ(50行以内)
  • organisms/: 複雑な機能の統合(100〜200行)
  • molecules/: 複合コンポーネント(50〜100行)
  • atoms/: 基本的なUI要素(30〜50行)
  • composables/: ビジネスロジックと状態管理

ディレクトリ構造:

pedaru-vue/
├── pages/
│   └── index.vue           # 薄いルートコンポーネント(50行以内)
├── components/
│   ├── organisms/
│   │   └── PdfViewer.vue   # PDFビューワー全体
│   ├── molecules/
│   │   ├── PdfToolbar.vue  # ツールバー
│   │   └── PdfPageNav.vue  # ページナビゲーション
│   └── atoms/
│       ├── BaseButton.vue  # ボタン
│       └── BaseIcon.vue    # アイコン
├── composables/
│   ├── usePdfViewer.ts     # 型定義もこのファイル内に配置
│   └── usePdfNavigation.ts
└── stores/
    └── pdf.ts              # Pinia store(型定義も同じファイル内)

Props/Emitsの型安全な定義

ガイドライン:

  • Propsは必ずTypeScriptのinterfaceで定義
  • 必須とオプショナルを明示的に区別?を使う)
  • Emitsも型定義する(ペイロードの型を明確に)
  • シンプルな通知はEmit複雑な処理フローはコールバック関数
  • コールバック関数を使う理由をコメントで明示

✅ Good: 型安全なProps/Emits定義

<script setup lang="ts">
// Props定義をinterfaceで明示
interface Props {
  isOnCamera: boolean;
  isOnAudio: boolean;
  nurseName: string;
  patientName?: string;  // オプショナルは明示的に
}

const props = defineProps<Props>();

// Emits定義も型安全に
interface Emits {
  (e: 'update:modelValue', value: boolean): void;
  (e: 'leave', reason: 'user-action' | 'timeout'): void;
}

const emit = defineEmits<Emits>();
</script>

コールバック vs Emit の使い分け:

<!-- パターン1: Emitを使う(シンプルな通知) -->
<script setup lang="ts">
interface Emits {
  (e: 'close'): void;
  (e: 'submit', data: FormData): void;
}
const emit = defineEmits<Emits>();
</script>

<!-- パターン2: コールバック関数を使う(複雑な処理フロー) -->
<script setup lang="ts">
interface Props {
  isDisplayVideoWindow: boolean;
  leaveSession: () => Promise<void>;  // 関数を直接渡す
}

const props = defineProps<Props>();

const onLeaveClick = async () => {
  // NOTE: 親コンポーネントで定義した処理を利用する必要がある
  // 理由:親のVideoStatusPanelで表示制御のフラグ更新とZoomのビデオ退出を行う
  await props.leaveSession();
};
</script>

コンポーネント肥大化の防止

ガイドライン:

  • コンポーネントは100行以内を目標
  • ロジックはcomposablesに分離
  • UIはAtomic Designに基づいて分割
  • テンプレートも100行を超えたら分割を検討
  • 1ファイル200行を超えたら必ず分割

TypeScript型安全性のベストプラクティス

型定義の配置方針

ガイドライン:

  • 型定義はロジックに近い場所に配置する(専用のtypes/ディレクトリは作らない)
  • composableで使う型はそのcomposableファイル内に定義
  • storeで使う型はそのstoreファイル内に定義
  • 複数ファイルで共有する型のみ、関連するcomposableからexport

❌ Bad: 別ディレクトリに型定義を分離

composables/
└── usePdfViewer.ts
types/
└── pdf.ts          # 型定義が離れている

✅ Good: ロジックと型定義を同じファイルに配置

// 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<PdfViewerState>({
    currentPage: 1,
    totalPages: 0,
    scale: 1.0,
  });
  const status = ref<PdfLoadStatus>('idle');

  // ...
  return { state, status };
}

メリット:

  • 型とロジックが近いため、変更時の影響範囲が明確
  • ファイルを開くだけで型の定義がわかる
  • 不要な型が残りにくい(ロジック削除時に型も一緒に削除される)

as constとUnion型の活用

ガイドライン:

  • 定数はas constで定義してUnion型を自動生成
  • 文字列リテラル型を活用して型安全性を確保
  • Template Literal Typeで命名規則を型で表現
  • keyof typeofでオブジェクトからUnion型を生成

❌ Bad: 文字列リテラルを直接使用

const status = ref<string>('notYetStarted');
const updateStatus = (newStatus: string) => {
  status.value = newStatus; // 任意の文字列を許容してしまう
};
updateStatus('typo-status'); // コンパイルエラーにならない

✅ Good: as constとUnion型の活用

// 定数オブジェクトを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<OnlineReservationVideoSessionStatusType>(
  OnlineReservationVideoSessionStatus.notYetStarted
);

updateStatus(OnlineReservationVideoSessionStatus.sessionStarted); // ✅ OK
updateStatus('typo-status'); // ❌ コンパイルエラー

Template Literal Typeの活用:

// 命名規則を型レベルで表現
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にキャスト

const onErrorOccur = (e: unknown) => {
  const error = e as any; // anyにキャストして型チェックを回避
  if (error.errorCode) {
    Sentry.captureMessage(`Error code: ${error.errorCode}`);
  }
};

✅ Good: Type Guardで安全に型を絞り込む

// 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: 実装の詳細をテスト

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: 振る舞いをテスト

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: 実際の時間を待つ

it('1秒後に経過時間が更新される', async () => {
  const { elapsedTime } = useSessionElapsedTime(sessionStartTime);
  await new Promise(resolve => setTimeout(resolve, 1000)); // テストが遅い
  expect(elapsedTime.value).toBe('00:00:01');
});

✅ Good: Fake Timersを使用

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が更新される)

実装段階

  • <script setup>とTypeScriptを使用しているか?
  • 型定義はロジックに近い場所に配置しているか?(専用のtypes/ディレクトリは作らない)
  • Props/Emitsに型定義を付けているか?
  • 既存メソッドを再利用できる場合はコールバック関数Propsを使っているか?
  • マスターデータをas constで定義しているか?
  • as constとUnion型を活用しているか?
  • Type Guardで安全に型を絞り込んでいるか?
  • anyを使っていないか?
  • v-forでUIを自動生成しているか?(ハードコードを避ける)
  • keyには一意なID(item.id、item.key)を使用しているか?(indexを使っていないか)
  • タイマーやイベントリスナーのクリーンアップ処理を実装したか?
  • getCurrentInstance()でコンポーネント外での使用を考慮したか?
  • 複雑な処理フローではコールバック関数を使っているか?
  • コメントで設計意図を明示しているか?

テスト段階

  • 振る舞いをテストしているか?(実装の詳細ではなく)
  • 正常系とエラー系の両方をカバーしているか?
  • タイマーテストでFake Timersを使っているか?
  • モックは外部依存のみに限定しているか?
  • テストの独立性を保っているか?

まとめ

AIに実装を依頼する際は、以下を意識してください:

基本原則

  1. Vue3 Composition API + TypeScriptを使用: 常に<script setup>を使用
  2. 単一責任の原則を守る: 1つのcomposable/コンポーネントは1つの関心事のみ
  3. レイヤーを分離する: 技術層とビジネス層を明確に分ける
  4. Atomic Designを適用する: pages → organisms → molecules → atoms
  5. 型安全性を確保する: as const、Union型、Type Guardを活用
  6. クリーンアップ処理を実装: タイマー、イベントリスナーは必ず解放
  7. データ駆動設計を推進: マスターデータからUIを自動生成(DRY原則)
  8. 既存コンポーネントへの影響を最小化: 新機能は新規コンポーネントに隔離

実装のポイント

  • Composables: 50〜100行、単一責任、技術層とビジネス層を分離、データ駆動設計、複数のcomposableを組み合わせる(Compose)
  • コンポーネント: 100行以内、ロジックはcomposablesに委譲、v-forで動的生成
  • Props/Emits: 必ずTypeScriptで型定義、既存メソッド再利用時はコールバック関数
  • TypeScript: as const、Union型、Type Guard、Template Literal Type
  • テスト: 振る舞いをテスト、Fake Timers、モック最小化

特に重要:pages/配下のコンポーネント肥大化防止

  • pages/: 50行以内の薄いルートコンポーネント
  • organisms/: 100〜200行の機能統合
  • molecules/: 50〜100行の複合コンポーネント
  • composables/: ビジネスロジックと状態管理

特に重要:データ駆動設計と拡張性

  • マスターデータ定義: as constで型推論、1箇所で管理
  • UIは自動生成: v-forでデータから動的に生成
  • 新規追加は容易: データ定義のみで完結(UIコードの修正不要)
  • 型安全性の確保: TypeScriptの型チェックで誤りを防止
  • テストの独立性: データ変更でUIが正しく更新されるかを検証

特に重要:既存コンポーネントへの影響最小化

  • 親コンポーネントへの変更は10行以内を目標
  • 既存メソッドの再利用: コールバック関数Propsで疎結合
  • 新機能は新規コンポーネントに隔離: リグレッションリスク最小化
  • Props/Emitsはシンプルに: 2〜3個まで
  • レビューしやすい差分: 変更箇所を最小限に

これらの原則に従うことで、保守性が高く、テストしやすく、拡張性のある、品質の高いVue/TypeScriptコードを実装できます。


参考

Repository
h5y1m141/pedaru-vue
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.