diff --git a/docs/doctoring/async-button-busy-state.md b/docs/doctoring/async-button-busy-state.md new file mode 100644 index 000000000..fe4e89f3c --- /dev/null +++ b/docs/doctoring/async-button-busy-state.md @@ -0,0 +1,422 @@ +# Async button busy-state accessibility + +## Decision + +Naruon exposes `aria-busy=true` only while the action represented by that control is actively processing. The existing `disabled` behavior remains responsible for preventing conflicting or duplicate activation; `aria-busy` communicates the processing state to the accessibility API rather than replacing the disabled-state contract. + +The bounded change applies to project candidate confirmation, project evidence-review save, repository document actions, and duplicate-thread intent actions. It does not claim whole-product accessibility conformance or imply that every statically disabled control is busy. + +## Evidence boundary + +WAI-ARIA defines `aria-busy` as a state indicating that an element is being modified and that assistive technologies can defer exposing intermediate changes until the operation is complete. The attribute is defined for all elements in the base markup and defaults to `false`. This supports binding `aria-busy` to the state of the represented asynchronous action while leaving controls disabled for another reason non-busy. + +A read-only prerequisite is therefore distinct from the mutation it gates. The project evidence-review button remains disabled while full evidence is being fetched, but that evidence GET does not make the correction-save button busy. `aria-busy` becomes true only after the user starts the correction save. + +## Action identity and lifecycle + +Repository document actions share a mutual-exclusion lock because concurrent upload, reparse, embedding regeneration, HWP conversion, and WebDAV materialization can race over the same refreshed quality surface. That shared lock is separate from action identity: only the initiating control exposes `aria-busy=true`; disabled siblings remain `aria-busy=false`. + +The lock covers the complete action lifecycle, including the quality-surface refresh that follows a successful document mutation. A mutation is not reported as complete and the shared lock is not released while that refresh is still pending. The in-memory guard prevents programmatic re-entry as well as duplicate pointer or keyboard activation, and cleanup clears an active identity only when it still belongs to the completing operation. + +## Regression evidence + +- `frontend/src/components/data-layout/DocumentRepositoryTab.busy-state.test.tsx` verifies the rendered document-action group reports only the initiating action as busy. +- `frontend/src/components/DataLayout.document-action-lifecycle.test.tsx` holds the post-action quality refresh pending and verifies a second document action cannot start before the first lifecycle settles. +- `frontend/src/components/ProjectsLayout.accessibility.test.tsx` verifies candidate-confirmation busy state and verifies a pending evidence GET disables the evidence-review save without announcing that save as busy. + +These regressions are source-level evidence. Merge readiness is determined only from the unchanged exact PR head after repository CI, security, coverage, review, and protected-branch requirements pass. The attributes and automated DOM tests are not a substitute for rendered assistive-technology validation across supported environments. + +## Reference — APA 7th + +World Wide Web Consortium. (2026, June 4). *Accessible Rich Internet Applications (WAI-ARIA) 1.3* (Working Draft). https://www.w3.org/TR/2026/WD-wai-aria-1.3-20260604/ + +## Action identity + +A shared loading lock may disable sibling document actions to prevent conflicting writes, but it must not announce every sibling as the operation that is currently processing. Naruon therefore records the initiating document action separately from the shared lock. Only the initiating upload, reparse, embedding-regeneration, HWP-conversion, or WebDAV-materialization button exposes `aria-busy=true`; disabled siblings remain `aria-busy=false`. + +The focused server-rendered regression exercises the real button group and fails if a shared boolean again marks every document action busy. Stable `data-document-action` identifiers exist only to bind rendered accessibility evidence to the initiating operation; they do not authorize or execute an action. + +## Project review prerequisite versus submission (2026-09-07) + +The residual review finding on #1352 at `eb8af38ed00be32bc4e8ec8a2a210faab80d08ea` +was still present in `ProjectsLayout`: `evidenceLoading` describes fetching the +selected evidence, whereas `correctionSubmitting` describes saving its review. +The save button must remain disabled during either operation, but only the +submission changes its busy state and label. The repair changes that one ARIA +binding; it does not change request payloads, authorization, or the shared lock. + +`ProjectsLayout.accessibility.test.tsx` now renders the actual component with +independently deferred evidence GET and correction POST responses. It checks +disabled/non-busy before evidence arrives, no premature POST, enabled/non-busy +after evidence arrives, disabled/busy during submission, exactly one POST despite +a repeated click, and enabled/non-busy after success or rejection, including the +failure alert. The existing confirmation and document-action tests remain. + +The initial regression failed both new cases at expected `false` versus actual +`true` while fetching evidence. After the one-line repair, an initial combined +run hit the default five-second timeout and a subsequent state assertion failed; +an isolated run also timed out. These failures are retained, and host load is an +observation, not a proven cause. A diagnostic-only 30-second invocation passed +three tests; the repository timeout was not changed. The identical default +invocation then passed all four tests across the two related files (46.67 s). +Focused ESLint with `--max-warnings=0` and `git diff --check` passed. + +Run from `frontend`: `corepack pnpm exec vitest run +src/components/ProjectsLayout.accessibility.test.tsx +src/components/data-layout/DocumentRepositoryTab.busy-state.test.tsx`. +This is mocked API/DOM evidence, not browser, screen-reader, full-suite, build, +hosted, or protected-merge acceptance. Those results must be attached to the +final immutable candidate separately before delivery is claimed. + +## Unsaved project memo safety repair (2026-09-07) + +The separate generic memo editor called `saveProjectEvidence`, which only set +a success message. Its page test and full-product smoke script mistook that +message for persistence. This is distinct from the semantic object's review +correction POST and must not be credited as a working save feature. + +The inspected contracts are `backend/api/webdav.py` (folder reads and separately +scoped materialization intents), `backend/api/projects.py` (semantic object +corrections), and `project_registration.apply_project_correction` (candidate +group/object membership). They do not establish a generic folder/backlog memo +and source-selection persistence contract. Reinterpreting the memo as an +arbitrary graph object's summary or replacing its attributes would change the +domain meaning and ownership boundary, so that workaround is not used. + +The immediate repair removes the false-success handler, disables the unsupported +save, labels the local preview unsaved, and explains that a refresh loses the +draft. The existing page regression first failed on the enabled save button; +it now checks unavailable saving, no POST and no success message. Smoke evidence +labels distinguish unavailable saving from persistence and no longer claim a +saved memo. This safety repair does not complete the product requirement. + +The remaining product Gap requires a Naruon-owned versioned memo/source contract, +stable project identity including non-graph projects, tenant/workspace and source +authorization, audited writes, conflict handling, and reload/readback persistence +with real PostgreSQL and actual-browser evidence. Failed-source readiness and +zero-value metric rendering are separate unresolved findings. Parent `1b465`'s +build and error-screen inspection do not validate this new runtime delta; new-head +full/build/visual evidence remains required before delivery. + +## Project source readiness safety repair + +Four source requests (folders, tasks, semantic candidates and session) feed the +same project screen. Previously, a rejected request cleared arrays and the +render path converted them into a waiting queue and zero-valued milestones. +The first regression failed for each request: three displayed the fallback as +empty data, while the failed session did not show an error at all. + +The screen now renders loading/error states before exposing derived project +metrics, rejects an unavailable session, and retries the same source batch from +an explicit action. A normal empty response still renders the empty workspace. +Tests distinguish pending sources, each failed request followed by recovery, +successful empty data, real component bindings and existing review submission. +The initial API client returns JSON without member validation. The project +ingress now checks folder/task/candidate members, nullable fields, task enums, +finite scores and integer counts before storing the batch. These checks match +`ProjectFolderResponse`, `TicketTaskResponse` and `ProjectCandidateResponse`, +including nullable citation paths and candidate timestamps. This is not a +whole-product schema audit or a server authorization change. +Independent degraded source panels and durable memo persistence remain product +work; no current-head full/build/hosted acceptance is inferred from focused tests. + +The first malformed-response regression produced eight failures (invalid top +levels, null members and a non-text candidate title), with the no-claims +anonymous response correctly rejected. The next two-file invocation never ran +test bodies: two fork workers failed to start after 124.84 seconds. That remains +failed evidence, not a product pass or a proven host root cause. The fixed-lock +environment is pnpm 11.5.3 / Vitest 4.1.10; one later diagnostic keeps the same +default timeout and `--maxWorkers=1` without changing repository configuration. +That diagnostic terminated with exit 1 after 111.46 seconds: 18 of 21 tests +passed, two existing positive flows exceeded the default 5000 ms timeout, and +the contradictory session-response regression failed because no error state +was shown. All three existing busy-state tests passed. No worker-start error +occurred in this diagnostic. These results neither prove the cause of the two +timeouts nor complete the required positive-flow evidence; no further blind +rerun or timeout increase was performed. Focused ESLint exited 0 separately. + +A contradictory `authenticated: false` response carrying a non-null user claim +is also a required regression. `ApiClient.getServerSessionClaims` ignored that +flag until local commit `e11a8e9754f826f2bcac6da9b820a9d54ea10e37` added an +explicit-true guard at the shared client. The actual `/auth/session` serializer returns anonymous claims +when unauthenticated, so this is malformed-response rejection work, not evidence +of a server issuing an authenticated identity to an anonymous caller. The shared +client repair was coordinated with its owner and kept in a separate two-file +commit. Eight flag regressions first failed. After repair, the API-client suite +passed all 25 tests (including true/network/malformed cases); the combined +working-tree run with pending project-readiness changes passed 46 tests across +three files in 20.37 seconds. Both earlier positive-flow timeout cases passed +in that run, without proving their earlier cause or repairing full-suite failures. +Focused API ESLint and diff checks passed. This is local evidence, not protected +integration, complete system verification, or a backend authorization change. + +### Durable memo implementation prerequisites (proposed, not implemented) + +`ProjectFolder` in `backend/db/models.py` has an opaque folder UID and user/org +ownership, but no workspace field; its read service filters only user/org. +Graph objects already carry workspace identity, while the fallback task queue +is a UI-derived object. A single unscoped memo key or graph correction cannot +represent all three safely. + +First establish authoritative workspace ownership for existing folder records +and a stable, server-resolved project reference. Do not assign historical rows +to the caller's current workspace or infer ownership from a display title. +`_candidate_groups` currently uses a persisted explicit candidate UID when one +exists, otherwise `_synthetic_project_uid` derives an automatic UID from scope +and source identity. Adding an explicit candidate can therefore change the +project UID. A memo contract must preserve identity/alias continuity across that +transition rather than silently creating a different project or orphaning notes. +Then add the product-owned memo resource with a unique scoped project reference, +version number, note and authorized source reference; use a conditional write +against the expected version and record the audit event in the same transaction. +Reads must enforce the same owner/org/workspace boundary. The UI's source-kind +dropdown is not an authorized source identity and needs an actual source selector. +Reopening and refreshing must recover committed content; conflicting writes must +preserve the local draft and expose a reload/resolve action. Real PostgreSQL +scope/conflict/rollback tests and actual-browser save/readback complete the proof. +The disabled-save safety repair is only an intermediate step toward this feature. + +## Progress provenance repair after 6abe0743 + +The next local delta starts at `6abe0743bd4f4747c0def1c76c4d01335fb9640d`. +It remains unpublished until writer coordination and independent diff review. +CodeGraph and direct caller inspection found two invalid denominators: +`semanticProgress` converted candidate ranking score into percent in the sidebar, +overview and relationship panel; `buildProjects` copied the completion ratio and +status of every returned task onto every WebDAV folder. An empty response also +produced a fabricated zero percent. `_candidate_score` in +`backend/services/project_graph/project_registration.py` combines fixed object +weights, confidence and source counts; it does not measure completed work. + +The reproducible RED command was +`corepack pnpm exec vitest run --maxWorkers=1 src/app/projects/page.test.tsx` +in the frontend directory with an isolated environment. Session 5415 exited 1: +3 failures and 15 passes in 31.81 seconds. Its rendered output contained folder +50%, candidate 87%, and empty-folder 0%, rather than unavailable progress. +These are unit fixtures only, not customer data or browser evidence. + +The repair removes score-derived percentages and represents unsupported folder +and candidate progress as null. Folder status is also unavailable rather than +inherited from unrelated tasks. Only the returned task queue can retain a +completion ratio when its denominator is nonzero; native progress elements +carry the label, value and maximum. Empty, zero-complete, half-complete and +all-complete cases are separate regression cases. Unknown progress is not zero. + +The list request is `apiClient.get('/api/tasks')`, not a project-scoped query. +At the baseline, `backend/api/tasks.py:170-198` filters owner and organization, +has no limit/offset, and serializes `result.all()`. It does not filter workspace; +`TicketTask` also lacks a workspace field. Therefore the display must say +**retrieved tasks**, expose completed/returned counts, and never promise all +project or workspace work. Renaming UI copy does not repair this missing data +contract. A canonical, server-authorized project/task relationship and scoped +aggregate with explicit denominator remain necessary. Historical records must +not be assigned to the current workspace without ownership evidence. + +The backend ranking heuristic, real project completion contract and persistent +memo identity/readback remain unfinished. Removing misleading metrics is an +intermediate safety repair, not completion of those product requirements. +The previous 6abe desktop/mobile error/retry inspection does not verify the new +normal progress surfaces. No approved working backend/account is available for +that inspection; do not inject browser fixtures or bypass authentication to +manufacture evidence. Local unit success, hosted checks, normal-state visual +inspection, protected merge and deployed behavior remain separate claims. + +Final focused GREEN used `corepack pnpm exec vitest run --maxWorkers=1 +src/app/projects/page.test.tsx src/components/ProjectsLayout.accessibility.test.tsx` +in the isolated frontend environment. Session 33954 exited 0 with 2 files and +25 tests passing in 20.07 seconds. This includes the revised retrieved-count +wording and denominator assertions; the earlier 21-pass/65.26-second and +25-pass/53.10-second runs predate that final wording. No raw log file was +created; the command session's stdout and exit code are the original evidence. +The full browser smoke script's region locator was updated but that synthetic +legacy script was not executed as real-product visual evidence. +Session 65374 also exited 0 for focused ESLint with `--max-warnings=0`, +`node --check scripts/full-product-ui-smoke.mjs`, and `git diff --check`. + +## 517e560a 이후 문서 쓰기·목록 갱신 수리 + +정상 merge `517e560a073ebfde087f4477ba0ea8125b7c25ff`는 부모 +`0b28f8e289d50a0aff35565399395d00907fc107`과 +`67fed84c000c86fb1da12560decea2edd13b47b2`의 유효 변경을 보존한다. +여기에는 보호 브랜치의 첨부파일 경로 우회 방지 수정도 포함된다. +이 HEAD의 7489 실행은 실패했다. API 25개·busy 3개는 통과했으나 Projects 22개는 +fork worker 응답 timeout으로 실행되지 않았다. 첨부파일 검증 84038은 21개 통과, +44.88초, 종료코드 0이었다. 집중 lint·구문 검사도 통과했다. +승인된 동일 HEAD의 Projects 단독 진단 21052는 기본 timeout과 worker 1개를 유지한 채 +22개를 55.63초에 통과했다. 단독 통과로 원래 통합 실행이나 worker 오류의 RCA를 +완료 처리하지 않는다. 원시 진단 파일은 +`/private/tmp/naruon-1352-merge-evidence.ju75sI/`에 있다. +원래 통합 출력은 도구 stdout 전사이며 전체 원시 로그를 재구성한 자료가 아니다. + +### 문제·재현·소유권 + +원격 `67fed84c`의 미해결 CodeRabbit 리뷰는 두 문서 처리 함수가 +`loadDataQualitySurface()` 종료 전에 성공을 표시하고, finally에서 활성 작업을 +조건 없이 지운다고 지적했다. 호출부를 더 확인하니 `handleDocumentFileChange`도 +업로드 도중 같은 상태를 초기화했다. 동기 요청 잠금이 없어 한 React 이벤트 배치에서 +두 번 호출하면 disabled 상태가 DOM에 반영되기 전에 POST 두 건이 전송된다. + +열린 PR 파일 목록은 네 페이지를 모두 조회했으며 잘린 파일 목록은 없었다. +#1352 외에 #1404(`a1a3d461`, 미리보기), #1449(`e25a3995`, pending 식별·오류 표시), +#1472(`d396ac49`, 작업별 문구)가 DataLayout을 변경한다. +파일 중첩은 현재 활동 중인 writer의 존재·부재를 증명하지 않는다. +#1449의 provider-write-false·409 충돌·422 입력 오류 처리는 유효 delta로 보존한다. +이번 로컬 수리가 그 기능까지 구현하거나 완전히 승계한 것은 아니다. +[승계 조정 댓글](https://github.com/ContextualWisdomLab/naruon/pull/1449#issuecomment-5563458717)은 +정상 승계를 요청하며 자동 close·retarget·소스 복사를 허용하지 않는다. + +생산 소스를 바꾸기 전 4174 실행에서 5개 단언이 모두 실패했다(31.07초). +업로드와 재분석은 갱신 중 잠금이 풀렸고, 동기 연속 호출은 POST를 두 번 보냈다. +파일 선택 변경도 진행 중인 업로드를 풀었으며, 쓰기 성공 뒤 갱신 실패를 구분하는 +안내와 조회 전용 재시도 경로가 없었다. 테스트는 실제 DataLayout·DocumentRepositoryTab을 +렌더링하되 네트워크 자료는 단위 테스트에만 사용한다. 고객 데이터 저장이나 +실제 브라우저 Visual Inspection의 증거로 삼지 않는다. + +### 선택한 수리와 보존할 계약 + +컴포넌트 내부 요청 식별자를 POST와 후속 갱신이 끝날 때까지 유지한다. +이벤트 처리 함수가 동기적으로 잠금을 얻고, 해당 요청만 종료 상태를 기록하거나 +잠금을 해제한다. 네이티브 파일 입력 disabled와 처리 함수의 guard를 함께 둬 +진행 중 파일 선택 변경을 막았다. 별도 조회 revision은 늦은 초기 응답이나 +이미 떠난 화면의 응답이 목록·스냅샷 상태를 덮어쓰지 못하게 한다. +이미 전송한 POST의 효과가 취소되거나 rollback된다고 가정하지 않는다. + +스냅샷 실패는 부분 성공으로 남아야 한다. +`frontend/src/app/data/page.test.tsx`의 +`keeps quality checks usable when evidence snapshot fetch fails`가 +스냅샷은 null이어도 품질 목록은 사용할 수 있어야 한다는 기존 계약이다. +첫 초안은 스냅샷 실패가 두 자료를 모두 무효화하도록 잘못 바꿨다. +기존 단위 테스트를 확인해 이를 수정했다. 스냅샷의 좁은 오류 진단은 유지하되 +상태 반영은 동일한 최신 조회 revision으로 검사한다. + +새 문구는 “요청 결과를 받았지만 목록을 새로 불러오지 못했습니다.”로, +외부 시스템 쓰기가 완료됐다고 주장하지 않는다. 조회만 재시도하고 이전 POST 결과는 +보존한다. 응답 종류별 intent·provider 오류 문구는 #1449/#1472의 계약과 이어야 하며, +문구를 맞추려고 같은 POST를 다시 보내서는 안 된다. + +### 검증 결과와 실패 기록 + +첫 수리 실행 22515는 static busy 1개 통과·생명주기 5개 실패였다(38.38초). +첫 사례가 기본 5초 timeout에 걸렸고 뒤이어 겹친 act 경고와 DOM 부재가 나타났다. +경고 필터나 timeout 상향은 적용하지 않았다. +초기 0초 effect 타이머만 명시적으로 진행하고, 실패 뒤 재시도에는 새 응답을 공급하도록 +테스트를 수정했다. 예상된 503 진단의 내용·횟수를 정확히 검사하며 React 경고 같은 +예상 밖 출력을 정상 증거로 받아들이지 않는다. + +3713 실행도 4개 통과·3개 실패, 160.44초였다. +첫 timeout·후속 act 겹침·최신 화면 반영 전 단언이 남았으므로 타이머 제어만으로 +문제가 해결됐다고 볼 수 없다. `data_lifecycle_scheduled_timers.log`에 원시 출력을 보존했다. +그 뒤 저장소 기존 페이지 테스트의 Promise-resolved JSON stub을 재사용했다. +단위 테스트가 의도치 않게 Node Response stream의 스케줄링까지 시험하지 않도록 한 조치다. +비활성 수집·임베딩·품질 탭만 mock하며 실제 DataLayout·DocumentRepositoryTab·ApiClient는 +그대로 실행한다. 전체 탭 통합이나 HTTP decoder를 검증했다고 주장하지 않는다. + +12304는 7개 통과, 48.54초, 종료코드 0, React 경고 없음이었다. +`data_lifecycle_project_fixture.log`와 종료코드 파일을 보존했다. +늦은 초기 응답 무시와 조회 재시도 성공 후 목록 복원·오류 제거·busy 해제를 포함하지만, +이 실행은 뒤에 추가한 unmount 회귀보다 앞선다. 52048의 focused ESLint도 종료코드 0이다. +73925는 기존 스냅샷 부분 성공 사례 1개를 17.07초에 통과했다. 다른 11개는 선택 대상 밖이었다. + +26080은 Data 관련 3파일 20개를 34.96초에 통과했지만, 기존 테스트 두 개에서 +스냅샷 응답 mock 누락으로 오류 진단이 남았다. 이를 깨끗한 GREEN으로 기록하지 않는다. +두 fixture에 기존 스냅샷 응답을 추가하고 예상 밖 console.error가 없다는 단언을 보강했다. +앞선 실패를 지우지 않으며 전체 프런트엔드 검사·배포·정상 상태 실제 VI는 여전히 별도 증거가 필요하다. + +fixture 보강 후 97315는 동일한 Data 관련 3파일 20개를 37.32초에 통과했다. +종료코드는 0이며 예상 밖 stderr·React 경고는 없었다. +원시 파일은 `data_lifecycle_complete_fixture.log`, 종료코드 파일은 +`data_lifecycle_complete_fixture_exit.txt`이다. 실행 명령은 frontend에서 +`corepack pnpm exec vitest run --maxWorkers=1 src/components/DataLayout.document-lifecycle.test.tsx src/components/data-layout/DocumentRepositoryTab.busy-state.test.tsx src/app/data/page.test.tsx`이며 +`env -i PATH="$PATH"`로 실행했다. 기본 timeout을 유지했고 제외한 Data 테스트는 없다. + +### 공식 문서 근거 + +통합 직전 원격 PR이 `15ed98a1a6ad5804f1f0abbe646a8799cbc4f239`로 이동했다. +로컬 검증 변경은 먼저 `8c9418de9f966778ecd9a0433debefbab95f12e9`에 보존했다. +원격의 6커밋·5파일 변경을 읽고 정상 merge로 통합하며 강제 push나 변경 폐기는 하지 않는다. +두 개의 잠금을 병렬로 남기지 않고 Symbol의 현재 요청 검사로 통합한다. +원격의 `activeDocumentAction !== null || documentActionStatus === 'loading'` 비활성화 조건, +직접 props 재진입 테스트 175줄, Projects의 조회 중 비-busy 사례와 문서 원칙을 보존했다. +직접 props 사례는 계정·파일이 없는 요청의 validation도 진행 중 상태를 덮지 않아야 한다는 +추가 조건을 검사하므로 DOM 회귀와 구분해 유지한다. 검증 전 소스 추론을 원격 테스트의 +실행 실패로 기록하지 않는다. 로컬의 갱신 오류·GET 전용 재시도·늦은 초기 응답·unmount +보호와 이전 실패 기록도 유지한다. 20개 통과는 이 원격 통합 이전 결과이며 새 HEAD는 +Data 관련 테스트와 Projects 접근성을 다시 실행해야 한다. + +React의 ref 계약은 렌더링 상태를 동기 잠금처럼 쓰지 않고 이벤트 처리 함수에서 +가변 요청 식별자를 유지하는 방법을 설명한다. effect 문서는 오래된 비동기 결과를 +무시하는 것과 외부 작업 자체를 취소하는 것을 구별한다. +Context7은 quota 제한으로 사용할 수 없어 아래 공식 문서를 직접 확인했다. +이는 구현 방법의 근거이며 모든 예외 상황이나 이 구현의 검증 완료를 뜻하지 않는다. + +React. (n.d.). *useRef*. Retrieved September 7, 2026, from +https://react.dev/reference/react/useRef + +React. (n.d.). *useEffect*. Retrieved September 7, 2026, from +https://react.dev/reference/react/useEffect + +### 원격 재진입 수리 승계와 검증 순서 + +`215db677fbf3cf46ec368db9e5ff1d7c23f321c9`는 원격 +`c45ed60bd09000341f2e54fea5683902d44962c5`를 정상 병합했다. +원격의 validation 이전 재진입 차단을 Symbol 현재 요청 검사로 승계했으며, +tree `d8314808f10b2888ac9daa7d6afa79a7242d9176`은 앞선 +`ff6f82c6c738611eb78642c727b597a98bab507e`와 동일하다. +ff6f의 5파일 25개 검증은 24개 통과, 기존 Data 탭 사례의 기본 5000ms timeout +1개로 실패했다(117.49초). lint와 diff 검사는 통과했지만 전체 GREEN은 아니다. + +검증 중 merge를 시작한 실행 순서 오류로 단독 진단 19082가 충돌 표시를 읽고 +PARSE_ERROR, 테스트 0개, 종료코드 1로 끝났다. 이는 제품 회귀나 timeout 진단 +증거가 아니다. 모든 실행 핸들의 terminal 결과 회수, 충돌 해소와 diff 검사, +commit/tree 고정, 검증 순서를 지킨다. 검증 중에는 merge를 포함한 소스 변경을 +하지 않으며 읽기 전용 조사만 진행한다. 복구 후 같은 tree의 단독 진단 58875는 +1개 통과(1447ms), 전체 4.26초였다. 이 결과로 앞선 25개 실패를 대체하지 않는다. + +### 동일 렌더의 불필요한 DOM 순회 + +`frontend/src/app/data/page.test.tsx`의 +`renders API-backed pipeline embedding and quality tabs`는 품질 탭의 act 완료부터 +스냅샷 복사 버튼 클릭 전까지, 변화 없는 DOM에서 `textContent`를 133번 읽었다. +각 읽기는 전체 하위 노드의 텍스트를 다시 모은다. 해당 구간만 `qualityPanelText`로 +한 번 읽고 기존 긍정·부정 단언 133개와 기대 문자열을 모두 보존한다. 이후 클릭과 +클립보드 내용 검증, 다른 탭의 렌더, API fixture와 기본 timeout은 바꾸지 않는다. + +계측용 시작·종료 시각만 넣은 baseline 83279는 해당 단언 구간 392.934ms, +테스트 1105ms로 통과했다. 한 번 읽기로 바꾼 비교 10446은 단언 구간의 종료 +시각을 출력하기 전에 기본 timeout으로 실패했다(테스트 10640ms, 전체 48.31초). +따라서 133회에서 1회로 줄어든 읽기 횟수는 소스로 확인할 수 있지만, 이 관측으로 +실행 시간 개선률이나 timeout의 유일한 원인을 확정할 수는 없다. 계측 출력은 +최종 테스트에서 제거한다. 원래 5파일 25개를 동일 설정으로 다시 검증해야 하며, +정상 인증 상태의 실제 화면 검증은 여전히 별도 미완료 항목이다. + +### hosted Projects smoke의 응답 계약 누락 + +`cc30ba6c52dd00913dc46ca9ec07682230aee1e8`의 Application CI +run `34073478896`, frontend job `101595066425`는 2026-09-07 01:40:16 UTC에 +실패했다. `full-product-ui-smoke.mjs:1220`에서 “관련 문서/메일 연결” 링크를 +10000ms 동안 기다렸으나 표시되지 않았다. 로컬 25개 통과는 이 hosted 실패를 +상쇄하지 않는다. 시간 제한을 늘리거나 Projects의 입력 검증을 느슨하게 하지 않는다. + +기존 smoke는 `page.route`로 합성 응답을 주입한다. 등록된 `/auth/session` 응답에 +`authenticated: true`가 없고 작업 3개의 `created_at`이 빠져 있었다. +`/api/projects/candidates`는 handler가 없어 일반 `{ ok: true }`로 응답했다. +실제 ProjectsLayout은 인증·작업·후보 목록을 모두 검증하므로 정상 링크 대신 +오류 화면을 표시한다. backend의 TicketTaskResponse와 ProjectCandidateListResponse, +공유 ApiClient의 명시적 인증 계약을 유지하면서 공급 측 응답만 맞춘다. + +기존 installRoutes에 export만 추가해 단위 더블로 실제 등록 handler를 호출한다. +`scripts/full-product-project-contract.test.mjs`의 세 응답 계약과 실제 ProjectsLayout +준비 상태 단위 회귀는 수리 전 4개 모두 실패했다(54025, 19.93초, 종료코드 1). +원시 `smoke_source_contract_red.log`는 누락 필드·잘못된 후보 응답·실제 오류 DOM을 +보존한다. 응답 fixture를 별도로 복사하거나 인증 우회 코드를 제품에 넣지 않는다. + +이 작업은 합성 자료를 단위 테스트 안에서만 실행한다. 기존 합성 browser smoke를 +실제 고객·provider·서명된 backend의 E2E 또는 Visual Inspection으로 인정하지 않는다. +hosted smoke 재검증과 승인된 실데이터 경로로의 전환, 정상 인증 화면 VI는 별도 +미완료다. 이 수리로 전체 제품 smoke가 통과했다고 주장하지 않는다. + +수리 후 54560의 단위 검증은 네 파일 63개 통과, 33.02초, 종료코드 0이었다. +새 등록 응답·실제 Projects 단위 4개, 기존 smoke helper 12개, Projects 22개, +API 클라이언트 25개를 포함한다. `smoke_source_contract_green.log`를 보존했다. +69169의 변경 두 script ESLint·구문·diff 검사도 종료코드 0이었다. +이는 작업 트리 검증이며 최종 commit의 독립 hosted 결과와 구분한다. diff --git a/frontend/scripts/full-product-project-contract.test.mjs b/frontend/scripts/full-product-project-contract.test.mjs new file mode 100644 index 000000000..d837e8e5b --- /dev/null +++ b/frontend/scripts/full-product-project-contract.test.mjs @@ -0,0 +1,66 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; +import { installRoutes } from "./full-product-ui-smoke.mjs"; +import { ProjectsLayout } from "../src/components/ProjectsLayout"; + +vi.mock("next/link", () => ({ + default: ({ children, ...props }) => React.createElement("a", props, children), +})); + +let renderRoot; +let renderContainer; + +afterEach(async () => { + if (renderRoot) await act(async () => renderRoot.unmount()); + renderRoot = undefined; + renderContainer?.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function registeredResponse(endpointPath) { + const routeHandlers = new Map(); + await installRoutes({ route: async (routePattern, routeHandler) => { + routeHandlers.set(routePattern, routeHandler); + } }); + let responseBody; + const selectedHandler = routeHandlers.get(endpointPath === "/auth/session" ? "**/auth/session" : "**/api/**"); + await selectedHandler({ + request: () => ({ url: () => `http://127.0.0.1:3001${endpointPath}`, method: () => "GET" }), + fulfill: async (responseValue) => { responseBody = JSON.parse(responseValue.body); }, + }); + return responseBody; +} + +it("declares the authenticated session contract explicitly", async () => { + expect(await registeredResponse("/auth/session")).toMatchObject({ + authenticated: true, claims: { userId: "smoke-user" }, + }); +}); + +it("supplies creation timestamps for every returned task", async () => { + const taskRows = await registeredResponse("/api/tasks"); + expect(taskRows).toHaveLength(3); + for (const taskRow of taskRows) expect(taskRow.created_at).toEqual(expect.any(String)); +}); + +it("returns a candidate collection instead of generic placeholder success", async () => { + expect(await registeredResponse("/api/projects/candidates")).toEqual({ candidates: [] }); +}); + +it("renders actual project readiness from the registered unit responses", async () => { + vi.stubGlobal("fetch", vi.fn(async (requestPath) => ({ + ok: true, status: 200, + json: async () => registeredResponse(String(requestPath)), + }))); + renderContainer = document.createElement("div"); + document.body.appendChild(renderContainer); + renderRoot = createRoot(renderContainer); + await act(async () => renderRoot.render(React.createElement(ProjectsLayout))); + expect(renderContainer.textContent).not.toContain("프로젝트 근거를 불러오지 못했습니다"); + expect(Array.from(renderContainer.querySelectorAll("a")).some((linkElement) => + linkElement.textContent === "관련 문서/메일 연결", + )).toBe(true); +}); diff --git a/frontend/scripts/full-product-ui-smoke.mjs b/frontend/scripts/full-product-ui-smoke.mjs index 008d61267..00ac5d93a 100644 --- a/frontend/scripts/full-product-ui-smoke.mjs +++ b/frontend/scripts/full-product-ui-smoke.mjs @@ -358,6 +358,7 @@ const task = { source_type: "email", source_email_id: String(sourceEmail.id), related_thread_id: sourceEmail.thread_id, + created_at: "2026-07-02T05:00:00Z", updated_at: "2026-07-02T05:00:00Z", }; @@ -369,6 +370,7 @@ const knowledgeTask = { source_type: "self_sent_knowledge", source_email_id: String(sourceEmail.id), related_thread_id: sourceEmail.thread_id, + created_at: "2026-07-02T05:10:00Z", updated_at: "2026-07-02T05:10:00Z", }; @@ -380,6 +382,7 @@ const webdavTask = { source_type: "webdav", source_email_id: String(sourceEmail.id), related_thread_id: sourceEmail.thread_id, + created_at: "2026-07-02T05:20:00Z", updated_at: "2026-07-02T05:20:00Z", }; @@ -777,12 +780,13 @@ function routeJson(route, body, status = 200) { }); } -async function installRoutes(page) { +export async function installRoutes(page) { let emailSendCount = 0; let savedAccountConfig = { ...accountConfig }; let savedLlmProviders = [{ ...llmProvider }]; await page.route("**/auth/session", (route) => routeJson(route, { + authenticated: true, claims: { userId: "smoke-user", organizationId: "org-acme", @@ -862,6 +866,7 @@ async function installRoutes(page) { }); } if (endpoint === "/api/webdav/folders") return routeJson(route, [projectFolder]); + if (endpoint === "/api/projects/candidates") return routeJson(route, { candidates: [] }); if (endpoint === "/api/webdav/accounts") return routeJson(route, [webdavAccount]); if (endpoint === "/api/webdav/writeback-intent") { return routeJson(route, { @@ -1226,13 +1231,15 @@ async function runCriticalInteractionSmoke(page, routeSpec, viewportSpec) { await projectContent.getByText("저장소 경계 확인됨", { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); await projectContent.getByText("WebDAV 폴더 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); await projectContent.getByText("스레드 근거 연결됨", { exact: true }).first().waitFor({ state: "visible", timeout: 10_000 }); - await page.getByRole("region", { name: "프로젝트 작업 목록" }).getByText("문서 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); + await page.getByRole("region", { name: "조회된 작업 목록" }).getByText("문서 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); const evidenceEditor = page.getByRole("region", { name: "프로젝트 근거 편집" }); - await evidenceEditor.getByLabel("프로젝트 근거 메모", { exact: true }).fill("20B 구매 심사용 WebDAV 경계와 이사회 승인 근거를 함께 저장합니다."); + await evidenceEditor.getByLabel("프로젝트 근거 메모", { exact: true }).fill("검토할 근거를 미저장 메모로 작성합니다."); await evidenceEditor.getByLabel("연결 원본 변경", { exact: true }).selectOption({ label: "문서 근거" }); - await evidenceEditor.getByRole("button", { name: "근거 저장", exact: true }).click(); - await evidenceEditor.getByText("프로젝트 근거가 저장되었습니다: 문서 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); - await evidenceEditor.getByText("20B 구매 심사용 WebDAV 경계와 이사회 승인 근거를 함께 저장합니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); + if (await evidenceEditor.getByRole("button", { name: "근거 저장", exact: true }).isEnabled()) { + throw new Error("Project evidence saving must remain unavailable without a persistence contract"); + } + await evidenceEditor.getByText("메모 저장은 아직 지원하지 않습니다. 입력 내용은 이 화면에서만 유지되며 새로고침하면 사라집니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); + await evidenceEditor.getByText("검토할 근거를 미저장 메모로 작성합니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); const connectedResources = page.getByRole("region", { name: "연결된 자원" }); await connectedResources.getByText("원본 종류", { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); await connectedResources.locator("li").filter({ hasText: "원본 종류" }).getByText("3", { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); @@ -1246,8 +1253,8 @@ async function runCriticalInteractionSmoke(page, routeSpec, viewportSpec) { evidence("projects:verify-document-source-attachment"), evidence("projects:edit-evidence-note"), evidence("projects:mutate-evidence-source"), - evidence("projects:save-evidence-note"), - evidence("projects:verify-evidence-save-state"), + evidence("projects:verify-evidence-save-unavailable"), + evidence("projects:verify-unsaved-evidence-preview"), evidence("projects:verify-source-type-count"), ]; } diff --git a/frontend/src/app/data/page.test.tsx b/frontend/src/app/data/page.test.tsx index d18afe06a..8d2d7e14f 100644 --- a/frontend/src/app/data/page.test.tsx +++ b/frontend/src/app/data/page.test.tsx @@ -2086,139 +2086,141 @@ describe("DataPage", () => { await act(async () => { qualityTab?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(container.textContent).toContain("Thread id integrity"); - expect(container.textContent).toContain("Some scoped emails need canonical thread ids."); - expect(container.textContent).toContain("Content segment text readiness"); - expect(container.textContent).toContain("Knowledge graph evidence endpoints"); - expect(container.textContent).toContain("paragraph segment evidence endpoints"); - expect(container.textContent).toContain("실사 스냅샷"); - expect(container.textContent).toContain("실사 스냅샷 JSON 복사"); - expect(container.textContent).toContain("raw 본문/첨부 원문 제외"); - expect(container.textContent).toContain("0123456789ab"); - expect(container.textContent).toContain("sha256"); - expect(container.textContent).toContain("Snapshot verification handoff"); - expect(container.textContent).toContain("python scripts/verify_evidence_snapshot.py "); - expect(container.textContent).toContain("file_path_or_stdin"); - expect(container.textContent).toContain("digest_mismatch"); - expect(container.textContent).toContain("4"); - expect(container.textContent).toContain("Buyer diligence packet checklist"); - expect(container.textContent).toContain("Privacy redaction policy"); - expect(container.textContent).toContain("Attachment parser manifest"); - expect(container.textContent).toContain("DOM paragraph topology"); - expect(container.textContent).toContain("Offline snapshot verification"); - expect(container.textContent).toContain("redacted_snapshot_policy"); - expect(container.textContent).toContain("buyer_evidence_readiness_gate"); - expect(container.textContent).toContain("Data room package manifest"); - expect(container.textContent).toContain("naruon-evidence-snapshot.json"); - expect(container.textContent).toContain("verify-evidence-snapshot.py"); - expect(container.textContent).toContain("knowledge-graph-evidence-samples.json"); - expect(container.textContent).toContain("acquisition-readiness-summary.json"); - expect(container.textContent).toContain("raw content: no"); - expect(container.textContent).toContain("stable IDs: no"); - expect(container.textContent).toContain("Diligence exception register"); - expect(container.textContent).toContain("critical"); - expect(container.textContent).toContain("quality_checks.thread_id_integrity"); - expect(container.textContent).toContain("blocks close: yes"); - expect(container.textContent).toContain("Semantic relation source backing"); - expect(container.textContent).toContain("semantic-relation-evidence-samples.json"); - expect(container.textContent).toContain("Diligence risk matrix"); - expect(container.textContent).toContain("Critical close blocker concentration"); - expect(container.textContent).toContain("2 critical exception(s)"); - expect(container.textContent).toContain("exception_repair_thread_id_integrity"); - expect(container.textContent).toContain("Diligence close decision summary"); - expect(container.textContent).toContain("close_blocked"); - expect(container.textContent).toContain("Close remains blocked"); - expect(container.textContent).toContain("6 proof requirement(s)"); - expect(container.textContent).toContain("5 required artifact(s)"); - expect(container.textContent).toContain("offline snapshot verifier"); - expect(container.textContent).toContain("Snapshot verification"); - expect(container.textContent).toContain("required"); - expect(container.textContent).toContain("Diligence close artifact review queue"); - expect(container.textContent).toContain("executive diligence reviewer"); - expect(container.textContent).toContain("data quality reviewer"); - expect(container.textContent).toContain("Proof counts"); - expect(container.textContent).toContain("total 2"); - expect(container.textContent).toContain("blocked 2"); - expect(container.textContent).toContain("attachment_parsing"); - expect(container.textContent).toContain("Diligence close owner handoff queue"); - expect(container.textContent).toContain("coverage reviewer"); - expect(container.textContent).toContain("Reviewer roles"); - expect(container.textContent).toContain("Handoff status"); - expect(container.textContent).toContain("assigned to attachment_parsing"); - expect(container.textContent).toContain("Diligence close traceability map"); - expect(container.textContent).toContain("acquisition_readiness_gate"); - expect(container.textContent).toContain("Trace keys"); - expect(container.textContent).toContain("review_acquisition_readiness_summary_json"); - expect(container.textContent).toContain("handoff_email_ingestion"); - expect(container.textContent).toContain("close proof traceability"); - expect(container.textContent).toContain("Diligence close proof plan"); - expect(container.textContent).toContain("critical evidence gate"); - expect(container.textContent).toContain("blocked"); - expect(container.textContent).toContain("All 2 exception(s)"); - expect(container.textContent).toContain("Regenerate the evidence snapshot"); - expect(container.textContent).toContain("verify_evidence_snapshot.py"); - expect(container.textContent).toContain("Proof artifact"); - expect(container.textContent).toContain("첨부 parser 형식별 현황"); - expect(container.textContent).toContain("application/octet-stream"); - expect(container.textContent).toContain("text/markdown"); - expect(container.textContent).toContain("application/pdf"); - expect(container.textContent).toContain("unsupported_content_type"); - expect(container.textContent).toContain("DOM/문단 구조별 현황"); - expect(container.textContent).toContain("KG edge 형식별 현황"); - expect(container.textContent).toContain("문단 근거 샘플"); - expect(container.textContent).toContain("KG 근거 샘플"); - expect(container.textContent).toContain("Semantic KG readiness"); - expect(container.textContent).toContain("Entity/relation extraction"); - expect(container.textContent).toContain("ready"); - expect(container.textContent).toContain("segment_citation"); - expect(container.textContent).toContain("Semantic relation evidence"); - expect(container.textContent).toContain("Vendor"); - expect(container.textContent).toContain("message_thread"); - expect(container.textContent).toContain("prepare_response_draft"); - expect(container.textContent).toContain("Buyer evidence readiness"); - expect(container.textContent).toContain("25%"); - expect(container.textContent).toContain("증거 패킷 생성됨"); - expect(container.textContent).toContain("Snapshot verification ready"); - expect(container.textContent).toContain("thread_id_integrity"); - expect(container.textContent).toContain("Acquisition decision summary"); - expect(container.textContent).toContain("Remediate acquisition evidence gaps before close."); - expect(container.textContent).toContain("remediate_before_close"); - expect(container.textContent).toContain("Resolve critical and high remediation actions"); - expect(container.textContent).toContain("buyer_diligence_decision"); - expect(container.textContent).toContain("Acquisition KPI targets"); - expect(container.textContent).toContain("Thread id integrity target"); - expect(container.textContent).toContain("75% / 100%"); - expect(container.textContent).toContain("Semantic KG evidence target"); - expect(container.textContent).toContain("Semantic KG evidence must remain provenance-approved"); - expect(container.textContent).toContain("Remediation actions"); - expect(container.textContent).toContain("Canonical thread repair"); - expect(container.textContent).toContain("email_ingestion"); - expect(container.textContent).toContain("Run canonical threading repair"); - expect(container.textContent).toContain("Attachment parser coverage"); - expect(container.textContent).toContain("email_body"); - expect(container.textContent).toContain("paragraph"); - expect(container.textContent).toContain("node_has_segment"); - expect(container.textContent).toContain("/document[1]/paragraph[1]"); - expect(container.textContent).toContain("/document[1]/paragraph[1]/has/segment[1]"); - expect(container.textContent).toContain("문단 근거 연결됨"); - expect(container.textContent).toContain("의도만 기록"); - expect(container.textContent).not.toContain("provider_write_executed=false"); - expect(container.textContent).not.toContain("email_attachments.content_type"); - expect(container.textContent).not.toContain("content_segments.source_kind"); - expect(container.textContent).not.toContain("content_segments.safe_text_content"); - expect(container.textContent).not.toContain("content_segments.segment_path"); - expect(container.textContent).not.toContain("knowledge_graph_edges.source_kind"); - expect(container.textContent).not.toContain("knowledge_graph_edges.edge_kind"); - expect(container.textContent).not.toContain("knowledge_graph_edges.source_segment_id"); - expect(container.textContent).not.toContain("knowledge_graph_edges.edge_path"); - expect(container.textContent).not.toContain("sender_relationships.source_message_id"); - expect(container.textContent).not.toContain("segment_hidden_1"); - expect(container.textContent).not.toContain("edge_hidden_1"); - expect(container.textContent).not.toContain("relation_hidden_1"); - expect(container.textContent).not.toContain("snapshot_segment_hidden_1"); - expect(container.textContent).not.toContain("snapshot_edge_hidden_1"); - expect(container.textContent).not.toContain("snapshot_relation_hidden_1"); - expect(container.textContent).not.toContain("발견된 심각한 데이터 품질 문제가 없습니다."); + // Read this committed render once; do not reuse it after the next interaction. + const qualityPanelText = container.textContent; + expect(qualityPanelText).toContain("Thread id integrity"); + expect(qualityPanelText).toContain("Some scoped emails need canonical thread ids."); + expect(qualityPanelText).toContain("Content segment text readiness"); + expect(qualityPanelText).toContain("Knowledge graph evidence endpoints"); + expect(qualityPanelText).toContain("paragraph segment evidence endpoints"); + expect(qualityPanelText).toContain("실사 스냅샷"); + expect(qualityPanelText).toContain("실사 스냅샷 JSON 복사"); + expect(qualityPanelText).toContain("raw 본문/첨부 원문 제외"); + expect(qualityPanelText).toContain("0123456789ab"); + expect(qualityPanelText).toContain("sha256"); + expect(qualityPanelText).toContain("Snapshot verification handoff"); + expect(qualityPanelText).toContain("python scripts/verify_evidence_snapshot.py "); + expect(qualityPanelText).toContain("file_path_or_stdin"); + expect(qualityPanelText).toContain("digest_mismatch"); + expect(qualityPanelText).toContain("4"); + expect(qualityPanelText).toContain("Buyer diligence packet checklist"); + expect(qualityPanelText).toContain("Privacy redaction policy"); + expect(qualityPanelText).toContain("Attachment parser manifest"); + expect(qualityPanelText).toContain("DOM paragraph topology"); + expect(qualityPanelText).toContain("Offline snapshot verification"); + expect(qualityPanelText).toContain("redacted_snapshot_policy"); + expect(qualityPanelText).toContain("buyer_evidence_readiness_gate"); + expect(qualityPanelText).toContain("Data room package manifest"); + expect(qualityPanelText).toContain("naruon-evidence-snapshot.json"); + expect(qualityPanelText).toContain("verify-evidence-snapshot.py"); + expect(qualityPanelText).toContain("knowledge-graph-evidence-samples.json"); + expect(qualityPanelText).toContain("acquisition-readiness-summary.json"); + expect(qualityPanelText).toContain("raw content: no"); + expect(qualityPanelText).toContain("stable IDs: no"); + expect(qualityPanelText).toContain("Diligence exception register"); + expect(qualityPanelText).toContain("critical"); + expect(qualityPanelText).toContain("quality_checks.thread_id_integrity"); + expect(qualityPanelText).toContain("blocks close: yes"); + expect(qualityPanelText).toContain("Semantic relation source backing"); + expect(qualityPanelText).toContain("semantic-relation-evidence-samples.json"); + expect(qualityPanelText).toContain("Diligence risk matrix"); + expect(qualityPanelText).toContain("Critical close blocker concentration"); + expect(qualityPanelText).toContain("2 critical exception(s)"); + expect(qualityPanelText).toContain("exception_repair_thread_id_integrity"); + expect(qualityPanelText).toContain("Diligence close decision summary"); + expect(qualityPanelText).toContain("close_blocked"); + expect(qualityPanelText).toContain("Close remains blocked"); + expect(qualityPanelText).toContain("6 proof requirement(s)"); + expect(qualityPanelText).toContain("5 required artifact(s)"); + expect(qualityPanelText).toContain("offline snapshot verifier"); + expect(qualityPanelText).toContain("Snapshot verification"); + expect(qualityPanelText).toContain("required"); + expect(qualityPanelText).toContain("Diligence close artifact review queue"); + expect(qualityPanelText).toContain("executive diligence reviewer"); + expect(qualityPanelText).toContain("data quality reviewer"); + expect(qualityPanelText).toContain("Proof counts"); + expect(qualityPanelText).toContain("total 2"); + expect(qualityPanelText).toContain("blocked 2"); + expect(qualityPanelText).toContain("attachment_parsing"); + expect(qualityPanelText).toContain("Diligence close owner handoff queue"); + expect(qualityPanelText).toContain("coverage reviewer"); + expect(qualityPanelText).toContain("Reviewer roles"); + expect(qualityPanelText).toContain("Handoff status"); + expect(qualityPanelText).toContain("assigned to attachment_parsing"); + expect(qualityPanelText).toContain("Diligence close traceability map"); + expect(qualityPanelText).toContain("acquisition_readiness_gate"); + expect(qualityPanelText).toContain("Trace keys"); + expect(qualityPanelText).toContain("review_acquisition_readiness_summary_json"); + expect(qualityPanelText).toContain("handoff_email_ingestion"); + expect(qualityPanelText).toContain("close proof traceability"); + expect(qualityPanelText).toContain("Diligence close proof plan"); + expect(qualityPanelText).toContain("critical evidence gate"); + expect(qualityPanelText).toContain("blocked"); + expect(qualityPanelText).toContain("All 2 exception(s)"); + expect(qualityPanelText).toContain("Regenerate the evidence snapshot"); + expect(qualityPanelText).toContain("verify_evidence_snapshot.py"); + expect(qualityPanelText).toContain("Proof artifact"); + expect(qualityPanelText).toContain("첨부 parser 형식별 현황"); + expect(qualityPanelText).toContain("application/octet-stream"); + expect(qualityPanelText).toContain("text/markdown"); + expect(qualityPanelText).toContain("application/pdf"); + expect(qualityPanelText).toContain("unsupported_content_type"); + expect(qualityPanelText).toContain("DOM/문단 구조별 현황"); + expect(qualityPanelText).toContain("KG edge 형식별 현황"); + expect(qualityPanelText).toContain("문단 근거 샘플"); + expect(qualityPanelText).toContain("KG 근거 샘플"); + expect(qualityPanelText).toContain("Semantic KG readiness"); + expect(qualityPanelText).toContain("Entity/relation extraction"); + expect(qualityPanelText).toContain("ready"); + expect(qualityPanelText).toContain("segment_citation"); + expect(qualityPanelText).toContain("Semantic relation evidence"); + expect(qualityPanelText).toContain("Vendor"); + expect(qualityPanelText).toContain("message_thread"); + expect(qualityPanelText).toContain("prepare_response_draft"); + expect(qualityPanelText).toContain("Buyer evidence readiness"); + expect(qualityPanelText).toContain("25%"); + expect(qualityPanelText).toContain("증거 패킷 생성됨"); + expect(qualityPanelText).toContain("Snapshot verification ready"); + expect(qualityPanelText).toContain("thread_id_integrity"); + expect(qualityPanelText).toContain("Acquisition decision summary"); + expect(qualityPanelText).toContain("Remediate acquisition evidence gaps before close."); + expect(qualityPanelText).toContain("remediate_before_close"); + expect(qualityPanelText).toContain("Resolve critical and high remediation actions"); + expect(qualityPanelText).toContain("buyer_diligence_decision"); + expect(qualityPanelText).toContain("Acquisition KPI targets"); + expect(qualityPanelText).toContain("Thread id integrity target"); + expect(qualityPanelText).toContain("75% / 100%"); + expect(qualityPanelText).toContain("Semantic KG evidence target"); + expect(qualityPanelText).toContain("Semantic KG evidence must remain provenance-approved"); + expect(qualityPanelText).toContain("Remediation actions"); + expect(qualityPanelText).toContain("Canonical thread repair"); + expect(qualityPanelText).toContain("email_ingestion"); + expect(qualityPanelText).toContain("Run canonical threading repair"); + expect(qualityPanelText).toContain("Attachment parser coverage"); + expect(qualityPanelText).toContain("email_body"); + expect(qualityPanelText).toContain("paragraph"); + expect(qualityPanelText).toContain("node_has_segment"); + expect(qualityPanelText).toContain("/document[1]/paragraph[1]"); + expect(qualityPanelText).toContain("/document[1]/paragraph[1]/has/segment[1]"); + expect(qualityPanelText).toContain("문단 근거 연결됨"); + expect(qualityPanelText).toContain("의도만 기록"); + expect(qualityPanelText).not.toContain("provider_write_executed=false"); + expect(qualityPanelText).not.toContain("email_attachments.content_type"); + expect(qualityPanelText).not.toContain("content_segments.source_kind"); + expect(qualityPanelText).not.toContain("content_segments.safe_text_content"); + expect(qualityPanelText).not.toContain("content_segments.segment_path"); + expect(qualityPanelText).not.toContain("knowledge_graph_edges.source_kind"); + expect(qualityPanelText).not.toContain("knowledge_graph_edges.edge_kind"); + expect(qualityPanelText).not.toContain("knowledge_graph_edges.source_segment_id"); + expect(qualityPanelText).not.toContain("knowledge_graph_edges.edge_path"); + expect(qualityPanelText).not.toContain("sender_relationships.source_message_id"); + expect(qualityPanelText).not.toContain("segment_hidden_1"); + expect(qualityPanelText).not.toContain("edge_hidden_1"); + expect(qualityPanelText).not.toContain("relation_hidden_1"); + expect(qualityPanelText).not.toContain("snapshot_segment_hidden_1"); + expect(qualityPanelText).not.toContain("snapshot_edge_hidden_1"); + expect(qualityPanelText).not.toContain("snapshot_relation_hidden_1"); + expect(qualityPanelText).not.toContain("발견된 심각한 데이터 품질 문제가 없습니다."); const snapshotButton = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.textContent?.includes("실사 스냅샷 JSON 복사"), @@ -2579,9 +2581,11 @@ describe("DataPage", () => { }); it("sanitizes WebDAV source labels that contain opaque source ids", async () => { + const consoleError = vi.spyOn(console, "error"); const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const path = String(input); if (path === "/api/data/quality-surface") return jsonResponse(dataQualitySurface); + if (path === "/api/data/quality-surface/evidence-snapshot") return jsonResponse(dataEvidenceSnapshot); if (path === "/api/webdav/accounts") { void init; return jsonResponse([ @@ -2617,6 +2621,7 @@ describe("DataPage", () => { expect(container.textContent).toContain("WebDAV 저장소 1"); expect(container.textContent).not.toContain("WebDAV source webdav_src_primary"); + expect(consoleError).not.toHaveBeenCalled(); expect(container.textContent).not.toContain("webdav_src_primary"); const button = Array.from(container.querySelectorAll("button")).find((candidate) => @@ -2635,6 +2640,7 @@ describe("DataPage", () => { }); it("lets the user choose a specific WebDAV source and distinguishes If-Match conflicts", async () => { + const consoleError = vi.spyOn(console, "error"); const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const path = String(input); if (path === "/api/webdav/accounts") { @@ -2655,6 +2661,7 @@ describe("DataPage", () => { } if (path === "/api/webdav/folders") return jsonResponse([]); if (path === "/api/data/quality-surface") return jsonResponse(dataQualitySurface); + if (path === "/api/data/quality-surface/evidence-snapshot") return jsonResponse(dataEvidenceSnapshot); expect(path).toBe("/api/webdav/writeback-intent"); expect(JSON.parse(String(init?.body))).toEqual({ target_source_id: "webdav_src_team", @@ -2686,6 +2693,7 @@ describe("DataPage", () => { }); expect(container.textContent).toContain("If-Match/ETag 충돌"); + expect(consoleError).not.toHaveBeenCalled(); expect(container.textContent).not.toContain("webdav_src_team"); }); diff --git a/frontend/src/app/projects/page.test.tsx b/frontend/src/app/projects/page.test.tsx index ba935c2e1..a020cf14b 100644 --- a/frontend/src/app/projects/page.test.tsx +++ b/frontend/src/app/projects/page.test.tsx @@ -85,6 +85,13 @@ describe("ProjectsPage", () => { owner_user_id: "alice", organization_id: "org-acme", }, + { + folder_uid: "webdav_folder_second", + project_name: "Second Project", + webdav_path: "/Projects/Second_Project", + owner_user_id: "alice", + organization_id: "org-acme", + }, { folder_uid: "webdav_folder_rival", project_name: "Rival Project", @@ -144,13 +151,20 @@ describe("ProjectsPage", () => { expect(container.textContent).not.toContain("/Projects/Naruon_Roadmap_2026"); expect(container.textContent).toContain("외부 저장소 쓰기는 별도 승인 전까지 실행하지 않습니다"); expect(container.textContent).toContain("WebDAV 폴더 근거"); + expect(container.textContent).toContain("진행률 확인 불가"); + expect(container.textContent).not.toContain("50%"); + expect(container.querySelectorAll('progress')).toHaveLength(0); + const secondProjectButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('Second Project') && button.textContent?.includes('WebDAV 프로젝트')); + expect(secondProjectButton?.textContent).toContain("진행률 확인 불가"); + expect(secondProjectButton?.textContent).toContain("상태 확인 불가"); + expect(container.querySelector('[aria-label="프로젝트 개요"]')?.textContent).toContain("상태 확인 불가"); expect(container.textContent).toContain("리소스 배정 검토 회의"); expect(container.textContent).toContain("스레드 근거 연결됨"); expect(container.textContent).not.toContain("thread-q2"); expect(container.textContent).not.toContain(""); expect(container.textContent).toContain("프로젝트 액션"); expect(container.textContent).toContain("새 프로젝트"); - expect(container.textContent).toContain("마일스톤 추가"); + expect(container.textContent).toContain("작업 보드 열기"); expect(container.textContent).toContain("의사결정 추가"); expect(container.textContent).toContain("관련 문서/메일 연결"); expect(container.textContent).not.toContain("Naruon 2.0 런칭"); @@ -161,6 +175,8 @@ describe("ProjectsPage", () => { expect(evidenceNote).not.toBeNull(); expect(evidenceSource).not.toBeNull(); expect(saveButton).toBeDefined(); + expect(saveButton?.disabled).toBe(true); + expect(container.textContent).toContain("메모 저장은 아직 지원하지 않습니다. 입력 내용은 이 화면에서만 유지되며 새로고침하면 사라집니다."); await act(async () => { setNativeValue(evidenceNote!, "이사회 승인 근거와 WebDAV 경계를 함께 검토합니다."); @@ -173,7 +189,8 @@ describe("ProjectsPage", () => { await act(async () => { saveButton!.click(); }); - expect(container.textContent).toContain("프로젝트 근거가 저장되었습니다: 문서 근거"); + expect(container.textContent).not.toContain("프로젝트 근거가 저장되었습니다"); + expect(fetchMock.mock.calls.some(([, init]) => init?.method === "POST")).toBe(false); expect(container.textContent).toContain("이사회 승인 근거와 WebDAV 경계를 함께 검토합니다."); }); @@ -405,6 +422,9 @@ describe("ProjectsPage", () => { expect(fetchMock).toHaveBeenCalledWith("/api/projects/project_candidate%3Aalpha/traceability", expect.objectContaining({ headers: expect.any(Object) })); expect(fetchMock).toHaveBeenCalledWith("/api/projects/project_candidate%3Aalpha/evidence/requirement%3Aalpha-payment-retry", expect.objectContaining({ headers: expect.any(Object) })); expect(container.textContent).toContain("Project: Alpha Checkout"); + expect(container.textContent).toContain("진행률 확인 불가"); + expect(container.textContent).not.toContain("87%"); + expect(container.querySelectorAll('progress')).toHaveLength(0); expect(container.textContent).toContain("프로젝트 관계 맥락"); expect(container.textContent).toContain("Traceability Map"); expect(container.textContent).toContain("Evidence Inspector"); @@ -475,8 +495,67 @@ describe("ProjectsPage", () => { expect(container.textContent).not.toContain("correction-alpha-1"); }); - it("renders an actionable fallback when project evidence fails", async () => { - vi.stubGlobal("fetch", vi.fn(() => jsonResponse({ detail: "failed" }, false, 500))); + it("does not render empty metrics while project sources are pending", async () => { + const pendingSources = Promise.withResolvers(); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + await pendingSources.promise; + const requestPath = String(input); + if (requestPath === "/auth/session") return jsonResponse({ authenticated: true, claims: { userId: "alice", organizationId: "org-acme" } }); + if (requestPath === "/api/projects/candidates") return jsonResponse({ candidates: [] }); + return jsonResponse([]); + })); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { root?.render(); }); + expect(container.querySelector('[role="status"]')?.textContent).toContain("불러오는 중"); + expect(container.textContent).not.toContain("원본 연결 작업 대기열"); + expect(container.querySelector('[aria-label="조회된 작업 현황"]')).toBeNull(); + await act(async () => { pendingSources.resolve(); }); + await flushAsyncWork(); + expect(container.textContent).toContain("원본 연결 작업 대기열"); + expect(container.querySelector('[aria-label="조회된 작업 현황"]')).not.toBeNull(); + }); + + it.each([ + { sourcePath: "/api/webdav/folders", responseBody: {} }, + { sourcePath: "/api/webdav/folders", responseBody: [null] }, + { sourcePath: "/api/tasks", responseBody: {} }, + { sourcePath: "/api/tasks", responseBody: [null] }, + { sourcePath: "/api/tasks", responseBody: [{ id: "task-invalid", title: "Invalid task", status: "unknown", priority: "normal" }] }, + { sourcePath: "/api/projects/candidates", responseBody: {} }, + { sourcePath: "/api/projects/candidates", responseBody: { candidates: [null] } }, + { sourcePath: "/api/projects/candidates", responseBody: { candidates: [{ project_uid: "project-invalid", title: {} }] } }, + { sourcePath: "/auth/session", responseBody: { authenticated: false } }, + { sourcePath: "/auth/session", responseBody: { authenticated: false, claims: { userId: "alice", organizationId: "org-acme" } } }, + ])("rejects malformed successful response from $sourcePath ($responseBody)", async ({ sourcePath, responseBody }) => { + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const requestPath = String(input); + if (requestPath === sourcePath) return jsonResponse(responseBody); + if (requestPath === "/auth/session") return jsonResponse({ authenticated: true, claims: { userId: "alice", organizationId: "org-acme" } }); + if (requestPath === "/api/projects/candidates") return jsonResponse({ candidates: [] }); + return jsonResponse([]); + })); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { root?.render(); }); + await flushAsyncWork(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("프로젝트 근거를 불러오지 못했습니다"); + expect(container.querySelector('[aria-label="조회된 작업 현황"]')).toBeNull(); + expect(container.textContent).not.toContain("원본 연결 작업 대기열"); + }); + + it.each(["/api/webdav/folders", "/api/tasks", "/api/projects/candidates", "/auth/session"])("keeps %s failure distinct from empty data and retries", async (failedPath) => { + let sourceUnavailable = true; + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const requestPath = String(input); + if (requestPath === failedPath && sourceUnavailable) return jsonResponse({ detail: "failed" }, false, 500); + if (requestPath === "/auth/session") return jsonResponse({ authenticated: true, claims: { userId: "alice", organizationId: "org-acme" } }); + if (requestPath === "/api/projects/candidates") return jsonResponse({ candidates: [] }); + if (requestPath === "/api/webdav/folders" || requestPath === "/api/tasks") return jsonResponse([]); + return jsonResponse({}, false, 404); + })); container = document.createElement("div"); document.body.appendChild(container); @@ -491,10 +570,60 @@ describe("ProjectsPage", () => { expect( Array.from(container.querySelectorAll('a[href="/data"]')).some((link) => link.textContent?.includes("원본 연결") || link.textContent?.includes("새 프로젝트")), ).toBe(true); + expect(container.textContent).not.toContain("원본 연결 작업 대기열"); + expect(container.querySelector('[aria-label="조회된 작업 현황"]')).toBeNull(); + expect(container.textContent).not.toContain("서명된 개인 워크스페이스"); + const retryButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "다시 불러오기"); + expect(retryButton).toBeDefined(); + sourceUnavailable = false; + await act(async () => { retryButton?.click(); }); + await flushAsyncWork(); + expect(container.querySelector('[role="alert"]')).toBeNull(); expect(container.textContent).toContain("원본 연결 작업 대기열"); + expect(container.querySelector('[aria-label="조회된 작업 현황"]')).not.toBeNull(); + }); + + it.each([ + { taskStatuses: [], expectedProgress: null }, + { taskStatuses: ['open'], expectedProgress: 0 }, + { taskStatuses: ['done', 'open'], expectedProgress: 50 }, + { taskStatuses: ['done'], expectedProgress: 100 }, + ])("uses only the returned task queue denominator: $taskStatuses", async ({ taskStatuses, expectedProgress }) => { + vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => { + const requestPath = String(input); + if (requestPath === '/auth/session') return jsonResponse({ authenticated: true, claims: { userId: 'alice', organizationId: 'org-acme' } }); + if (requestPath === '/api/webdav/folders') return jsonResponse([]); + if (requestPath === '/api/projects/candidates') return jsonResponse({ candidates: [] }); + if (requestPath === '/api/tasks') return jsonResponse(taskStatuses.map((taskStatus, taskIndex) => ({ + id: `task-queue-${taskIndex}`, title: '조회된 작업 검토', status: taskStatus, + priority: 'normal', source_type: 'email', source_email_id: null, related_thread_id: null, + created_at: '2026-09-01T00:00:00Z', updated_at: '2026-09-01T00:00:00Z', + }))); + return jsonResponse({}, false, 404); + })); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { root?.render(); }); + await flushAsyncWork(); + expect(container.textContent).toContain('원본 연결 작업 대기열'); + const progressElements = container.querySelectorAll('progress'); + if (expectedProgress === null) { + expect(progressElements).toHaveLength(0); + expect(container.textContent).toContain('진행률 확인 불가'); + expect(container.textContent).not.toContain('0%'); + } else { + expect(progressElements).toHaveLength(2); + expect(container.textContent).toContain(`조회된 ${taskStatuses.length}건 중 ${taskStatuses.filter((taskStatus) => taskStatus === 'done').length}건 완료. 프로젝트별 집계가 아닙니다.`); + for (const progressElement of progressElements) { + expect(progressElement.value).toBe(expectedProgress); + expect(progressElement.max).toBe(100); + expect(progressElement.getAttribute('aria-label')).toBe('조회된 작업 완료율'); + } + } }); - it("renders an actionable empty state when a project has no linked tasks", async () => { + it("renders an actionable empty state when no returned tasks are registered", async () => { vi.stubGlobal( "fetch", vi.fn((input: RequestInfo | URL) => { @@ -532,9 +661,11 @@ describe("ProjectsPage", () => { await flushAsyncWork(); expect(container.textContent).toContain("Evidence Empty Project"); - expect(container.textContent).toContain("연결된 실행 항목이 아직 없습니다."); - expect(container.textContent).toContain("작업 API에 프로젝트와 연결된 메일, 문서, 스레드 근거"); - expect(container.querySelector('[role="status"]')?.textContent).toContain("연결된 실행 항목"); + expect(container.textContent).toContain("진행률 확인 불가"); + expect(container.textContent).not.toContain("0%"); + expect(container.textContent).toContain("등록된 작업이 아직 없습니다."); + expect(container.textContent).toContain("프로젝트별 작업 연결은 아직 지원하지 않습니다."); + expect(container.querySelector('[role="status"]')?.textContent).toContain("등록된 작업"); expect(Array.from(container.querySelectorAll('a[href="/tasks"]')).some((link) => link.textContent?.includes("작업 보드 열기"))).toBe(true); expect(Array.from(container.querySelectorAll('a[href="/search"]')).some((link) => link.textContent?.includes("관련 근거 찾기"))).toBe(true); }); diff --git a/frontend/src/components/DataLayout.document-action-lifecycle.test.tsx b/frontend/src/components/DataLayout.document-action-lifecycle.test.tsx new file mode 100644 index 000000000..91bc49b2a --- /dev/null +++ b/frontend/src/components/DataLayout.document-action-lifecycle.test.tsx @@ -0,0 +1,175 @@ +/* @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +interface RepositoryProps { + isDocumentActionLoading: boolean; + activeDocumentAction: string | null; + documentActionStatus: string; + requestDocumentUpload: () => Promise; + requestDocumentAction: ( + action: 'reparse' | 'embedding-regeneration-intent' | 'webdav-materialization-intent', + ) => Promise; +} + +const apiClientMock = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + postForm: vi.fn(), +})); +const repositoryProps = vi.hoisted(() => ({ + current: null as RepositoryProps | null, +})); + +vi.mock('@/lib/api-client', () => ({ apiClient: apiClientMock })); +vi.mock('lucide-react', () => ({ + Database: () => , +})); +vi.mock('./data-layout/DocumentRepositoryTab', () => ({ + DocumentRepositoryTab: (props: RepositoryProps) => { + repositoryProps.current = props; + return null; + }, +})); +vi.mock('./data-layout/IngestionPipelineTab', () => ({ IngestionPipelineTab: () => null })); +vi.mock('./data-layout/EmbeddingTab', () => ({ EmbeddingTab: () => null })); +vi.mock('./data-layout/QualityCheckTab', () => ({ QualityCheckTab: () => null })); + +import { DataLayout } from './DataLayout'; + +const workspaceDocument = { + asset_key: 'workspace-document-1', + asset_type: 'workspace_document', + display_name: '운영 계약.md', + source_label: '로컬 문서', + state_code: 'ready', + detail_text: '검토 가능한 문서 근거', + content_chars: 128, + captured_at: '2026-09-07T00:00:00Z', + evidence_source: 'workspace_document', + thread_key: 'thread-1', + provider_write_executed: false, +}; + +const qualitySurface = { + repositories: [], + pipeline_stages: [], + connector_events: [], + repository_assets: [workspaceDocument], + provider_write_executed: false, +}; + +const evidenceSnapshot = { + snapshot_version: 'data_quality_evidence_snapshot.v1', + privacy_redaction_policy: { raw_content_exposed: false }, +}; + +async function flushAsyncWork() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('DataLayout document action lifecycle', () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; + repositoryProps.current = null; + vi.clearAllMocks(); + }); + + it('keeps the shared action lock until the post-action quality refresh settles', async () => { + let qualityRequestCount = 0; + let resolveRefresh: ((value: typeof qualitySurface) => void) | undefined; + const pendingRefresh = new Promise((resolve) => { + resolveRefresh = resolve; + }); + + apiClientMock.get.mockImplementation((path: string) => { + if (path === '/api/webdav/accounts') return Promise.resolve([]); + if (path === '/api/webdav/folders') return Promise.resolve([]); + if (path === '/api/data/quality-surface/evidence-snapshot') return Promise.resolve(evidenceSnapshot); + if (path === '/api/data/quality-surface') { + qualityRequestCount += 1; + return qualityRequestCount === 1 ? Promise.resolve(qualitySurface) : pendingRefresh; + } + return Promise.reject(new Error(`Unexpected GET path: ${path}`)); + }); + apiClientMock.post.mockResolvedValue({ + document_name: workspaceDocument.display_name, + message: '재파싱 완료', + provider_write_executed: false, + }); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + await flushAsyncWork(); + await flushAsyncWork(); + + expect(repositoryProps.current).not.toBeNull(); + expect(repositoryProps.current?.isDocumentActionLoading).toBe(false); + + let firstRequest: Promise | undefined; + await act(async () => { + firstRequest = repositoryProps.current?.requestDocumentAction('reparse'); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(apiClientMock.post).toHaveBeenCalledTimes(1); + expect(repositoryProps.current?.isDocumentActionLoading).toBe(true); + expect(repositoryProps.current?.activeDocumentAction).toBe('reparse'); + expect(repositoryProps.current?.documentActionStatus).toBe('loading'); + + await act(async () => { + void repositoryProps.current?.requestDocumentAction('embedding-regeneration-intent'); + await Promise.resolve(); + }); + expect(apiClientMock.post).toHaveBeenCalledTimes(1); + expect(repositoryProps.current?.activeDocumentAction).toBe('reparse'); + expect(repositoryProps.current?.documentActionStatus).toBe('loading'); + + // A rejected re-entry must not overwrite the active action's status while + // validating prerequisites for an action that cannot start. + await act(async () => { + void repositoryProps.current?.requestDocumentAction('webdav-materialization-intent'); + await Promise.resolve(); + }); + expect(apiClientMock.post).toHaveBeenCalledTimes(1); + expect(repositoryProps.current?.activeDocumentAction).toBe('reparse'); + expect(repositoryProps.current?.documentActionStatus).toBe('loading'); + + // Upload shares the same lock; even a programmatic call without a selected + // file must leave the active operation's status untouched. + await act(async () => { + void repositoryProps.current?.requestDocumentUpload(); + await Promise.resolve(); + }); + expect(apiClientMock.post).toHaveBeenCalledTimes(1); + expect(repositoryProps.current?.activeDocumentAction).toBe('reparse'); + expect(repositoryProps.current?.documentActionStatus).toBe('loading'); + + await act(async () => { + resolveRefresh?.(qualitySurface); + await pendingRefresh; + await firstRequest; + }); + + expect(repositoryProps.current?.isDocumentActionLoading).toBe(false); + expect(repositoryProps.current?.activeDocumentAction).toBeNull(); + expect(repositoryProps.current?.documentActionStatus).toBe('success'); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/DataLayout.document-lifecycle.test.tsx b/frontend/src/components/DataLayout.document-lifecycle.test.tsx new file mode 100644 index 000000000..573fb1d7e --- /dev/null +++ b/frontend/src/components/DataLayout.document-lifecycle.test.tsx @@ -0,0 +1,197 @@ +/* @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./data-layout/IngestionPipelineTab', () => ({ IngestionPipelineTab: () => null })); +vi.mock('./data-layout/EmbeddingTab', () => ({ EmbeddingTab: () => null })); +vi.mock('./data-layout/QualityCheckTab', () => ({ QualityCheckTab: () => null })); + +import { DataLayout } from './DataLayout'; + +const documentAsset = { + asset_key: 'document-lifecycle', asset_type: 'workspace_document', + display_name: 'review-note.md', source_label: '문서', state_code: 'ready', + detail_text: '문서 검토', content_chars: 12, captured_at: '2026-09-01T00:00:00Z', + evidence_source: 'workspace_document', thread_key: null, provider_write_executed: false, +}; +const qualitySurface = { + repositories: [], pipeline_stages: [], connector_events: [], + repository_assets: [documentAsset], provider_write_executed: false, +}; +const storedDocument = { + document_id: documentAsset.asset_key, workspace_id: 'workspace-test', + document_name: documentAsset.display_name, document_type: 'text/markdown', + document_status: 'parsed', content_chars: 12, provider_write_executed: false, + provenance: 'server-authoritative', audit_event: 'data.document.reparsed', + message: '문서 작업 완료', +}; + +function jsonResponse(responseBody: unknown, responseStatus = 200) { + return { + ok: responseStatus >= 200 && responseStatus < 300, + status: responseStatus, + statusText: responseStatus < 400 ? 'OK' : 'Service Unavailable', + json: async () => responseBody, + } as Response; +} + +async function flushState() { + await act(async () => { await vi.runOnlyPendingTimersAsync(); }); +} + +describe('DataLayout document request lifecycle', () => { + let reactRoot: Root | null = null; + let testContainer: HTMLDivElement | null = null; + const pendingResponses: Array>> = []; + + beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); }); + + afterEach(async () => { + await act(async () => { + pendingResponses.splice(0).forEach((pendingResponse) => pendingResponse.resolve(jsonResponse(qualitySurface))); + await Promise.resolve(); + }); + if (reactRoot) act(() => reactRoot?.unmount()); + reactRoot = null; + testContainer?.remove(); + testContainer = null; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + function deferredResponse() { + const pendingResponse = Promise.withResolvers(); + pendingResponses.push(pendingResponse); + return pendingResponse; + } + + async function mountWorkspace(postResponse: Promise, refreshResponse: Promise | (() => Promise), initialResponse?: Promise) { + let surfaceReads = 0; + const fetchMock = vi.fn(async (requestInput: RequestInfo | URL, requestInit?: RequestInit) => { + const requestPath = String(requestInput); + if (requestInit?.method === 'POST') return postResponse; + if (requestPath === '/api/data/quality-surface') { + if (++surfaceReads === 1) return initialResponse ?? jsonResponse(qualitySurface); + return typeof refreshResponse === 'function' ? refreshResponse() : refreshResponse; + } + if (requestPath === '/api/data/quality-surface/evidence-snapshot') return jsonResponse({ + snapshot_version: 'data_quality_evidence_snapshot.v1', privacy_redaction_policy: { raw_content_exposed: false }, + }); + if (requestPath === '/api/webdav/accounts' || requestPath === '/api/webdav/folders') return jsonResponse([]); + throw new Error(`Unexpected unit request: ${requestPath}`); + }); + vi.stubGlobal('fetch', fetchMock); + testContainer = document.createElement('div'); + document.body.appendChild(testContainer); + reactRoot = createRoot(testContainer); + await act(async () => { reactRoot?.render(); }); + await flushState(); + if (!initialResponse) expect(actionButton('reparse').disabled).toBe(false); + return fetchMock; + } + + function actionButton(actionName: string) { + const buttonElement = testContainer?.querySelector(`[data-document-action="${actionName}"]`); + expect(buttonElement).not.toBeNull(); + return buttonElement!; + } + + async function selectDocument(fileName: string) { + const fileInput = testContainer!.querySelector('input[accept*=".markdown"]')!; + const sourceFile = new File(['review text'], fileName, { type: 'text/markdown' }); + Object.defineProperty(sourceFile, 'text', { value: async () => 'review text' }); + Object.defineProperty(fileInput, 'files', { configurable: true, value: [sourceFile] }); + await act(async () => { fileInput.dispatchEvent(new Event('change', { bubbles: true })); }); + return fileInput; + } + + it.each(['upload', 'reparse'])('keeps %s locked until the post-write refresh completes', async (actionName) => { + const postResponse = deferredResponse(); + const refreshResponse = deferredResponse(); + await mountWorkspace(postResponse.promise, refreshResponse.promise); + if (actionName === 'upload') await selectDocument('review-note.md'); + await act(async () => { actionButton(actionName).click(); }); + await act(async () => { postResponse.resolve(jsonResponse(storedDocument)); }); + await flushState(); + expect(actionButton(actionName).getAttribute('aria-busy')).toBe('true'); + expect(actionButton(actionName).disabled).toBe(true); + expect(actionButton('embedding-regeneration-intent').disabled).toBe(true); + await act(async () => { refreshResponse.resolve(jsonResponse(qualitySurface)); }); + await flushState(); + expect(actionButton(actionName).getAttribute('aria-busy')).toBe('false'); + expect(actionButton(actionName).disabled).toBe(false); + }); + + it('does not send two writes when the same action is invoked before React commits', async () => { + const postResponse = deferredResponse(); + const fetchMock = await mountWorkspace(postResponse.promise, Promise.resolve(jsonResponse(qualitySurface))); + await act(async () => { + actionButton('reparse').click(); + actionButton('reparse').click(); + }); + expect(fetchMock.mock.calls.filter(([, requestInit]) => requestInit?.method === 'POST')).toHaveLength(1); + expect(actionButton('reparse').getAttribute('aria-busy')).toBe('true'); + }); + + it('keeps an upload locked when the file selection changes during its write', async () => { + const postResponse = deferredResponse(); + const fetchMock = await mountWorkspace(postResponse.promise, Promise.resolve(jsonResponse(qualitySurface))); + await selectDocument('first-note.md'); + await act(async () => { actionButton('upload').click(); }); + await selectDocument('second-note.md'); + expect(actionButton('upload').disabled).toBe(true); + expect(actionButton('upload').getAttribute('aria-busy')).toBe('true'); + await act(async () => { actionButton('upload').click(); }); + expect(fetchMock.mock.calls.filter(([, requestInit]) => requestInit?.method === 'POST')).toHaveLength(1); + }); + + it('distinguishes a committed write from a failed refresh and retries only reads', async () => { + const refreshResponse = deferredResponse(); + let refreshAttempts = 0; + const fetchMock = await mountWorkspace(Promise.resolve(jsonResponse(storedDocument)), () => ++refreshAttempts === 1 ? refreshResponse.promise : Promise.resolve(jsonResponse(qualitySurface))); + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => undefined); + await act(async () => { actionButton('reparse').click(); }); + await act(async () => { refreshResponse.resolve(jsonResponse({ message: 'unavailable' }, 503)); }); + await flushState(); + expect(errorLog.mock.calls).toEqual([['Data quality surface fetch error', { status: 503, error_name: 'ApiClientError' }]]); + expect(testContainer!.textContent).toContain('요청 결과를 받았지만 목록을 새로 불러오지 못했습니다.'); + expect(testContainer!.textContent).not.toContain('문서 작업에 실패했습니다.'); + const retryButton = Array.from(testContainer!.querySelectorAll('button')).find((buttonElement) => buttonElement.textContent === '목록 다시 불러오기'); + expect(retryButton).toBeDefined(); + await act(async () => { retryButton?.click(); }); + await flushState(); + expect(fetchMock.mock.calls.filter(([, requestInit]) => requestInit?.method === 'POST')).toHaveLength(1); + expect(fetchMock.mock.calls.filter(([requestInput]) => String(requestInput) === '/api/data/quality-surface')).toHaveLength(3); + expect(testContainer!.textContent).not.toContain('요청 결과를 받았지만 목록을 새로 불러오지 못했습니다.'); + expect(actionButton('reparse').disabled).toBe(false); + expect(errorLog).toHaveBeenCalledTimes(1); + }); + + it('ignores an initial response arriving after the post-write refresh', async () => { + const initialResponse = deferredResponse(); + const freshSurface = { ...qualitySurface, repository_assets: [{ ...documentAsset, display_name: 'fresh-after-write.md' }] }; + await mountWorkspace(Promise.resolve(jsonResponse(storedDocument)), Promise.resolve(jsonResponse(freshSurface)), initialResponse.promise); + await selectDocument('review-note.md'); + await act(async () => { actionButton('upload').click(); }); + await flushState(); + expect(testContainer!.textContent).toContain('fresh-after-write.md'); + await act(async () => { initialResponse.resolve(jsonResponse({ ...qualitySurface, repository_assets: [{ ...documentAsset, display_name: 'obsolete-before-write.md' }] })); }); + await flushState(); + expect(testContainer!.textContent).toContain('fresh-after-write.md'); + expect(testContainer!.textContent).not.toContain('obsolete-before-write.md'); + }); + + it('does not start a refresh for a write response received after unmount', async () => { + const postResponse = deferredResponse(); + const fetchMock = await mountWorkspace(postResponse.promise, Promise.resolve(jsonResponse(qualitySurface))); + await act(async () => { actionButton('reparse').click(); }); + act(() => reactRoot?.unmount()); + reactRoot = null; + await act(async () => { postResponse.resolve(jsonResponse(storedDocument)); }); + await flushState(); + expect(fetchMock.mock.calls.filter(([, requestInit]) => requestInit?.method === 'POST')).toHaveLength(1); + expect(fetchMock.mock.calls.filter(([requestInput]) => String(requestInput) === '/api/data/quality-surface')).toHaveLength(1); + }); +}); diff --git a/frontend/src/components/DataLayout.tsx b/frontend/src/components/DataLayout.tsx index f0c062b82..6b56a9a49 100644 --- a/frontend/src/components/DataLayout.tsx +++ b/frontend/src/components/DataLayout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState, useEffect, useMemo, type ChangeEvent, type KeyboardEvent } from 'react'; +import { useCallback, useState, useEffect, useMemo, useRef, type ChangeEvent, type KeyboardEvent } from 'react'; import { Database } from 'lucide-react'; import { apiClient } from '@/lib/api-client'; @@ -18,6 +18,8 @@ import { UniqueThreadStatus, EmailImportStatus, DocumentActionStatus, + ActiveDocumentAction, + DocumentOperation, DataSurfaceStatus, DataEvidenceSnapshotResponse, DataQualitySurfaceResponse, @@ -65,12 +67,15 @@ export function DataLayout() { const [emailImportResult, setEmailImportResult] = useState(null); const [emailImportFiles, setEmailImportFiles] = useState([]); const [documentActionStatus, setDocumentActionStatus] = useState('idle'); + const [activeDocumentAction, setActiveDocumentAction] = useState(null); const [documentActionResult, setDocumentActionResult] = useState(null); const [documentUploadFiles, setDocumentUploadFiles] = useState([]); const [dataSurfaceStatus, setDataSurfaceStatus] = useState('loading'); const [dataQualitySurface, setDataQualitySurface] = useState(null); const [dataEvidenceSnapshot, setDataEvidenceSnapshot] = useState(null); const [selectedRepositoryAssetKey, setSelectedRepositoryAssetKey] = useState(null); + const activeDocumentRequest = useRef(null); + const surfaceRequestRevision = useRef(0); const webdavAccountMap = useMemo( () => new Map(webdavAccounts.map((account, index) => [ @@ -80,42 +85,44 @@ export function DataLayout() { [webdavAccounts], ); - const loadDataEvidenceSnapshot = useCallback(async () => { - try { - const snapshot = await apiClient.get('/api/data/quality-surface/evidence-snapshot'); - if ( - snapshot.snapshot_version !== 'data_quality_evidence_snapshot.v1' - || snapshot.privacy_redaction_policy.raw_content_exposed !== false - ) { - throw new Error('Invalid evidence snapshot response'); - } - setDataEvidenceSnapshot(snapshot); - return snapshot; - } catch (error: unknown) { - console.error('Data evidence snapshot fetch error', getSafeErrorSummary(error)); - setDataEvidenceSnapshot(null); - return null; - } - }, []); - const loadDataQualitySurface = useCallback(async () => { + const requestRevision = ++surfaceRequestRevision.current; try { - const [data] = await Promise.all([ + const snapshotResponse = apiClient.get('/api/data/quality-surface/evidence-snapshot') + .then((snapshot) => { + if (snapshot.snapshot_version !== 'data_quality_evidence_snapshot.v1' + || snapshot.privacy_redaction_policy.raw_content_exposed !== false) { + throw new Error('Invalid evidence snapshot response'); + } + return snapshot; + }) + .catch((error: unknown) => { + if (requestRevision === surfaceRequestRevision.current) { + console.error('Data evidence snapshot fetch error', getSafeErrorSummary(error)); + } + return null; + }); + const [data, snapshot] = await Promise.all([ apiClient.get('/api/data/quality-surface'), - loadDataEvidenceSnapshot(), + snapshotResponse, ]); + if (requestRevision !== surfaceRequestRevision.current) return false; if (!Array.isArray(data.repositories) || !Array.isArray(data.pipeline_stages)) { throw new Error('Invalid data quality surface response'); } setDataQualitySurface(data); + setDataEvidenceSnapshot(snapshot); setDataSurfaceStatus('ready'); + return true; } catch (error: unknown) { + if (requestRevision !== surfaceRequestRevision.current) return false; console.error('Data quality surface fetch error', getSafeErrorSummary(error)); setDataQualitySurface(null); setDataEvidenceSnapshot(null); setDataSurfaceStatus('error'); + return false; } - }, [loadDataEvidenceSnapshot]); + }, []); useEffect(() => { const dataQualitySurfaceTimer = window.setTimeout(() => { @@ -140,7 +147,11 @@ export function DataLayout() { .then(data => Array.isArray(data) && setProjectFolders(data)) .catch((error: unknown) => console.error('WebDAV folders fetch error', getSafeErrorSummary(error))); - return () => window.clearTimeout(dataQualitySurfaceTimer); + return () => { + window.clearTimeout(dataQualitySurfaceTimer); + surfaceRequestRevision.current += 1; + activeDocumentRequest.current = null; + }; }, [loadDataQualitySurface]); const requestWebdavWritebackIntent = useCallback(async () => { @@ -221,12 +232,14 @@ export function DataLayout() { }, [emailImportFiles]); const handleDocumentFileChange = useCallback((event: ChangeEvent) => { + if (activeDocumentRequest.current) return; setDocumentUploadFiles(Array.from(event.target.files ?? [])); setDocumentActionResult(null); setDocumentActionStatus('idle'); }, []); const requestDocumentUpload = useCallback(async () => { + if (activeDocumentRequest.current) return; const [file] = documentUploadFiles; if (!file) { setDocumentActionStatus('error'); @@ -239,15 +252,20 @@ export function DataLayout() { return; } + const documentType = getDocumentTypeForFile(file); + if (!isTextDocumentUploadType(documentType)) { + setDocumentActionStatus('error'); + return; + } + + const requestIdentity = Symbol('document-upload'); + activeDocumentRequest.current = requestIdentity; + setActiveDocumentAction('upload'); setDocumentActionStatus('loading'); setDocumentActionResult(null); try { - const documentType = getDocumentTypeForFile(file); - if (!isTextDocumentUploadType(documentType)) { - setDocumentActionStatus('error'); - return; - } const documentContent = await file.text(); + if (activeDocumentRequest.current !== requestIdentity) return; const result = await apiClient.post( '/api/data/documents', { @@ -256,19 +274,29 @@ export function DataLayout() { document_content: documentContent, }, ); + if (activeDocumentRequest.current !== requestIdentity) return; setDocumentActionResult(result); - setDocumentActionStatus('success'); setDataSurfaceStatus('loading'); - await loadDataQualitySurface(); + const refreshSucceeded = await loadDataQualitySurface(); + if (activeDocumentRequest.current === requestIdentity) { + setDocumentActionStatus(refreshSucceeded ? 'success' : 'refresh_error'); + } } catch (error: unknown) { + if (activeDocumentRequest.current !== requestIdentity) return; const status = getApiErrorStatus(error); setDocumentActionStatus(status === 401 || status === 403 ? 'auth' : 'error'); + } finally { + if (activeDocumentRequest.current === requestIdentity) { + activeDocumentRequest.current = null; + setActiveDocumentAction(null); + } } }, [documentUploadFiles, loadDataQualitySurface]); const requestDocumentAction = useCallback(async ( - action: 'reparse' | 'embedding-regeneration-intent' | 'hwp-conversion-intent' | 'webdav-materialization-intent', + action: DocumentOperation, ) => { + if (activeDocumentRequest.current) return; const asset = dataQualitySurface?.repository_assets.find((candidate) => ( candidate.asset_key === selectedRepositoryAssetKey )) ?? dataQualitySurface?.repository_assets[0] ?? null; @@ -284,6 +312,9 @@ export function DataLayout() { return; } + const requestIdentity = Symbol('document-action'); + activeDocumentRequest.current = requestIdentity; + setActiveDocumentAction(action); setDocumentActionStatus('loading'); setDocumentActionResult(null); try { @@ -293,13 +324,22 @@ export function DataLayout() { ? { target_source_id: targetSourceId, execute_provider: true } : {}, ); + if (activeDocumentRequest.current !== requestIdentity) return; setDocumentActionResult(result); - setDocumentActionStatus('success'); setDataSurfaceStatus('loading'); - await loadDataQualitySurface(); + const refreshSucceeded = await loadDataQualitySurface(); + if (activeDocumentRequest.current === requestIdentity) { + setDocumentActionStatus(refreshSucceeded ? 'success' : 'refresh_error'); + } } catch (error: unknown) { + if (activeDocumentRequest.current !== requestIdentity) return; const status = getApiErrorStatus(error); setDocumentActionStatus(status === 401 || status === 403 ? 'auth' : 'error'); + } finally { + if (activeDocumentRequest.current === requestIdentity) { + activeDocumentRequest.current = null; + setActiveDocumentAction(null); + } } }, [ dataQualitySurface, @@ -310,12 +350,30 @@ export function DataLayout() { webdavAccountStatus, ]); + const retryDocumentRefresh = useCallback(async () => { + if (activeDocumentRequest.current || !documentActionResult) return; + const requestIdentity = Symbol('document-refresh'); + activeDocumentRequest.current = requestIdentity; + setDocumentActionStatus('loading'); + setDataSurfaceStatus('loading'); + try { + const refreshSucceeded = await loadDataQualitySurface(); + if (activeDocumentRequest.current === requestIdentity) { + setDocumentActionStatus(refreshSucceeded ? 'success' : 'refresh_error'); + } + } finally { + if (activeDocumentRequest.current === requestIdentity) { + activeDocumentRequest.current = null; + } + } + }, [documentActionResult, loadDataQualitySurface]); + const isWritebackLoading = writebackStatus === 'loading'; const isWebdavSourceLoading = webdavAccountStatus === 'loading'; const canRequestWebdavWriteback = webdavAccountStatus === 'ready'; const isUniqueThreadLoading = uniqueThreadStatus === 'loading'; const isEmailImportLoading = emailImportStatus === 'loading'; - const isDocumentActionLoading = documentActionStatus === 'loading'; + const isDocumentActionLoading = activeDocumentAction !== null || documentActionStatus === 'loading'; const selectedWebdavAccount = webdavAccounts.find((account) => ( account.source_id === selectedWebdavSourceId && account.writeback_enabled )) ?? webdavAccounts.find((account) => account.writeback_enabled) ?? null; @@ -440,7 +498,9 @@ export function DataLayout() { emailImportResult={emailImportResult} handleDocumentFileChange={handleDocumentFileChange} requestDocumentUpload={requestDocumentUpload} + retryDocumentRefresh={retryDocumentRefresh} isDocumentActionLoading={isDocumentActionLoading} + activeDocumentAction={activeDocumentAction} documentUploadFiles={documentUploadFiles} documentActionStatus={documentActionStatus} documentActionResult={documentActionResult} diff --git a/frontend/src/components/ProjectsLayout.accessibility.test.tsx b/frontend/src/components/ProjectsLayout.accessibility.test.tsx index 9bb58aebe..748b5cb9c 100644 --- a/frontend/src/components/ProjectsLayout.accessibility.test.tsx +++ b/frontend/src/components/ProjectsLayout.accessibility.test.tsx @@ -44,6 +44,18 @@ const candidate = { updated_at: "2026-08-03T00:00:00Z", }; +const projectObject = { + object_uid: "requirement:alpha", + object_type: "requirement", + title: "Checkout approval evidence", + summary: "Evidence review remains separate from evidence retrieval.", + status_code: "open", + confidence: 0.9, + source_segment_uids: ["segment-alpha-1"], + citation_bundle: [], + attributes: {}, +}; + async function flushAsyncWork() { await act(async () => { await Promise.resolve(); @@ -63,6 +75,71 @@ describe("ProjectsLayout accessibility", () => { vi.clearAllMocks(); }); + it.each(["success", "failure"])("separates evidence loading from review saving (%s)", async (outcome) => { + const traceObject = { + object_uid: "project_object:requirement", + object_type: "requirement", + title: "Review requirement", + summary: "Source-backed requirement", + status_code: "needs_review", + confidence: 0.87, + source_segment_uids: [], + citation_bundle: [], + attributes: {}, + }; + const evidence = { ...traceObject, project_uid: candidate.project_uid }; + const pendingEvidence = Promise.withResolvers(); + const pendingSave = Promise.withResolvers(); + const projectPath = "/api/projects/project_candidate%3Aalpha"; + apiClientMock.get.mockImplementation((requestPath: string) => { + if (requestPath === "/api/webdav/folders" || requestPath === "/api/tasks") return Promise.resolve([]); + if (requestPath === "/api/projects/candidates") return Promise.resolve({ candidates: [candidate] }); + if (requestPath === `${projectPath}/traceability`) { + return Promise.resolve({ project_uid: candidate.project_uid, candidate, objects: [traceObject], edges: [] }); + } + if (requestPath === `${projectPath}/evidence/project_object%3Arequirement`) return pendingEvidence.promise; + return Promise.reject(new Error(`Unexpected GET path: ${requestPath}`)); + }); + apiClientMock.getServerSessionClaims.mockResolvedValue({ userId: "alice", organizationId: "org-acme" }); + apiClientMock.post.mockReturnValue(pendingSave.promise); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { root?.render(); }); + await flushAsyncWork(); + const saveButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "문단 근거 검토 저장", + ); + expect(saveButton).toBeDefined(); + expect(saveButton?.disabled).toBe(true); + expect(saveButton?.getAttribute("aria-busy")).toBe("false"); + saveButton?.click(); + expect(apiClientMock.post).not.toHaveBeenCalled(); + await act(async () => { pendingEvidence.resolve(evidence); await pendingEvidence.promise; }); + expect(saveButton?.disabled).toBe(false); + expect(saveButton?.getAttribute("aria-busy")).toBe("false"); + await act(async () => { saveButton?.click(); }); + expect(saveButton?.disabled).toBe(true); + expect(saveButton?.getAttribute("aria-busy")).toBe("true"); + expect(saveButton?.textContent).toBe("검토 저장 중"); + saveButton?.click(); + expect(apiClientMock.post).toHaveBeenCalledTimes(1); + expect(apiClientMock.post).toHaveBeenCalledWith(`${projectPath}/corrections`, expect.objectContaining({ + object_uid: traceObject.object_uid, correction_action: "mark_evidence_reviewed", + })); + await act(async () => { + if (outcome === "success") { + pendingSave.resolve({ object_uid: traceObject.object_uid, after_json: { status_code: "approved" }, created_at: candidate.updated_at }); + } else { + pendingSave.reject(new Error("Save unavailable")); + } + }); + expect(saveButton?.disabled).toBe(false); + expect(saveButton?.getAttribute("aria-busy")).toBe("false"); + expect(saveButton?.textContent).toBe("문단 근거 검토 저장"); + if (outcome === "failure") expect(container.querySelector('[role="alert"]')?.textContent).toContain("저장하지 못했습니다"); + }); + it("announces candidate confirmation as busy while the request is pending", async () => { let resolveConfirmation: ((value: typeof candidate) => void) | undefined; const pendingConfirmation = new Promise((resolve) => { @@ -131,4 +208,51 @@ describe("ProjectsLayout accessibility", () => { expect(confirmButton?.getAttribute("aria-busy")).toBe("false"); expect(confirmButton?.textContent).toContain("프로젝트 후보 확정됨"); }); -}); \ No newline at end of file + + it("keeps evidence retrieval from being announced as a correction save", async () => { + const pendingEvidence = new Promise(() => undefined); + + apiClientMock.get.mockImplementation((path: string) => { + if (path === "/api/webdav/folders") return Promise.resolve([]); + if (path === "/api/tasks") return Promise.resolve([]); + if (path === "/api/projects/candidates") { + return Promise.resolve({ candidates: [candidate] }); + } + if (path === "/api/projects/project_candidate%3Aalpha/traceability") { + return Promise.resolve({ + project_uid: candidate.project_uid, + candidate, + objects: [projectObject], + edges: [], + }); + } + if (path === "/api/projects/project_candidate%3Aalpha/evidence/requirement%3Aalpha") { + return pendingEvidence; + } + return Promise.reject(new Error(`Unexpected GET path: ${path}`)); + }); + apiClientMock.getServerSessionClaims.mockResolvedValue({ + userId: "alice", + organizationId: "org-acme", + workspaceId: "workspace-org-acme", + }); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await flushAsyncWork(); + await flushAsyncWork(); + await flushAsyncWork(); + + const saveButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("문단 근거 검토 저장"), + ); + expect(saveButton).toBeDefined(); + expect(saveButton?.disabled).toBe(true); + expect(saveButton?.getAttribute("aria-busy")).toBe("false"); + }); +}); diff --git a/frontend/src/components/ProjectsLayout.tsx b/frontend/src/components/ProjectsLayout.tsx index 2f750ffd9..603c6fae5 100644 --- a/frontend/src/components/ProjectsLayout.tsx +++ b/frontend/src/components/ProjectsLayout.tsx @@ -104,8 +104,8 @@ interface ProjectCorrectionResponse { interface ProjectSummary { id: string; title: string; - status: '진행 중' | '대기 중' | '완료' | '검토 중'; - progress: number; + status: '진행 중' | '대기 중' | '완료' | '검토 중' | '상태 확인 불가'; + progress: number | null; category: string; evidence: string; sourcePath: string | null; @@ -121,6 +121,7 @@ const projectStatusClass = { '진행 중': 'bg-blue-100 text-blue-700', '검토 중': 'bg-violet-100 text-violet-700', '대기 중': 'bg-slate-100 text-slate-700', + '상태 확인 불가': 'bg-slate-100 text-slate-700', } satisfies Record; const taskStatusLabel: Record = { @@ -154,6 +155,52 @@ function safeText(value: string | null | undefined, fallback = '') { return toSafeReactText(value, fallback).trim() || fallback; } +function isSourceRecord(sourceValue: unknown): sourceValue is Record { + return sourceValue !== null && typeof sourceValue === 'object' && !Array.isArray(sourceValue); +} + +function hasTextFields(sourceRecord: Record, fieldNames: string[]) { + return fieldNames.every((fieldName) => typeof sourceRecord[fieldName] === 'string'); +} + +function isNullableText(sourceValue: unknown) { + return sourceValue === null || typeof sourceValue === 'string'; +} + +function isProjectFolder(sourceValue: unknown): sourceValue is ProjectFolder { + return isSourceRecord(sourceValue) + && hasTextFields(sourceValue, ['folder_uid', 'project_name', 'webdav_path', 'owner_user_id']) + && isNullableText(sourceValue.organization_id); +} + +function isProjectTask(sourceValue: unknown): sourceValue is TicketTask { + return isSourceRecord(sourceValue) + && hasTextFields(sourceValue, ['id', 'title', 'source_type', 'created_at', 'updated_at']) + && typeof sourceValue.status === 'string' && ['open', 'in_progress', 'blocked', 'done'].includes(sourceValue.status) + && typeof sourceValue.priority === 'string' && ['low', 'normal', 'high', 'urgent'].includes(sourceValue.priority) + && isNullableText(sourceValue.source_email_id) + && isNullableText(sourceValue.related_thread_id); +} + +function isProjectCandidate(sourceValue: unknown): sourceValue is ProjectCandidate { + return isSourceRecord(sourceValue) + && hasTextFields(sourceValue, ['candidate_uid', 'project_uid', 'title', 'status_code']) + && typeof sourceValue.score === 'number' && Number.isFinite(sourceValue.score) + && sourceValue.score >= 0 && sourceValue.score <= 1 + && ['object_count', 'requirement_count', 'issue_count', 'milestone_count', 'deliverable_count', 'participant_count', 'source_segment_count'].every((fieldName) => { + const fieldValue = sourceValue[fieldName]; + return typeof fieldValue === 'number' && Number.isSafeInteger(fieldValue) && fieldValue >= 0; + }) + && Array.isArray(sourceValue.representative_object_uids) + && sourceValue.representative_object_uids.every((objectUid) => typeof objectUid === 'string') + && Array.isArray(sourceValue.citation_bundle) + && sourceValue.citation_bundle.every((citationValue) => isSourceRecord(citationValue) + && hasTextFields(citationValue, ['content_segment_uid', 'source_kind', 'source_record_uid', 'safe_text_excerpt']) + && isNullableText(citationValue.heading_path) && isNullableText(citationValue.segment_path) + && typeof citationValue.ordinal_index === 'number' && Number.isSafeInteger(citationValue.ordinal_index)) + && isNullableText(sourceValue.updated_at); +} + function formatDate(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) return '날짜 미정'; @@ -161,7 +208,7 @@ function formatDate(value: string) { } function buildProgress(tasks: TicketTask[]) { - if (tasks.length === 0) return 0; + if (tasks.length === 0) return null; return Math.round((tasks.filter((task) => task.status === 'done').length / tasks.length) * 100); } @@ -216,13 +263,11 @@ function isAuthorizedToViewProject(folder: ProjectFolder, scope: ProjectAccessSc } function buildProjects(folders: ProjectFolder[], tasks: TicketTask[]): ProjectSummary[] { - const progress = buildProgress(tasks); - const status = buildProjectStatus(tasks); - const folderProjects = folders.map((folder) => ({ + const folderProjects: ProjectSummary[] = folders.map((folder) => ({ id: folder.folder_uid, title: safeText(folder.project_name, '이름 없는 프로젝트'), - status, - progress, + status: '상태 확인 불가', + progress: null, category: 'WebDAV 프로젝트', evidence: 'project_folders', sourcePath: safeText(folder.webdav_path, ''), @@ -234,8 +279,8 @@ function buildProjects(folders: ProjectFolder[], tasks: TicketTask[]): ProjectSu { id: 'workspace_task_backlog', title: '원본 연결 작업 대기열', - status, - progress, + status: tasks.length ? buildProjectStatus(tasks) : '상태 확인 불가', + progress: buildProgress(tasks), category: '작업 대기열', evidence: 'ticket_tasks', sourcePath: null, @@ -253,16 +298,12 @@ function semanticStatusToProjectStatus(statusCode: string): ProjectSummary['stat return '대기 중'; } -function semanticProgress(candidate: ProjectCandidate) { - return Math.max(0, Math.min(99, Math.round(candidate.score * 100))); -} - function buildSemanticProjects(candidates: ProjectCandidate[]): ProjectSummary[] { return candidates.map((candidate) => ({ id: candidate.project_uid, title: safeText(candidate.title, '이름 없는 프로젝트 후보'), status: semanticStatusToProjectStatus(candidate.status_code), - progress: semanticProgress(candidate), + progress: null, category: 'Semantic KG 프로젝트', evidence: 'project_graph', sourcePath: null, @@ -324,6 +365,7 @@ export function ProjectsLayout() { const [correctionError, setCorrectionError] = useState(null); const [lastCorrection, setLastCorrection] = useState(null); const [loading, setLoading] = useState(true); + const [sourceRetryRevision, setSourceRetryRevision] = useState(0); const [error, setError] = useState(null); const [selectedProjectId, setSelectedProjectId] = useState(null); const [viewMode, setViewMode] = useState('프로젝트 상세'); @@ -333,7 +375,6 @@ export function ProjectsLayout() { }); const [evidenceDraft, setEvidenceDraft] = useState('WebDAV 프로젝트 폴더를 작업 경계로 사용합니다.'); const [evidenceSource, setEvidenceSource] = useState('webdav_folder'); - const [evidenceSaveStatus, setEvidenceSaveStatus] = useState(null); useEffect(() => { let cancelled = false; @@ -346,9 +387,16 @@ export function ProjectsLayout() { ]) .then(([folderRows, taskRows, candidateRows, claims]) => { if (cancelled) return; - setFolders(Array.isArray(folderRows) ? folderRows : []); - setTasks(Array.isArray(taskRows) ? taskRows : []); - setSemanticCandidates(candidateRows && Array.isArray(candidateRows.candidates) ? candidateRows.candidates : []); + if (!claims.userId) throw new Error('Project session unavailable'); + if (!Array.isArray(folderRows) || !folderRows.every(isProjectFolder) + || !Array.isArray(taskRows) || !taskRows.every(isProjectTask) + || !isSourceRecord(candidateRows) || !Array.isArray(candidateRows.candidates) + || !candidateRows.candidates.every(isProjectCandidate)) { + throw new Error('Project source response invalid'); + } + setFolders(folderRows); + setTasks(taskRows); + setSemanticCandidates(candidateRows.candidates); setProjectScope({ userId: claims.userId, organizationId: claims.organizationId, @@ -369,7 +417,7 @@ export function ProjectsLayout() { return () => { cancelled = true; }; - }, []); + }, [sourceRetryRevision]); const authorizedFolders = useMemo( () => folders.filter((folder) => isAuthorizedToViewProject(folder, projectScope)), @@ -381,17 +429,17 @@ export function ProjectsLayout() { }, [authorizedFolders, semanticCandidates, tasks]); const activeProject = projects.find((project) => project.id === selectedProjectId) ?? projects[0]; const activeSemanticCandidate = semanticCandidates.find((candidate) => candidate.project_uid === activeProject.id) ?? null; - const projectTasks = tasks; - const openCount = countByStatus(projectTasks, 'open'); - const inProgressCount = countByStatus(projectTasks, 'in_progress'); - const blockedCount = countByStatus(projectTasks, 'blocked'); - const doneCount = countByStatus(projectTasks, 'done'); - const sourceTypeCount = new Set(projectTasks.map((task) => task.source_type)).size; + const loadedTasks = tasks; + const openCount = countByStatus(loadedTasks, 'open'); + const inProgressCount = countByStatus(loadedTasks, 'in_progress'); + const blockedCount = countByStatus(loadedTasks, 'blocked'); + const doneCount = countByStatus(loadedTasks, 'done'); + const sourceTypeCount = new Set(loadedTasks.map((task) => task.source_type)).size; const projectEvidenceLabel = getProjectEvidenceLabel(activeProject.evidence); const projectBoundaryLabel = getProjectBoundaryLabel(activeProject); const workspaceScopeLabel = getWorkspaceScopeLabel(projectScope); const selectedEvidenceOption = projectEvidenceSourceOptions.find((option) => option.value === evidenceSource) ?? projectEvidenceSourceOptions[0]; - const savedEvidenceNote = safeText(evidenceDraft, '근거 메모 없음'); + const evidenceDraftPreview = safeText(evidenceDraft, '근거 메모 없음'); const currentTraceability = traceability?.project_uid === activeSemanticCandidate?.project_uid ? traceability : null; const currentObjects = useMemo(() => currentTraceability?.objects ?? [], [currentTraceability?.objects]); const groupedObjects = useMemo(() => groupProjectTraceObjects(currentObjects), [currentObjects]); @@ -402,9 +450,9 @@ export function ProjectsLayout() { const selectedTraceObject = currentTraceability?.objects.find((item) => item.object_uid === selectedObjectUid) ?? currentTraceability?.objects[0] ?? null; const selectedEvidenceProjectUid = activeSemanticCandidate?.project_uid ?? null; // ⚡ Bolt: Memoize project tasks list to prevent O(N) array mapping overhead - const projectTasksList = useMemo(() => ( + const loadedTasksList = useMemo(() => (
    - {projectTasks.slice(0, 8).map((task) => ( + {loadedTasks.slice(0, 8).map((task) => (
  1. @@ -419,7 +467,7 @@ export function ProjectsLayout() {
  2. ))}
- ), [projectTasks]); + ), [loadedTasks]); const selectedEvidenceObjectUid = selectedTraceObject?.object_uid ?? null; const selectedEvidenceKey = selectedEvidenceProjectUid && selectedEvidenceObjectUid ? `${selectedEvidenceProjectUid}:${selectedEvidenceObjectUid}` : null; @@ -428,7 +476,6 @@ export function ProjectsLayout() { const evidenceCitations = currentEvidence?.citation_bundle ?? selectedTraceObject?.citation_bundle ?? []; const currentCorrection = selectedTraceObject && lastCorrection?.object_uid === selectedTraceObject.object_uid ? lastCorrection : null; const candidateConfirmed = activeSemanticCandidate ? activeSemanticCandidate.status_code === 'confirmed' || lastConfirmedCandidateUid === activeSemanticCandidate.candidate_uid : false; - const graphHealthPercent = activeSemanticCandidate ? semanticProgress(activeSemanticCandidate) : 0; useEffect(() => { if (!activeSemanticCandidate) { @@ -479,10 +526,6 @@ export function ProjectsLayout() { }; }, [selectedEvidenceKey, selectedEvidenceObjectUid, selectedEvidenceProjectUid]); - function saveProjectEvidence() { - setEvidenceSaveStatus(`프로젝트 근거가 저장되었습니다: ${selectedEvidenceOption.label}`); - } - async function handleConfirmCandidate() { if (!activeSemanticCandidate || confirmSubmitting) return; setConfirmSubmitting(true); @@ -564,6 +607,27 @@ export function ProjectsLayout() { } } + if (loading || error) { + return ( +
+

프로젝트 워크스페이스

+

+ {loading ? '프로젝트 근거를 불러오는 중입니다.' : error} +

+ {!loading && ( +
+ + 원본 연결 +
+ )} +
+ ); + } + return (
- {loading ? ( -
프로젝트 근거를 불러오는 중입니다.
- ) : null} {projects.map((project) => ( ))} @@ -651,12 +716,6 @@ export function ProjectsLayout() {
- {error ? ( -
- {error} -
- ) : null} - {activeSemanticCandidate ? (
@@ -704,14 +763,6 @@ export function ProjectsLayout() {
))}
-
-
-
-
-
- {graphHealthPercent}% -
-
) : null} @@ -969,6 +1020,7 @@ export function ProjectsLayout() { type="button" onClick={handleMarkEvidenceReviewed} disabled={correctionSubmitting || evidenceLoading} + aria-busy={correctionSubmitting} className="mt-3 min-h-9 w-full rounded-md bg-primary px-3 text-xs font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground" > {correctionSubmitting ? '검토 저장 중' : '문단 근거 검토 저장'} @@ -1008,10 +1060,10 @@ export function ProjectsLayout() { ) : null} {(viewMode === '프로젝트 상세' || viewMode === '마일스톤') && ( -
+
-

마일스톤

- 마일스톤 추가 +

조회된 작업 현황

+ 작업 보드 열기
{[ @@ -1023,7 +1075,7 @@ export function ProjectsLayout() {
{milestone.label}

{milestone.count}

-

원본 연결 작업

+

조회된 작업 기준

))}
@@ -1050,10 +1102,10 @@ export function ProjectsLayout() {

작업 흐름 반영

- {projectTasks.length}개 작업 + {loadedTasks.length}개 작업

- 메일과 스레드 근거가 연결된 실행 항목을 기준으로 상태와 완료 흐름을 집계합니다. + 조회된 작업을 집계한 현황입니다. 선택한 프로젝트와 각 작업의 연결 여부는 아직 확인할 수 없습니다.

근거: 실행 항목

@@ -1071,18 +1123,18 @@ export function ProjectsLayout() {
)} -
+
-

연결 작업

- {projectTasks.length}건 +

조회된 작업

+ {loadedTasks.length}건
- {projectTasks.length > 0 ? projectTasksList : ( + {loadedTasks.length > 0 ? loadedTasksList : (
-

연결된 실행 항목이 아직 없습니다.

+

등록된 작업이 아직 없습니다.

- 서명 세션의 작업 API에 프로젝트와 연결된 메일, 문서, 스레드 근거가 기록되면 이 목록에 표시됩니다. + 작업 보드에서 작업을 등록하거나 관련 메일과 문서를 찾아보세요. 프로젝트별 작업 연결은 아직 지원하지 않습니다.

@@ -1101,7 +1153,7 @@ export function ProjectsLayout() {
새 프로젝트 - 마일스톤 추가 + 작업 보드 열기 관련 문서/메일 연결
@@ -1119,13 +1171,20 @@ export function ProjectsLayout() {
{activeProject.status}
-
진행률
+
{activeProject.evidence === 'ticket_tasks' ? '조회된 작업 완료율' : '진행률'}
-
-
-
- {activeProject.progress}% + {activeProject.progress === null ? ( + 진행률 확인 불가 — 완료한 작업과 전체 작업 수를 확인할 근거가 없습니다. + ) : ( + <> + + {activeProject.progress}% + + )}
+ {activeProject.progress !== null ? ( +
조회된 {loadedTasks.length}건 중 {doneCount}건 완료. 프로젝트별 집계가 아닙니다.
+ ) : null}
원본 근거
@@ -1139,7 +1198,7 @@ export function ProjectsLayout() {

근거 편집

-

판매 심사용 판단 근거와 연결 원본을 저장합니다.

+

메모 저장은 아직 지원하지 않습니다. 입력 내용은 이 화면에서만 유지되며 새로고침하면 사라집니다.

{selectedEvidenceOption.label}
@@ -1148,10 +1207,10 @@ export function ProjectsLayout() {