From 30af25591beefad9b7ebea04b0825f8f15176254 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:20:44 +0900 Subject: [PATCH 01/41] feat(wbs): add searchable hierarchy context --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 2 + README.md | 1 + app.js | 98 +++++++++++++++----- docs/product-technical-gap-baseline.md | 122 +++++++++++++++++++++++++ docs/user-guide.md | 8 ++ index.html | 10 ++ styles.css | 58 ++++++++++++ tests/e2e/scopeweave.spec.js | 20 ++++ 9 files changed, 298 insertions(+), 23 deletions(-) create mode 100644 docs/product-technical-gap-baseline.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4688d27b..8d41b54c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,7 +8,7 @@ - `toast-state.css`: cloud overlay `.toast.visible` rendering so SaaS status messages stay visually observable. - `app.js`: state, rendering, editing, validation, persistence, - import/export, and Gantt logic. + import/export, WBS filtering, and Gantt logic. - `analytics.js`: EVM, S-curve, CPM, workload, cost, and requirements/RFI/RFP WBS-estimation readiness analysis. - `wbs.json`: seed data in the user-specified JSON array format. diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..878d5a77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added accessible WBS field search with hierarchy context and an empty-result + recovery action. - Added deterministic PM analysis for requirements/RFI/RFP readiness, WBS estimation coverage, dependency risk, and procurement package section checks. - Preserved PM-analysis research papers, NASA WBS handbook, BCP 14, and JSON diff --git a/README.md b/README.md index 6340c1f4..3d288bf6 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ two modes: - Automatic day, weight, planned progress, actual progress, and weighted progress calculations - CSV import/export using the screen column contract +- WBS search across task fields with matching hierarchy context - Local autosave with optional File System Access API sync to `wbs.json` - Weekly Gantt modal with planned (`#333333`) and actual (`#34cb03`) overlays - Responsive column reduction for screens under 800px diff --git a/app.js b/app.js index a04aae71..6268a5a1 100644 --- a/app.js +++ b/app.js @@ -178,6 +178,7 @@ const state = { projectName: DEFAULT_PROJECT_NAME, baseDate: formatLocalDateInput(new Date()), tasks: [], + taskQuery: '', editor: { ...DEFAULT_EDITOR_STATE, errors: [] }, jsonSyncHandle: null, dragTaskId: null, @@ -224,6 +225,9 @@ const elements = { closeGanttButton: document.getElementById('close-gantt'), connectJsonSyncButton: document.getElementById('connect-json-sync'), syncStatus: document.getElementById('sync-status'), + taskFilterInput: document.getElementById('task-filter'), + clearTaskFilterButton: document.getElementById('clear-task-filter'), + taskFilterStatus: document.getElementById('task-filter-status'), toast: document.getElementById('toast') }; @@ -336,6 +340,16 @@ function bindHeaderEvents(persistAndRenderMetadata) { } await connectJsonSync(); }); + + elements.taskFilterInput.addEventListener('input', (event) => { + state.taskQuery = String(event.target.value).slice(0, 120); + renderAll(); + }); + elements.clearTaskFilterButton.addEventListener('click', () => { + state.taskQuery = ''; + renderAll(); + elements.taskFilterInput.focus(); + }); } function bindModalEvents() { @@ -513,6 +527,9 @@ function renderAll() { elements.plannedProgress.textContent = formatPercent(metrics.totalWeightedPlannedRatio * 100, 2); elements.actualProgress.textContent = formatPercent(metrics.totalWeightedActualRatio * 100, 2); elements.syncStatus.textContent = state.jsonSyncHandle ? '연결된 wbs.json 파일에 자동저장 중' : '브라우저 로컬 자동저장 사용 중'; + if (elements.taskFilterInput.value !== state.taskQuery) { + elements.taskFilterInput.value = state.taskQuery; + } if (typeof window !== 'undefined') { window.ScopeWeaveAnalytics?.render?.({ @@ -528,6 +545,11 @@ function renderAll() { const visibleTasks = getVisibleTasks(); const rows = []; + const filterActive = Boolean(state.taskQuery.trim()); + elements.clearTaskFilterButton.hidden = !filterActive; + elements.taskFilterStatus.textContent = filterActive + ? `${visibleTasks.length}개 작업 표시 중 (전체 ${state.tasks.length}개)` + : `전체 ${state.tasks.length}개 작업`; const hasTasks = state.tasks.length > 0; if (!hasTasks) { @@ -586,40 +608,47 @@ function createEmptyStateRow() { const icon = document.createElement('div'); icon.className = 'empty-icon'; icon.setAttribute('aria-hidden', 'true'); - icon.textContent = '📋'; + const filtered = Boolean(state.taskQuery.trim()); + icon.textContent = filtered ? '🔎' : '📋'; const title = document.createElement('h3'); title.className = 'empty-title'; - title.textContent = '등록된 작업이 없습니다'; + title.textContent = filtered ? '검색 결과가 없습니다' : '등록된 작업이 없습니다'; const description = document.createElement('p'); description.className = 'empty-desc'; - description.append( - "하단의 '최상위 작업 추가' 버튼을 눌러 프로젝트를 시작하거나,", - document.createElement('br'), - "'CSV 가져오기'를 통해 기존 데이터를 불러오세요." - ); + if (filtered) { + description.textContent = `‘${state.taskQuery}’에 일치하는 작업이 없습니다.`; + } else { + description.append( + "하단의 '최상위 작업 추가' 버튼을 눌러 프로젝트를 시작하거나,", + document.createElement('br'), + "'CSV 가져오기'를 통해 기존 데이터를 불러오세요." + ); + } const actions = document.createElement('div'); actions.className = 'empty-actions editor-actions'; - const addRootBtn = document.createElement('button'); - addRootBtn.type = 'button'; - addRootBtn.className = 'primary-button'; - addRootBtn.textContent = '최상위 작업 추가'; - addRootBtn.addEventListener('click', () => { - openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() }); - }); + if (!filtered) { + const addRootBtn = document.createElement('button'); + addRootBtn.type = 'button'; + addRootBtn.className = 'primary-button'; + addRootBtn.textContent = '최상위 작업 추가'; + addRootBtn.addEventListener('click', () => { + openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() }); + }); - const importCsvBtn = document.createElement('button'); - importCsvBtn.type = 'button'; - importCsvBtn.className = 'secondary-button'; - importCsvBtn.textContent = 'CSV 가져오기'; - importCsvBtn.addEventListener('click', () => { - document.getElementById('csv-file-input').click(); - }); + const importCsvBtn = document.createElement('button'); + importCsvBtn.type = 'button'; + importCsvBtn.className = 'secondary-button'; + importCsvBtn.textContent = 'CSV 가져오기'; + importCsvBtn.addEventListener('click', () => { + document.getElementById('csv-file-input').click(); + }); - actions.append(addRootBtn, importCsvBtn); + actions.append(addRootBtn, importCsvBtn); + } emptyState.append(icon, title, description, actions); cell.appendChild(emptyState); @@ -1496,11 +1525,36 @@ function getDateRangeWarning(startDate, endDate, message) { } const cachedHiddenParentIds = new Set(); +const TASK_SEARCH_FIELDS = [ + 'phase', 'activity', 'task', 'categoryLarge', 'categoryMedium', 'documentName', + 'owner', 'supportTeam', 'actualProgressStatus', 'plannedStartDate', + 'plannedEndDate', 'actualStartDate', 'actualEndDate', 'predecessors', 'sprint' +]; + +function taskSearchText(task) { + return TASK_SEARCH_FIELDS.map((field) => String(task[field] ?? '')).join(' ').toLowerCase(); +} function getVisibleTasks() { const visible = []; cachedHiddenParentIds.clear(); + const query = state.taskQuery.trim().toLowerCase(); + if (query) { + const tasksById = new Map(state.tasks.map((task) => [task.id, task])); + const matchingIds = new Set(); + state.tasks.forEach((task) => { + if (taskSearchText(task).includes(query)) { + let current = task; + while (current) { + matchingIds.add(current.id); + current = tasksById.get(current.parentId); + } + } + }); + return state.tasks.filter((task) => matchingIds.has(task.id)); + } + // ⚡ Bolt Optimization: Single-pass O(N) visible task filtering to avoid redundant O(N * Depth) tree traversals state.tasks.forEach((task) => { if (cachedHiddenParentIds.has(task.parentId)) { diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 00000000..f56aa1b4 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,122 @@ +# ScopeWeave 제품·기술 Gap Baseline + +> 기준일: 2026-08-28 | 기준 브랜치: `develop` | 기준 HEAD: `2c328875e00e86537df3e965170be80532571cad` + +이 문서는 현재 저장소의 PRD, 기술 계약, 구현, 테스트, 운영 게이트를 한 +곳에서 추적하는 기준선이다. 문서의 상태는 의도나 열린 PR의 제목이 아니라 +현재 파일과 실행 증거를 기준으로 기록한다. + +## 1. 제품 목표와 구매자 + +주 구매자는 일정·공정 데이터를 WBS로 관리하고, 계획 대비 실적과 지연 +원인을 설명해야 하는 PM과 PMO다. 제품의 핵심 가치는 다음 세 가지다. + +1. `단계 > Activity > Task` 구조를 빠르게 편집한다. +2. 날짜·진척·선행작업에서 일정 통제 신호를 재현 가능하게 계산한다. +3. CSV와 브라우저 저장을 통해 별도 플랫폼에서도 계획을 회수한다. + +현재 범위는 정적 브라우저 클라이언트와 선택적 Node SaaS 계층이다. 정적 +호스팅에서 서버 파일을 덮어쓰지 않는다는 제약은 유지한다. + +## 2. PRD/TRD 추적성 + +| 요구 | 현재 구현 증거 | 상태 | +| --- | --- | --- | +| 3단계 WBS 편집·계층 보존 | `app.js`의 단일 `state.tasks`, `renderAll()`, expand/collapse·subtree 이동 | 완료 | +| 계획/실적 진척 및 일정 통제 | `analytics.js`의 EVM, S-curve, CPM, workload, PM readiness | 완료 | +| 계획을 찾고 계층 맥락을 유지 | `#task-filter`, `getVisibleTasks()`, `tests/e2e/scopeweave.spec.js` 검색 회귀 | 완료(이번 변경) | +| CSV 왕복 | `exportCsv()`, CSV parser/validation, E2E·fuzz 테스트 | 완료 | +| JSON seed·로컬 자동 저장 | `loadSeedTasks()`, `localStorage`, `exportJsonArray()` | 부분 완료: 명시적 JSON 다운로드는 미제공 | +| 정적 배포 | `pages.yml`, 상대 경로 자산, `404.html` | 구현 완료, 실제 출판은 별도 런타임 증거 필요 | +| Cloud 인증·멀티테넌시·협업 | `server/`, `cloud-sync.js`, API smoke/E2E | 코드·테스트 존재, 운영 배포는 환경별 검증 필요 | + +## 3. UML 및 데이터 흐름 + +```mermaid +classDiagram + class ScopeWeaveState { + +string projectName + +string baseDate + +Task[] tasks + +string taskQuery + } + class Task { + +string id + +string parentId + +number depth + +string phase + +string activity + +string task + +string plannedStartDate + +string plannedEndDate + } + class AppController { + +bootstrap() + +renderAll() + +persistState() + } + class AnalyticsBridge { + +render(input) + +computeCpm(tasks) + +computeEvm(input) + } + class BrowserStorage { + +load() + +save(state) + } + ScopeWeaveState "1" *-- "0..*" Task + AppController --> ScopeWeaveState + AppController --> AnalyticsBridge + AppController --> BrowserStorage +``` + +`tasks`가 유일한 원천이며, 사용자 입력·파일 seed·Cloud snapshot은 이 +상태로 정규화된다. 화면 갱신은 `renderAll()` 하나를 통과하고, 분석은 +`window.ScopeWeaveAnalytics` 경계를 통해 선택적으로 호출된다. + +## 4. Gap 및 조치 상태 + +| ID | Gap / 고객 영향 | 조치 | 상태 | +| --- | --- | --- | --- | +| G-01 | 큰 WBS에서 작업 위치를 찾는 비용이 높았음 | 작업·담당자·산출물 등 고객 필드를 검색하고 일치 행의 상위 계층을 함께 표시 | **완료** | +| G-02 | 정적 사용자가 JSON을 파일로 회수하려면 File System Access API에 의존 | 브라우저 다운로드용 JSON export를 CSV와 같은 portability 계약으로 추가 | 다음 개발 | +| G-03 | 빈 화면에서 첫 계획을 만드는 안내가 seed 데이터 유무에 따라 달라짐 | 최소 온보딩/샘플 사용 경로와 삭제 가능한 샘플 상태를 제품 결정 후 추가 | 조사 필요 | +| G-04 | 키보드·스크린리더 회귀는 E2E 일부로 보호되지만 시각 회귀 자동 검사는 없음 | 핵심 상태의 실제 브라우저 스크린샷과 WCAG 2.2 점검을 릴리스 증거에 포함 | 다음 검증 | +| G-05 | 보호 PR 큐는 소스와 무관한 Strix 공급자 429/Invalid URL 및 승인 부재로 차단될 수 있음 | 게이트를 약화하지 않고 원인 로그·artifact·현재 HEAD를 재검증한 뒤 재실행/중앙 수정 | 외부 상태 대기 | + +## 5. 품질·보안 기준선 + +- 런타임 의존성은 브라우저 native API와 현재 서버의 최소 의존성만 사용한다. +- `app.js`는 `new Function` 테스트 계약 때문에 top-level ESM import/export를 + 사용하지 않는다. +- 입력은 신뢰 경계에서 길이·날짜·CSV 수식·JSON prototype pollution을 + 검증하고, 동적 HTML 삽입 대신 `textContent`를 사용한다. +- 접근성은 레이블, landmark, keyboard focus, live status, disabled 상태, + reduced motion을 최소 기준으로 삼는다. +- 검증 명령은 `npm run test:unit`, `npm run test:api`, + `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. + 이번 G-01의 직접 증거는 `npm run test:e2e` 77개 통과와 검색 회귀 테스트의 + 통과다. + +## 6. 표준·연구 근거 + +- 국제표준화기구. (2020). *ISO 21502:2020: Project, programme and portfolio + management—Guidance on project management*. https://www.iso.org/standard/74947.html +- 국제표준화기구. (2018). *ISO 21511:2018: Work breakdown structures for + project and programme management*. https://www.iso.org/standard/69702.html + 현재 개정안 ISO/DIS 21511은 초안이므로 이 기준선의 normative 계약으로 + 사용하지 않는다. +- 국제표준화기구. (2026). *ISO 21508:2026: Project, programme and portfolio + management—Earned value management*. https://www.iso.org/standard/87899.html +- World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines + (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ +- Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software + development framework (SSDF) version 1.1: Recommendations for mitigating + the risk of software vulnerabilities* (NIST Special Publication 800-218). + National Institute of Standards and Technology. + https://doi.org/10.6028/NIST.SP.800-218 + +Repository-specific research and existing design decisions remain linked from +[`docs/plans/2026-04-20-scopeweave-design.md`](plans/2026-04-20-scopeweave-design.md), +[`docs/research/pm-analysis/README.md`](research/pm-analysis/README.md), and +[`ARCHITECTURE.md`](../ARCHITECTURE.md). diff --git a/docs/user-guide.md b/docs/user-guide.md index fb32af2f..938f9d28 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -12,6 +12,14 @@ ScopeWeave Planner는 프로젝트용 WBS를 순수 HTML/CSS/JavaScript만으로 4. 계획/실적 날짜와 실적진척상태를 입력하면 요약 수치와 가중치가 자동 재계산됩니다. 5. **CSV 내보내기** / **CSV 가져오기** / **간트차트보기**로 산출물을 활용합니다. +## WBS 검색 + +- WBS 표 위의 **WBS 작업 검색**은 단계, 작업, 산출물, 담당자, 일정 등 입력된 + 필드를 검색합니다. +- 일치한 작업의 상위 계층은 함께 표시되어 검색 결과의 맥락을 유지합니다. +- 검색어는 브라우저 저장 데이터에 포함되지 않으며, **검색 지우기**로 전체 + 계층을 즉시 복원할 수 있습니다. + ## 계층 규칙 - 최대 깊이는 3단계입니다. diff --git a/index.html b/index.html index d24b2a88..4df9ced6 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,8 @@ ScopeWeave Planner + + @@ -53,6 +55,14 @@

ScopeWeave Planner

+
+ + + 전체 0개 작업 +
diff --git a/styles.css b/styles.css index 9d715f00..a1cda55b 100644 --- a/styles.css +++ b/styles.css @@ -205,6 +205,49 @@ button { min-width: 0; } +.table-toolbar { + display: flex; + align-items: end; + gap: 12px; + padding: 20px 24px; + border-bottom: 1px solid var(--border); + background: var(--surface-header); +} + +.task-filter { + display: flex; + flex: 1 1 360px; + max-width: 560px; + flex-direction: column; + gap: 6px; + color: var(--text-muted); + font-size: 0.8125rem; + font-weight: 700; +} + +.task-filter input { + min-height: 44px; + width: 100%; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: #ffffff; + color: var(--text); + padding: 10px 14px; +} + +.task-filter-status { + min-height: 44px; + display: inline-flex; + align-items: center; + color: var(--text-muted); + font-size: 0.875rem; + white-space: nowrap; +} + +.filter-clear { + min-height: 44px; +} + .table-scroll { overflow: auto; contain: paint; @@ -841,6 +884,21 @@ select[data-inline-progress]:focus { min-width: 1300px; } + .table-toolbar { + align-items: stretch; + flex-wrap: wrap; + padding: 16px; + } + + .task-filter { + flex-basis: 100%; + max-width: none; + } + + .task-filter-status { + min-height: 0; + } + .priority-desktop { display: none; } diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index dc0cda8d..fd7ca841 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -90,6 +90,26 @@ test.describe('ScopeWeave Planner', () => { await expect(page).toHaveTitle('My New Project - ScopeWeave Planner'); }); + test('filters WBS rows while preserving matching task hierarchy context', async ({ page }) => { + const rows = page.locator('tbody tr[data-task-id]'); + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + + await expect(rows).toHaveCount(4); + await search.fill('단계작업계획'); + + await expect(rows).toHaveCount(3); + await expect(rows).toContainText(['P0000.준비단계', '프로젝트준비', '단계작업계획']); + await expect(rows.filter({ hasText: '사업수행계획' })).toHaveCount(0); + await expect(page.locator('#task-filter-status')).toHaveText('3개 작업 표시 중 (전체 4개)'); + + await search.fill('없는작업'); + await expect(rows).toHaveCount(0); + await expect(page.locator('.table-empty')).toContainText('검색 결과가 없습니다'); + + await page.getByRole('button', { name: '검색 지우기' }).click(); + await expect(rows).toHaveCount(4); + }); + [ { name: 'desktop', width: 1440, height: 1000 }, { name: 'mobile', width: 375, height: 667 } From 4a008b29aa875754b6819dc7f8eb3088e00fa4af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:31:11 +0900 Subject: [PATCH 02/41] fix(wbs): disable collapse during search --- app.js | 9 ++++++--- docs/user-guide.md | 1 + tests/e2e/scopeweave.spec.js | 7 +++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app.js b/app.js index 6268a5a1..1803fdc8 100644 --- a/app.js +++ b/app.js @@ -710,12 +710,15 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { if (hasChildren) { const toggleButton = toggleButtonTemplate.cloneNode(false); - const toggleLabel = task.expanded ? '접기' : '펼치기'; + const filterActive = Boolean(state.taskQuery.trim()); + const expanded = filterActive || task.expanded; + const toggleLabel = filterActive ? '검색 중 계층 맥락 고정' : (task.expanded ? '접기' : '펼치기'); toggleButton.setAttribute('aria-label', `${toggleLabel} - ${rowEntityName}`); - toggleButton.setAttribute('aria-expanded', String(task.expanded)); + toggleButton.setAttribute('aria-expanded', String(expanded)); toggleButton.title = `${toggleLabel} - ${rowEntityName}`; + toggleButton.disabled = filterActive; const toggleIcon = toggleIconTemplate.cloneNode(false); - toggleIcon.textContent = task.expanded ? '▼' : '▶'; + toggleIcon.textContent = expanded ? '▼' : '▶'; toggleButton.appendChild(toggleIcon); actionStack.appendChild(toggleButton); } else { diff --git a/docs/user-guide.md b/docs/user-guide.md index 938f9d28..0c7776fc 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -17,6 +17,7 @@ ScopeWeave Planner는 프로젝트용 WBS를 순수 HTML/CSS/JavaScript만으로 - WBS 표 위의 **WBS 작업 검색**은 단계, 작업, 산출물, 담당자, 일정 등 입력된 필드를 검색합니다. - 일치한 작업의 상위 계층은 함께 표시되어 검색 결과의 맥락을 유지합니다. +- 검색 중에는 계층 맥락을 유지하기 위해 상위 행의 접기 버튼이 비활성화됩니다. - 검색어는 브라우저 저장 데이터에 포함되지 않으며, **검색 지우기**로 전체 계층을 즉시 복원할 수 있습니다. diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index fd7ca841..34768d8f 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -95,11 +95,16 @@ test.describe('ScopeWeave Planner', () => { const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); await expect(rows).toHaveCount(4); + await rows.first().locator('button[data-action="toggle"]').click(); + await expect(rows).toHaveCount(1); await search.fill('단계작업계획'); await expect(rows).toHaveCount(3); await expect(rows).toContainText(['P0000.준비단계', '프로젝트준비', '단계작업계획']); await expect(rows.filter({ hasText: '사업수행계획' })).toHaveCount(0); + const contextToggle = rows.first().locator('button[data-action="toggle"]'); + await expect(contextToggle).toBeDisabled(); + await expect(contextToggle).toHaveAttribute('aria-expanded', 'true'); await expect(page.locator('#task-filter-status')).toHaveText('3개 작업 표시 중 (전체 4개)'); await search.fill('없는작업'); @@ -107,6 +112,8 @@ test.describe('ScopeWeave Planner', () => { await expect(page.locator('.table-empty')).toContainText('검색 결과가 없습니다'); await page.getByRole('button', { name: '검색 지우기' }).click(); + await expect(rows).toHaveCount(1); + await rows.first().locator('button[data-action="toggle"]').click(); await expect(rows).toHaveCount(4); }); From 0ab7b846ce29d9e26546fcf3427ea0e0d7430ea0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:37:36 +0900 Subject: [PATCH 03/41] feat(wbs): add portable JSON download --- CHANGELOG.md | 1 + README.md | 2 +- app.js | 17 +++++++++++++++++ docs/product-technical-gap-baseline.md | 4 ++-- docs/user-guide.md | 3 ++- index.html | 1 + tests/e2e/scopeweave.spec.js | 21 +++++++++++++++++++++ 7 files changed, 45 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 878d5a77..5511019e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added accessible WBS field search with hierarchy context and an empty-result recovery action. +- Added browser JSON download for portable WBS backups alongside CSV export. - Added deterministic PM analysis for requirements/RFI/RFP readiness, WBS estimation coverage, dependency risk, and procurement package section checks. - Preserved PM-analysis research papers, NASA WBS handbook, BCP 14, and JSON diff --git a/README.md b/README.md index 3d288bf6..cef57151 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ two modes: subtree reorder - Automatic day, weight, planned progress, actual progress, and weighted progress calculations -- CSV import/export using the screen column contract - WBS search across task fields with matching hierarchy context +- JSON/CSV export and CSV import using the screen column contract - Local autosave with optional File System Access API sync to `wbs.json` - Weekly Gantt modal with planned (`#333333`) and actual (`#34cb03`) overlays - Responsive column reduction for screens under 800px diff --git a/app.js b/app.js index 1803fdc8..2b2e794f 100644 --- a/app.js +++ b/app.js @@ -217,6 +217,7 @@ const elements = { tableBody: document.getElementById('task-table-body'), addRootButton: document.getElementById('add-root-task'), exportCsvButton: document.getElementById('export-csv'), + exportJsonButton: document.getElementById('export-json'), importCsvButton: document.getElementById('import-csv'), csvFileInput: document.getElementById('csv-file-input'), ganttModal: document.getElementById('gantt-modal'), @@ -315,6 +316,14 @@ function bindHeaderEvents(persistAndRenderMetadata) { } exportCsv(); }); + elements.exportJsonButton.addEventListener('click', (e) => { + if (elements.exportJsonButton.getAttribute('aria-disabled') === 'true') { + e.preventDefault(); + showToast('내보낼 작업이 없습니다. 하단의 버튼을 통해 작업을 추가해주세요.'); + return; + } + exportJson(); + }); elements.importCsvButton.addEventListener('click', () => elements.csvFileInput.click()); elements.csvFileInput.addEventListener('change', handleCsvImport); elements.openGanttButton.addEventListener('click', (e) => { @@ -554,12 +563,15 @@ function renderAll() { const hasTasks = state.tasks.length > 0; if (!hasTasks) { elements.exportCsvButton.setAttribute('aria-disabled', 'true'); + elements.exportJsonButton.setAttribute('aria-disabled', 'true'); elements.openGanttButton.setAttribute('aria-disabled', 'true'); } else { elements.exportCsvButton.removeAttribute('aria-disabled'); + elements.exportJsonButton.removeAttribute('aria-disabled'); elements.openGanttButton.removeAttribute('aria-disabled'); } elements.exportCsvButton.title = hasTasks ? '' : '내보낼 작업이 없습니다. 하단의 버튼을 통해 작업을 추가해주세요.'; + elements.exportJsonButton.title = elements.exportCsvButton.title; elements.openGanttButton.title = hasTasks ? '' : '간트 차트로 표시할 작업이 없습니다. 작업을 먼저 추가해주세요.'; // ⚡ Bolt: Cache parent IDs to convert O(N^2) render loop to O(N) @@ -2032,6 +2044,11 @@ function exportCsv() { downloadFile(csvText, `wbs_export_${formatCompactDate(new Date())}.csv`, 'text/csv;charset=utf-8'); } +function exportJson() { + const jsonText = JSON.stringify(exportJsonArray(), null, 2); + downloadFile(jsonText, `wbs_export_${formatCompactDate(new Date())}.json`, 'application/json;charset=utf-8'); +} + async function handleCsvImport(event) { const [file] = event.target.files || []; if (!file) { diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f56aa1b4..ef8240c2 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -26,7 +26,7 @@ | 계획/실적 진척 및 일정 통제 | `analytics.js`의 EVM, S-curve, CPM, workload, PM readiness | 완료 | | 계획을 찾고 계층 맥락을 유지 | `#task-filter`, `getVisibleTasks()`, `tests/e2e/scopeweave.spec.js` 검색 회귀 | 완료(이번 변경) | | CSV 왕복 | `exportCsv()`, CSV parser/validation, E2E·fuzz 테스트 | 완료 | -| JSON seed·로컬 자동 저장 | `loadSeedTasks()`, `localStorage`, `exportJsonArray()` | 부분 완료: 명시적 JSON 다운로드는 미제공 | +| JSON seed·로컬 자동 저장 | `loadSeedTasks()`, `localStorage`, `exportJsonArray()`, JSON download | 완료 | | 정적 배포 | `pages.yml`, 상대 경로 자산, `404.html` | 구현 완료, 실제 출판은 별도 런타임 증거 필요 | | Cloud 인증·멀티테넌시·협업 | `server/`, `cloud-sync.js`, API smoke/E2E | 코드·테스트 존재, 운영 배포는 환경별 검증 필요 | @@ -79,7 +79,7 @@ classDiagram | ID | Gap / 고객 영향 | 조치 | 상태 | | --- | --- | --- | --- | | G-01 | 큰 WBS에서 작업 위치를 찾는 비용이 높았음 | 작업·담당자·산출물 등 고객 필드를 검색하고 일치 행의 상위 계층을 함께 표시 | **완료** | -| G-02 | 정적 사용자가 JSON을 파일로 회수하려면 File System Access API에 의존 | 브라우저 다운로드용 JSON export를 CSV와 같은 portability 계약으로 추가 | 다음 개발 | +| G-02 | 정적 사용자가 JSON을 파일로 회수하려면 File System Access API에 의존 | 브라우저 다운로드용 JSON export를 CSV와 같은 portability 계약으로 추가 | **완료** | | G-03 | 빈 화면에서 첫 계획을 만드는 안내가 seed 데이터 유무에 따라 달라짐 | 최소 온보딩/샘플 사용 경로와 삭제 가능한 샘플 상태를 제품 결정 후 추가 | 조사 필요 | | G-04 | 키보드·스크린리더 회귀는 E2E 일부로 보호되지만 시각 회귀 자동 검사는 없음 | 핵심 상태의 실제 브라우저 스크린샷과 WCAG 2.2 점검을 릴리스 증거에 포함 | 다음 검증 | | G-05 | 보호 PR 큐는 소스와 무관한 Strix 공급자 429/Invalid URL 및 승인 부재로 차단될 수 있음 | 게이트를 약화하지 않고 원인 로그·artifact·현재 HEAD를 재검증한 뒤 재실행/중앙 수정 | 외부 상태 대기 | diff --git a/docs/user-guide.md b/docs/user-guide.md index 0c7776fc..87d86e7e 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -10,7 +10,8 @@ ScopeWeave Planner는 프로젝트용 WBS를 순수 HTML/CSS/JavaScript만으로 2. 하단 **최상위 작업 추가** 또는 각 행의 **+** 버튼으로 작업을 추가합니다. 3. 행 클릭 또는 **✎** 버튼으로 인라인 편집 모드를 엽니다. 4. 계획/실적 날짜와 실적진척상태를 입력하면 요약 수치와 가중치가 자동 재계산됩니다. -5. **CSV 내보내기** / **CSV 가져오기** / **간트차트보기**로 산출물을 활용합니다. +5. **JSON 내보내기** / **CSV 내보내기** / **CSV 가져오기** / **간트차트보기**로 + 산출물을 활용합니다. ## WBS 검색 diff --git a/index.html b/index.html index 4df9ced6..66d8f312 100644 --- a/index.html +++ b/index.html @@ -99,6 +99,7 @@

ScopeWeave Planner

+
diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index 34768d8f..39b317e7 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -212,6 +212,7 @@ test.describe('ScopeWeave Planner', () => { await expect(page.locator('.table-empty').getByRole('button', { name: 'CSV 가져오기' })).toBeVisible(); await expect(page.getByRole('button', { name: 'CSV 내보내기' })).toHaveAttribute('aria-disabled', 'true'); await expect(page.getByRole('button', { name: 'CSV 내보내기' })).toHaveAttribute('title', '내보낼 작업이 없습니다. 하단의 버튼을 통해 작업을 추가해주세요.'); + await expect(page.getByRole('button', { name: 'JSON 내보내기' })).toHaveAttribute('aria-disabled', 'true'); await expect(page.getByRole('button', { name: '간트차트보기' })).toHaveAttribute('aria-disabled', 'true'); await expect(page.getByRole('button', { name: '간트차트보기' })).toHaveAttribute('title', '간트 차트로 표시할 작업이 없습니다. 작업을 먼저 추가해주세요.'); }); @@ -1008,6 +1009,26 @@ test.describe('ScopeWeave Planner', () => { expect(csvText).toContain(`"'|'cmd' /C calc'!A0"`); }); + test('can trigger JSON export download in the seed contract', async ({ page }, testInfo) => { + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'JSON 내보내기' }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/^wbs_export_\d{8}\.json$/); + const downloadPath = await download.path(); + const jsonPath = downloadPath || testInfo.outputPath(download.suggestedFilename()); + if (!downloadPath) { + await download.saveAs(jsonPath); + } + const exported = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); + expect(exported).toHaveLength(2); + expect(exported[1]).toMatchObject({ + task: '단계작업계획', + plannedEndDate: '2026-05-15', + plannedEndDdate: '2026-05-15' + }); + expect(exported[0]).not.toHaveProperty('id'); + }); + test('can trigger CSV import file chooser', async ({ page }) => { const fileChooserPromise = page.waitForEvent('filechooser'); await page.getByRole('button', { name: 'CSV 가져오기' }).click(); From 763a491c9274dd4ac0657aa01d57d1e7f04e7022 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:40:47 +0900 Subject: [PATCH 04/41] fix(wbs): model forced search ancestors --- app.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app.js b/app.js index 1803fdc8..ec8d80f9 100644 --- a/app.js +++ b/app.js @@ -710,13 +710,13 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { if (hasChildren) { const toggleButton = toggleButtonTemplate.cloneNode(false); - const filterActive = Boolean(state.taskQuery.trim()); - const expanded = filterActive || task.expanded; - const toggleLabel = filterActive ? '검색 중 계층 맥락 고정' : (task.expanded ? '접기' : '펼치기'); + const searchExpanded = cachedSearchExpandedParentIds.has(task.id); + const expanded = searchExpanded || task.expanded; + const toggleLabel = searchExpanded ? '검색 중 계층 맥락 고정' : (task.expanded ? '접기' : '펼치기'); toggleButton.setAttribute('aria-label', `${toggleLabel} - ${rowEntityName}`); toggleButton.setAttribute('aria-expanded', String(expanded)); toggleButton.title = `${toggleLabel} - ${rowEntityName}`; - toggleButton.disabled = filterActive; + toggleButton.disabled = searchExpanded; const toggleIcon = toggleIconTemplate.cloneNode(false); toggleIcon.textContent = expanded ? '▼' : '▶'; toggleButton.appendChild(toggleIcon); @@ -1528,6 +1528,7 @@ function getDateRangeWarning(startDate, endDate, message) { } const cachedHiddenParentIds = new Set(); +const cachedSearchExpandedParentIds = new Set(); const TASK_SEARCH_FIELDS = [ 'phase', 'activity', 'task', 'categoryLarge', 'categoryMedium', 'documentName', 'owner', 'supportTeam', 'actualProgressStatus', 'plannedStartDate', @@ -1541,6 +1542,7 @@ function taskSearchText(task) { function getVisibleTasks() { const visible = []; cachedHiddenParentIds.clear(); + cachedSearchExpandedParentIds.clear(); const query = state.taskQuery.trim().toLowerCase(); if (query) { @@ -1549,8 +1551,13 @@ function getVisibleTasks() { state.tasks.forEach((task) => { if (taskSearchText(task).includes(query)) { let current = task; - while (current) { + const visitedIds = new Set(); + while (current && !visitedIds.has(current.id)) { + visitedIds.add(current.id); matchingIds.add(current.id); + if (current.id !== task.id) { + cachedSearchExpandedParentIds.add(current.id); + } current = tasksById.get(current.parentId); } } From 105ec1c49806a3113ef63ff423fbf11a59328a7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:42:35 +0900 Subject: [PATCH 05/41] docs: refresh verification count --- docs/product-technical-gap-baseline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index ef8240c2..2d15da02 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -95,7 +95,7 @@ classDiagram reduced motion을 최소 기준으로 삼는다. - 검증 명령은 `npm run test:unit`, `npm run test:api`, `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. - 이번 G-01의 직접 증거는 `npm run test:e2e` 77개 통과와 검색 회귀 테스트의 + 이번 G-01의 직접 증거는 `npm run test:e2e` 78개 통과와 검색 회귀 테스트의 통과다. ## 6. 표준·연구 근거 From ff442e0fc992ba799294d2561941f7d83c4beac2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 19:44:39 -0700 Subject: [PATCH 06/41] test(wbs): prove search stays ephemeral --- tests/e2e/wbs-search-persistence.spec.js | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/e2e/wbs-search-persistence.spec.js diff --git a/tests/e2e/wbs-search-persistence.spec.js b/tests/e2e/wbs-search-persistence.spec.js new file mode 100644 index 00000000..cb42310b --- /dev/null +++ b/tests/e2e/wbs-search-persistence.spec.js @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; + +const STORAGE_KEY = 'scopeweave:planner-state:v1'; + +test('keeps WBS search ephemeral across persistence and reload', async ({ page }) => { + await page.goto('./'); + + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + const rows = page.locator('tbody tr[data-task-id]'); + + await expect(rows).toHaveCount(4); + await search.fill('단계작업계획'); + await expect(rows).toHaveCount(3); + + const persistedState = await page.evaluate((storageKey) => { + const raw = localStorage.getItem(storageKey); + return raw ? JSON.parse(raw) : null; + }, STORAGE_KEY); + + expect(persistedState).not.toBeNull(); + expect(persistedState).not.toHaveProperty('taskQuery'); + + await page.reload(); + + await expect(page.getByRole('searchbox', { name: 'WBS 작업 검색' })).toHaveValue(''); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); +}); From d24689e1e4398c20714d78b526d68affc2f12a63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 19:46:51 -0700 Subject: [PATCH 07/41] test(wbs): expose filtered interaction hazards --- .../e2e/wbs-search-interaction-safety.spec.js | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/e2e/wbs-search-interaction-safety.spec.js diff --git a/tests/e2e/wbs-search-interaction-safety.spec.js b/tests/e2e/wbs-search-interaction-safety.spec.js new file mode 100644 index 00000000..dfab707b --- /dev/null +++ b/tests/e2e/wbs-search-interaction-safety.spec.js @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; + +test.describe('WBS search interaction safety', () => { + test.beforeEach(async ({ page }) => { + await page.goto('./'); + }); + + test('prevents drag reordering while filtered rows hide sibling context', async ({ page }) => { + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + await search.fill('단계작업계획'); + + const rows = page.locator('tbody tr[data-task-id]'); + await expect(rows).toHaveCount(3); + await expect(rows.first()).toHaveAttribute('draggable', 'false'); + await expect(rows.nth(1)).toHaveAttribute('draggable', 'false'); + await expect(rows.nth(2)).toHaveAttribute('draggable', 'false'); + }); + + test('keeps an open editor visible by pausing search changes until editing finishes', async ({ page }) => { + const firstRow = page.locator('tbody tr[data-task-id]').first(); + await firstRow.getByRole('button', { name: /편집 -/ }).click(); + + await expect(page.locator('.editor-panel')).toBeVisible(); + await expect(page.getByRole('searchbox', { name: 'WBS 작업 검색' })).toBeDisabled(); + }); +}); From af70cd8b640cc63f67555876f314d675657ca9a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 19:47:35 -0700 Subject: [PATCH 08/41] fix(wbs): guard filtered edit and reorder interactions --- wbs-search-safety.js | 61 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 wbs-search-safety.js diff --git a/wbs-search-safety.js b/wbs-search-safety.js new file mode 100644 index 00000000..1e1bcd40 --- /dev/null +++ b/wbs-search-safety.js @@ -0,0 +1,61 @@ +const filterInput = document.getElementById('task-filter'); +const tableBody = document.getElementById('task-table-body'); + +const isFilterActive = () => Boolean(filterInput?.value.trim()); +const isEditorOpen = () => Boolean(tableBody?.querySelector('.editor-panel')); + +/** + * Keep drag-and-drop aligned with the visible hierarchy. + * + * A filtered table intentionally hides siblings and descendants that do not + * match the query. Reordering against that partial view can therefore place a + * subtree somewhere the customer could not see. Filtered rows are made + * non-draggable and dragstart is rejected before the planner's reorder handler + * can mutate state. + */ +export function synchronizeFilteredDragSafety() { + if (!tableBody) return; + const draggable = !isFilterActive(); + tableBody.querySelectorAll('tr[data-task-id]').forEach((row) => { + row.draggable = draggable; + }); +} + +/** + * Keep an active inline editor visible until the user saves or cancels it. + * + * Search changes re-render only rows that match the query. Disabling the + * search box while an editor is open prevents a draft from disappearing from + * the visible table even though the draft remains in memory. + */ +export function synchronizeEditorSearchSafety() { + if (!filterInput) return; + const editorOpen = isEditorOpen(); + filterInput.disabled = editorOpen; + if (editorOpen) { + filterInput.setAttribute('aria-disabled', 'true'); + filterInput.title = '편집을 완료하거나 취소한 후 검색할 수 있습니다.'; + } else { + filterInput.removeAttribute('aria-disabled'); + filterInput.removeAttribute('title'); + } +} + +function synchronizeSearchInteractions() { + synchronizeFilteredDragSafety(); + synchronizeEditorSearchSafety(); +} + +if (filterInput && tableBody) { + tableBody.addEventListener('dragstart', (event) => { + if (!isFilterActive()) return; + event.preventDefault(); + event.stopImmediatePropagation(); + }, true); + + filterInput.addEventListener('input', () => queueMicrotask(synchronizeSearchInteractions)); + + const observer = new MutationObserver(synchronizeSearchInteractions); + observer.observe(tableBody, { childList: true, subtree: true }); + synchronizeSearchInteractions(); +} From 6ca301ab305961b07bbfe31f420d4ad28ef8b407 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:48:32 +0900 Subject: [PATCH 09/41] fix(wbs): preserve planning fields in JSON export --- CHANGELOG.md | 3 +- app.js | 48 ++++++++++++++--------- docs/product-technical-gap-baseline.md | 4 +- docs/user-guide.md | 4 ++ tests/e2e/scopeweave.spec.js | 53 ++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5511019e..19db6ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added accessible WBS field search with hierarchy context and an empty-result recovery action. -- Added browser JSON download for portable WBS backups alongside CSV export. +- Added browser JSON download for portable WBS backups, including extended + planning fields, alongside CSV export. - Added deterministic PM analysis for requirements/RFI/RFP readiness, WBS estimation coverage, dependency risk, and procurement package section checks. - Preserved PM-analysis research papers, NASA WBS handbook, BCP 14, and JSON diff --git a/app.js b/app.js index 845d9e45..dd1665b4 100644 --- a/app.js +++ b/app.js @@ -2052,7 +2052,7 @@ function exportCsv() { } function exportJson() { - const jsonText = JSON.stringify(exportJsonArray(), null, 2); + const jsonText = JSON.stringify(exportJsonArray({ includeExtendedFields: true }), null, 2); downloadFile(jsonText, `wbs_export_${formatCompactDate(new Date())}.json`, 'application/json;charset=utf-8'); } @@ -2272,23 +2272,35 @@ async function writeJsonSyncFile() { await writable.close(); } -function exportJsonArray() { - return state.tasks.filter((task) => !task.isSynthetic).map((task) => ({ - phase: task.phase, - activity: task.activity, - task: task.task, - categoryLarge: task.categoryLarge, - categoryMedium: task.categoryMedium, - documentName: task.documentName, - owner: task.owner, - supportTeam: task.supportTeam, - plannedStartDate: task.plannedStartDate, - plannedEndDate: task.plannedEndDate, - [LEGACY_PLANNED_END_FIELD]: task.plannedEndDate, - actualProgressStatus: task.actualProgressStatus, - actualStartDate: task.actualStartDate, - actualEndDate: task.actualEndDate - })); +function exportJsonArray({ includeExtendedFields = false } = {}) { + return state.tasks.filter((task) => !task.isSynthetic).map((task) => { + const record = { + phase: task.phase, + activity: task.activity, + task: task.task, + categoryLarge: task.categoryLarge, + categoryMedium: task.categoryMedium, + documentName: task.documentName, + owner: task.owner, + supportTeam: task.supportTeam, + plannedStartDate: task.plannedStartDate, + plannedEndDate: task.plannedEndDate, + [LEGACY_PLANNED_END_FIELD]: task.plannedEndDate, + actualProgressStatus: task.actualProgressStatus, + actualStartDate: task.actualStartDate, + actualEndDate: task.actualEndDate + }; + if (includeExtendedFields) { + Object.assign(record, { + predecessors: task.predecessors ?? '', + budget: task.budget ?? '', + actualCost: task.actualCost ?? '', + sprint: task.sprint ?? '', + storyPoints: task.storyPoints ?? '' + }); + } + return record; + }); } function openGanttModal() { diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 2d15da02..b6179afe 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -79,7 +79,7 @@ classDiagram | ID | Gap / 고객 영향 | 조치 | 상태 | | --- | --- | --- | --- | | G-01 | 큰 WBS에서 작업 위치를 찾는 비용이 높았음 | 작업·담당자·산출물 등 고객 필드를 검색하고 일치 행의 상위 계층을 함께 표시 | **완료** | -| G-02 | 정적 사용자가 JSON을 파일로 회수하려면 File System Access API에 의존 | 브라우저 다운로드용 JSON export를 CSV와 같은 portability 계약으로 추가 | **완료** | +| G-02 | 정적 사용자가 JSON을 파일로 회수하려면 File System Access API에 의존 | 추가 계획 필드를 보존하는 브라우저 다운로드용 JSON export를 추가하고, 자동저장 seed 계약은 유지 | **완료** | | G-03 | 빈 화면에서 첫 계획을 만드는 안내가 seed 데이터 유무에 따라 달라짐 | 최소 온보딩/샘플 사용 경로와 삭제 가능한 샘플 상태를 제품 결정 후 추가 | 조사 필요 | | G-04 | 키보드·스크린리더 회귀는 E2E 일부로 보호되지만 시각 회귀 자동 검사는 없음 | 핵심 상태의 실제 브라우저 스크린샷과 WCAG 2.2 점검을 릴리스 증거에 포함 | 다음 검증 | | G-05 | 보호 PR 큐는 소스와 무관한 Strix 공급자 429/Invalid URL 및 승인 부재로 차단될 수 있음 | 게이트를 약화하지 않고 원인 로그·artifact·현재 HEAD를 재검증한 뒤 재실행/중앙 수정 | 외부 상태 대기 | @@ -95,7 +95,7 @@ classDiagram reduced motion을 최소 기준으로 삼는다. - 검증 명령은 `npm run test:unit`, `npm run test:api`, `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. - 이번 G-01의 직접 증거는 `npm run test:e2e` 78개 통과와 검색 회귀 테스트의 + 이번 G-01/G-02의 직접 증거는 `npm run test:e2e` 79개 통과와 검색·JSON 회귀 테스트의 통과다. ## 6. 표준·연구 근거 diff --git a/docs/user-guide.md b/docs/user-guide.md index 87d86e7e..080ee0ac 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -13,6 +13,9 @@ ScopeWeave Planner는 프로젝트용 WBS를 순수 HTML/CSS/JavaScript만으로 5. **JSON 내보내기** / **CSV 내보내기** / **CSV 가져오기** / **간트차트보기**로 산출물을 활용합니다. +- **JSON 내보내기**는 선행작업, 예산, 실투입비, 스프린트, 스토리포인트를 + 포함한 휴대용 백업 파일(`wbs_export_YYYYMMDD.json`)을 다운로드합니다. + ## WBS 검색 - WBS 표 위의 **WBS 작업 검색**은 단계, 작업, 산출물, 담당자, 일정 등 입력된 @@ -42,6 +45,7 @@ ScopeWeave Planner는 프로젝트용 WBS를 순수 HTML/CSS/JavaScript만으로 - Chromium 계열 브라우저에서 **wbs.json 자동저장 연결** 버튼을 누르면 쓰기 가능한 `wbs.json` 파일을 연결할 수 있습니다. - 연결이 끝나면 변경할 때마다 같은 JSON 스키마로 자동 저장됩니다. - 외부 저장 JSON에는 내부 계층 관리용 synthetic row / internal id 필드가 포함되지 않습니다. +- 자동저장 연결은 seed 호환 스키마를 유지하며, 브라우저 다운로드는 추가 계획 필드까지 보존합니다. ## CSV 사용 diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index 39b317e7..b1cb79ed 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -1029,6 +1029,59 @@ test.describe('ScopeWeave Planner', () => { expect(exported[0]).not.toHaveProperty('id'); }); + test('preserves extended planning fields in JSON download', async ({ page }, testInfo) => { + await page.evaluate(() => { + localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({ + projectName: 'Extended JSON', + baseDate: '2026-05-01', + tasks: [{ + id: 'extended-1', + parentId: null, + depth: 1, + expanded: true, + isSynthetic: false, + phase: 'P1000.계획', + activity: '', + task: '', + categoryLarge: '', + categoryMedium: '', + documentName: '', + owner: '', + supportTeam: '', + plannedStartDate: '2026-05-01', + plannedEndDate: '2026-05-05', + actualProgressStatus: '미착수(0%)', + actualStartDate: '', + actualEndDate: '', + predecessors: 'P2000', + budget: '1000', + actualCost: '200', + sprint: 'S1', + storyPoints: '5' + }] + })); + }); + await page.reload(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(1); + + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'JSON 내보내기' }).click(); + const download = await downloadPromise; + const downloadPath = await download.path(); + const jsonPath = downloadPath || testInfo.outputPath(download.suggestedFilename()); + if (!downloadPath) { + await download.saveAs(jsonPath); + } + const [exported] = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); + expect(exported).toMatchObject({ + predecessors: 'P2000', + budget: '1000', + actualCost: '200', + sprint: 'S1', + storyPoints: '5' + }); + }); + test('can trigger CSV import file chooser', async ({ page }) => { const fileChooserPromise = page.waitForEvent('filechooser'); await page.getByRole('button', { name: 'CSV 가져오기' }).click(); From 7603c8c44484d6fbeb5692900bf367352a56f5c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 19:48:39 -0700 Subject: [PATCH 10/41] fix(wbs): load search interaction guardrails --- index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/index.html b/index.html index 4df9ced6..a4f09e7c 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,7 @@ + @@ -125,5 +126,6 @@

간트 차트

+ From 1335531b1b646535d82f077064981102c0863856 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:52:58 +0900 Subject: [PATCH 11/41] test(wbs): seed persistence state before search check --- tests/e2e/wbs-search-persistence.spec.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/e2e/wbs-search-persistence.spec.js b/tests/e2e/wbs-search-persistence.spec.js index cb42310b..2eff23c3 100644 --- a/tests/e2e/wbs-search-persistence.spec.js +++ b/tests/e2e/wbs-search-persistence.spec.js @@ -9,6 +9,9 @@ test('keeps WBS search ephemeral across persistence and reload', async ({ page } const rows = page.locator('tbody tr[data-task-id]'); await expect(rows).toHaveCount(4); + await page.getByTestId('project-name-input').fill('Search persistence'); + await expect.poll(async () => page.evaluate((storageKey) => localStorage.getItem(storageKey), STORAGE_KEY)).not.toBeNull(); + await search.fill('단계작업계획'); await expect(rows).toHaveCount(3); From 76287474b80deb5770530ccc709629fa1b34a97a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:54:40 +0900 Subject: [PATCH 12/41] docs: refresh WBS verification evidence --- docs/product-technical-gap-baseline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b6179afe..bd453f5f 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -95,7 +95,7 @@ classDiagram reduced motion을 최소 기준으로 삼는다. - 검증 명령은 `npm run test:unit`, `npm run test:api`, `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. - 이번 G-01/G-02의 직접 증거는 `npm run test:e2e` 79개 통과와 검색·JSON 회귀 테스트의 + 이번 G-01/G-02의 직접 증거는 `npm run test:e2e` 82개 통과와 검색·JSON 회귀 테스트의 통과다. ## 6. 표준·연구 근거 From aa5abaff74668ed63af97deacdc1bd2113bf0ebb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 19:56:34 -0700 Subject: [PATCH 13/41] test(wbs): reproduce hidden create editor under search --- tests/e2e/wbs-search-interaction-safety.spec.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/e2e/wbs-search-interaction-safety.spec.js b/tests/e2e/wbs-search-interaction-safety.spec.js index dfab707b..e99cfe7d 100644 --- a/tests/e2e/wbs-search-interaction-safety.spec.js +++ b/tests/e2e/wbs-search-interaction-safety.spec.js @@ -23,4 +23,12 @@ test.describe('WBS search interaction safety', () => { await expect(page.locator('.editor-panel')).toBeVisible(); await expect(page.getByRole('searchbox', { name: 'WBS 작업 검색' })).toBeDisabled(); }); + + test('blocks create actions while filtered context could hide the editor anchor', async ({ page }) => { + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + await search.fill('단계작업계획'); + + await expect(page.getByRole('button', { name: '최상위 작업 추가' })).toBeDisabled(); + await expect(page.locator('tbody tr[data-task-id]').first().getByRole('button', { name: /하위 추가 -/ })).toBeDisabled(); + }); }); From 7939e00eec476922fa38d01adf17c17092957c64 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 19:57:34 -0700 Subject: [PATCH 14/41] fix(wbs): block hidden create anchors during search --- wbs-search-safety.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/wbs-search-safety.js b/wbs-search-safety.js index 1e1bcd40..a664b378 100644 --- a/wbs-search-safety.js +++ b/wbs-search-safety.js @@ -1,5 +1,6 @@ const filterInput = document.getElementById('task-filter'); const tableBody = document.getElementById('task-table-body'); +const addRootButton = document.getElementById('add-root-task'); const isFilterActive = () => Boolean(filterInput?.value.trim()); const isEditorOpen = () => Boolean(tableBody?.querySelector('.editor-panel')); @@ -21,6 +22,39 @@ export function synchronizeFilteredDragSafety() { }); } +/** + * Prevent create controls from opening an editor at a row hidden by search. + * + * Create-mode editors are anchored after an existing task. Search can hide + * that anchor, so creation is paused until the complete hierarchy is visible. + */ +export function synchronizeFilteredCreateSafety() { + const filterActive = isFilterActive(); + if (addRootButton) { + addRootButton.disabled = filterActive; + } + if (!tableBody) return; + tableBody.querySelectorAll('button[data-action="add-child"]').forEach((button) => { + const structurallyDisabled = button.getAttribute('aria-disabled') === 'true'; + button.disabled = filterActive || structurallyDisabled; + }); +} + +/** + * Prevent collapse state from changing invisibly while search owns visibility. + * + * Filtered results are selected from matches plus context ancestors rather than + * from each task's persisted expanded state. Toggle controls are therefore + * paused until normal hierarchy rendering resumes. + */ +export function synchronizeFilteredToggleSafety() { + if (!tableBody) return; + const filterActive = isFilterActive(); + tableBody.querySelectorAll('button[data-action="toggle"]').forEach((button) => { + button.disabled = filterActive; + }); +} + /** * Keep an active inline editor visible until the user saves or cancels it. * @@ -43,6 +77,8 @@ export function synchronizeEditorSearchSafety() { function synchronizeSearchInteractions() { synchronizeFilteredDragSafety(); + synchronizeFilteredCreateSafety(); + synchronizeFilteredToggleSafety(); synchronizeEditorSearchSafety(); } From fef17cb10f7a74cbc7feb1acabf72aa4bc0d21c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 12:01:54 +0900 Subject: [PATCH 15/41] fix(wbs): guard filtered hierarchy interactions --- CHANGELOG.md | 2 + app.js | 59 ++++++++++++++++-- docs/user-guide.md | 1 + index.html | 2 - .../e2e/wbs-search-interaction-safety.spec.js | 14 +++++ wbs-search-safety.js | 61 ------------------- 6 files changed, 71 insertions(+), 68 deletions(-) delete mode 100644 wbs-search-safety.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 878d5a77..74ae2989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added accessible WBS field search with hierarchy context and an empty-result recovery action. +- Added search-mode guardrails that keep hierarchy edits and drag reordering + out of the filtered view while an inline editor is open. - Added deterministic PM analysis for requirements/RFI/RFP readiness, WBS estimation coverage, dependency risk, and procurement package section checks. - Preserved PM-analysis research papers, NASA WBS handbook, BCP 14, and JSON diff --git a/app.js b/app.js index ec8d80f9..29167ac9 100644 --- a/app.js +++ b/app.js @@ -306,7 +306,14 @@ function bindHeaderEvents(persistAndRenderMetadata) { }); elements.baseDateInput.addEventListener('blur', persistAndRenderMetadata.flush); - elements.addRootButton.addEventListener('click', () => openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() })); + elements.addRootButton.addEventListener('click', (event) => { + if (elements.addRootButton.getAttribute('aria-disabled') === 'true') { + event.preventDefault(); + showToast('검색 중에는 작업을 추가할 수 없습니다. 검색을 먼저 지워주세요.'); + return; + } + openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() }); + }); elements.exportCsvButton.addEventListener('click', (e) => { if (elements.exportCsvButton.getAttribute('aria-disabled') === 'true') { e.preventDefault(); @@ -342,6 +349,9 @@ function bindHeaderEvents(persistAndRenderMetadata) { }); elements.taskFilterInput.addEventListener('input', (event) => { + if (state.editor.mode) { + return; + } state.taskQuery = String(event.target.value).slice(0, 120); renderAll(); }); @@ -448,6 +458,10 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { }); elements.tableBody.addEventListener('dragstart', (event) => { + if (state.taskQuery.trim()) { + event.preventDefault(); + return; + } const row = event.target.closest('tr[data-task-id]'); if (!row) { return; @@ -467,6 +481,9 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { }); elements.tableBody.addEventListener('dragover', (event) => { + if (state.taskQuery.trim()) { + return; + } const row = event.target.closest('tr[data-task-id]'); if (!row || !state.dragTaskId || row.dataset.taskId === state.dragTaskId) { return; @@ -496,6 +513,11 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { }); elements.tableBody.addEventListener('drop', (event) => { + if (state.taskQuery.trim()) { + event.preventDefault(); + clearDragState(); + return; + } const row = event.target.closest('tr[data-task-id]'); if (!row || !state.dragTaskId) { return; @@ -519,6 +541,8 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { const cachedHasChildrenSet = new Set(); function renderAll() { const metrics = computeTaskMetrics(); + const filterActive = Boolean(state.taskQuery.trim()); + const editorOpen = Boolean(state.editor.mode); elements.projectNameInput.value = state.projectName; document.title = state.projectName === DEFAULT_PROJECT_NAME ? DEFAULT_PROJECT_NAME : `${state.projectName} - ${DEFAULT_PROJECT_NAME}`; @@ -530,6 +554,14 @@ function renderAll() { if (elements.taskFilterInput.value !== state.taskQuery) { elements.taskFilterInput.value = state.taskQuery; } + elements.taskFilterInput.disabled = editorOpen; + if (editorOpen) { + elements.taskFilterInput.setAttribute('aria-disabled', 'true'); + elements.taskFilterInput.title = '편집을 완료하거나 취소한 후 검색할 수 있습니다.'; + } else { + elements.taskFilterInput.removeAttribute('aria-disabled'); + elements.taskFilterInput.removeAttribute('title'); + } if (typeof window !== 'undefined') { window.ScopeWeaveAnalytics?.render?.({ @@ -545,7 +577,6 @@ function renderAll() { const visibleTasks = getVisibleTasks(); const rows = []; - const filterActive = Boolean(state.taskQuery.trim()); elements.clearTaskFilterButton.hidden = !filterActive; elements.taskFilterStatus.textContent = filterActive ? `${visibleTasks.length}개 작업 표시 중 (전체 ${state.tasks.length}개)` @@ -561,6 +592,8 @@ function renderAll() { } elements.exportCsvButton.title = hasTasks ? '' : '내보낼 작업이 없습니다. 하단의 버튼을 통해 작업을 추가해주세요.'; elements.openGanttButton.title = hasTasks ? '' : '간트 차트로 표시할 작업이 없습니다. 작업을 먼저 추가해주세요.'; + elements.addRootButton.setAttribute('aria-disabled', String(filterActive)); + elements.addRootButton.title = filterActive ? '검색 중에는 작업을 추가할 수 없습니다. 검색을 먼저 지워주세요.' : ''; // ⚡ Bolt: Cache parent IDs to convert O(N^2) render loop to O(N) cachedHasChildrenSet.clear(); @@ -702,6 +735,8 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { const row = taskRowTemplate.cloneNode(false); row.className = `task-row depth-${task.depth} ${index % 2 === 1 ? 'striped-even' : ''}`; row.dataset.taskId = task.id; + const filterActive = Boolean(state.taskQuery.trim()); + row.draggable = !filterActive; const actionCell = actionCellTemplate.cloneNode(false); const actionStack = actionStackTemplate.cloneNode(false); @@ -712,11 +747,13 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { const toggleButton = toggleButtonTemplate.cloneNode(false); const searchExpanded = cachedSearchExpandedParentIds.has(task.id); const expanded = searchExpanded || task.expanded; - const toggleLabel = searchExpanded ? '검색 중 계층 맥락 고정' : (task.expanded ? '접기' : '펼치기'); + const toggleLabel = searchExpanded + ? '검색 중 계층 맥락 고정' + : (filterActive ? '검색 중 비활성화' : (task.expanded ? '접기' : '펼치기')); toggleButton.setAttribute('aria-label', `${toggleLabel} - ${rowEntityName}`); toggleButton.setAttribute('aria-expanded', String(expanded)); toggleButton.title = `${toggleLabel} - ${rowEntityName}`; - toggleButton.disabled = searchExpanded; + toggleButton.disabled = filterActive || searchExpanded; const toggleIcon = toggleIconTemplate.cloneNode(false); toggleIcon.textContent = expanded ? '▼' : '▶'; toggleButton.appendChild(toggleIcon); @@ -731,8 +768,11 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { const isLeaf = task.depth >= 3; const addChildButton = createActionButton(`하위 추가 - ${rowEntityName}`, '+', 'add-child', isLeaf ? '최대 3단계까지만 추가할 수 있습니다.' : `하위 추가 - ${rowEntityName}`); - if (isLeaf) { + if (isLeaf || filterActive) { addChildButton.setAttribute('aria-disabled', 'true'); + if (filterActive && !isLeaf) { + addChildButton.title = '검색 중에는 작업을 추가할 수 없습니다. 검색을 먼저 지워주세요.'; + } } else { addChildButton.removeAttribute('aria-disabled'); } @@ -1145,6 +1185,11 @@ function handleRowAction(action, taskId) { return; } + if (state.taskQuery.trim() && (action === 'toggle' || action === 'add-child')) { + showToast('검색 중에는 계층을 변경할 수 없습니다. 검색을 먼저 지워주세요.'); + return; + } + if (action === 'toggle') { task.expanded = !task.expanded; persistState(); @@ -1208,6 +1253,10 @@ function handleRowAction(action, taskId) { } function openEditor({ mode, targetId = null, parentId = null, depth = 1, insertAfterId = null, draft = null }) { + if (mode === 'create' && state.taskQuery.trim()) { + showToast('검색 중에는 작업을 추가할 수 없습니다. 검색을 먼저 지워주세요.'); + return; + } state.previousFocus = document.activeElement; if (mode === 'edit') { const task = findTask(targetId); diff --git a/docs/user-guide.md b/docs/user-guide.md index 0c7776fc..b8f48c02 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -18,6 +18,7 @@ ScopeWeave Planner는 프로젝트용 WBS를 순수 HTML/CSS/JavaScript만으로 필드를 검색합니다. - 일치한 작업의 상위 계층은 함께 표시되어 검색 결과의 맥락을 유지합니다. - 검색 중에는 계층 맥락을 유지하기 위해 상위 행의 접기 버튼이 비활성화됩니다. +- 검색 중에는 계층을 바꿀 수 없도록 드래그와 최상위/하위 작업 추가도 비활성화됩니다. - 검색어는 브라우저 저장 데이터에 포함되지 않으며, **검색 지우기**로 전체 계층을 즉시 복원할 수 있습니다. diff --git a/index.html b/index.html index a4f09e7c..4df9ced6 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,6 @@ - @@ -126,6 +125,5 @@

간트 차트

- diff --git a/tests/e2e/wbs-search-interaction-safety.spec.js b/tests/e2e/wbs-search-interaction-safety.spec.js index dfab707b..57523cd3 100644 --- a/tests/e2e/wbs-search-interaction-safety.spec.js +++ b/tests/e2e/wbs-search-interaction-safety.spec.js @@ -16,6 +16,20 @@ test.describe('WBS search interaction safety', () => { await expect(rows.nth(2)).toHaveAttribute('draggable', 'false'); }); + test('disables hierarchy changes while filtered rows hide context', async ({ page }) => { + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + await search.fill('단계작업계획'); + + const rows = page.locator('tbody tr[data-task-id]'); + await expect(page.getByRole('button', { name: '최상위 작업 추가' })).toHaveAttribute('aria-disabled', 'true'); + await expect(rows.first().locator('button[data-action="toggle"]')).toBeDisabled(); + + const addChild = rows.first().getByRole('button', { name: /하위 추가 -/ }); + await expect(addChild).toHaveAttribute('aria-disabled', 'true'); + await addChild.dispatchEvent('click'); + await expect(page.locator('.editor-panel')).toHaveCount(0); + }); + test('keeps an open editor visible by pausing search changes until editing finishes', async ({ page }) => { const firstRow = page.locator('tbody tr[data-task-id]').first(); await firstRow.getByRole('button', { name: /편집 -/ }).click(); diff --git a/wbs-search-safety.js b/wbs-search-safety.js deleted file mode 100644 index 1e1bcd40..00000000 --- a/wbs-search-safety.js +++ /dev/null @@ -1,61 +0,0 @@ -const filterInput = document.getElementById('task-filter'); -const tableBody = document.getElementById('task-table-body'); - -const isFilterActive = () => Boolean(filterInput?.value.trim()); -const isEditorOpen = () => Boolean(tableBody?.querySelector('.editor-panel')); - -/** - * Keep drag-and-drop aligned with the visible hierarchy. - * - * A filtered table intentionally hides siblings and descendants that do not - * match the query. Reordering against that partial view can therefore place a - * subtree somewhere the customer could not see. Filtered rows are made - * non-draggable and dragstart is rejected before the planner's reorder handler - * can mutate state. - */ -export function synchronizeFilteredDragSafety() { - if (!tableBody) return; - const draggable = !isFilterActive(); - tableBody.querySelectorAll('tr[data-task-id]').forEach((row) => { - row.draggable = draggable; - }); -} - -/** - * Keep an active inline editor visible until the user saves or cancels it. - * - * Search changes re-render only rows that match the query. Disabling the - * search box while an editor is open prevents a draft from disappearing from - * the visible table even though the draft remains in memory. - */ -export function synchronizeEditorSearchSafety() { - if (!filterInput) return; - const editorOpen = isEditorOpen(); - filterInput.disabled = editorOpen; - if (editorOpen) { - filterInput.setAttribute('aria-disabled', 'true'); - filterInput.title = '편집을 완료하거나 취소한 후 검색할 수 있습니다.'; - } else { - filterInput.removeAttribute('aria-disabled'); - filterInput.removeAttribute('title'); - } -} - -function synchronizeSearchInteractions() { - synchronizeFilteredDragSafety(); - synchronizeEditorSearchSafety(); -} - -if (filterInput && tableBody) { - tableBody.addEventListener('dragstart', (event) => { - if (!isFilterActive()) return; - event.preventDefault(); - event.stopImmediatePropagation(); - }, true); - - filterInput.addEventListener('input', () => queueMicrotask(synchronizeSearchInteractions)); - - const observer = new MutationObserver(synchronizeSearchInteractions); - observer.observe(tableBody, { childList: true, subtree: true }); - synchronizeSearchInteractions(); -} From abbdff942898c2635468e5902d8ee55961b5ab29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:05:17 -0700 Subject: [PATCH 16/41] test(wbs): align guarded actions with explanatory controls --- .../e2e/wbs-search-interaction-safety.spec.js | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/tests/e2e/wbs-search-interaction-safety.spec.js b/tests/e2e/wbs-search-interaction-safety.spec.js index 785c44e3..2484242f 100644 --- a/tests/e2e/wbs-search-interaction-safety.spec.js +++ b/tests/e2e/wbs-search-interaction-safety.spec.js @@ -16,17 +16,24 @@ test.describe('WBS search interaction safety', () => { await expect(rows.nth(2)).toHaveAttribute('draggable', 'false'); }); - test('disables hierarchy changes while filtered rows hide context', async ({ page }) => { + test('blocks hierarchy changes while filtered rows hide context', async ({ page }) => { const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); await search.fill('단계작업계획'); const rows = page.locator('tbody tr[data-task-id]'); - await expect(page.getByRole('button', { name: '최상위 작업 추가' })).toHaveAttribute('aria-disabled', 'true'); - await expect(rows.first().locator('button[data-action="toggle"]')).toBeDisabled(); - + const addRoot = page.getByRole('button', { name: '최상위 작업 추가' }); const addChild = rows.first().getByRole('button', { name: /하위 추가 -/ }); + + await expect(addRoot).toHaveAttribute('aria-disabled', 'true'); + await expect(rows.first().locator('button[data-action="toggle"]')).toBeDisabled(); await expect(addChild).toHaveAttribute('aria-disabled', 'true'); - await addChild.dispatchEvent('click'); + + await addChild.click(); + await expect(page.locator('#toast')).toContainText('검색 중에는 작업을 추가할 수 없습니다.'); + await expect(page.locator('.editor-panel')).toHaveCount(0); + + await addRoot.click(); + await expect(page.locator('#toast')).toContainText('검색 중에는 작업을 추가할 수 없습니다.'); await expect(page.locator('.editor-panel')).toHaveCount(0); }); @@ -38,11 +45,13 @@ test.describe('WBS search interaction safety', () => { await expect(page.getByRole('searchbox', { name: 'WBS 작업 검색' })).toBeDisabled(); }); - test('blocks create actions while filtered context could hide the editor anchor', async ({ page }) => { - const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); - await search.fill('단계작업계획'); + test('keeps the depth-limit explanation actionable when search is inactive', async ({ page }) => { + const leafAddChild = page.locator('tbody tr[data-task-id].depth-3').first().locator('button[data-action="add-child"]'); - await expect(page.getByRole('button', { name: '최상위 작업 추가' })).toBeDisabled(); - await expect(page.locator('tbody tr[data-task-id]').first().getByRole('button', { name: /하위 추가 -/ })).toBeDisabled(); + await expect(leafAddChild).toHaveAttribute('aria-disabled', 'true'); + await expect(leafAddChild).toBeEnabled(); + await leafAddChild.click(); + await expect(page.locator('#toast')).toContainText('최대 3단계까지만 추가할 수 있습니다.'); + await expect(page.locator('.editor-panel')).toHaveCount(0); }); }); From 50dede2ce6f0ec2a6af34c7865b75fb85497e3cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 12:07:24 +0900 Subject: [PATCH 17/41] test(wbs): use DOM dispatch for guarded controls --- tests/e2e/wbs-search-interaction-safety.spec.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/e2e/wbs-search-interaction-safety.spec.js b/tests/e2e/wbs-search-interaction-safety.spec.js index 2484242f..5f14ef57 100644 --- a/tests/e2e/wbs-search-interaction-safety.spec.js +++ b/tests/e2e/wbs-search-interaction-safety.spec.js @@ -28,11 +28,11 @@ test.describe('WBS search interaction safety', () => { await expect(rows.first().locator('button[data-action="toggle"]')).toBeDisabled(); await expect(addChild).toHaveAttribute('aria-disabled', 'true'); - await addChild.click(); + await addChild.dispatchEvent('click'); await expect(page.locator('#toast')).toContainText('검색 중에는 작업을 추가할 수 없습니다.'); await expect(page.locator('.editor-panel')).toHaveCount(0); - await addRoot.click(); + await addRoot.dispatchEvent('click'); await expect(page.locator('#toast')).toContainText('검색 중에는 작업을 추가할 수 없습니다.'); await expect(page.locator('.editor-panel')).toHaveCount(0); }); @@ -49,8 +49,7 @@ test.describe('WBS search interaction safety', () => { const leafAddChild = page.locator('tbody tr[data-task-id].depth-3').first().locator('button[data-action="add-child"]'); await expect(leafAddChild).toHaveAttribute('aria-disabled', 'true'); - await expect(leafAddChild).toBeEnabled(); - await leafAddChild.click(); + await leafAddChild.dispatchEvent('click'); await expect(page.locator('#toast')).toContainText('최대 3단계까지만 추가할 수 있습니다.'); await expect(page.locator('.editor-panel')).toHaveCount(0); }); From 0d7901e7f9b956e6c06d484bbeb1c470cde1725f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 12:15:59 +0900 Subject: [PATCH 18/41] fix(wbs): block deletes while filtered --- app.js | 9 +++++++-- tests/e2e/wbs-search-interaction-safety.spec.js | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index b58bff71..b8f13cd8 100644 --- a/app.js +++ b/app.js @@ -792,7 +792,12 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { const editButton = createActionButton(`편집 - ${rowEntityName}`, '✎', 'edit', `편집 - ${rowEntityName}`); editButton.setAttribute('aria-haspopup', 'dialog'); - const deleteButton = createActionButton(`삭제 - ${rowEntityName}`, '🗑', 'delete', `삭제 - ${rowEntityName}`); + const deleteButton = createActionButton(`삭제 - ${rowEntityName}`, '🗑', 'delete', filterActive + ? '검색 중에는 작업을 삭제할 수 없습니다. 검색을 먼저 지워주세요.' + : `삭제 - ${rowEntityName}`); + if (filterActive) { + deleteButton.setAttribute('aria-disabled', 'true'); + } actionStack.append( dragHandle, @@ -1197,7 +1202,7 @@ function handleRowAction(action, taskId) { return; } - if (state.taskQuery.trim() && (action === 'toggle' || action === 'add-child')) { + if (state.taskQuery.trim() && (action === 'toggle' || action === 'add-child' || action === 'delete')) { showToast('검색 중에는 계층을 변경할 수 없습니다. 검색을 먼저 지워주세요.'); return; } diff --git a/tests/e2e/wbs-search-interaction-safety.spec.js b/tests/e2e/wbs-search-interaction-safety.spec.js index 5f14ef57..123b3bc9 100644 --- a/tests/e2e/wbs-search-interaction-safety.spec.js +++ b/tests/e2e/wbs-search-interaction-safety.spec.js @@ -23,10 +23,12 @@ test.describe('WBS search interaction safety', () => { const rows = page.locator('tbody tr[data-task-id]'); const addRoot = page.getByRole('button', { name: '최상위 작업 추가' }); const addChild = rows.first().getByRole('button', { name: /하위 추가 -/ }); + const deleteButton = rows.first().getByRole('button', { name: /삭제 -/ }); await expect(addRoot).toHaveAttribute('aria-disabled', 'true'); await expect(rows.first().locator('button[data-action="toggle"]')).toBeDisabled(); await expect(addChild).toHaveAttribute('aria-disabled', 'true'); + await expect(deleteButton).toHaveAttribute('aria-disabled', 'true'); await addChild.dispatchEvent('click'); await expect(page.locator('#toast')).toContainText('검색 중에는 작업을 추가할 수 없습니다.'); @@ -35,6 +37,10 @@ test.describe('WBS search interaction safety', () => { await addRoot.dispatchEvent('click'); await expect(page.locator('#toast')).toContainText('검색 중에는 작업을 추가할 수 없습니다.'); await expect(page.locator('.editor-panel')).toHaveCount(0); + + await deleteButton.dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('검색 중에는 작업을 삭제할 수 없습니다.'); + await expect(rows).toHaveCount(3); }); test('keeps an open editor visible by pausing search changes until editing finishes', async ({ page }) => { From 24726fb37887efe3b24847ec6d3329652ff8b7df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 12:15:59 +0900 Subject: [PATCH 19/41] fix(wbs): block deletes while filtered --- app.js | 9 +++++++-- tests/e2e/wbs-search-interaction-safety.spec.js | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index 29167ac9..14689acf 100644 --- a/app.js +++ b/app.js @@ -780,7 +780,12 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { const editButton = createActionButton(`편집 - ${rowEntityName}`, '✎', 'edit', `편집 - ${rowEntityName}`); editButton.setAttribute('aria-haspopup', 'dialog'); - const deleteButton = createActionButton(`삭제 - ${rowEntityName}`, '🗑', 'delete', `삭제 - ${rowEntityName}`); + const deleteButton = createActionButton(`삭제 - ${rowEntityName}`, '🗑', 'delete', filterActive + ? '검색 중에는 작업을 삭제할 수 없습니다. 검색을 먼저 지워주세요.' + : `삭제 - ${rowEntityName}`); + if (filterActive) { + deleteButton.setAttribute('aria-disabled', 'true'); + } actionStack.append( dragHandle, @@ -1185,7 +1190,7 @@ function handleRowAction(action, taskId) { return; } - if (state.taskQuery.trim() && (action === 'toggle' || action === 'add-child')) { + if (state.taskQuery.trim() && (action === 'toggle' || action === 'add-child' || action === 'delete')) { showToast('검색 중에는 계층을 변경할 수 없습니다. 검색을 먼저 지워주세요.'); return; } diff --git a/tests/e2e/wbs-search-interaction-safety.spec.js b/tests/e2e/wbs-search-interaction-safety.spec.js index 5f14ef57..123b3bc9 100644 --- a/tests/e2e/wbs-search-interaction-safety.spec.js +++ b/tests/e2e/wbs-search-interaction-safety.spec.js @@ -23,10 +23,12 @@ test.describe('WBS search interaction safety', () => { const rows = page.locator('tbody tr[data-task-id]'); const addRoot = page.getByRole('button', { name: '최상위 작업 추가' }); const addChild = rows.first().getByRole('button', { name: /하위 추가 -/ }); + const deleteButton = rows.first().getByRole('button', { name: /삭제 -/ }); await expect(addRoot).toHaveAttribute('aria-disabled', 'true'); await expect(rows.first().locator('button[data-action="toggle"]')).toBeDisabled(); await expect(addChild).toHaveAttribute('aria-disabled', 'true'); + await expect(deleteButton).toHaveAttribute('aria-disabled', 'true'); await addChild.dispatchEvent('click'); await expect(page.locator('#toast')).toContainText('검색 중에는 작업을 추가할 수 없습니다.'); @@ -35,6 +37,10 @@ test.describe('WBS search interaction safety', () => { await addRoot.dispatchEvent('click'); await expect(page.locator('#toast')).toContainText('검색 중에는 작업을 추가할 수 없습니다.'); await expect(page.locator('.editor-panel')).toHaveCount(0); + + await deleteButton.dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('검색 중에는 작업을 삭제할 수 없습니다.'); + await expect(rows).toHaveCount(3); }); test('keeps an open editor visible by pausing search changes until editing finishes', async ({ page }) => { From 4f133086c7563d473aec493c890c4eebf78196b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 12:47:46 +0900 Subject: [PATCH 20/41] feat(wbs): add first-visit sample onboarding --- ARCHITECTURE.md | 3 ++ CHANGELOG.md | 2 + README.md | 1 + app.js | 43 +++++++++++++++ docs/plans/2026-04-20-scopeweave-design.md | 4 ++ docs/product-technical-gap-baseline.md | 6 +-- docs/user-guide.md | 12 +++++ index.html | 11 ++++ styles.css | 63 ++++++++++++++++++++++ tests/e2e/scopeweave.spec.js | 38 ++++++++++++- 10 files changed, 179 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8d41b54c..f21c8dca 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -35,6 +35,9 @@ optional File System Access API sync for `wbs.json` where supported. - Static hosting treats repository `wbs.json` as seed data; export/manual save remains the portability path. +- A first seed-only visit exposes an accessible onboarding notice; its dismissal + marker is separate from the planner payload, while confirmed clearing persists + an empty `tasks` array through the normal `renderAll()` and autosave path. - Imported flat JSON may synthesize hierarchy wrapper nodes internally, but external `wbs.json` sync strips synthetic rows so the saved array stays in the requested user schema. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b40c8dd..5ea1f3f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 recovery action. - Added browser JSON download for portable WBS backups, including extended planning fields, alongside CSV export. +- Added first-visit sample WBS onboarding with persistent dismissal and a + confirmed clear-to-empty-plan action. - Added search-mode guardrails that keep hierarchy edits and drag reordering out of the filtered view while an inline editor is open. - Added deterministic PM analysis for requirements/RFI/RFP readiness, WBS diff --git a/README.md b/README.md index cef57151..61c7fbb7 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ two modes: - Automatic day, weight, planned progress, actual progress, and weighted progress calculations - WBS search across task fields with matching hierarchy context +- First-visit sample WBS guidance with dismissible notice and clear-to-empty-plan action - JSON/CSV export and CSV import using the screen column contract - Local autosave with optional File System Access API sync to `wbs.json` - Weekly Gantt modal with planned (`#333333`) and actual (`#34cb03`) overlays diff --git a/app.js b/app.js index b8f13cd8..3118c467 100644 --- a/app.js +++ b/app.js @@ -1,4 +1,5 @@ const STORAGE_KEY = 'scopeweave:planner-state:v1'; +const ONBOARDING_DISMISSED_KEY = 'scopeweave:onboarding-dismissed:v1'; const DEFAULT_PROJECT_NAME = 'ScopeWeave Planner'; const MAX_PROJECT_NAME_LENGTH = 120; const MAX_BASE_DATE_LENGTH = 10; @@ -179,6 +180,7 @@ const state = { baseDate: formatLocalDateInput(new Date()), tasks: [], taskQuery: '', + showSeedOnboarding: false, editor: { ...DEFAULT_EDITOR_STATE, errors: [] }, jsonSyncHandle: null, dragTaskId: null, @@ -215,6 +217,9 @@ const elements = { plannedProgress: document.getElementById('summary-planned-progress'), actualProgress: document.getElementById('summary-actual-progress'), tableBody: document.getElementById('task-table-body'), + seedOnboarding: document.getElementById('seed-onboarding'), + dismissSeedOnboardingButton: document.getElementById('dismiss-seed-onboarding'), + clearSeedDataButton: document.getElementById('clear-seed-data'), addRootButton: document.getElementById('add-root-task'), exportCsvButton: document.getElementById('export-csv'), exportJsonButton: document.getElementById('export-json'), @@ -250,16 +255,19 @@ async function bootstrap() { const cloudState = cloudApi ? await cloudApi.boot() : null; if (cloudState) { + state.showSeedOnboarding = false; hydrateState(cloudState); persistState({ syncCloud: false }); } else { const savedState = loadLocalState(); if (savedState) { + state.showSeedOnboarding = false; hydrateState(savedState); persistState(); } else { const seedData = await loadSeedTasks(); state.tasks = normalizeImportedTasks(seedData); + state.showSeedOnboarding = state.tasks.length > 0 && !isSeedOnboardingDismissed(); invalidateTaskIndexCache(); } } @@ -369,6 +377,8 @@ function bindHeaderEvents(persistAndRenderMetadata) { renderAll(); elements.taskFilterInput.focus(); }); + elements.dismissSeedOnboardingButton.addEventListener('click', dismissSeedOnboarding); + elements.clearSeedDataButton.addEventListener('click', clearSeedData); } function bindModalEvents() { @@ -586,6 +596,7 @@ function renderAll() { const visibleTasks = getVisibleTasks(); const rows = []; + elements.seedOnboarding.hidden = !state.showSeedOnboarding; elements.clearTaskFilterButton.hidden = !filterActive; elements.taskFilterStatus.textContent = filterActive ? `${visibleTasks.length}개 작업 표시 중 (전체 ${state.tasks.length}개)` @@ -1768,6 +1779,7 @@ function findTask(taskId) { } function persistState({ syncCloud = true } = {}) { + state.showSeedOnboarding = false; const payload = { projectName: state.projectName, baseDate: state.baseDate, @@ -1791,6 +1803,37 @@ function persistState({ syncCloud = true } = {}) { } } +function isSeedOnboardingDismissed() { + try { + return localStorage.getItem(ONBOARDING_DISMISSED_KEY) === 'true'; + } catch { + return false; + } +} + +function dismissSeedOnboarding() { + try { + localStorage.setItem(ONBOARDING_DISMISSED_KEY, 'true'); + } catch { + // The notice is still dismissible for the current session if storage is unavailable. + } + state.showSeedOnboarding = false; + renderAll(); +} + +function clearSeedData() { + if (!state.showSeedOnboarding || !window.confirm('샘플 데이터를 지우고 빈 계획으로 시작하시겠습니까?')) { + return; + } + state.tasks = []; + state.showSeedOnboarding = false; + invalidateTaskIndexCache(); + persistState(); + renderAll(); + showToast('샘플 데이터를 삭제했습니다. 첫 단계를 추가해 계획을 시작하세요.'); + requestAnimationFrame(() => elements.addRootButton.focus()); +} + function loadLocalState() { try { const raw = localStorage.getItem(STORAGE_KEY); diff --git a/docs/plans/2026-04-20-scopeweave-design.md b/docs/plans/2026-04-20-scopeweave-design.md index 62c4824d..ea58fec4 100644 --- a/docs/plans/2026-04-20-scopeweave-design.md +++ b/docs/plans/2026-04-20-scopeweave-design.md @@ -47,6 +47,10 @@ ## Persistence decision - `wbs.json` in the repository is treated as the initial seed and export format. - Every data mutation autosaves immediately to `localStorage`. +- On a first visit with only seed data, the UI labels the sample explicitly and + provides a dismissible notice or a confirmed clear-to-empty-plan action. +- The onboarding dismissal marker is stored separately from the planner payload + so the existing seed/autosave contract remains unchanged. - On browsers supporting File System Access API, the user can grant a writable handle for `wbs.json`; after that, each change also writes the JSON array to the chosen file automatically. - Where that API is unavailable, the app remains functional and exposes explicit JSON/CSV export paths; this is the safest achievable static-hosting behavior. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 77fefc72..7de053e8 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -80,7 +80,7 @@ classDiagram | --- | --- | --- | --- | | G-01 | 큰 WBS에서 작업 위치를 찾는 비용이 높았음 | 작업·담당자·산출물 등 고객 필드를 검색하고 일치 행의 상위 계층을 함께 표시 | **완료** | | G-02 | 정적 사용자가 JSON을 파일로 회수하려면 File System Access API에 의존 | 추가 계획 필드를 보존하는 브라우저 다운로드용 JSON export를 추가하고, 자동저장 seed 계약은 유지 | **완료** | -| G-03 | 빈 화면에서 첫 계획을 만드는 안내가 seed 데이터 유무에 따라 달라짐 | 최소 온보딩/샘플 사용 경로와 삭제 가능한 샘플 상태를 제품 결정 후 추가 | 조사 필요 | +| G-03 | 첫 방문자가 seed 데이터와 실제 계획을 구분하기 어려움 | 첫 seed 방문에 샘플 WBS 안내를 표시하고, 안내 숨김과 확인 가능한 샘플 삭제 후 빈 계획 시작 경로를 제공 | **완료** | | G-04 | 키보드·스크린리더 회귀는 E2E 일부로 보호되지만 시각 회귀 자동 검사는 없음 | 핵심 상태의 실제 브라우저 스크린샷과 WCAG 2.2 점검을 릴리스 증거에 포함 | 다음 검증 | | G-05 | 보호 PR 큐는 소스와 무관한 Strix 공급자 429/Invalid URL 및 승인 부재로 차단될 수 있음 | 게이트를 약화하지 않고 원인 로그·artifact·현재 HEAD를 재검증한 뒤 재실행/중앙 수정 | 외부 상태 대기 | @@ -95,8 +95,8 @@ classDiagram reduced motion을 최소 기준으로 삼는다. - 검증 명령은 `npm run test:unit`, `npm run test:api`, `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. - 이번 G-01/G-02의 직접 증거는 `npm run test:e2e` 83개 통과와 검색·JSON 회귀 테스트의 - 통과다. + 이번 G-01/G-02/G-03의 직접 증거는 `npm run test:e2e` 86개 통과와 검색·JSON·온보딩 + 회귀 테스트의 통과다. ## 6. 표준·연구 근거 diff --git a/docs/user-guide.md b/docs/user-guide.md index 0bc1dd7d..3355be99 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -16,6 +16,18 @@ ScopeWeave Planner는 프로젝트용 WBS를 순수 HTML/CSS/JavaScript만으로 - **JSON 내보내기**는 선행작업, 예산, 실투입비, 스프린트, 스토리포인트를 포함한 휴대용 백업 파일(`wbs_export_YYYYMMDD.json`)을 다운로드합니다. +## 첫 방문 샘플 + +- 저장된 계획이 없는 첫 방문에는 `wbs.json`의 예시 데이터를 구분할 수 있도록 + **샘플 WBS가 준비되어 있습니다** 안내가 표시됩니다. +- 예시를 둘러보려면 **안내 숨기기**를 선택합니다. 이 선택은 같은 브라우저에서 + 유지되며 계획 데이터와 별도로 저장됩니다. +- 실제 계획으로 시작하려면 **샘플 지우고 시작**을 선택하고 확인합니다. 샘플은 + 삭제되어 빈 계획으로 저장되고, 첫 단계를 추가할 수 있도록 최상위 작업 추가 + 버튼에 포커스가 이동합니다. +- 이미 로컬 또는 Cloud 계획이 저장된 브라우저에는 seed 안내가 다시 표시되지 + 않습니다. + ## WBS 검색 - WBS 표 위의 **WBS 작업 검색**은 단계, 작업, 산출물, 담당자, 일정 등 입력된 diff --git a/index.html b/index.html index 66d8f312..73fe3281 100644 --- a/index.html +++ b/index.html @@ -63,6 +63,17 @@

ScopeWeave Planner

전체 0개 작업 +
diff --git a/styles.css b/styles.css index a1cda55b..7ad10995 100644 --- a/styles.css +++ b/styles.css @@ -34,6 +34,10 @@ box-sizing: border-box; } +[hidden] { + display: none !important; +} + body { margin: 0; background: var(--bg); @@ -244,6 +248,50 @@ button { white-space: nowrap; } +.seed-onboarding { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 18px; + align-items: center; + margin: 0 20px 18px; + padding: 18px 20px; + border: 1px solid #99f6e4; + border-left: 4px solid #0f766e; + border-radius: var(--radius-sm); + background: linear-gradient(120deg, #f0fdfa 0%, #ffffff 72%); + box-shadow: var(--shadow-sm); +} + +.seed-onboarding-mark { + color: #0f766e; + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +.seed-onboarding-copy h2 { + margin: 0 0 4px; + color: var(--text); + font-size: 1rem; + line-height: 1.35; +} + +.seed-onboarding-copy p { + margin: 0; + color: var(--text-muted); + font-size: 0.875rem; + line-height: 1.55; +} + +.seed-onboarding-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + .filter-clear { min-height: 44px; } @@ -899,6 +947,21 @@ select[data-inline-progress]:focus { min-height: 0; } + .seed-onboarding { + grid-template-columns: 1fr; + gap: 10px; + margin: 0 16px 16px; + padding: 16px; + } + + .seed-onboarding-actions { + justify-content: stretch; + } + + .seed-onboarding-actions > button { + flex: 1 1 180px; + } + .priority-desktop { display: none; } diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index b1cb79ed..25d2a957 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -76,8 +76,10 @@ test.describe('ScopeWeave Planner', () => { await expect(page.locator('link[rel="modulepreload"][href="cloud-sync.js"]')).toHaveCount(1); await expect(page.locator('link[rel="modulepreload"][href="analytics.js"]')).toHaveCount(1); await expect(page.locator('link[rel="modulepreload"][href="app.js"]')).toHaveCount(1); - await expect(page.getByRole('button', { name: '최상위 작업 추가' })).toBeVisible(); + await expect(page.locator('#add-root-task')).toBeVisible(); await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + await expect(page.locator('#seed-onboarding')).toBeVisible(); + await expect(page.locator('#seed-onboarding')).toContainText('샘플 WBS가 준비되어 있습니다'); await expect(page.getByTestId('project-name-input')).toHaveValue(/ScopeWeave/i); await expect(page.getByTestId('summary-total-days')).not.toHaveText('0일'); await expect(page.getByTestId('summary-planned-progress')).toContainText('%'); @@ -90,6 +92,40 @@ test.describe('ScopeWeave Planner', () => { await expect(page).toHaveTitle('My New Project - ScopeWeave Planner'); }); + test('remembers hiding the first-run sample onboarding notice', async ({ page }) => { + const onboarding = page.locator('#seed-onboarding'); + + await expect(onboarding).toBeVisible(); + await expect(onboarding).toHaveAttribute('aria-labelledby', 'seed-onboarding-title'); + await expect(onboarding).toHaveAttribute('aria-describedby', 'seed-onboarding-description'); + await onboarding.getByRole('button', { name: '안내 숨기기' }).click(); + await expect(onboarding).toBeHidden(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + + await page.reload(); + await expect(onboarding).toBeHidden(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + }); + + test('clears the first-run sample into an empty plan', async ({ page }) => { + page.once('dialog', dialog => dialog.dismiss()); + await page.locator('#clear-seed-data').click(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + await expect(page.locator('#seed-onboarding')).toBeVisible(); + + page.once('dialog', dialog => dialog.accept()); + + await page.locator('#clear-seed-data').click(); + + await expect(page.locator('#seed-onboarding')).toBeHidden(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(0); + await expect(page.locator('.table-empty')).toContainText('등록된 작업이 없습니다'); + await expect(page.locator('#toast')).toContainText('샘플 데이터를 삭제했습니다.'); + await expect(page.locator('#add-root-task')).toBeVisible(); + const savedTasks = await page.evaluate(() => JSON.parse(localStorage.getItem('scopeweave:planner-state:v1')).tasks); + expect(savedTasks).toEqual([]); + }); + test('filters WBS rows while preserving matching task hierarchy context', async ({ page }) => { const rows = page.locator('tbody tr[data-task-id]'); const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); From 41979ec09eb8a4fa837d3e120867728d08703852 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 12:56:42 +0900 Subject: [PATCH 21/41] fix(wbs): hide onboarding while searching --- app.js | 2 +- docs/user-guide.md | 3 +++ tests/e2e/scopeweave.spec.js | 8 ++++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app.js b/app.js index 3118c467..b7c05268 100644 --- a/app.js +++ b/app.js @@ -596,7 +596,7 @@ function renderAll() { const visibleTasks = getVisibleTasks(); const rows = []; - elements.seedOnboarding.hidden = !state.showSeedOnboarding; + elements.seedOnboarding.hidden = !state.showSeedOnboarding || filterActive; elements.clearTaskFilterButton.hidden = !filterActive; elements.taskFilterStatus.textContent = filterActive ? `${visibleTasks.length}개 작업 표시 중 (전체 ${state.tasks.length}개)` diff --git a/docs/user-guide.md b/docs/user-guide.md index 3355be99..38a02888 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -27,6 +27,9 @@ ScopeWeave Planner는 프로젝트용 WBS를 순수 HTML/CSS/JavaScript만으로 버튼에 포커스가 이동합니다. - 이미 로컬 또는 Cloud 계획이 저장된 브라우저에는 seed 안내가 다시 표시되지 않습니다. +- 프로젝트 이름이나 기준일을 먼저 바꾸어 저장하면 현재 seed가 사용자의 로컬 + 계획으로 채택된 것으로 간주되어 안내가 종료됩니다. 샘플을 둘러본 뒤 실제 + 계획을 시작할 때는 **샘플 지우고 시작**을 먼저 선택하세요. ## WBS 검색 diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index 25d2a957..07ba0bd3 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -131,25 +131,25 @@ test.describe('ScopeWeave Planner', () => { const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); await expect(rows).toHaveCount(4); - await rows.first().locator('button[data-action="toggle"]').click(); - await expect(rows).toHaveCount(1); + await expect(page.locator('#seed-onboarding')).toBeVisible(); await search.fill('단계작업계획'); await expect(rows).toHaveCount(3); + await expect(page.locator('#seed-onboarding')).toBeHidden(); await expect(rows).toContainText(['P0000.준비단계', '프로젝트준비', '단계작업계획']); await expect(rows.filter({ hasText: '사업수행계획' })).toHaveCount(0); const contextToggle = rows.first().locator('button[data-action="toggle"]'); await expect(contextToggle).toBeDisabled(); await expect(contextToggle).toHaveAttribute('aria-expanded', 'true'); await expect(page.locator('#task-filter-status')).toHaveText('3개 작업 표시 중 (전체 4개)'); + await search.fill(''); + await expect(page.locator('#seed-onboarding')).toBeVisible(); await search.fill('없는작업'); await expect(rows).toHaveCount(0); await expect(page.locator('.table-empty')).toContainText('검색 결과가 없습니다'); await page.getByRole('button', { name: '검색 지우기' }).click(); - await expect(rows).toHaveCount(1); - await rows.first().locator('button[data-action="toggle"]').click(); await expect(rows).toHaveCount(4); }); From 53c7d32fcb17a99cf1ea9cdd56fa3699c0cb5a27 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 13:00:18 +0900 Subject: [PATCH 22/41] docs: clarify sample sync onboarding --- docs/user-guide.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/user-guide.md b/docs/user-guide.md index 38a02888..ab5795ec 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -30,6 +30,9 @@ ScopeWeave Planner는 프로젝트용 WBS를 순수 HTML/CSS/JavaScript만으로 - 프로젝트 이름이나 기준일을 먼저 바꾸어 저장하면 현재 seed가 사용자의 로컬 계획으로 채택된 것으로 간주되어 안내가 종료됩니다. 샘플을 둘러본 뒤 실제 계획을 시작할 때는 **샘플 지우고 시작**을 먼저 선택하세요. +- `wbs.json 자동저장 연결`은 샘플을 연결된 파일에 저장할 뿐이므로 안내를 + 종료하지 않습니다. 샘플을 계속 사용할지, 숨기거나 지울지 명시적으로 + 선택할 수 있습니다. ## WBS 검색 From e47d6de27b93c70a12728fd15c7286d21888f8b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 19:38:59 +0900 Subject: [PATCH 23/41] test(a11y): retain visual accessibility evidence --- .../visual-accessibility-evidence.yml | 54 ++++++++++++++ .../visual-accessibility-evidence.md | 19 +++++ docs/product-technical-gap-baseline.md | 7 +- .../e2e/visual-accessibility-evidence.spec.js | 70 +++++++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/visual-accessibility-evidence.yml create mode 100644 docs/doctoring/visual-accessibility-evidence.md create mode 100644 tests/e2e/visual-accessibility-evidence.spec.js diff --git a/.github/workflows/visual-accessibility-evidence.yml b/.github/workflows/visual-accessibility-evidence.yml new file mode 100644 index 00000000..dee9c225 --- /dev/null +++ b/.github/workflows/visual-accessibility-evidence.yml @@ -0,0 +1,54 @@ +name: Visual Accessibility Evidence + +on: + pull_request: + push: + branches: [develop] + +permissions: + contents: read + +concurrency: + group: visual-accessibility-evidence-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + browser-evidence: + runs-on: ubuntu-latest + steps: + - name: Checkout exact revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Verify exact checkout + env: + EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + test "$actual_sha" = "$EXPECTED_CHECKOUT_SHA" + + - name: Setup Node 22.13 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22.13.0 + + - name: Install + run: npm ci + + - name: Install Playwright (chromium) + run: npx playwright install chromium --with-deps + + - name: Capture visual and accessibility evidence + run: npm run test:e2e -- tests/e2e/visual-accessibility-evidence.spec.js + + - name: Preserve visual accessibility evidence + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scopeweave-visual-accessibility-${{ github.run_id }}-${{ github.run_attempt }} + path: test-results + if-no-files-found: error + retention-days: 3 diff --git a/docs/doctoring/visual-accessibility-evidence.md b/docs/doctoring/visual-accessibility-evidence.md new file mode 100644 index 00000000..7e7eed7f --- /dev/null +++ b/docs/doctoring/visual-accessibility-evidence.md @@ -0,0 +1,19 @@ +# Visual and accessibility evidence + +## Status + +The repository-local `Visual Accessibility Evidence` workflow captures real Chromium screenshots for the sample, skip-link focus, and empty-plan states on the exact pull-request head. It retains the artifact for three days as release evidence. + +## WCAG 2.2 baseline checks + +The browser test also verifies the skip link target, keyboard-focusable `main` landmark, labeled WBS search control, scoped table headers, and body foreground/background contrast at the WCAG 2.2 normal-text threshold of 4.5:1. These checks are intentionally limited to deterministic contracts; they do not claim a complete automated accessibility audit. + +No Storybook, Figma runtime, axe dependency, or application runtime dependency is needed for this static-hostable product. Design artifacts remain the rendered production page and its retained browser evidence. + +## Exact-head and artifact contract + +The workflow checks out `github.event.pull_request.head.sha`, verifies `git rev-parse HEAD`, uses no credential persistence, and uploads only the Playwright `test-results` evidence with a three-day retention limit. A failed browser test still retains any screenshots produced before the failure. + +## Rollback + +Rollback removes the workflow and its test. There is no persisted-data, API, authentication, or schema impact. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 7de053e8..435c4e3f 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -81,7 +81,7 @@ classDiagram | G-01 | 큰 WBS에서 작업 위치를 찾는 비용이 높았음 | 작업·담당자·산출물 등 고객 필드를 검색하고 일치 행의 상위 계층을 함께 표시 | **완료** | | G-02 | 정적 사용자가 JSON을 파일로 회수하려면 File System Access API에 의존 | 추가 계획 필드를 보존하는 브라우저 다운로드용 JSON export를 추가하고, 자동저장 seed 계약은 유지 | **완료** | | G-03 | 첫 방문자가 seed 데이터와 실제 계획을 구분하기 어려움 | 첫 seed 방문에 샘플 WBS 안내를 표시하고, 안내 숨김과 확인 가능한 샘플 삭제 후 빈 계획 시작 경로를 제공 | **완료** | -| G-04 | 키보드·스크린리더 회귀는 E2E 일부로 보호되지만 시각 회귀 자동 검사는 없음 | 핵심 상태의 실제 브라우저 스크린샷과 WCAG 2.2 점검을 릴리스 증거에 포함 | 다음 검증 | +| G-04 | 키보드·스크린리더 회귀는 E2E 일부로 보호되지만 시각 회귀 자동 검사는 없음 | `Visual Accessibility Evidence`가 exact-head Chromium에서 핵심 상태 PNG와 WCAG 2.2 기준선 검사를 실행하고 artifact를 3일 보존 | **완료** | | G-05 | 보호 PR 큐는 소스와 무관한 Strix 공급자 429/Invalid URL 및 승인 부재로 차단될 수 있음 | 게이트를 약화하지 않고 원인 로그·artifact·현재 HEAD를 재검증한 뒤 재실행/중앙 수정 | 외부 상태 대기 | ## 5. 품질·보안 기준선 @@ -95,8 +95,9 @@ classDiagram reduced motion을 최소 기준으로 삼는다. - 검증 명령은 `npm run test:unit`, `npm run test:api`, `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. - 이번 G-01/G-02/G-03의 직접 증거는 `npm run test:e2e` 86개 통과와 검색·JSON·온보딩 - 회귀 테스트의 통과다. + G-01/G-02/G-03의 직접 증거는 검색·JSON·온보딩 회귀 테스트이며, G-04는 + `tests/e2e/visual-accessibility-evidence.spec.js`와 `Visual Accessibility Evidence` + artifact가 exact-head 브라우저 상태를 증명한다. ## 6. 표준·연구 근거 diff --git a/tests/e2e/visual-accessibility-evidence.spec.js b/tests/e2e/visual-accessibility-evidence.spec.js new file mode 100644 index 00000000..906e4d92 --- /dev/null +++ b/tests/e2e/visual-accessibility-evidence.spec.js @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; + +const STORAGE_KEY = 'scopeweave:planner-state:v1'; + +function luminance([red, green, blue]) { + const channels = [red, green, blue].map((channel) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return (0.2126 * channels[0]) + (0.7152 * channels[1]) + (0.0722 * channels[2]); +} + +function contrastRatio(foreground, background) { + const foregroundLuminance = luminance(foreground); + const backgroundLuminance = luminance(background); + return (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) + / (Math.min(foregroundLuminance, backgroundLuminance) + 0.05); +} + +function parseRgb(value) { + const match = value.match(/rgba?\(\s*([\d.]+)[, ]+\s*([\d.]+)[, ]+\s*([\d.]+)/i); + return match ? match.slice(1, 4).map(Number) : null; +} + +test('captures core planner states with WCAG 2.2 baseline evidence', async ({ page }, testInfo) => { + await page.goto('./'); + + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + await expect(page.locator('.seed-onboarding')).toBeVisible(); + await expect(page.locator('main#main-content')).toHaveAttribute('tabindex', '-1'); + await expect(page.locator('a.skip-link')).toHaveAttribute('href', '#main-content'); + await expect(page.getByRole('searchbox', { name: 'WBS 작업 검색' })) + .toHaveAttribute('aria-controls', 'task-table-body'); + await expect(page.locator('th[scope="col"]')).toHaveCount(21); + + const contrast = await page.locator('body').evaluate((element) => ({ + foreground: getComputedStyle(element).color, + background: getComputedStyle(element).backgroundColor, + })); + const foreground = parseRgb(contrast.foreground); + const background = parseRgb(contrast.background); + expect(foreground, `expected body foreground color, got ${contrast.foreground}`).not.toBeNull(); + expect(background, `expected body background color, got ${contrast.background}`).not.toBeNull(); + expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5); + + const samplePath = testInfo.outputPath('scopeweave-sample.png'); + await page.screenshot({ path: samplePath, fullPage: true }); + await testInfo.attach('scopeweave-sample', { path: samplePath, contentType: 'image/png' }); + + await page.keyboard.press('Tab'); + await expect(page.locator('a.skip-link')).toBeFocused(); + const focusPath = testInfo.outputPath('scopeweave-skip-link-focus.png'); + await page.screenshot({ path: focusPath, fullPage: true }); + await testInfo.attach('scopeweave-skip-link-focus', { path: focusPath, contentType: 'image/png' }); + + await page.evaluate((storageKey) => { + localStorage.setItem(storageKey, JSON.stringify({ + projectName: 'Empty visual evidence', + baseDate: '2026-08-29', + tasks: [], + })); + }, STORAGE_KEY); + await page.reload(); + await expect(page.locator('.empty-state-cell')).toBeVisible(); + await expect(page.locator('.empty-state-cell').getByRole('button', { name: '최상위 작업 추가' })).toBeVisible(); + + const emptyPath = testInfo.outputPath('scopeweave-empty.png'); + await page.screenshot({ path: emptyPath, fullPage: true }); + await testInfo.attach('scopeweave-empty', { path: emptyPath, contentType: 'image/png' }); +}); From 50c6d07fd766da216fbb30e359d0304855ebde01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 19:53:38 +0900 Subject: [PATCH 24/41] fix(wbs): clear stale search and seed state --- CHANGELOG.md | 2 + app.js | 15 ++++-- docs/product-technical-gap-baseline.md | 2 +- tests/e2e/cloud.spec.js | 17 +++++++ tests/e2e/scopeweave.spec.js | 2 +- .../e2e/wbs-search-interaction-safety.spec.js | 49 ++++++++++++++++++- 6 files changed, 80 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ea1f3f7..297f5243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added accessible WBS field search with hierarchy context and an empty-result recovery action. +- Added planning-field search coverage and safe state reset when Cloud or file + imports replace the current plan. - Added browser JSON download for portable WBS backups, including extended planning fields, alongside CSV export. - Added first-visit sample WBS onboarding with persistent dismissal and a diff --git a/app.js b/app.js index b7c05268..115b31b4 100644 --- a/app.js +++ b/app.js @@ -776,7 +776,11 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { toggleButton.setAttribute('aria-label', `${toggleLabel} - ${rowEntityName}`); toggleButton.setAttribute('aria-expanded', String(expanded)); toggleButton.title = `${toggleLabel} - ${rowEntityName}`; - toggleButton.disabled = filterActive || searchExpanded; + if (filterActive || searchExpanded) { + toggleButton.setAttribute('aria-disabled', 'true'); + } else { + toggleButton.removeAttribute('aria-disabled'); + } const toggleIcon = toggleIconTemplate.cloneNode(false); toggleIcon.textContent = expanded ? '▼' : '▶'; toggleButton.appendChild(toggleIcon); @@ -1609,7 +1613,8 @@ const cachedSearchExpandedParentIds = new Set(); const TASK_SEARCH_FIELDS = [ 'phase', 'activity', 'task', 'categoryLarge', 'categoryMedium', 'documentName', 'owner', 'supportTeam', 'actualProgressStatus', 'plannedStartDate', - 'plannedEndDate', 'actualStartDate', 'actualEndDate', 'predecessors', 'sprint' + 'plannedEndDate', 'actualStartDate', 'actualEndDate', 'predecessors', 'budget', + 'actualCost', 'sprint', 'storyPoints' ]; function taskSearchText(task) { @@ -1849,6 +1854,8 @@ function hydrateState(savedState) { state.tasks = Array.isArray(savedState.tasks) ? savedState.tasks.filter(isTaskRecord).map(normalizeStoredTask) : []; + state.taskQuery = ''; + state.showSeedOnboarding = false; invalidateTaskIndexCache(); } @@ -2175,8 +2182,8 @@ async function handleCsvImport(event) { try { const text = await file.text(); const imported = parseCsv(text); - state.tasks = validateImportedTasks(normalizeImportedTasks(imported)); - invalidateTaskIndexCache(); + const importedTasks = validateImportedTasks(normalizeImportedTasks(imported)); + hydrateState({ projectName: state.projectName, baseDate: state.baseDate, tasks: importedTasks }); closeEditor(true); persistState(); renderAll(); diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 435c4e3f..f9cda042 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -78,7 +78,7 @@ classDiagram | ID | Gap / 고객 영향 | 조치 | 상태 | | --- | --- | --- | --- | -| G-01 | 큰 WBS에서 작업 위치를 찾는 비용이 높았음 | 작업·담당자·산출물 등 고객 필드를 검색하고 일치 행의 상위 계층을 함께 표시 | **완료** | +| G-01 | 큰 WBS에서 작업 위치를 찾는 비용이 높았음 | 작업·담당자·산출물·예산·실투입비·스토리포인트 등 고객 필드를 검색하고 일치 행의 상위 계층을 함께 표시 | **완료** | | G-02 | 정적 사용자가 JSON을 파일로 회수하려면 File System Access API에 의존 | 추가 계획 필드를 보존하는 브라우저 다운로드용 JSON export를 추가하고, 자동저장 seed 계약은 유지 | **완료** | | G-03 | 첫 방문자가 seed 데이터와 실제 계획을 구분하기 어려움 | 첫 seed 방문에 샘플 WBS 안내를 표시하고, 안내 숨김과 확인 가능한 샘플 삭제 후 빈 계획 시작 경로를 제공 | **완료** | | G-04 | 키보드·스크린리더 회귀는 E2E 일부로 보호되지만 시각 회귀 자동 검사는 없음 | `Visual Accessibility Evidence`가 exact-head Chromium에서 핵심 상태 PNG와 WCAG 2.2 기준선 검사를 실행하고 artifact를 3일 보존 | **완료** | diff --git a/tests/e2e/cloud.spec.js b/tests/e2e/cloud.spec.js index fa18cc2e..31780314 100644 --- a/tests/e2e/cloud.spec.js +++ b/tests/e2e/cloud.spec.js @@ -60,6 +60,23 @@ async function loginAndOpen(page) { await page.waitForSelector('#cloud-auth select'); } +test('opening a cloud project clears standalone seed onboarding', async ({ page }) => { + await page.goto(`${BASE}/`); + await expect(page.locator('#seed-onboarding')).toBeVisible(); + + await page.evaluate(([t]) => { + localStorage.setItem('scopeweave:token', t); + localStorage.setItem('scopeweave:project', '1'); + }, [token]); + await page.reload(); + await page.waitForSelector('#cloud-auth select'); + + await expect(page.locator('#seed-onboarding')).toBeHidden(); + await expect(page.locator('#clear-seed-data')).toBeHidden(); + const project = await api('/api/projects/1', { tok: token }); + expect(project.tasks.length).toBeGreaterThan(0); +}); + test('cloud bar renders the full toolset when logged in', async ({ page }) => { await loginAndOpen(page); const labels = await page.$$eval('#cloud-auth button', (bs) => bs.map((b) => b.textContent)); diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index 07ba0bd3..8fa25657 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -139,7 +139,7 @@ test.describe('ScopeWeave Planner', () => { await expect(rows).toContainText(['P0000.준비단계', '프로젝트준비', '단계작업계획']); await expect(rows.filter({ hasText: '사업수행계획' })).toHaveCount(0); const contextToggle = rows.first().locator('button[data-action="toggle"]'); - await expect(contextToggle).toBeDisabled(); + await expect(contextToggle).toHaveAttribute('aria-disabled', 'true'); await expect(contextToggle).toHaveAttribute('aria-expanded', 'true'); await expect(page.locator('#task-filter-status')).toHaveText('3개 작업 표시 중 (전체 4개)'); await search.fill(''); diff --git a/tests/e2e/wbs-search-interaction-safety.spec.js b/tests/e2e/wbs-search-interaction-safety.spec.js index 123b3bc9..494b5ef0 100644 --- a/tests/e2e/wbs-search-interaction-safety.spec.js +++ b/tests/e2e/wbs-search-interaction-safety.spec.js @@ -26,7 +26,12 @@ test.describe('WBS search interaction safety', () => { const deleteButton = rows.first().getByRole('button', { name: /삭제 -/ }); await expect(addRoot).toHaveAttribute('aria-disabled', 'true'); - await expect(rows.first().locator('button[data-action="toggle"]')).toBeDisabled(); + const toggle = rows.first().locator('button[data-action="toggle"]'); + await expect(toggle).toHaveAttribute('aria-disabled', 'true'); + await toggle.focus(); + await expect(toggle).toBeFocused(); + await toggle.dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('검색 중 계층 맥락 고정'); await expect(addChild).toHaveAttribute('aria-disabled', 'true'); await expect(deleteButton).toHaveAttribute('aria-disabled', 'true'); @@ -43,6 +48,48 @@ test.describe('WBS search interaction safety', () => { await expect(rows).toHaveCount(3); }); + test('searches planning values and clears the query when CSV replaces the plan', async ({ page }) => { + await page.evaluate(() => { + localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({ + projectName: 'Planning search', + baseDate: '2026-08-29', + tasks: [{ + id: 'planning-search-task', + parentId: null, + depth: 3, + expanded: true, + phase: '계획', + activity: '검색', + task: '계획 값', + budget: '125000', + actualCost: '90000', + storyPoints: '13' + }] + })); + }); + await page.reload(); + + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + const rows = page.locator('tbody tr[data-task-id]'); + for (const query of ['125000', '90000', '13']) { + await search.fill(query); + await expect(rows).toHaveCount(1); + } + + page.once('dialog', dialog => dialog.accept()); + await page.locator('#csv-file-input').setInputFiles({ + name: 'replacement.csv', + mimeType: 'text/csv', + buffer: Buffer.from([ + '단계,Activity,Task,대분류,중분류,산출물,담당자,지원팀,진행상태,계획시작일,계획종료일,일수,계획진척률,가중치,가중치진척률,실적진척상태,실적진척률,실적시작일,실적종료일,가중치실적진척률,__id,__parentId,__depth,선행작업,예산,실투입비,스프린트,스토리포인트', + '교체 단계,,,,,,,,,,,,,,미착수(0%),,,,,replacement-task,,,3,,,,,' + ].join('\n')) + }); + await expect(search).toHaveValue(''); + await expect(rows).toHaveCount(1); + await expect(rows).toContainText('교체 단계'); + }); + test('keeps an open editor visible by pausing search changes until editing finishes', async ({ page }) => { const firstRow = page.locator('tbody tr[data-task-id]').first(); await firstRow.getByRole('button', { name: /편집 -/ }).click(); From 88f31d42616eb86180ceddb64e9bcd2d156c81e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 19:55:05 +0900 Subject: [PATCH 25/41] test(cloud): cover active plan replacement --- tests/e2e/cloud.spec.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/e2e/cloud.spec.js b/tests/e2e/cloud.spec.js index 31780314..9ff988ec 100644 --- a/tests/e2e/cloud.spec.js +++ b/tests/e2e/cloud.spec.js @@ -64,15 +64,17 @@ test('opening a cloud project clears standalone seed onboarding', async ({ page await page.goto(`${BASE}/`); await expect(page.locator('#seed-onboarding')).toBeVisible(); - await page.evaluate(([t]) => { - localStorage.setItem('scopeweave:token', t); - localStorage.setItem('scopeweave:project', '1'); - }, [token]); - await page.reload(); + await page.getByRole('button', { name: /클라우드 로그인/ }).click(); + await page.fill('#cloud-email', 'e2e@cloud.com'); + await page.fill('#cloud-password', 'password123'); + await page.click('#cloud-submit'); await page.waitForSelector('#cloud-auth select'); + await page.getByRole('searchbox', { name: 'WBS 작업 검색' }).fill('사업수행계획'); + await page.locator('#cloud-auth select').selectOption('1'); await expect(page.locator('#seed-onboarding')).toBeHidden(); await expect(page.locator('#clear-seed-data')).toBeHidden(); + await expect(page.getByRole('searchbox', { name: 'WBS 작업 검색' })).toHaveValue(''); const project = await api('/api/projects/1', { tok: token }); expect(project.tasks.length).toBeGreaterThan(0); }); @@ -154,6 +156,8 @@ test('share link: anonymous visitor gets a read-only view; revoke kills it', asy test('MSP import: XML file populates the tree and saves to the cloud', async ({ page }) => { await loginAndOpen(page); + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + await search.fill('설계'); page.on('dialog', (d) => d.accept()); await page.click('#cloud-auth button:has-text("MSP 가져오기")'); const xml = ` @@ -161,6 +165,7 @@ test('MSP import: XML file populates the tree and saves to the cloud', async ({ 2MSP액티비티22026-03-02T08:00:002026-03-06T17:00:00 `; await page.setInputFiles('#msp-file-input', { name: 'plan.xml', mimeType: 'text/xml', buffer: Buffer.from(xml) }); + await expect(search).toHaveValue(''); await page.waitForFunction(() => document.querySelector('#task-table-body')?.textContent.includes('MSP단계')); // wait for the debounced cloud push, then confirm server state await page.waitForTimeout(1200); From d1ed68a6ca84749368b8c5b6f2fa1cdcc314f188 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 20:08:44 +0900 Subject: [PATCH 26/41] docs(quality): record coverage readiness gap --- CHANGELOG.md | 2 ++ docs/doctoring/coverage-evidence.md | 50 ++++++++++++++++++++++++++ docs/product-technical-gap-baseline.md | 7 +++- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 docs/doctoring/coverage-evidence.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 297f5243..a657efb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added workflow ownership regression coverage so central review workflows stay inherited from `ContextualWisdomLab/.github`, not copied into this repository. +- Added an exact-head coverage audit documenting the current c8 scope and the + remaining work required before claiming 100% frontend/backend coverage. ### Security diff --git a/docs/doctoring/coverage-evidence.md b/docs/doctoring/coverage-evidence.md new file mode 100644 index 00000000..42f6c76e --- /dev/null +++ b/docs/doctoring/coverage-evidence.md @@ -0,0 +1,50 @@ +# Coverage evidence and 100% readiness + +## Exact-head measurement + +On 2026-08-29, the working head `88f31d42616eb86180ceddb64e9bcd2d156c81e4` +ran `npm run test:coverage` successfully. The generated c8 summary was: + +| Metric | Covered | Total | Result | +| --- | ---: | ---: | ---: | +| Lines/statements | 3,399 | 7,722 | 44.01% | +| Functions | 78 | 236 | 33.05% | +| Branches | 798 | 984 | 81.09% | + +The command's successful exit means the listed test cases completed; it does +not mean the 100% quality target was met. + +## Scope boundary + +The current c8 command includes `app.js`, `cloud-sync.js`, the CI helper, and +the Node server modules. Its coverage process runs Node test cases only. +Playwright launches the browser in a separate process, so the 89 passing E2E +tests are user-flow evidence but are not included in this c8 summary. The +repository therefore has no current single report proving 100% frontend, +backend, and edge-case coverage. + +## Required remediation + +1. Collect browser-side coverage for the shipped client scripts and merge it + with the Node report without excluding uncovered production files. +2. Add tests for every remaining server branch and client error/empty-state + path, including the current `app.js` and `cloud-sync.js` uncovered regions. +3. Make the exact-head coverage command fail below 100% lines, functions, and + branches after the merged report exists. + +Until those three conditions are true, G-06 remains **측정됨, 진행 중** and no +release note may describe the repository as having 100% coverage. + +## References + +bcoe. (n.d.). *c8: Output coverage reports using Node.js' built-in coverage* +[Computer software]. GitHub. Retrieved August 29, 2026, from +https://github.com/bcoe/c8 + +Microsoft. (n.d.). *Coverage*. Playwright. Retrieved August 29, 2026, from +https://playwright.dev/docs/api/class-coverage + +## Rollback + +Remove this record, the G-06 baseline row, and its changelog entry together; +there is no runtime or persisted-data impact. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index f9cda042..2899f1e9 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # ScopeWeave 제품·기술 Gap Baseline -> 기준일: 2026-08-28 | 기준 브랜치: `develop` | 기준 HEAD: `2c328875e00e86537df3e965170be80532571cad` +> 기준일: 2026-08-29 | 기준 브랜치: `develop` | 기준 HEAD: `2c328875e00e86537df3e965170be80532571cad` 이 문서는 현재 저장소의 PRD, 기술 계약, 구현, 테스트, 운영 게이트를 한 곳에서 추적하는 기준선이다. 문서의 상태는 의도나 열린 PR의 제목이 아니라 @@ -83,6 +83,7 @@ classDiagram | G-03 | 첫 방문자가 seed 데이터와 실제 계획을 구분하기 어려움 | 첫 seed 방문에 샘플 WBS 안내를 표시하고, 안내 숨김과 확인 가능한 샘플 삭제 후 빈 계획 시작 경로를 제공 | **완료** | | G-04 | 키보드·스크린리더 회귀는 E2E 일부로 보호되지만 시각 회귀 자동 검사는 없음 | `Visual Accessibility Evidence`가 exact-head Chromium에서 핵심 상태 PNG와 WCAG 2.2 기준선 검사를 실행하고 artifact를 3일 보존 | **완료** | | G-05 | 보호 PR 큐는 소스와 무관한 Strix 공급자 429/Invalid URL 및 승인 부재로 차단될 수 있음 | 게이트를 약화하지 않고 원인 로그·artifact·현재 HEAD를 재검증한 뒤 재실행/중앙 수정 | 외부 상태 대기 | +| G-06 | 현재 coverage 명령은 Node 계층만 c8로 계측하고 브라우저 E2E를 합산하지 않아 저장소 전체 100%를 증명하지 못함 | 서버·클라이언트·edge-case를 같은 exact-head coverage 보고서로 합산하고 100% threshold를 활성화한 뒤에만 완료 처리 | **측정됨, 진행 중** | ## 5. 품질·보안 기준선 @@ -95,6 +96,10 @@ classDiagram reduced motion을 최소 기준으로 삼는다. - 검증 명령은 `npm run test:unit`, `npm run test:api`, `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. + `npm run test:coverage`의 2026-08-29 exact-head 측정치는 lines 44.01%, + functions 33.05%, branches 81.09%이며, 현재는 threshold를 통과시키는 + 명령이 아니다. 이 결과는 `docs/doctoring/coverage-evidence.md`에 + 기록하고 100% 품질 기준의 미충족 증거로 취급한다. G-01/G-02/G-03의 직접 증거는 검색·JSON·온보딩 회귀 테스트이며, G-04는 `tests/e2e/visual-accessibility-evidence.spec.js`와 `Visual Accessibility Evidence` artifact가 exact-head 브라우저 상태를 증명한다. From b130c4ff535cb29fe785799c380769e6df9e91d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 20:17:34 +0900 Subject: [PATCH 27/41] test(coverage): merge browser and node reports --- package-lock.json | 4 +- package.json | 8 +++- scripts/ci/check-coverage.mjs | 10 +++++ scripts/ci/merge-browser-coverage.mjs | 43 +++++++++++++++++++ scripts/ci/run-browser-coverage.mjs | 23 ++++++++++ tests/e2e/beforeunload.spec.js | 2 +- tests/e2e/cloud.spec.js | 2 +- tests/e2e/coverage-fixtures.js | 27 ++++++++++++ tests/e2e/csv_formula_fuzz.spec.js | 2 +- tests/e2e/scopeweave.spec.js | 3 +- tests/e2e/test_getTaskSubtreeRange.spec.js | 2 +- tests/e2e/toast-accessibility.spec.js | 2 +- .../e2e/visual-accessibility-evidence.spec.js | 2 +- .../e2e/wbs-search-interaction-safety.spec.js | 2 +- tests/e2e/wbs-search-persistence.spec.js | 2 +- tests/unit/coverage-script-contract.test.mjs | 20 ++++++--- 16 files changed, 137 insertions(+), 17 deletions(-) create mode 100644 scripts/ci/check-coverage.mjs create mode 100644 scripts/ci/merge-browser-coverage.mjs create mode 100644 scripts/ci/run-browser-coverage.mjs create mode 100644 tests/e2e/coverage-fixtures.js diff --git a/package-lock.json b/package-lock.json index 00a99254..ff493704 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "devDependencies": { "@playwright/test": "1.62.1", "c8": "12.0.0", - "fast-check": "4.9.0" + "fast-check": "4.9.0", + "istanbul-lib-coverage": "3.2.2", + "v8-to-istanbul": "9.3.0" }, "engines": { "node": "^22.13.0 || >=23.4.0" diff --git a/package.json b/package.json index 8cefdc74..752a78ac 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage": "npm run test:coverage:node && node scripts/ci/run-browser-coverage.mjs && node scripts/ci/merge-browser-coverage.mjs", + "test:coverage:node": "c8 --all --include=app.js --include=cloud-sync.js --include=analytics.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reports-dir=coverage/node --temp-directory=coverage/node/tmp --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:strict": "npm run test:coverage && node scripts/ci/check-coverage.mjs", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", @@ -29,6 +31,8 @@ "devDependencies": { "@playwright/test": "1.62.1", "c8": "12.0.0", - "fast-check": "4.9.0" + "fast-check": "4.9.0", + "istanbul-lib-coverage": "3.2.2", + "v8-to-istanbul": "9.3.0" } } diff --git a/scripts/ci/check-coverage.mjs b/scripts/ci/check-coverage.mjs new file mode 100644 index 00000000..7eca2a75 --- /dev/null +++ b/scripts/ci/check-coverage.mjs @@ -0,0 +1,10 @@ +import { readFile } from 'node:fs/promises'; + +const summary = JSON.parse(await readFile('coverage/coverage-summary.json', 'utf8')); +const failed = ['lines', 'statements', 'functions', 'branches'].filter((metric) => Number(summary[metric].pct) < 100); + +if (failed.length) { + throw new Error(`Coverage below 100%: ${failed.join(', ')}`); +} + +console.log('Coverage meets the 100% lines, statements, functions, and branches threshold.'); diff --git a/scripts/ci/merge-browser-coverage.mjs b/scripts/ci/merge-browser-coverage.mjs new file mode 100644 index 00000000..92154d76 --- /dev/null +++ b/scripts/ci/merge-browser-coverage.mjs @@ -0,0 +1,43 @@ +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import v8ToIstanbul from 'v8-to-istanbul'; +import coverageLib from 'istanbul-lib-coverage'; + +const { createCoverageMap } = coverageLib; + +const root = path.resolve(new URL('../..', import.meta.url).pathname); +const nodeReportPath = path.join(root, 'coverage/node/coverage-final.json'); +const browserDirectory = path.join(root, 'coverage/browser'); +const outputDirectory = path.join(root, 'coverage'); +const browserFiles = (await readdir(browserDirectory)).filter((file) => file.endsWith('.json')); +const coverageMap = createCoverageMap(JSON.parse(await readFile(nodeReportPath, 'utf8'))); +const browserSources = new Map([ + ['/app.js', path.join(root, 'app.js')], + ['/cloud-sync.js', path.join(root, 'cloud-sync.js')], + ['/analytics.js', path.join(root, 'analytics.js')], +]); +let mergedEntries = 0; + +for (const file of browserFiles) { + const entries = JSON.parse(await readFile(path.join(browserDirectory, file), 'utf8')); + for (const entry of entries) { + const pathname = new URL(entry.url).pathname; + const localPath = browserSources.get(pathname); + if (!localPath || !entry.source) continue; + + const converter = v8ToIstanbul(localPath, 0, { source: entry.source }); + await converter.load(); + converter.applyCoverage(entry.functions); + coverageMap.merge(createCoverageMap(converter.toIstanbul())); + mergedEntries += 1; + } +} + +if (mergedEntries === 0) { + throw new Error('No browser coverage entries were merged'); +} + +const summary = coverageMap.getCoverageSummary().toJSON(); +await writeFile(path.join(outputDirectory, 'coverage-final.json'), JSON.stringify(coverageMap.toJSON(), null, 2)); +await writeFile(path.join(outputDirectory, 'coverage-summary.json'), JSON.stringify(summary, null, 2)); +console.log(JSON.stringify({ mergedEntries, summary }, null, 2)); diff --git a/scripts/ci/run-browser-coverage.mjs b/scripts/ci/run-browser-coverage.mjs new file mode 100644 index 00000000..8b53a0c0 --- /dev/null +++ b/scripts/ci/run-browser-coverage.mjs @@ -0,0 +1,23 @@ +import { spawn } from 'node:child_process'; +import { mkdir, rm } from 'node:fs/promises'; +import path from 'node:path'; + +const root = path.resolve(new URL('../..', import.meta.url).pathname); +const coverageDirectory = path.join(root, 'coverage/browser'); + +await rm(coverageDirectory, { recursive: true, force: true }); +await mkdir(coverageDirectory, { recursive: true }); + +const exitCode = await new Promise((resolve, reject) => { + const child = spawn('npm', ['run', 'test:e2e'], { + cwd: root, + env: { ...process.env, SCOPEWEAVE_BROWSER_COVERAGE: '1' }, + stdio: 'inherit', + }); + child.once('error', reject); + child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); +}); + +if (exitCode !== 0) { + process.exitCode = exitCode; +} diff --git a/tests/e2e/beforeunload.spec.js b/tests/e2e/beforeunload.spec.js index c94c44ae..8a8d8366 100644 --- a/tests/e2e/beforeunload.spec.js +++ b/tests/e2e/beforeunload.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; test.describe('Inline editor unsaved-change guards', () => { test('Escape on dirty editor prompts before discard', async ({ page }) => { diff --git a/tests/e2e/cloud.spec.js b/tests/e2e/cloud.spec.js index 9ff988ec..b7b10d28 100644 --- a/tests/e2e/cloud.spec.js +++ b/tests/e2e/cloud.spec.js @@ -1,7 +1,7 @@ // Cloud (SaaS) UI e2e — self-contained: spawns the Node API server itself, so // the static python webServer from playwright.config is untouched. // Run: npx playwright test tests/e2e/cloud.spec.js -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; import { spawn } from 'node:child_process'; const PORT = 8830; diff --git a/tests/e2e/coverage-fixtures.js b/tests/e2e/coverage-fixtures.js new file mode 100644 index 00000000..b00fd2b7 --- /dev/null +++ b/tests/e2e/coverage-fixtures.js @@ -0,0 +1,27 @@ +import { test as base } from '@playwright/test'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const coverageEnabled = process.env.SCOPEWEAVE_BROWSER_COVERAGE === '1'; +const coverageDirectory = path.resolve('coverage/browser'); + +export const test = base.extend({ + page: async ({ page }, use, testInfo) => { + if (!coverageEnabled) { + await use(page); + return; + } + + await page.coverage.startJSCoverage({ reportAnonymousScripts: false }); + try { + await use(page); + } finally { + const entries = await page.coverage.stopJSCoverage(); + await mkdir(coverageDirectory, { recursive: true }); + const filename = `${testInfo.workerIndex}-${testInfo.testId.replaceAll(/[^a-zA-Z0-9_-]/g, '_')}.json`; + await writeFile(path.join(coverageDirectory, filename), JSON.stringify(entries)); + } + }, +}); + +export { expect } from '@playwright/test'; diff --git a/tests/e2e/csv_formula_fuzz.spec.js b/tests/e2e/csv_formula_fuzz.spec.js index 5ff20a48..6cc86ed2 100644 --- a/tests/e2e/csv_formula_fuzz.spec.js +++ b/tests/e2e/csv_formula_fuzz.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; import fc from 'fast-check'; test.describe('CSV formula fuzzing', () => { diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index 8fa25657..a2dc2457 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; import fs from 'node:fs'; @@ -1228,6 +1228,7 @@ test.describe('ScopeWeave Planner', () => { }); test('renders empty cells as independent DOM clones', async ({ page }) => { + await expect(page.locator('.empty-cell').nth(1)).toBeAttached(); const result = await page.evaluate(() => { const emptyCells = Array.from(document.querySelectorAll('.empty-cell')); const [first, second] = emptyCells; diff --git a/tests/e2e/test_getTaskSubtreeRange.spec.js b/tests/e2e/test_getTaskSubtreeRange.spec.js index bc1eb38a..d8f799e5 100644 --- a/tests/e2e/test_getTaskSubtreeRange.spec.js +++ b/tests/e2e/test_getTaskSubtreeRange.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; test.describe('getTaskSubtreeRange function tests', () => { test('should return correct range for root task, sub task and non-existent task', async ({ page }) => { diff --git a/tests/e2e/toast-accessibility.spec.js b/tests/e2e/toast-accessibility.spec.js index 5e45cb79..9d7d8fe9 100644 --- a/tests/e2e/toast-accessibility.spec.js +++ b/tests/e2e/toast-accessibility.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; test('cloud status feedback is visibly rendered as a non-focus-taking live status', async ({ page }) => { await page.goto('/?share=ABCDEFGHIJKLMNOP'); diff --git a/tests/e2e/visual-accessibility-evidence.spec.js b/tests/e2e/visual-accessibility-evidence.spec.js index 906e4d92..ae93d27c 100644 --- a/tests/e2e/visual-accessibility-evidence.spec.js +++ b/tests/e2e/visual-accessibility-evidence.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; const STORAGE_KEY = 'scopeweave:planner-state:v1'; diff --git a/tests/e2e/wbs-search-interaction-safety.spec.js b/tests/e2e/wbs-search-interaction-safety.spec.js index 494b5ef0..02e1e9f3 100644 --- a/tests/e2e/wbs-search-interaction-safety.spec.js +++ b/tests/e2e/wbs-search-interaction-safety.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; test.describe('WBS search interaction safety', () => { test.beforeEach(async ({ page }) => { diff --git a/tests/e2e/wbs-search-persistence.spec.js b/tests/e2e/wbs-search-persistence.spec.js index 2eff23c3..983c7b69 100644 --- a/tests/e2e/wbs-search-persistence.spec.js +++ b/tests/e2e/wbs-search-persistence.spec.js @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from './coverage-fixtures.js'; const STORAGE_KEY = 'scopeweave:planner-state:v1'; diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..f8228b5e 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -16,24 +16,34 @@ assert.equal( ); assert.match( scripts['test:coverage'], + /test:coverage:node.*run-browser-coverage.*merge-browser-coverage/, + 'test:coverage runs Node and browser coverage before merging reports', +); +assert.match( + scripts['test:coverage:node'], /\bc8\b.*--reporter=json(?![-\w]).*npm run test:coverage:cases/, - 'test:coverage creates Istanbul JSON before executing coverage cases', + 'the Node coverage producer creates Istanbul JSON before executing cases', ); assert.match( - scripts['test:coverage'], + scripts['test:coverage:node'], /--reporter=json-summary\b/, - 'test:coverage also creates the Istanbul JSON summary', + 'the Node coverage producer also creates the Istanbul JSON summary', ); assert.match( - scripts['test:coverage'], + scripts['test:coverage:node'], /--include=server\/attachment_status\.mjs/, 'the bounded refresh module is instrumented', ); assert.match( - scripts['test:coverage'], + scripts['test:coverage:node'], /--include=server\/clearfolio\.mjs/, 'the abortable Clearfolio adapter is instrumented', ); +assert.equal( + scripts['test:coverage:strict'], + 'npm run test:coverage && node scripts/ci/check-coverage.mjs', + 'the strict command checks the merged coverage summary', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, From 5ae5fef2b84ecae96f64f95b1485e5232d15c7ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 20:22:31 +0900 Subject: [PATCH 28/41] test(coverage): include all server modules --- package.json | 2 +- tests/unit/coverage-script-contract.test.mjs | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 752a78ac..ca3aafab 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "npm run test:coverage:node && node scripts/ci/run-browser-coverage.mjs && node scripts/ci/merge-browser-coverage.mjs", - "test:coverage:node": "c8 --all --include=app.js --include=cloud-sync.js --include=analytics.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reports-dir=coverage/node --temp-directory=coverage/node/tmp --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:node": "c8 --all --include=app.js --include=cloud-sync.js --include=analytics.js --include=scripts/ci/static_coverage_evidence.mjs --include='server/*.mjs' --reports-dir=coverage/node --temp-directory=coverage/node/tmp --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:strict": "npm run test:coverage && node scripts/ci/check-coverage.mjs", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index f8228b5e..82eee748 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -31,13 +31,8 @@ assert.match( ); assert.match( scripts['test:coverage:node'], - /--include=server\/attachment_status\.mjs/, - 'the bounded refresh module is instrumented', -); -assert.match( - scripts['test:coverage:node'], - /--include=server\/clearfolio\.mjs/, - 'the abortable Clearfolio adapter is instrumented', + /--include=['"]?server\/\*\.mjs/, + 'all server modules are instrumented', ); assert.equal( scripts['test:coverage:strict'], From 158300b8e70f5b0b26b990804a68bcbfb1fa7593 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 20:24:27 +0900 Subject: [PATCH 29/41] docs(quality): record merged coverage evidence --- CHANGELOG.md | 4 +-- docs/doctoring/coverage-evidence.md | 38 +++++++++++++------------- docs/product-technical-gap-baseline.md | 11 ++++---- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a657efb5..63d294ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added workflow ownership regression coverage so central review workflows stay inherited from `ContextualWisdomLab/.github`, not copied into this repository. -- Added an exact-head coverage audit documenting the current c8 scope and the - remaining work required before claiming 100% frontend/backend coverage. +- Added exact-head Node/Chromium coverage collection and report merging; the + remaining uncovered paths still block a 100% frontend/backend claim. ### Security diff --git a/docs/doctoring/coverage-evidence.md b/docs/doctoring/coverage-evidence.md index 42f6c76e..eecf34fe 100644 --- a/docs/doctoring/coverage-evidence.md +++ b/docs/doctoring/coverage-evidence.md @@ -2,37 +2,37 @@ ## Exact-head measurement -On 2026-08-29, the working head `88f31d42616eb86180ceddb64e9bcd2d156c81e4` -ran `npm run test:coverage` successfully. The generated c8 summary was: +On 2026-08-29, exact head `5ae5fef2b84ecae96f64f95b1485e5232d15c7ae` +ran `npm run test:coverage` successfully. The merged Node/Chromium summary +contained 267 source entries: | Metric | Covered | Total | Result | | --- | ---: | ---: | ---: | -| Lines/statements | 3,399 | 7,722 | 44.01% | -| Functions | 78 | 236 | 33.05% | -| Branches | 798 | 984 | 81.09% | +| Lines/statements | 7,184 | 8,675 | 82.81% | +| Functions | 264 | 290 | 91.03% | +| Branches | 2,032 | 2,199 | 92.40% | -The command's successful exit means the listed test cases completed; it does -not mean the 100% quality target was met. +The command's successful exit means the listed test cases completed and the +Node plus browser reports were merged; it does not mean the 100% quality +target was met. `node scripts/ci/check-coverage.mjs` fails with all four +thresholded metrics below 100%. ## Scope boundary -The current c8 command includes `app.js`, `cloud-sync.js`, the CI helper, and -the Node server modules. Its coverage process runs Node test cases only. -Playwright launches the browser in a separate process, so the 89 passing E2E -tests are user-flow evidence but are not included in this c8 summary. The -repository therefore has no current single report proving 100% frontend, -backend, and edge-case coverage. +The Node phase uses c8 with `--all` for `app.js`, `cloud-sync.js`, +`analytics.js`, the CI helper, and every `server/*.mjs` module. The browser +phase collects Chromium V8 JavaScript coverage during all 89 passing E2E +tests, converts the shipped client scripts, and merges both reports into one +Istanbul summary. Uncovered production lines remain in the report. ## Required remediation -1. Collect browser-side coverage for the shipped client scripts and merge it - with the Node report without excluding uncovered production files. -2. Add tests for every remaining server branch and client error/empty-state +1. Add tests for every remaining server branch and client error/empty-state path, including the current `app.js` and `cloud-sync.js` uncovered regions. -3. Make the exact-head coverage command fail below 100% lines, functions, and - branches after the merged report exists. +2. Keep the merged exact-head report and make the strict command pass 100% + lines, statements, functions, and branches. -Until those three conditions are true, G-06 remains **측정됨, 진행 중** and no +Until those conditions are true, G-06 remains **측정됨, 진행 중** and no release note may describe the repository as having 100% coverage. ## References diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 2899f1e9..146d0b33 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -83,7 +83,7 @@ classDiagram | G-03 | 첫 방문자가 seed 데이터와 실제 계획을 구분하기 어려움 | 첫 seed 방문에 샘플 WBS 안내를 표시하고, 안내 숨김과 확인 가능한 샘플 삭제 후 빈 계획 시작 경로를 제공 | **완료** | | G-04 | 키보드·스크린리더 회귀는 E2E 일부로 보호되지만 시각 회귀 자동 검사는 없음 | `Visual Accessibility Evidence`가 exact-head Chromium에서 핵심 상태 PNG와 WCAG 2.2 기준선 검사를 실행하고 artifact를 3일 보존 | **완료** | | G-05 | 보호 PR 큐는 소스와 무관한 Strix 공급자 429/Invalid URL 및 승인 부재로 차단될 수 있음 | 게이트를 약화하지 않고 원인 로그·artifact·현재 HEAD를 재검증한 뒤 재실행/중앙 수정 | 외부 상태 대기 | -| G-06 | 현재 coverage 명령은 Node 계층만 c8로 계측하고 브라우저 E2E를 합산하지 않아 저장소 전체 100%를 증명하지 못함 | 서버·클라이언트·edge-case를 같은 exact-head coverage 보고서로 합산하고 100% threshold를 활성화한 뒤에만 완료 처리 | **측정됨, 진행 중** | +| G-06 | 현재 합산 coverage가 저장소 전체 100%에 미달해 모든 클라이언트·서버 경로를 증명하지 못함 | Node c8과 Chromium V8 결과를 같은 exact-head 보고서로 합산하고 남은 경로를 보강한 뒤 100% threshold 통과 시에만 완료 처리 | **측정됨, 진행 중** | ## 5. 품질·보안 기준선 @@ -96,10 +96,11 @@ classDiagram reduced motion을 최소 기준으로 삼는다. - 검증 명령은 `npm run test:unit`, `npm run test:api`, `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. - `npm run test:coverage`의 2026-08-29 exact-head 측정치는 lines 44.01%, - functions 33.05%, branches 81.09%이며, 현재는 threshold를 통과시키는 - 명령이 아니다. 이 결과는 `docs/doctoring/coverage-evidence.md`에 - 기록하고 100% 품질 기준의 미충족 증거로 취급한다. + `npm run test:coverage`의 2026-08-29 exact-head 측정치는 Node·Chromium + 합산 기준 lines/statements 82.81%, functions 91.03%, branches 92.40%이며, + `node scripts/ci/check-coverage.mjs`는 아직 threshold 미달로 실패한다. + 이 결과는 `docs/doctoring/coverage-evidence.md`에 기록하고 100% 품질 + 기준의 미충족 증거로 취급한다. G-01/G-02/G-03의 직접 증거는 검색·JSON·온보딩 회귀 테스트이며, G-04는 `tests/e2e/visual-accessibility-evidence.spec.js`와 `Visual Accessibility Evidence` artifact가 exact-head 브라우저 상태를 증명한다. From c4660e9f3e8cf73c07900031fabaef9179fd2c14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 20:26:05 +0900 Subject: [PATCH 30/41] test(coverage): support portable report paths --- package.json | 2 +- scripts/ci/merge-browser-coverage.mjs | 3 ++- scripts/ci/run-browser-coverage.mjs | 3 ++- tests/unit/coverage-script-contract.test.mjs | 12 +++++++++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index ca3aafab..6c0a9747 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "npm run test:coverage:node && node scripts/ci/run-browser-coverage.mjs && node scripts/ci/merge-browser-coverage.mjs", - "test:coverage:node": "c8 --all --include=app.js --include=cloud-sync.js --include=analytics.js --include=scripts/ci/static_coverage_evidence.mjs --include='server/*.mjs' --reports-dir=coverage/node --temp-directory=coverage/node/tmp --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:node": "c8 --all --include=app.js --include=cloud-sync.js --include=analytics.js --include=scripts/ci/static_coverage_evidence.mjs --include=\"server/*.mjs\" --reports-dir=coverage/node --temp-directory=coverage/node/tmp --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:strict": "npm run test:coverage && node scripts/ci/check-coverage.mjs", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", diff --git a/scripts/ci/merge-browser-coverage.mjs b/scripts/ci/merge-browser-coverage.mjs index 92154d76..e5ecf146 100644 --- a/scripts/ci/merge-browser-coverage.mjs +++ b/scripts/ci/merge-browser-coverage.mjs @@ -1,11 +1,12 @@ import { readFile, readdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import v8ToIstanbul from 'v8-to-istanbul'; import coverageLib from 'istanbul-lib-coverage'; const { createCoverageMap } = coverageLib; -const root = path.resolve(new URL('../..', import.meta.url).pathname); +const root = fileURLToPath(new URL('../..', import.meta.url)); const nodeReportPath = path.join(root, 'coverage/node/coverage-final.json'); const browserDirectory = path.join(root, 'coverage/browser'); const outputDirectory = path.join(root, 'coverage'); diff --git a/scripts/ci/run-browser-coverage.mjs b/scripts/ci/run-browser-coverage.mjs index 8b53a0c0..8257a9b3 100644 --- a/scripts/ci/run-browser-coverage.mjs +++ b/scripts/ci/run-browser-coverage.mjs @@ -1,8 +1,9 @@ import { spawn } from 'node:child_process'; import { mkdir, rm } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; import path from 'node:path'; -const root = path.resolve(new URL('../..', import.meta.url).pathname); +const root = fileURLToPath(new URL('../..', import.meta.url)); const coverageDirectory = path.join(root, 'coverage/browser'); await rm(coverageDirectory, { recursive: true, force: true }); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 82eee748..20cde583 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -8,6 +8,14 @@ const packageJson = JSON.parse( readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), ); const scripts = packageJson.scripts; +const runBrowserCoverage = readFileSync( + new URL('../../scripts/ci/run-browser-coverage.mjs', import.meta.url), + 'utf8', +); +const mergeBrowserCoverage = readFileSync( + new URL('../../scripts/ci/merge-browser-coverage.mjs', import.meta.url), + 'utf8', +); assert.equal( scripts.coverage, @@ -31,9 +39,11 @@ assert.match( ); assert.match( scripts['test:coverage:node'], - /--include=['"]?server\/\*\.mjs/, + /--include=["']server\/\*\.mjs["']/, 'all server modules are instrumented', ); +assert.match(runBrowserCoverage, /fileURLToPath\(new URL\(/, 'browser coverage resolves file URLs safely'); +assert.match(mergeBrowserCoverage, /fileURLToPath\(new URL\(/, 'merged coverage resolves file URLs safely'); assert.equal( scripts['test:coverage:strict'], 'npm run test:coverage && node scripts/ci/check-coverage.mjs', From 87ed563b204ee8771b3b0dfc46eb4ee1cb625cdf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 20:29:05 +0900 Subject: [PATCH 31/41] fix(onboarding): preserve notice after failed save --- app.js | 2 +- tests/e2e/scopeweave.spec.js | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app.js b/app.js index 115b31b4..c3953fb0 100644 --- a/app.js +++ b/app.js @@ -1784,7 +1784,6 @@ function findTask(taskId) { } function persistState({ syncCloud = true } = {}) { - state.showSeedOnboarding = false; const payload = { projectName: state.projectName, baseDate: state.baseDate, @@ -1792,6 +1791,7 @@ function persistState({ syncCloud = true } = {}) { }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + state.showSeedOnboarding = false; } catch (error) { console.error('State persistence failed:', error); showToast('로컬 스토리지 용량이 초과되어 저장하지 못했습니다.'); diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index a2dc2457..81b647f6 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -107,6 +107,22 @@ test.describe('ScopeWeave Planner', () => { await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); }); + test('keeps seed onboarding visible when persistence fails', async ({ page }) => { + await page.evaluate(() => { + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = function setItem(key, value) { + if (key === 'scopeweave:planner-state:v1') { + throw new DOMException('quota exceeded', 'QuotaExceededError'); + } + return originalSetItem.call(this, key, value); + }; + }); + + await page.getByTestId('project-name-input').fill('Unsaved sample edit'); + await expect(page.locator('#toast')).toContainText('로컬 스토리지 용량이 초과되어 저장하지 못했습니다.'); + await expect(page.locator('#seed-onboarding')).toBeVisible(); + }); + test('clears the first-run sample into an empty plan', async ({ page }) => { page.once('dialog', dialog => dialog.dismiss()); await page.locator('#clear-seed-data').click(); From 2cdd1976063e309e5657537ec923800fd2bfa636 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 20:30:54 +0900 Subject: [PATCH 32/41] docs(quality): refresh coverage snapshot --- CHANGELOG.md | 1 + docs/doctoring/coverage-evidence.md | 10 +++++----- docs/product-technical-gap-baseline.md | 5 +++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d294ef..f9504ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 recovery action. - Added planning-field search coverage and safe state reset when Cloud or file imports replace the current plan. +- Kept first-visit sample guidance visible when local persistence fails. - Added browser JSON download for portable WBS backups, including extended planning fields, alongside CSV export. - Added first-visit sample WBS onboarding with persistent dismissal and a diff --git a/docs/doctoring/coverage-evidence.md b/docs/doctoring/coverage-evidence.md index eecf34fe..954b5b83 100644 --- a/docs/doctoring/coverage-evidence.md +++ b/docs/doctoring/coverage-evidence.md @@ -2,15 +2,15 @@ ## Exact-head measurement -On 2026-08-29, exact head `5ae5fef2b84ecae96f64f95b1485e5232d15c7ae` +On 2026-08-29, PR #632 working head `87ed563b204ee8771b3b0dfc46eb4ee1cb625cdf` ran `npm run test:coverage` successfully. The merged Node/Chromium summary -contained 267 source entries: +contained 270 source entries: | Metric | Covered | Total | Result | | --- | ---: | ---: | ---: | -| Lines/statements | 7,184 | 8,675 | 82.81% | +| Lines/statements | 7,183 | 8,675 | 82.80% | | Functions | 264 | 290 | 91.03% | -| Branches | 2,032 | 2,199 | 92.40% | +| Branches | 2,031 | 2,198 | 92.40% | The command's successful exit means the listed test cases completed and the Node plus browser reports were merged; it does not mean the 100% quality @@ -21,7 +21,7 @@ thresholded metrics below 100%. The Node phase uses c8 with `--all` for `app.js`, `cloud-sync.js`, `analytics.js`, the CI helper, and every `server/*.mjs` module. The browser -phase collects Chromium V8 JavaScript coverage during all 89 passing E2E +phase collects Chromium V8 JavaScript coverage during all 90 passing E2E tests, converts the shipped client scripts, and merges both reports into one Istanbul summary. Uncovered production lines remain in the report. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 146d0b33..560988e1 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -96,8 +96,9 @@ classDiagram reduced motion을 최소 기준으로 삼는다. - 검증 명령은 `npm run test:unit`, `npm run test:api`, `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. - `npm run test:coverage`의 2026-08-29 exact-head 측정치는 Node·Chromium - 합산 기준 lines/statements 82.81%, functions 91.03%, branches 92.40%이며, + `npm run test:coverage`의 2026-08-29 PR #632 working head + `87ed563b204ee8771b3b0dfc46eb4ee1cb625cdf` 측정치는 Node·Chromium 합산 + 기준 lines/statements 82.80%, functions 91.03%, branches 92.40%이며, `node scripts/ci/check-coverage.mjs`는 아직 threshold 미달로 실패한다. 이 결과는 `docs/doctoring/coverage-evidence.md`에 기록하고 100% 품질 기준의 미충족 증거로 취급한다. From 2c0466c76c7f810c5c2cf14bfa073f3cb7541a6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 20:48:00 +0900 Subject: [PATCH 33/41] test(cloud): cover sprint and baseline metrics --- package.json | 4 +- tests/unit/cloud-sync-metrics.test.mjs | 75 ++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 tests/unit/cloud-sync-metrics.test.mjs diff --git a/package.json b/package.json index 6c0a9747..e5b09d23 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,11 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/cloud-sync-metrics.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "npm run test:coverage:node && node scripts/ci/run-browser-coverage.mjs && node scripts/ci/merge-browser-coverage.mjs", "test:coverage:node": "c8 --all --include=app.js --include=cloud-sync.js --include=analytics.js --include=scripts/ci/static_coverage_evidence.mjs --include=\"server/*.mjs\" --reports-dir=coverage/node --temp-directory=coverage/node/tmp --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:strict": "npm run test:coverage && node scripts/ci/check-coverage.mjs", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/cloud-sync-metrics.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", diff --git a/tests/unit/cloud-sync-metrics.test.mjs b/tests/unit/cloud-sync-metrics.test.mjs new file mode 100644 index 00000000..54319d14 --- /dev/null +++ b/tests/unit/cloud-sync-metrics.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; + +import { compareBaseline, computeBurndown, computeSprintStats } from '../../cloud-sync.js'; + +const tasks = [ + { id: 'done', sprint: 'S1', storyPoints: 3, actualProgress: 100 }, + { id: 'doing', sprint: 'S1', storyPoints: 2, actualProgress: 50 }, + { id: 'closed', sprint: 'S2', storyPoints: 5, actualProgress: 100 }, + { id: 'backlog', storyPoints: 1, actualProgress: 0 }, + { id: 'unknown', sprint: 'S9', storyPoints: 1, actualProgress: 0 }, + { id: 'synthetic', sprint: 'S1', storyPoints: 99, isSynthetic: true }, +]; + +const sprintStats = computeSprintStats(tasks, [ + { id: 's1', name: 'S1', startDate: '2026-01-01', endDate: '2026-01-05', goal: 'ship' }, + { id: 's2', name: 'S2', startDate: '2026-01-06', endDate: '2026-01-20', goal: 'polish' }, +], '2026-01-10'); +assert.deepEqual(sprintStats.rows.map(({ name, committed, completed, remaining, closed }) => ({ + name, committed, completed, remaining, closed, +})), [ + { name: 'S1', committed: 5, completed: 3, remaining: 2, closed: true }, + { name: 'S2', committed: 5, completed: 5, remaining: 0, closed: false }, +]); +assert.equal(sprintStats.velocity, 3); +assert.equal(sprintStats.backlogCount, 2); +assert.deepEqual(computeSprintStats(), { rows: [], velocity: null, backlogCount: 0 }); + +assert.equal(computeBurndown(tasks, null, '2026-01-02'), null); +assert.equal(computeBurndown(tasks, { name: 'S1', startDate: '2026-01-03', endDate: '2026-01-02' }, '2026-01-02'), null); +assert.equal(computeBurndown([{ sprint: 'S1', storyPoints: 0 }], { name: 'S1', startDate: '2026-01-01', endDate: '2026-01-02' }, '2026-01-02'), null); + +const burndown = computeBurndown([ + { sprint: 'S1', storyPoints: 2, actualEndDate: '2026-01-01' }, + { sprint: 'S1', storyPoints: 1, actualProgress: 100 }, + { sprint: 'S1', storyPoints: 2, actualProgress: 20 }, +], { name: 'S1', startDate: '2026-01-01', endDate: '2026-01-03' }, '2026-01-02'); +assert.deepEqual(burndown.days, ['2026-01-01', '2026-01-02', '2026-01-03']); +assert.deepEqual(burndown.ideal, [5, 2.5, 0]); +assert.deepEqual(burndown.actual, [3, 2, null]); + +const oneDay = computeBurndown( + [{ sprint: 'S1', storyPoints: 1, actualProgress: 100 }], + { name: 'S1', startDate: '2026-01-01', endDate: '2026-01-01' }, + '2026-01-01', +); +assert.deepEqual(oneDay.ideal, [0]); +assert.deepEqual(oneDay.actual, [0]); + +const capped = computeBurndown( + [{ sprint: 'S1', storyPoints: 1 }], + { name: 'S1', startDate: '2026-01-01', endDate: '2026-05-01' }, + '2026-01-01', +); +assert.equal(capped.days.length, 121); + +const comparison = compareBaseline([ + { id: 'same', name: '같음', plannedStartDate: '2026-01-01', plannedEndDate: '2026-01-10' }, + { id: 'moved', name: '이동', plannedStartDate: '2026-01-01', plannedEndDate: '2026-01-10' }, + { id: 'removed', name: '삭제' }, + { id: 'invalid', name: '잘못됨', plannedStartDate: 'invalid' }, +], [ + { id: 'same', name: '같음', plannedStartDate: '2026-01-01', plannedEndDate: '2026-01-10' }, + { id: 'moved', name: '이동', plannedStartDate: '2026-01-02', plannedEndDate: '2026-01-12' }, + { id: 'added', name: '추가' }, + { id: 'invalid', name: '잘못됨', plannedStartDate: '2026-01-02' }, +]); +assert.deepEqual(comparison.rows, [ + { id: 'moved', name: '이동', kind: 'moved', baseEnd: '2026-01-10', curEnd: '2026-01-12', endSlip: 2 }, + { id: 'added', name: '추가', kind: 'added', endSlip: null }, + { id: 'removed', name: '삭제', kind: 'removed', endSlip: null }, +]); +assert.deepEqual(comparison.summary, { changed: 3, slipped: 1, maxSlip: 2 }); +assert.deepEqual(compareBaseline(), { rows: [], summary: { changed: 0, slipped: 0, maxSlip: 0 } }); + +console.log('✓ cloud-sync metric tests passed'); From 33ea978fd422a7928118ff33ca4a6e41c4b943bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 21:18:02 +0900 Subject: [PATCH 34/41] test(cloud): cover management workflows --- cloud-sync.js | 4 +- tests/e2e/cloud.spec.js | 202 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 200 insertions(+), 6 deletions(-) diff --git a/cloud-sync.js b/cloud-sync.js index 0e015ebe..be0924e6 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -203,7 +203,7 @@ function ensureAuthUI() { }; $('#cloud-toggle').addEventListener('click', () => setMode(mode === 'login' ? 'signup' : 'login')); $('#cloud-sso').addEventListener('click', () => { window.location.href = '/api/auth/oidc/start'; }); - modal.addEventListener('click', (e) => { if (e.target.dataset.cloudClose) modal.classList.add('hidden'); }); + modal.addEventListener('click', (e) => { if (e.target.closest?.('[data-cloud-close]')) modal.classList.add('hidden'); }); $('#cloud-form').addEventListener('submit', async (e) => { e.preventDefault(); const email = $('#cloud-email').value.trim(); @@ -1748,7 +1748,7 @@ async function openTeamModal() {

`; document.body.appendChild(modal); - modal.addEventListener('click', (e) => { if (e.target.dataset.teamClose) modal.classList.add('hidden'); }); + modal.addEventListener('click', (e) => { if (e.target.closest?.('[data-team-close]')) modal.classList.add('hidden'); }); modal.querySelector('#team-invite').addEventListener('submit', async (e) => { e.preventDefault(); const email = modal.querySelector('#team-email').value.trim(); diff --git a/tests/e2e/cloud.spec.js b/tests/e2e/cloud.spec.js index b7b10d28..77dbf06e 100644 --- a/tests/e2e/cloud.spec.js +++ b/tests/e2e/cloud.spec.js @@ -50,12 +50,12 @@ test.beforeAll(async () => { test.afterAll(() => { server?.kill(); }); -async function loginAndOpen(page) { +async function loginAndOpen(page, projectId = '1') { await page.goto(`${BASE}/`); - await page.evaluate(([t]) => { + await page.evaluate(([t, id]) => { localStorage.setItem('scopeweave:token', t); - localStorage.setItem('scopeweave:project', '1'); - }, [token]); + localStorage.setItem('scopeweave:project', id); + }, [token, projectId]); await page.reload(); await page.waitForSelector('#cloud-auth select'); } @@ -108,10 +108,14 @@ test('baseline: save then compare reports no diff', async ({ page }) => { test('comments: post appears in the list with author', async ({ page }) => { await loginAndOpen(page); await page.click('#cloud-auth button:has-text("코멘트")'); + await page.locator('#comments-panel select').selectOption('t1'); await page.fill('#comments-panel input[type="text"]', '일정 확인 부탁드립니다'); await page.click('#comments-panel button:has-text("등록")'); await expect(page.locator('#comments-panel .team-list')).toContainText('e2e@cloud.com'); + await expect(page.locator('#comments-panel .team-list')).toContainText('[설계]'); await expect(page.locator('#comments-panel .team-list')).toContainText('일정 확인 부탁드립니다'); + await page.click('#comments-panel .team-list button:has-text("삭제")'); + await expect(page.locator('#comments-panel .team-list')).toContainText('코멘트가 없습니다.'); }); test('portfolio dashboard: rollup renders SPI/status per project', async ({ page }) => { @@ -182,3 +186,193 @@ test('archive: project moves under the 보관됨 optgroup and restores', async ( await page.click('#cloud-auth button:has-text("보관 해제")'); await page.waitForFunction(() => !document.querySelector('#cloud-auth select optgroup[label="보관됨"]')); }); + +test('cloud management modals cover share, sprint, attachment, and search flows', async ({ page }) => { + const project = await api('/api/projects', { method: 'POST', body: { name: '모달 프로젝트' }, tok: token }); + await api(`/api/projects/${project.id}`, { + method: 'PUT', + tok: token, + body: { + tasks: [{ + id: 'modal-task', + name: '모달 작업', + sprint: '모달 스프린트', + storyPoints: 5, + plannedStartDate: '2026-01-01', + plannedEndDate: '2026-01-07', + actualProgress: 100, + }], + version: project.version, + }, + }); + await api(`/api/projects/${project.id}/sprints`, { + method: 'POST', + tok: token, + body: { name: '모달 스프린트', startDate: '2026-01-01', endDate: '2026-01-07' }, + }); + + await loginAndOpen(page, String(project.id)); + page.on('dialog', (dialog) => dialog.accept('')); + + await page.click('#cloud-auth button:has-text("공유")'); + await expect(page.locator('#share-panel')).toContainText('활성 공유 링크가 없습니다.'); + await page.click('#share-panel button:has-text("공유 링크 만들기")'); + await expect(page.locator('#share-panel')).toContainText('?share='); + await page.click('#share-panel button:has-text("복사")'); + await page.click('#share-panel button:has-text("철회")'); + await expect(page.locator('#share-panel')).toContainText('활성 공유 링크가 없습니다.'); + await page.click('#share-panel button[aria-label="공유 닫기"]'); + + await page.click('#cloud-auth button:has-text("스프린트")'); + await expect(page.locator('#sprint-panel')).toContainText('모달 스프린트'); + await page.selectOption('#methodology-select', 'hybrid'); + await page.click('#sprint-panel button:has-text("번다운")'); + await expect(page.locator('#burndown-holder svg')).toBeVisible(); + await page.locator('#sprint-panel input[placeholder*="스프린트 이름"]').fill('추가 스프린트'); + await page.locator('#sprint-panel input[type="date"]').nth(0).fill('2026-02-01'); + await page.locator('#sprint-panel input[type="date"]').nth(1).fill('2026-02-07'); + await page.click('#sprint-panel button:has-text("추가")'); + await expect(page.locator('#sprint-panel')).toContainText('추가 스프린트'); + const addedSprint = page.locator('#sprint-panel li').filter({ hasText: '추가 스프린트' }); + await addedSprint.getByRole('button', { name: '삭제' }).click(); + await expect(addedSprint).toHaveCount(0); + await page.click('#sprint-panel button[aria-label="스프린트 닫기"]'); + + await page.click('#cloud-auth button:has-text("산출물")'); + await expect(page.locator('#attachments-panel')).toContainText('첨부된 산출물이 없습니다.'); + await page.locator('#attachments-panel select').selectOption('modal-task'); + await page.setInputFiles('#attachment-file-input', { name: 'brief.txt', mimeType: 'text/plain', buffer: Buffer.from('e2e artifact') }); + await page.click('#attachments-panel button:has-text("업로드")'); + await expect(page.locator('#attachments-panel .team-list')).toContainText('brief.txt'); + const popupPromise = page.waitForEvent('popup'); + await page.click('#attachments-panel button:has-text("보기")'); + const popup = await popupPromise; + await popup.waitForLoadState('domcontentloaded'); + expect(new URL(popup.url()).pathname).toContain('/api/mock-clearfolio/'); + await popup.close(); + await page.click('#attachments-panel button:has-text("삭제")'); + await expect(page.locator('#attachments-panel')).toContainText('첨부된 산출물이 없습니다.'); + await page.click('#attachments-panel button[aria-label="산출물 닫기"]'); + + await page.click('#cloud-auth button:has-text("검색")'); + await page.locator('#search-panel input[type="search"]').fill('모달 작업'); + await page.click('#search-panel button:has-text("검색")'); + await expect(page.locator('#search-panel')).toContainText('모달 프로젝트'); + await page.click('#search-panel button:has-text("열기")'); + await expect(page.locator('#cloud-auth select')).toHaveValue(String(project.id)); +}); + +test('team management covers owner, member, billing, token, webhook, and audit flows', async ({ page }) => { + await loginAndOpen(page); + await page.click('#cloud-auth button:has-text("팀")'); + await expect(page.locator('#team-body')).toContainText('e2e@cloud.com'); + + page.once('dialog', (dialog) => dialog.accept('E2E 워크스페이스')); + await page.click('#team-body button:has-text("워크스페이스 이름 변경")'); + await expect(page.locator('#team-body')).toContainText('Free'); + await page.click('#team-body button:has-text("Pro 업그레이드")'); + await expect(page.locator('#toast')).toContainText('결제 연동'); + + await page.fill('#team-email', 'cancelled@cloud.com'); + await page.locator('#team-email').press('Enter'); + await expect(page.locator('#team-body')).toContainText('cancelled@cloud.com'); + await page.locator('#team-body li').filter({ hasText: 'cancelled@cloud.com' }).getByRole('button', { name: '초대 취소' }).dispatchEvent('click'); + await expect(page.locator('#team-body')).not.toContainText('cancelled@cloud.com'); + + await page.fill('#team-email', 'member@cloud.com'); + await page.locator('#team-email').press('Enter'); + const pending = await api('/api/orgs/1/members', { tok: token }); + const invite = pending.invites.find((row) => row.email === 'member@cloud.com'); + expect(invite?.token).toBeTruthy(); + const member = await api('/api/auth/signup', { method: 'POST', body: { email: 'member@cloud.com', password: 'password123' } }); + await api(`/api/invites/${invite.token}/accept`, { method: 'POST', tok: member.token }); + + await page.click('#team-modal button[aria-label="닫기"]'); + await expect(page.locator('#team-modal')).toBeHidden(); + await page.click('#cloud-auth button:has-text("팀")'); + const memberRow = page.locator('#team-body .team-list li').filter({ hasText: 'member@cloud.com' }); + await expect(memberRow).toBeVisible(); + await memberRow.getByRole('combobox').selectOption('viewer'); + page.once('dialog', (dialog) => dialog.dismiss()); + await memberRow.getByRole('button', { name: '소유권 이전' }).dispatchEvent('click'); + await memberRow.getByRole('button', { name: '제거' }).dispatchEvent('click'); + await expect(memberRow).toHaveCount(0); + + await page.fill('#team-body input[placeholder="https://example.com/webhook"]', 'https://example.com/hook'); + await page.locator('#team-body input[placeholder="https://example.com/webhook"]').press('Enter'); + await expect(page.locator('#team-body')).toContainText('서명 시크릿(한 번만 표시): whsec_'); + await page.click('#team-modal button[aria-label="닫기"]'); + await expect(page.locator('#team-modal')).toBeHidden(); + await page.click('#cloud-auth button:has-text("팀")'); + const webhookRow = page.locator('#team-body .team-list li').filter({ hasText: 'https://example.com/hook' }); + let rotateDialogs = 0; + const handleRotateDialog = (dialog) => { + rotateDialogs += 1; + dialog.accept(''); + if (rotateDialogs === 2) page.off('dialog', handleRotateDialog); + }; + page.on('dialog', handleRotateDialog); + await webhookRow.getByRole('button', { name: '키 교체' }).dispatchEvent('click'); + await webhookRow.getByRole('button', { name: '삭제' }).dispatchEvent('click'); + await expect(page.locator('#team-body')).not.toContainText('https://example.com/hook'); + + await page.fill('#team-body input[placeholder*="토큰 이름"]', 'CI'); + await page.locator('#team-body input[placeholder*="토큰 이름"]').press('Enter'); + await expect(page.locator('#team-body')).toContainText('한 번만 표시됩니다'); + await page.click('#team-modal button[aria-label="닫기"]'); + await expect(page.locator('#team-modal')).toBeHidden(); + await page.click('#cloud-auth button:has-text("팀")'); + const tokenRow = page.locator('#team-body .team-list li').filter({ hasText: 'CI' }); + await tokenRow.getByRole('button', { name: '폐기' }).dispatchEvent('click'); + await expect(page.locator('#team-body')).not.toContainText('CI'); + + const auditDownload = page.waitForEvent('download'); + await page.locator('#team-body button:has-text("CSV 다운로드")').dispatchEvent('click'); + expect((await auditDownload).suggestedFilename()).toContain('scopeweave-audit-'); + const exportDownload = page.waitForEvent('download'); + await page.locator('#team-body button:has-text("데이터 내보내기")').dispatchEvent('click'); + expect((await exportDownload).suggestedFilename()).toContain('scopeweave-org-'); + + await page.fill('#team-body input[placeholder="현재 비밀번호"]', 'password123'); + await page.fill('#team-body input[placeholder*="새 비밀번호"]', 'password456'); + await page.locator('#team-body input[placeholder*="새 비밀번호"]').press('Enter'); + await expect(page.locator('#toast')).toContainText('비밀번호를 변경했습니다'); + page.once('dialog', (dialog) => dialog.accept()); + await page.locator('#team-body button:has-text("다른 모든 기기에서 로그아웃")').dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('다른 모든 기기에서 로그아웃했습니다'); + await page.click('#team-modal button[aria-label="닫기"]'); + await expect(page.locator('#team-modal')).toBeHidden(); + await page.click('#cloud-auth button:has-text("로그아웃")'); + await expect(page.getByRole('button', { name: /클라우드 로그인/ })).toBeVisible(); +}); + +test('cloud project creation prompt creates a project for a new account', async ({ page }) => { + const creator = await api('/api/auth/signup', { + method: 'POST', + body: { email: 'creator@cloud.com', password: 'password123' }, + }); + await page.goto(`${BASE}/`); + await page.evaluate((t) => { + localStorage.setItem('scopeweave:token', t); + localStorage.removeItem('scopeweave:project'); + }, creator.token); + await page.reload(); + page.once('dialog', (dialog) => dialog.accept('UI 생성 프로젝트')); + await page.click('#cloud-auth button:has-text("+ 새 프로젝트")'); + await expect(page.locator('#cloud-auth select')).toContainText('UI 생성 프로젝트'); +}); + +test('cloud onboarding creates the first project from the sample', async ({ page }) => { + const sampleUser = await api('/api/auth/signup', { + method: 'POST', + body: { email: 'sample@cloud.com', password: 'password123' }, + }); + await page.goto(`${BASE}/`); + await page.evaluate((t) => { + localStorage.setItem('scopeweave:token', t); + localStorage.removeItem('scopeweave:project'); + }, sampleUser.token); + await page.reload(); + await page.getByRole('button', { name: '✨ 샘플로 시작' }).click(); + await expect(page.locator('#cloud-auth select')).toContainText('샘플 프로젝트'); +}); From 059226dad15ff994317770b4d5029cf73bdb76f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 21:19:17 +0900 Subject: [PATCH 35/41] docs(quality): refresh cloud coverage evidence --- CHANGELOG.md | 4 ++++ docs/doctoring/coverage-evidence.md | 15 ++++++++------- docs/product-technical-gap-baseline.md | 7 ++++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9504ab8..82280c3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 into this repository. - Added exact-head Node/Chromium coverage collection and report merging; the remaining uncovered paths still block a 100% frontend/backend claim. +- Added browser coverage for cloud sharing, sprint burndown, attachments, + search, team administration, project creation, and sample onboarding flows. ### Security @@ -43,6 +45,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replaced dynamic and lazy-regex MS Project XML block extraction with bounded linear scans to prevent pathological backtracking on malformed imports. - Rejected non-string password candidates at the authentication boundary. +- Fixed cloud login and team modal close controls when their nested icon is + clicked. - Added regression coverage that prevents array-valued passwords from being coerced into valid credentials. - Updated Hono runtime dependencies to patched supported releases. diff --git a/docs/doctoring/coverage-evidence.md b/docs/doctoring/coverage-evidence.md index 954b5b83..3213a0e1 100644 --- a/docs/doctoring/coverage-evidence.md +++ b/docs/doctoring/coverage-evidence.md @@ -2,15 +2,16 @@ ## Exact-head measurement -On 2026-08-29, PR #632 working head `87ed563b204ee8771b3b0dfc46eb4ee1cb625cdf` -ran `npm run test:coverage` successfully. The merged Node/Chromium summary -contained 270 source entries: +On 2026-08-29, PR #632 working head `33ea978fd422a7928118ff33ca4a6e41c4b943bb` +ran `BASE_URL=http://127.0.0.1:4174 npm run test:coverage` successfully with +a dedicated ScopeWeave static server. The merged Node/Chromium summary +contained 282 source entries: | Metric | Covered | Total | Result | | --- | ---: | ---: | ---: | -| Lines/statements | 7,183 | 8,675 | 82.80% | -| Functions | 264 | 290 | 91.03% | -| Branches | 2,031 | 2,198 | 92.40% | +| Lines/statements | 8,164 | 8,675 | 94.10% | +| Functions | 291 | 298 | 97.65% | +| Branches | 2,292 | 2,459 | 93.20% | The command's successful exit means the listed test cases completed and the Node plus browser reports were merged; it does not mean the 100% quality @@ -21,7 +22,7 @@ thresholded metrics below 100%. The Node phase uses c8 with `--all` for `app.js`, `cloud-sync.js`, `analytics.js`, the CI helper, and every `server/*.mjs` module. The browser -phase collects Chromium V8 JavaScript coverage during all 90 passing E2E +phase collects Chromium V8 JavaScript coverage during all 94 passing E2E tests, converts the shipped client scripts, and merges both reports into one Istanbul summary. Uncovered production lines remain in the report. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 560988e1..52bea437 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -96,9 +96,10 @@ classDiagram reduced motion을 최소 기준으로 삼는다. - 검증 명령은 `npm run test:unit`, `npm run test:api`, `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. - `npm run test:coverage`의 2026-08-29 PR #632 working head - `87ed563b204ee8771b3b0dfc46eb4ee1cb625cdf` 측정치는 Node·Chromium 합산 - 기준 lines/statements 82.80%, functions 91.03%, branches 92.40%이며, + `BASE_URL=http://127.0.0.1:4174 npm run test:coverage`의 2026-08-29 PR #632 + working head `33ea978fd422a7928118ff33ca4a6e41c4b943bb` 측정치는 전용 + ScopeWeave 정적 서버에서 Node·Chromium 결과를 합산한 lines/statements + 94.10%, functions 97.65%, branches 93.20%이며, `node scripts/ci/check-coverage.mjs`는 아직 threshold 미달로 실패한다. 이 결과는 `docs/doctoring/coverage-evidence.md`에 기록하고 100% 품질 기준의 미충족 증거로 취급한다. From b8c188badafcd559c46737550ddb598fead17c52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 21:29:00 +0900 Subject: [PATCH 36/41] fix(planner): preserve cloud task data --- app.js | 17 +++++++++++++++-- tests/e2e/cloud.spec.js | 16 ++++++++++++++++ tests/e2e/scopeweave.spec.js | 5 +++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index c3953fb0..8b8dcf07 100644 --- a/app.js +++ b/app.js @@ -1611,7 +1611,7 @@ function getDateRangeWarning(startDate, endDate, message) { const cachedHiddenParentIds = new Set(); const cachedSearchExpandedParentIds = new Set(); const TASK_SEARCH_FIELDS = [ - 'phase', 'activity', 'task', 'categoryLarge', 'categoryMedium', 'documentName', + 'name', 'phase', 'activity', 'task', 'categoryLarge', 'categoryMedium', 'documentName', 'owner', 'supportTeam', 'actualProgressStatus', 'plannedStartDate', 'plannedEndDate', 'actualStartDate', 'actualEndDate', 'predecessors', 'budget', 'actualCost', 'sprint', 'storyPoints' @@ -1795,6 +1795,7 @@ function persistState({ syncCloud = true } = {}) { } catch (error) { console.error('State persistence failed:', error); showToast('로컬 스토리지 용량이 초과되어 저장하지 못했습니다.'); + return false; } if (state.jsonSyncHandle) { @@ -1806,6 +1807,7 @@ function persistState({ syncCloud = true } = {}) { if (syncCloud && typeof window !== 'undefined') { window.ScopeWeaveCloud?.push?.(payload); } + return true; } function isSeedOnboardingDismissed() { @@ -1830,10 +1832,18 @@ function clearSeedData() { if (!state.showSeedOnboarding || !window.confirm('샘플 데이터를 지우고 빈 계획으로 시작하시겠습니까?')) { return; } + const previousTasks = state.tasks; + const previousOnboarding = state.showSeedOnboarding; state.tasks = []; state.showSeedOnboarding = false; invalidateTaskIndexCache(); - persistState(); + if (!persistState()) { + state.tasks = previousTasks; + state.showSeedOnboarding = previousOnboarding; + invalidateTaskIndexCache(); + renderAll(); + return; + } renderAll(); showToast('샘플 데이터를 삭제했습니다. 첫 단계를 추가해 계획을 시작하세요.'); requestAnimationFrame(() => elements.addRootButton.focus()); @@ -2396,6 +2406,9 @@ function exportJsonArray({ includeExtendedFields = false } = {}) { }; if (includeExtendedFields) { Object.assign(record, { + name: task.name, + plannedProgress: task.plannedProgress, + actualProgress: task.actualProgress, predecessors: task.predecessors ?? '', budget: task.budget ?? '', actualCost: task.actualCost ?? '', diff --git a/tests/e2e/cloud.spec.js b/tests/e2e/cloud.spec.js index 77dbf06e..942de019 100644 --- a/tests/e2e/cloud.spec.js +++ b/tests/e2e/cloud.spec.js @@ -3,6 +3,7 @@ // Run: npx playwright test tests/e2e/cloud.spec.js import { test, expect } from './coverage-fixtures.js'; import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; const PORT = 8830; const BASE = `http://127.0.0.1:${PORT}`; @@ -95,6 +96,21 @@ test('workload table aggregates per owner with behind highlight', async ({ page expect(rows.some((r) => r.includes('이담당'))).toBeTruthy(); }); +test('cloud task names are searchable and extended JSON backups retain progress', async ({ page }) => { + await loginAndOpen(page); + const search = page.getByRole('searchbox', { name: 'WBS 작업 검색' }); + await search.fill('설계'); + await expect(page.locator('tbody tr[data-task-id="t1"]')).toHaveCount(1); + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'JSON 내보내기' }).click(), + ]); + const backup = JSON.parse(await readFile(await download.path(), 'utf8')); + const task = backup.find((row) => row.name === '설계'); + expect(task?.actualProgress).toBe(40); +}); + test('baseline: save then compare reports no diff', async ({ page }) => { await loginAndOpen(page); page.on('dialog', (d) => d.accept('착수 기준선')); diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index 81b647f6..4f6db442 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -121,6 +121,11 @@ test.describe('ScopeWeave Planner', () => { await page.getByTestId('project-name-input').fill('Unsaved sample edit'); await expect(page.locator('#toast')).toContainText('로컬 스토리지 용량이 초과되어 저장하지 못했습니다.'); await expect(page.locator('#seed-onboarding')).toBeVisible(); + + page.once('dialog', dialog => dialog.accept()); + await page.locator('#clear-seed-data').click(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + await expect(page.locator('#seed-onboarding')).toBeVisible(); }); test('clears the first-run sample into an empty plan', async ({ page }) => { From 2e57f02267621721f75b410a612392ea743d51c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 21:31:58 +0900 Subject: [PATCH 37/41] docs(quality): refresh coverage snapshot --- docs/doctoring/coverage-evidence.md | 10 +++++----- docs/product-technical-gap-baseline.md | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/doctoring/coverage-evidence.md b/docs/doctoring/coverage-evidence.md index 3213a0e1..96bbec81 100644 --- a/docs/doctoring/coverage-evidence.md +++ b/docs/doctoring/coverage-evidence.md @@ -2,16 +2,16 @@ ## Exact-head measurement -On 2026-08-29, PR #632 working head `33ea978fd422a7928118ff33ca4a6e41c4b943bb` +On 2026-08-29, PR #632 working head `b8c188badafcd559c46737550ddb598fead17c52` ran `BASE_URL=http://127.0.0.1:4174 npm run test:coverage` successfully with a dedicated ScopeWeave static server. The merged Node/Chromium summary -contained 282 source entries: +contained 285 source entries: | Metric | Covered | Total | Result | | --- | ---: | ---: | ---: | -| Lines/statements | 8,164 | 8,675 | 94.10% | +| Lines/statements | 8,177 | 8,688 | 94.11% | | Functions | 291 | 298 | 97.65% | -| Branches | 2,292 | 2,459 | 93.20% | +| Branches | 2,297 | 2,464 | 93.22% | The command's successful exit means the listed test cases completed and the Node plus browser reports were merged; it does not mean the 100% quality @@ -22,7 +22,7 @@ thresholded metrics below 100%. The Node phase uses c8 with `--all` for `app.js`, `cloud-sync.js`, `analytics.js`, the CI helper, and every `server/*.mjs` module. The browser -phase collects Chromium V8 JavaScript coverage during all 94 passing E2E +phase collects Chromium V8 JavaScript coverage during all 95 passing E2E tests, converts the shipped client scripts, and merges both reports into one Istanbul summary. Uncovered production lines remain in the report. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 52bea437..632183d3 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -97,9 +97,9 @@ classDiagram - 검증 명령은 `npm run test:unit`, `npm run test:api`, `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. `BASE_URL=http://127.0.0.1:4174 npm run test:coverage`의 2026-08-29 PR #632 - working head `33ea978fd422a7928118ff33ca4a6e41c4b943bb` 측정치는 전용 + source/test working head `b8c188badafcd559c46737550ddb598fead17c52` 측정치는 전용 ScopeWeave 정적 서버에서 Node·Chromium 결과를 합산한 lines/statements - 94.10%, functions 97.65%, branches 93.20%이며, + 94.11%, functions 97.65%, branches 93.22%이며, `node scripts/ci/check-coverage.mjs`는 아직 threshold 미달로 실패한다. 이 결과는 `docs/doctoring/coverage-evidence.md`에 기록하고 100% 품질 기준의 미충족 증거로 취급한다. From 711232340075f0d53f5c755624d1ef6e998c61c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 21:54:20 +0900 Subject: [PATCH 38/41] test(coverage): include complete regression suites --- CHANGELOG.md | 2 ++ package.json | 2 +- tests/unit/coverage-script-contract.test.mjs | 7 ++++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82280c3e..22ebe0f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 into this repository. - Added exact-head Node/Chromium coverage collection and report merging; the remaining uncovered paths still block a 100% frontend/backend claim. +- Made the canonical coverage run execute the complete unit and API suites so + existing pure-logic and server regression evidence is included in the merged report. - Added browser coverage for cloud sharing, sprint burndown, attachments, search, team administration, project creation, and sample onboarding flows. diff --git a/package.json b/package.json index e5b09d23..1474e7b4 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test:coverage": "npm run test:coverage:node && node scripts/ci/run-browser-coverage.mjs && node scripts/ci/merge-browser-coverage.mjs", "test:coverage:node": "c8 --all --include=app.js --include=cloud-sync.js --include=analytics.js --include=scripts/ci/static_coverage_evidence.mjs --include=\"server/*.mjs\" --reports-dir=coverage/node --temp-directory=coverage/node/tmp --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:strict": "npm run test:coverage && node scripts/ci/check-coverage.mjs", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/cloud-sync-metrics.test.mjs && npm run test:api", + "test:coverage:cases": "npm run test:unit && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 20cde583..3d6e116a 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -51,8 +51,13 @@ assert.equal( ); assert.match( scripts['test:coverage:cases'], + /npm run test:unit.*npm run test:api/, + 'the complete unit and API suites execute under c8', +); +assert.match( + scripts['test:unit'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, - 'the Clearfolio signal and HTTP failure regression executes under c8', + 'the Clearfolio signal and HTTP failure regression remains in the unit suite', ); assert.doesNotMatch( scripts['test:coverage:cases'], From 8cb3eb45104e68f2871d842dc7b774a8d5cb7972 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 21:54:43 +0900 Subject: [PATCH 39/41] docs(quality): record complete-suite coverage --- docs/doctoring/coverage-evidence.md | 9 +++++---- docs/product-technical-gap-baseline.md | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/doctoring/coverage-evidence.md b/docs/doctoring/coverage-evidence.md index 96bbec81..e559e250 100644 --- a/docs/doctoring/coverage-evidence.md +++ b/docs/doctoring/coverage-evidence.md @@ -2,16 +2,16 @@ ## Exact-head measurement -On 2026-08-29, PR #632 working head `b8c188badafcd559c46737550ddb598fead17c52` +On 2026-08-29, PR #632 working head `711232340075f0d53f5c755624d1ef6e998c61c1` ran `BASE_URL=http://127.0.0.1:4174 npm run test:coverage` successfully with a dedicated ScopeWeave static server. The merged Node/Chromium summary contained 285 source entries: | Metric | Covered | Total | Result | | --- | ---: | ---: | ---: | -| Lines/statements | 8,177 | 8,688 | 94.11% | +| Lines/statements | 8,207 | 8,688 | 94.46% | | Functions | 291 | 298 | 97.65% | -| Branches | 2,297 | 2,464 | 93.22% | +| Branches | 2,361 | 2,533 | 93.20% | The command's successful exit means the listed test cases completed and the Node plus browser reports were merged; it does not mean the 100% quality @@ -21,7 +21,8 @@ thresholded metrics below 100%. ## Scope boundary The Node phase uses c8 with `--all` for `app.js`, `cloud-sync.js`, -`analytics.js`, the CI helper, and every `server/*.mjs` module. The browser +`analytics.js`, the CI helper, and every `server/*.mjs` module while executing +the complete unit and API suites. The browser phase collects Chromium V8 JavaScript coverage during all 95 passing E2E tests, converts the shipped client scripts, and merges both reports into one Istanbul summary. Uncovered production lines remain in the report. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 632183d3..3519c160 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -97,9 +97,9 @@ classDiagram - 검증 명령은 `npm run test:unit`, `npm run test:api`, `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. `BASE_URL=http://127.0.0.1:4174 npm run test:coverage`의 2026-08-29 PR #632 - source/test working head `b8c188badafcd559c46737550ddb598fead17c52` 측정치는 전용 + source/test working head `711232340075f0d53f5c755624d1ef6e998c61c1` 측정치는 전용 ScopeWeave 정적 서버에서 Node·Chromium 결과를 합산한 lines/statements - 94.11%, functions 97.65%, branches 93.22%이며, + 94.46%, functions 97.65%, branches 93.20%이며, `node scripts/ci/check-coverage.mjs`는 아직 threshold 미달로 실패한다. 이 결과는 `docs/doctoring/coverage-evidence.md`에 기록하고 100% 품질 기준의 미충족 증거로 취급한다. From bce21b4eb53bf22ea454fd8bce0450b4f49bced5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 22:09:14 +0900 Subject: [PATCH 40/41] test(coverage): exercise planner edge paths --- tests/e2e/scopeweave.spec.js | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index 4f6db442..c3a7047f 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -163,6 +163,8 @@ test.describe('ScopeWeave Planner', () => { await expect(contextToggle).toHaveAttribute('aria-disabled', 'true'); await expect(contextToggle).toHaveAttribute('aria-expanded', 'true'); await expect(page.locator('#task-filter-status')).toHaveText('3개 작업 표시 중 (전체 4개)'); + await contextToggle.dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('검색 중 계층 맥락 고정'); await search.fill(''); await expect(page.locator('#seed-onboarding')).toBeVisible(); @@ -272,6 +274,33 @@ test.describe('ScopeWeave Planner', () => { await expect(page.getByRole('button', { name: 'JSON 내보내기' })).toHaveAttribute('aria-disabled', 'true'); await expect(page.getByRole('button', { name: '간트차트보기' })).toHaveAttribute('aria-disabled', 'true'); await expect(page.getByRole('button', { name: '간트차트보기' })).toHaveAttribute('title', '간트 차트로 표시할 작업이 없습니다. 작업을 먼저 추가해주세요.'); + + await page.getByRole('button', { name: 'CSV 내보내기' }).dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('내보낼 작업이 없습니다'); + await page.getByRole('button', { name: 'JSON 내보내기' }).dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('내보낼 작업이 없습니다'); + await page.getByRole('button', { name: '간트차트보기' }).dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('간트 차트로 표시할 작업이 없습니다'); + + const chooserPromise = page.waitForEvent('filechooser'); + await page.locator('.table-empty').getByRole('button', { name: 'CSV 가져오기' }).click(); + await chooserPromise; + + await page.locator('.table-empty').getByRole('button', { name: '최상위 작업 추가' }).click(); + await expect(page.locator('.editor-panel')).toBeVisible(); + await page.locator('.editor-panel').getByRole('button', { name: '취소' }).click(); + await expect(page.locator('.table-empty')).toBeVisible(); + }); + + test('reports the native file-sync fallback when the picker is unavailable', async ({ page }) => { + await page.addInitScript(() => { delete window.showSaveFilePicker; }); + await page.reload(); + + const syncButton = page.locator('#connect-json-sync'); + await expect(syncButton).toHaveAttribute('aria-disabled', 'true'); + await expect(syncButton).toHaveAttribute('title', '이 브라우저는 wbs.json 직접 저장 연결을 지원하지 않습니다.'); + await syncButton.dispatchEvent('click'); + await expect(page.locator('#toast')).toContainText('이 브라우저는 wbs.json 직접 저장 연결을 지원하지 않습니다.'); }); test('keeps the empty WBS state inside the mobile table viewport', async ({ page }) => { @@ -333,6 +362,11 @@ test.describe('ScopeWeave Planner', () => { await expect(parentToggle).toHaveAttribute('aria-label', '접기 - P0000.준비단계'); await expect(parentToggle).toHaveAttribute('title', '접기 - P0000.준비단계'); + await parentToggle.click(); + await expect(childRow).toHaveCount(0); + await parentToggle.click(); + await expect(childRow).toHaveCount(1); + await childRow.getByRole('button', { name: '하위 추가' }).click(); await page.locator('[data-testid="editor-task"]').fill('세부업무'); await page.getByRole('button', { name: '저장', exact: true }).click(); @@ -968,6 +1002,13 @@ test.describe('ScopeWeave Planner', () => { await expect(page.locator('#task-table-body')).not.toContainText(overlongTaskName.substring(0, 1000)); }); + test('rejects CSV files larger than the browser import limit', async ({ page }) => { + await importCsv(page, Buffer.alloc(5 * 1024 * 1024 + 1, 65).toString('utf8')); + + await expect(page.locator('#toast')).toContainText('파일 크기는 5MB를 초과할 수 없습니다.'); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); + }); + test('normalizes imported task rows into a full phase-activity-task hierarchy', async ({ page }) => { await importCsv(page, ['단계,Activity,Task,대분류,중분류,산출물,담당자,지원팀,진행상태,계획시작일,계획종료일,일수,계획진척률,가중치,가중치진척률,실적진척상태,실적진척률,실적시작일,실적종료일,가중치실적진척률', 'P4000.이행단계,,고아Task,이행,,,담당자A,,,2026-06-01,2026-06-03,,,미착수(0%),,,'].join('\n')); From e2347944c48dd77a823366731874755831ad6607 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 22:10:07 +0900 Subject: [PATCH 41/41] docs(quality): record edge-path coverage --- CHANGELOG.md | 2 ++ docs/doctoring/coverage-evidence.md | 11 ++++++----- docs/product-technical-gap-baseline.md | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22ebe0f1..52e26ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 remaining uncovered paths still block a 100% frontend/backend claim. - Made the canonical coverage run execute the complete unit and API suites so existing pure-logic and server regression evidence is included in the merged report. +- Added browser regression coverage for empty-plan actions, unsupported file sync, + filtered hierarchy protection, collapse/expand, and oversized CSV rejection. - Added browser coverage for cloud sharing, sprint burndown, attachments, search, team administration, project creation, and sample onboarding flows. diff --git a/docs/doctoring/coverage-evidence.md b/docs/doctoring/coverage-evidence.md index e559e250..ed398a73 100644 --- a/docs/doctoring/coverage-evidence.md +++ b/docs/doctoring/coverage-evidence.md @@ -2,16 +2,17 @@ ## Exact-head measurement -On 2026-08-29, PR #632 working head `711232340075f0d53f5c755624d1ef6e998c61c1` +On 2026-08-29, PR #632 source/test working head +`bce21b4eb53bf22ea454fd8bce0450b4f49bced5` ran `BASE_URL=http://127.0.0.1:4174 npm run test:coverage` successfully with a dedicated ScopeWeave static server. The merged Node/Chromium summary -contained 285 source entries: +contained 291 source entries: | Metric | Covered | Total | Result | | --- | ---: | ---: | ---: | -| Lines/statements | 8,207 | 8,688 | 94.46% | +| Lines/statements | 8,252 | 8,688 | 94.98% | | Functions | 291 | 298 | 97.65% | -| Branches | 2,361 | 2,533 | 93.20% | +| Branches | 2,370 | 2,541 | 93.27% | The command's successful exit means the listed test cases completed and the Node plus browser reports were merged; it does not mean the 100% quality @@ -23,7 +24,7 @@ thresholded metrics below 100%. The Node phase uses c8 with `--all` for `app.js`, `cloud-sync.js`, `analytics.js`, the CI helper, and every `server/*.mjs` module while executing the complete unit and API suites. The browser -phase collects Chromium V8 JavaScript coverage during all 95 passing E2E +phase collects Chromium V8 JavaScript coverage during all 97 passing E2E tests, converts the shipped client scripts, and merges both reports into one Istanbul summary. Uncovered production lines remain in the report. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 3519c160..60cdf744 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -97,9 +97,9 @@ classDiagram - 검증 명령은 `npm run test:unit`, `npm run test:api`, `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. `BASE_URL=http://127.0.0.1:4174 npm run test:coverage`의 2026-08-29 PR #632 - source/test working head `711232340075f0d53f5c755624d1ef6e998c61c1` 측정치는 전용 + source/test working head `bce21b4eb53bf22ea454fd8bce0450b4f49bced5` 측정치는 전용 ScopeWeave 정적 서버에서 Node·Chromium 결과를 합산한 lines/statements - 94.46%, functions 97.65%, branches 93.20%이며, + 94.98%, functions 97.65%, branches 93.27%이며, `node scripts/ci/check-coverage.mjs`는 아직 threshold 미달로 실패한다. 이 결과는 `docs/doctoring/coverage-evidence.md`에 기록하고 100% 품질 기준의 미충족 증거로 취급한다.