Skip to main content
Glama
+page.svelte15.9 kB
<script> import { onMount } from 'svelte'; let testCases = []; let tasks = []; let loading = true; let error = null; let showLinkModal = false; let selectedTestCase = null; // 필터링 옵션 let selectedStatus = ''; let selectedType = ''; let sortOrder = 'created_desc'; // 기본값: 최근 등록순 const testTypes = [ { value: 'unit', label: '단위 테스트' }, { value: 'integration', label: '통합 테스트' }, { value: 'system', label: '시스템 테스트' }, { value: 'acceptance', label: '인수 테스트' }, { value: 'regression', label: '회귀 테스트' } ]; const testStatuses = [ { value: 'draft', label: '초안' }, { value: 'ready', label: '준비완료' }, { value: 'active', label: '활성' }, { value: 'inactive', label: '비활성' } ]; const sortOptions = [ { value: 'created_desc', label: '최근 등록순' }, { value: 'created_asc', label: '오래된 등록순' }, { value: 'updated_desc', label: '최근 수정순' }, { value: 'updated_asc', label: '오래된 수정순' }, { value: 'title_asc', label: '제목순 (가-하)' }, { value: 'title_desc', label: '제목순 (하-가)' } ]; onMount(async () => { await loadTestCases(); await loadTasks(); }); async function loadTestCases() { try { loading = true; let url = '/api/tests'; const params = new URLSearchParams(); if (selectedStatus) params.append('status', selectedStatus); if (selectedType) params.append('type', selectedType); if (params.toString()) { url += '?' + params.toString(); } const response = await fetch(url); if (response.ok) { testCases = await response.json(); } else { error = '테스트 케이스 목록을 불러올 수 없습니다'; } } catch (e) { error = '테스트 케이스 로딩 중 오류: ' + e.message; } finally { loading = false; } } async function loadTasks() { try { const response = await fetch('/api/tasks'); if (response.ok) { tasks = await response.json(); } } catch (e) { console.error('작업 목록 로딩 실패:', e.message); } } async function executeTestCase(id) { try { const response = await fetch(`/api/tests/${id}/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'pass', environment: 'development', executed_by: 'system', actual_result: 'Test executed successfully', notes: 'Quick execution from dashboard' }) }); if (response.ok) { alert('테스트 케이스가 실행되었습니다'); await loadTestCases(); } else { alert('테스트 실행에 실패했습니다'); } } catch (e) { alert('테스트 실행 중 오류: ' + e.message); } } async function deleteTestCase(id) { if (!confirm('이 테스트 케이스를 삭제하시겠습니까?')) return; try { const response = await fetch(`/api/tests/${id}`, { method: 'DELETE' }); if (response.ok) { await loadTestCases(); } else { alert('테스트 케이스 삭제 중 오류가 발생했습니다'); } } catch (e) { alert('삭제 중 오류: ' + e.message); } } function openLinkModal(testCase) { selectedTestCase = { ...testCase }; showLinkModal = true; } async function linkToTask(testCaseId, taskId) { try { const response = await fetch(`/api/tests/${testCaseId}/link`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId }) }); if (response.ok) { alert('작업에 성공적으로 연결되었습니다'); showLinkModal = false; selectedTestCase = null; await loadTestCases(); } else { alert('작업 연결에 실패했습니다'); } } catch (e) { alert('연결 중 오류: ' + e.message); } } function getStatusColor(status) { switch (status) { case 'ready': return 'bg-green-100 text-green-800'; case 'active': return 'bg-blue-100 text-blue-800'; case 'inactive': return 'bg-red-100 text-red-800'; case 'draft': return 'bg-gray-100 text-gray-800'; default: return 'bg-gray-100 text-gray-800'; } } function getStatusLabel(status) { switch (status) { case 'ready': return '준비완료'; case 'active': return '활성'; case 'inactive': return '비활성'; case 'draft': return '초안'; default: return status; } } function getTypeColor(type) { switch (type) { case 'unit': return 'bg-blue-100 text-blue-800'; case 'integration': return 'bg-purple-100 text-purple-800'; case 'system': return 'bg-green-100 text-green-800'; case 'acceptance': return 'bg-orange-100 text-orange-800'; case 'regression': return 'bg-red-100 text-red-800'; default: return 'bg-gray-100 text-gray-800'; } } function getTypeLabel(type) { const typeMap = testTypes.find(t => t.value === type); return typeMap ? typeMap.label : type; } function getPriorityColor(priority) { switch (priority) { case 'High': return 'bg-red-100 text-red-800'; case 'Medium': return 'bg-yellow-100 text-yellow-800'; case 'Low': return 'bg-green-100 text-green-800'; default: return 'bg-gray-100 text-gray-800'; } } function getPriorityLabel(priority) { switch (priority) { case 'High': return '높음'; case 'Medium': return '보통'; case 'Low': return '낮음'; default: return priority; } } // 반응형 필터링 및 정렬 $: filteredAndSortedTestCases = (() => { // 먼저 필터링 let filtered = testCases.filter(testCase => { if (selectedStatus && testCase.status !== selectedStatus) return false; if (selectedType && testCase.type !== selectedType) return false; return true; }); // 그다음 정렬 const sorted = [...filtered].sort((a, b) => { switch (sortOrder) { case 'created_desc': return new Date(b.created_at || b.createdAt || '').getTime() - new Date(a.created_at || a.createdAt || '').getTime(); case 'created_asc': return new Date(a.created_at || a.createdAt || '').getTime() - new Date(b.created_at || b.createdAt || '').getTime(); case 'updated_desc': return new Date(b.updated_at || b.updatedAt || '').getTime() - new Date(a.updated_at || a.updatedAt || '').getTime(); case 'updated_asc': return new Date(a.updated_at || a.updatedAt || '').getTime() - new Date(b.updated_at || b.updatedAt || '').getTime(); case 'title_asc': return (a.title || '').localeCompare(b.title || '', 'ko'); case 'title_desc': return (b.title || '').localeCompare(a.title || '', 'ko'); default: return 0; } }); return sorted; })(); // 반응형 필터링 업데이트 $: if (selectedStatus || selectedType) { loadTestCases(); } </script> <svelte:head> <title>테스트 케이스 관리 - WorkflowMCP</title> </svelte:head> <div class="space-y-6"> <div class="flex items-center justify-between"> <div> <h1 class="text-3xl font-bold text-gray-900">테스트 케이스 관리</h1> <p class="text-gray-600 mt-1">프로젝트 테스트 케이스를 관리합니다</p> </div> <a href="/tests/new" class="btn btn-primary"> 🧪 새 테스트 케이스 추가 </a> </div> <!-- 필터링 섹션 --> <div class="bg-white p-4 rounded-lg border border-gray-200 flex flex-wrap gap-4"> <div> <label class="text-sm font-medium text-gray-700 mb-1 block">📅 상태 필터</label> <select bind:value={selectedStatus} class="form-select text-sm"> <option value="">📦 전체 상태</option> {#each testStatuses as status} <option value={status.value}>{status.label}</option> {/each} </select> </div> <div> <label class="text-sm font-medium text-gray-700 mb-1 block">🔍 유형 필터</label> <select bind:value={selectedType} class="form-select text-sm"> <option value="">📊 전체 유형</option> {#each testTypes as type} <option value={type.value}>{type.label}</option> {/each} </select> </div> <div> <label class="text-sm font-medium text-gray-700 mb-1 block">⬇️ 정렬 순서</label> <select bind:value={sortOrder} class="form-select text-sm"> {#each sortOptions as option} <option value={option.value}>{option.label}</option> {/each} </select> </div> <div class="ml-auto flex items-end"> <div class="text-sm text-gray-600"> 📊 총 <strong>{filteredAndSortedTestCases.length}</strong>개 </div> </div> </div> {#if loading} <div class="flex justify-center items-center h-64"> <div class="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600"></div> </div> {:else if error} <div class="bg-red-50 border border-red-200 rounded-md p-4"> <div class="text-red-800">{error}</div> <button class="mt-2 text-sm text-red-600 hover:text-red-800" on:click={loadTestCases} > 다시 시도 </button> </div> {:else if filteredAndSortedTestCases.length === 0} <div class="text-center py-12"> <div class="text-gray-400 text-6xl mb-4">🧪</div> <h3 class="text-lg font-medium text-gray-900 mb-2">테스트 케이스가 없습니다</h3> <p class="text-gray-600 mb-6">첫 번째 테스트 케이스를 추가해보세요</p> <a href="/tests/new" class="btn btn-primary"> 새 테스트 케이스 추가 </a> </div> {:else} <!-- 테스트 케이스 목록 --> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {#each filteredAndSortedTestCases as testCase} <div class="card bg-white hover:shadow-md transition-shadow"> <div class="card-header d-flex justify-content-between align-items-start"> <h6 class="card-title mb-0 font-medium text-gray-900">{testCase.title}</h6> <div class="d-flex gap-1 ml-2 flex-shrink-0"> <span class="badge {getStatusColor(testCase.status)}">{getStatusLabel(testCase.status)}</span> <span class="badge {getPriorityColor(testCase.priority)}">{getPriorityLabel(testCase.priority)}</span> </div> </div> <div class="card-body"> <p class="text-sm text-gray-600 mb-2"> <strong>유형:</strong> <span class="badge {getTypeColor(testCase.type)} text-xs ml-1"> {getTypeLabel(testCase.type)} </span> </p> {#if testCase.description} <p class="text-sm text-gray-600 mb-3 line-clamp-2">{testCase.description}</p> {/if} {#if testCase.task_id} <p class="text-sm text-blue-600 mb-2"> <strong>연결된 작업:</strong> {tasks.find(t => t.id === testCase.task_id)?.title || testCase.task_id} </p> {/if} {#if testCase.summary} <div class="mb-2"> <small class="text-gray-500"> 실행: <strong>{testCase.summary.execution_count}</strong>회, 성공률: <strong>{testCase.summary.pass_rate}</strong>% {#if testCase.summary.last_status} , 최근: <span class="badge {testCase.summary.last_status === 'pass' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">{testCase.summary.last_status}</span> {/if} </small> </div> {/if} {#if testCase.tags && testCase.tags.length > 0} <div class="mb-2"> {#each testCase.tags as tag} <span class="badge bg-light text-dark me-1 text-xs">{tag}</span> {/each} </div> {/if} </div> <div class="card-footer"> <div class="flex space-x-1 mb-2"> <a href="/tests/{testCase.id}" class="flex-1 text-center text-xs px-2 py-1 bg-blue-50 text-blue-700 rounded hover:bg-blue-100" > 상세보기 </a> <a href="/tests/{testCase.id}/edit" class="flex-1 text-center text-xs px-2 py-1 bg-gray-50 text-gray-700 rounded hover:bg-gray-100" > 편집 </a> </div> <div class="flex space-x-1"> <button class="flex-1 text-xs px-2 py-1 bg-green-50 text-green-700 rounded hover:bg-green-100" on:click={() => executeTestCase(testCase.id)} > 실행 </button> <button class="flex-1 text-xs px-2 py-1 bg-blue-50 text-blue-700 rounded hover:bg-blue-100" on:click={() => openLinkModal(testCase)} > 작업 연결 </button> <button class="text-xs px-2 py-1 text-red-600 hover:text-red-800" on:click={() => deleteTestCase(testCase.id)} > 삭제 </button> </div> </div> </div> {/each} </div> <!-- 전체 통계 --> <div class="bg-gray-50 rounded-lg p-4"> <h3 class="text-sm font-medium text-gray-700 mb-2">전체 통계</h3> <div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <div> <div class="text-gray-500">전체 테스트</div> <div class="text-lg font-semibold text-gray-900">{testCases.length}</div> </div> <div> <div class="text-gray-500">활성</div> <div class="text-lg font-semibold text-blue-600">{testCases.filter(t => t.status === 'active').length}</div> </div> <div> <div class="text-gray-500">준비완료</div> <div class="text-lg font-semibold text-green-600">{testCases.filter(t => t.status === 'ready').length}</div> </div> <div> <div class="text-gray-500">실행율</div> <div class="text-lg font-semibold text-purple-600"> {testCases.length > 0 ? Math.round((testCases.filter(t => t.summary?.execution_count > 0).length / testCases.length) * 100) : 0}% </div> </div> </div> </div> {/if} </div> <!-- 작업 연결 모달 --> {#if showLinkModal && selectedTestCase} <div class="modal show d-block" style="background-color: rgba(0,0,0,0.5);"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">작업에 연결</h5> <button type="button" class="btn-close" on:click={() => showLinkModal = false}></button> </div> <div class="modal-body"> <p><strong>테스트 케이스:</strong> {selectedTestCase.title}</p> <div class="mb-3"> <label class="form-label">연결할 작업 선택:</label> <select class="form-select" bind:value={selectedTestCase.selected_task_id}> <option value="">작업을 선택하세요</option> {#each tasks as task} <option value={task.id}>{task.title}</option> {/each} </select> </div> {#if selectedTestCase.task_id} <div class="alert alert-info"> 현재 연결된 작업: {tasks.find(t => t.id === selectedTestCase.task_id)?.title || '알 수 없음'} </div> {/if} </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" on:click={() => showLinkModal = false}> 취소 </button> <button type="button" class="btn btn-primary" disabled={!selectedTestCase.selected_task_id} on:click={() => linkToTask(selectedTestCase.id, selectedTestCase.selected_task_id)} > 연결 </button> </div> </div> </div> </div> {/if} <style> .line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .badge { display: inline-flex; align-items: center; padding: 0.25rem 0.5rem; border-radius: 0.375rem; font-size: 0.75rem; font-weight: 500; } .card { transition: transform 0.2s; border: 1px solid #e5e7eb; border-radius: 0.5rem; } .card:hover { transform: translateY(-2px); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); } .card-header { padding: 1rem 1rem 0.5rem 1rem; border-bottom: 1px solid #f3f4f6; display: flex; justify-content: space-between; align-items: flex-start; } .card-body { padding: 0.5rem 1rem; } .card-footer { padding: 0.5rem 1rem 1rem 1rem; border-top: 1px solid #f3f4f6; background-color: #f9fafb; } </style>

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/foswmine/workflow-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server