v0.3.2 — cleanup cut (잠재 bug 4 + cosmetic 5 + #20 deferred) #32
1039
docs/superpowers/plans/2026-05-10-v032-cleanup.md
Normal file
1039
docs/superpowers/plans/2026-05-10-v032-cleanup.md
Normal file
File diff suppressed because it is too large
Load Diff
320
docs/superpowers/specs/2026-05-10-v032-cleanup-design.md
Normal file
320
docs/superpowers/specs/2026-05-10-v032-cleanup-design.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# v0.3.2 — Cleanup Cut Design
|
||||
|
||||
**작성일:** 2026-05-10
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/v024-backlog.md` (잔여 backlog audit)
|
||||
- `~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md` (stale memory — 본 cut 에서 폐기)
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md`
|
||||
|
||||
**Cut 라벨:** v0.3.2 — patch (기능 추가 X, 잠재 bug fix + cosmetic + 기록 정리)
|
||||
|
||||
---
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
기능 추가 X. backlog 잔여 23건 중 **잠재 bug 4건 + cosmetic 6건 + 기록 정리 2건 = 12건** 일괄 처리. data-dependent 항목 (telemetry 분포 의존) 과 cross-cutting refactor (TrayController class / Banner CSS variables) 는 dogfood 후 재평가. Cut F (v0.3.1) + Cut E (v0.3.0) 종합 dogfood ≥1주 soak 진입을 위한 baseline 정리.
|
||||
|
||||
---
|
||||
|
||||
## 2. 범위
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **포함 카테고리** | 잠재 bug + cosmetic + 기록 정리 |
|
||||
| **보류 카테고리** | data-dependent (9건) + cross-cutting refactor (4건) |
|
||||
| **테스트 +** | 단위 710 → 약 720 (+10), typecheck 0 |
|
||||
| **schema 변경** | 없음 (m007 이후) |
|
||||
| **단일 PR** | v0.3.2 — 12 항목 = 7~8 commit (카테고리별 묶음) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 잠재 bug fix (4건)
|
||||
|
||||
### 3-1. `vocabSet` COLLATE NOCASE 정합 (#31)
|
||||
|
||||
**현재 코드** (`src/main/ai/AiWorker.ts` `processJob`):
|
||||
|
||||
```ts
|
||||
const vocab = await this.repo.getTopUsedTags(VOCAB_TOP_N);
|
||||
const vocabSet = new Set(vocab);
|
||||
// ...
|
||||
if (vocabSet.has(tagName)) { ... } // strict-eq, DB COLLATE NOCASE 와 충돌 가능
|
||||
```
|
||||
|
||||
**문제**: vocab pool 확장 시 (사용자가 `'Design'` 같은 capital case 추가하면) `getTagIdByName('Design')` 은 COLLATE NOCASE 로 매치되지만 `vocabSet.has('Design')` strict-eq 는 lowercase 만 등록된 set 에 miss → tagId 있는데 vocab hit 0 → silently skip.
|
||||
|
||||
**수정**:
|
||||
|
||||
```ts
|
||||
const vocab = await this.repo.getTopUsedTags(VOCAB_TOP_N);
|
||||
const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
|
||||
// ...
|
||||
if (vocabSet.has(tagName.toLowerCase())) { ... }
|
||||
```
|
||||
|
||||
**테스트 (3건 신규)**:
|
||||
|
||||
- 대문자 vocab + lowercase AI tag → hit
|
||||
- lowercase vocab + 대문자 AI tag → hit
|
||||
- 동일 lowercase → hit (회귀)
|
||||
|
||||
### 3-2. Time-dependent test flake fix
|
||||
|
||||
**문제**: `NoteRevisions.test.ts` 의 v1 capture 가 `repo.create()` 호출 → `NoteRepository.create` 가 `new Date()` (NOW) 로 `created_at` / `edited_at` 박음. v2 는 fixed `2026-05-10T00:00:00Z` 명시 주입. 시스템 시계가 `2026-05-10T00:00:00Z` 초과 시 v1.edited_at > v2.edited_at → DESC ordering 깨짐. `upsertFromSync.test.ts` 도 동일 패턴.
|
||||
|
||||
**수정**: `NoteRepository.create(input, now?: Date)` 추가 (기존 `setStatus(id, status, reason, now: Date)` / `updateRawText(id, text, now: Date)` 패턴 정합).
|
||||
|
||||
```ts
|
||||
// src/main/repository/NoteRepository.ts
|
||||
create(input: CreateNoteInput, now: Date = new Date()): Note {
|
||||
const ts = now.toISOString();
|
||||
// 기존 INSERT 의 createdAt / updatedAt / edited_at 모두 ts 사용
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**기존 호출자 무영향** — production 코드는 `now` 생략 → 기본 `new Date()` 동일 동작.
|
||||
|
||||
**테스트 (5건 회복 + 2건 신규)**:
|
||||
|
||||
- `NoteRevisions.test.ts` 의 4 testcase v1 capture 도 fixed 시간 (`'2026-05-09T00:00:00Z'`) 주입 → v2 (`'2026-05-10T00:00:00Z'`) 이전 보장
|
||||
- `upsertFromSync.test.ts` 의 v1 capture 도 fixed 시간 주입
|
||||
- `NoteRepository.create` default 시간 (`now` 생략 시 `new Date()`) 단위 1
|
||||
- `NoteRepository.create` 명시 주입 단위 1
|
||||
|
||||
### 3-3. PII reason 마스킹 (#39)
|
||||
|
||||
**현재 코드** (`src/main/ai/LocalOllamaProvider.ts` `healthCheck`):
|
||||
|
||||
```ts
|
||||
} catch (e) {
|
||||
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason: `unreachable: ${(e as Error).message}` } });
|
||||
}
|
||||
```
|
||||
|
||||
**문제**: `err.message` 안에 `http://192.168.x.x:11434/api/tags` 같은 LAN endpoint URL 포함 가능 → telemetry 파일에 PII 우회 노출. v0.2.3.1 in-app endpoint UI 가 LAN 사용 흔하게 만들어 노출 경로 확대.
|
||||
|
||||
**수정**: error class 분류 + host 마스킹.
|
||||
|
||||
```ts
|
||||
function classifyFetchError(e: unknown): 'timeout' | 'network' | 'dns' | 'other' {
|
||||
const msg = (e as Error).message?.toLowerCase() ?? '';
|
||||
if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout';
|
||||
if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network';
|
||||
if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// emit
|
||||
const reason = classifyFetchError(e);
|
||||
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason } });
|
||||
```
|
||||
|
||||
`AiFailedReason` zod enum (`'unreachable' | 'schema' | 'timeout' | 'other'`) 와 별개 — `ollama_unreachable.payload.reason` 만 신규 enum 도입 또는 기존 union 확장. spec 단계 결정: **기존 `'unreachable'` 그대로 유지**, 신규 enum 추가 X (단순화). reason 변환 후 prefix 만 변경:
|
||||
|
||||
```ts
|
||||
const cls = classifyFetchError(e);
|
||||
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason: `unreachable:${cls}` } });
|
||||
```
|
||||
|
||||
**테스트 (4건 신규)**:
|
||||
|
||||
- `ECONNREFUSED` → `unreachable:network`
|
||||
- `ETIMEDOUT` → `unreachable:timeout`
|
||||
- `ENOTFOUND` → `unreachable:dns`
|
||||
- 그 외 → `unreachable:other`
|
||||
|
||||
### 3-4. KST_OFFSET_MS inline duplication 5 callsite → import (#19)
|
||||
|
||||
**현재**: canonical `src/shared/util/kstDate.ts` 가 있는데 5 callsite inline duplicate.
|
||||
|
||||
| 파일 | 라인 | 처리 |
|
||||
|---|---|---|
|
||||
| `src/main/repository/NoteRepository.ts:1042` | inline `const KST_OFFSET_MS = ...` | `import { KST_OFFSET_MS } from '@shared/util/kstDate.js'` |
|
||||
| `src/main/repository/ftsHelpers.ts:18` | 동 | 동 |
|
||||
| `src/main/services/BackupService.ts:6` | 동 | 동 |
|
||||
| `src/main/services/ContinuityService.ts:4` | 동 | 동 |
|
||||
| `src/renderer/inbox/components/NoteCard.tsx:30` | 동 | 동 (renderer alias 경계 X — `@shared/...` 양쪽 import 가능) |
|
||||
|
||||
**테스트**: 단위 추가 없음 — 기존 회귀 검사. `kstDate.ts` 의 export 가 동일 값 (9 \* 60 \* 60 \* 1000) 이므로 동작 무변화.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cosmetic / readability (6건)
|
||||
|
||||
### 4-1. 탭 ARIA 패턴 정정 (#14)
|
||||
|
||||
`aria-pressed` 는 toggle 버튼용. 본 UI 의 탭 (Inbox / 휴지통 / 회고 등) 은 `role="tab"` + `aria-selected` canonical. screen reader 동작 OK 였지만 a11y audit canonical 정정.
|
||||
|
||||
**파일**: `src/renderer/inbox/App.tsx` (탭 컨테이너) — grep 으로 `aria-pressed` 위치 확정 후 수정.
|
||||
|
||||
**테스트**: 단위 추가 없음 (기존 RTL 단위가 selectable 검증). 단 `aria-selected="true"` 관련 assertion 1건 추가 검증.
|
||||
|
||||
### 4-2. `loadExpired()` 미사용 제거 (#18)
|
||||
|
||||
`store.ts` 의 `loadExpired()` action 이 `loadInitial`/`refreshMeta` 가 inline fetch 하면서 사용 안 함. App.tsx 호출 0건. test 만 exercise.
|
||||
|
||||
**처리**: dead-code 제거. 관련 test 도 제거.
|
||||
|
||||
### 4-3. AiWorker per-tag emit `Promise.all` 병렬화 (#32)
|
||||
|
||||
**현재**:
|
||||
|
||||
```ts
|
||||
for (const tag of new Set(...)) {
|
||||
await this.telemetry.emit({ kind: 'tag_vocab_hit' or 'miss', ... }); // serial
|
||||
}
|
||||
```
|
||||
|
||||
**수정**:
|
||||
|
||||
```ts
|
||||
await Promise.all(
|
||||
Array.from(new Set(...)).map((tag) => this.telemetry.emit({ ... }))
|
||||
);
|
||||
```
|
||||
|
||||
**Risk**: emit 순서 변경 — telemetry 파일 라인 순서 의존 단위 없음 확인. `ai_succeeded` emit 도 serial 이지만 본 cut 은 **per-tag emit 만** 변경 (tag_vocab_hit / tag_vocab_miss). `ai_succeeded` 는 serial 유지 (per-tag 와 다른 호출 시점).
|
||||
|
||||
**테스트**: 회귀 단위 PASS 확인. 신규 단위 추가 없음 (병렬 동작 자체 검증은 unit 무리).
|
||||
|
||||
### 4-4. `emitRecallShown` / `emitRecallSnoozed` `ipcMain.handle → on` (#36)
|
||||
|
||||
`fire-and-forget` 정책 호출 측 (RecallBanner) → return value 사용 안 함. canonical pattern: `ipcMain.on` (return value 없음).
|
||||
|
||||
**파일**: `src/main/ipc/inboxApi.ts` 또는 telemetry IPC 정의 위치. `emitRecallShown` / `emitRecallSnoozed` 만 `handle → on` migration. 호출 측 (`window.api.emitRecallShown` 등) 의 `Promise<void>` 시그니처 그대로 (preload 가 `ipcRenderer.send`).
|
||||
|
||||
**Risk**: `ipcMain.handle` 의 return value 의존 호출자 grep 으로 0 확인.
|
||||
|
||||
**테스트 (1건 신규)**: `vision-ipc.test.ts` 패턴 정합 — `ipcRenderer.send` 호출 검증.
|
||||
|
||||
### 4-5. OllamaSettingsModal 폐기 확인 (#41+#42)
|
||||
|
||||
**현재 audit**: `grep "OllamaSettingsModal" src/` → 0건. v0.2.7 cut 에서 이미 폐기됨 (memory: "OllamaSettingsModal 제거 + onOpenOllamaSettings 채널 cleanup").
|
||||
|
||||
**처리**: backlog 항목 닫기만. **코드 변경 0**. v024-backlog.md 의 #41/#42 처리 이력 ✅ 추가.
|
||||
|
||||
### 4-6. Telemetry `.catch(() => {})` silent → debug log (#20)
|
||||
|
||||
**현재**: `CaptureService.listExpired` / `trashExpiredBatch` 가 `.catch(() => {})` 로 silent.
|
||||
|
||||
**수정**:
|
||||
|
||||
```ts
|
||||
this.telemetry.emit(...).catch((e) => {
|
||||
this.logger.debug('telemetry.emit.failed', { reason: String(e) });
|
||||
});
|
||||
```
|
||||
|
||||
`logger.debug` (project pattern) — production noise 0, 디버그 시 reproduce 가능.
|
||||
|
||||
**테스트**: 단위 추가 없음. 회귀 PASS.
|
||||
|
||||
---
|
||||
|
||||
## 5. 기록 정리 (2건)
|
||||
|
||||
### 5-1. v0.2.2 stale memory 폐기
|
||||
|
||||
`~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md` — 6건 모두 v0.2.3~v0.2.9 cut 들에서 처리됨 (Ollama 회복 / AI 영속 큐 / 태그 vocab / 휴지통 / 만료 추천 / RecallBanner). 8일 stale.
|
||||
|
||||
**처리**:
|
||||
|
||||
- 파일 삭제
|
||||
- `MEMORY.md` 의 line 8 (`- [v0.2.2 feedback]...`) 제거
|
||||
|
||||
### 5-2. v024-backlog.md 갱신
|
||||
|
||||
처리 이력 table 에 신규 12건 entry 추가:
|
||||
|
||||
```markdown
|
||||
| #14 (탭 ARIA) | ✅ 처리 | v0.3.2 |
|
||||
| #18 (loadExpired 미사용) | ✅ 처리 | v0.3.2 |
|
||||
| #19 (KST inline 5 callsite 잔여) | ✅ 처리 | v0.3.2 |
|
||||
| #20 (.catch silent → debug log) | ✅ 처리 | v0.3.2 |
|
||||
| #31 (vocabSet COLLATE) | ✅ 처리 | v0.3.2 |
|
||||
| #32 (per-tag Promise.all) | ✅ 처리 | v0.3.2 |
|
||||
| #36 (recall IPC handle→on) | ✅ 처리 | v0.3.2 |
|
||||
| #39 (PII reason 마스킹) | ✅ 처리 | v0.3.2 |
|
||||
| #41+#42 (OllamaSettingsModal 폐기) | ✅ 자연 소멸 (v0.2.7) | v0.3.2 audit |
|
||||
| time-dependent test flake | ✅ 처리 | v0.3.2 |
|
||||
```
|
||||
|
||||
총 처리 22 → 32, 잔여 23 → 11 (data-dependent 9 + future-proof 2).
|
||||
|
||||
---
|
||||
|
||||
## 6. 보류 항목 (dogfood 후 재평가)
|
||||
|
||||
### data-dependent (9건)
|
||||
|
||||
- #25 HealthChecker `inFlight` manual emit ordering — dogfood soak 결과 결정 (1초 윈도우 dedup 필요 여부)
|
||||
- #29 `getTopUsedTags(20)` magic number → `VOCAB_TOP_N` 모듈 상수 (이미 #29 처리, 튜닝 자체는 telemetry 후)
|
||||
- #30 `getTopUsedTags` SQL-side 필터 (overfetch+slice vs `GLOB`) — vocab pool 확장 결정
|
||||
- #33 `PROMPT_VERSION` telemetry payload 추가 — prompt 튜닝 후 hit-rate 추적 시
|
||||
- #35 `recall_shown` per-banner-lifetime dedup — telemetry 빈도 보고
|
||||
- #40 Settings 저장 vs HealthChecker race — visible 빈도 확인
|
||||
- #16 per-note 영구 삭제 telemetry 빈도 — 거의 0 이면 bulk emptyTrash 만 (UX 단순화)
|
||||
- #28 `unreachableBackoffStep` job-level — multi-provider 도입 시
|
||||
- recall_shown lifetime 영속 마커 (data-dependent #35 와 일부 중복)
|
||||
|
||||
### future-proof / cross-cutting (4건)
|
||||
|
||||
- #27 `refreshTrayFailedCount` exported singleton → TrayController class — multi-window 가설 검증 X
|
||||
- #24+#41 Banner CSS variables — modal 폐기로 일부 자연 소멸 + 잔여 4 banner 의 hardcode 색상은 단일 dogfood UX 영향 0
|
||||
- #37 NoteCard `id="note-${id}"` ref-forwarding — search 결과 scroll 등 신규 surface 등장 시
|
||||
- #28 단일 카운터 vs job-level — multi-provider 진입 시점
|
||||
|
||||
---
|
||||
|
||||
## 7. 테스트 전략
|
||||
|
||||
| 영역 | 단위 |
|
||||
|---|---|
|
||||
| `vocabSet` lowercase normalize | 3 (대/소문자 vocab × AI tag matrix) |
|
||||
| `NoteRepository.create(now)` param | 2 (default / 명시 주입) |
|
||||
| `NoteRevisions.test.ts` flake fix | 4 testcase 회복 (v1 capture 시간 주입) |
|
||||
| `upsertFromSync.test.ts` flake fix | 2 testcase 회복 |
|
||||
| PII reason classification | 4 (network/timeout/dns/other) |
|
||||
| KST inline → import migration | 회귀 PASS 확인 (단위 +0) |
|
||||
| 탭 ARIA `aria-selected` | 1 신규 assertion |
|
||||
| `loadExpired` 제거 | 회귀 (test 같이 제거) |
|
||||
| AiWorker `Promise.all` 회귀 | 회귀 PASS |
|
||||
| recall IPC `on` migration | 1 신규 (`ipcRenderer.send` 검증) |
|
||||
| Telemetry `.catch` debug log | 회귀 PASS |
|
||||
|
||||
목표: 단위 710 → **약 720** (+10 신규, -2 제거 [`loadExpired` test], net +8). typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risk
|
||||
|
||||
| Risk | 대응 |
|
||||
|---|---|
|
||||
| `NoteRepository.create(now)` signature 변경 호출자 영향 | optional + default = `new Date()`. 기존 호출자 0 무영향. typecheck 가 누락 catch |
|
||||
| KST inline → import 회귀 (값 다른 정의) | canonical export 값 (9 \* 60 \* 60 \* 1000) 동일 검증. 기존 단위 회귀 PASS = 알고리즘 동일 |
|
||||
| `ipcMain.handle → on` migration 시 return value 의존 호출자 누락 | grep 으로 호출자 enumerated. preload 시그니처 그대로 (`Promise<void>`) — 호출 측 무수정 |
|
||||
| AiWorker `Promise.all` 으로 emit 순서 변경 | telemetry 파일 라인 순서 의존 단위 0 확인. file-append round-trip 만 줄어듦 |
|
||||
| PII 마스킹으로 디버그 어려움 | error class enum + production reproduce 시 dev 환경에서 stack 그대로 노출 (telemetry 만 마스킹) |
|
||||
| time-dependent fix 시 다른 시간 의존 단위 발견 | grep `new Date()` in `repo` methods → `create` 외 다른 메서드 audit. 발견 시 spec 갱신 X (cleanup cut 외 deferred) |
|
||||
|
||||
---
|
||||
|
||||
## 9. v0.3.2 후
|
||||
|
||||
**Cut G** (v0.3.3 또는 v0.4.0 — F25 사이드바 + notebook_id) 진입 전:
|
||||
|
||||
- v0.3.2 release → main → tag → Windows exe + Gitea release
|
||||
- macOS host 핸드오프: dist:mac dmg + dist:linux AppImage/deb (v0.3.0 + v0.3.1 + v0.3.2 누적 backlog)
|
||||
- **다기기 종합 dogfood ≥1주 soak**:
|
||||
- sync (Cut E): 충돌 빈도 / 인증 흐름 / interval 적정성 / NTP 단조 가정
|
||||
- vision (Cut F): 이미지 capture 빈도 / 한국어 정확도 / capability detection 정확도
|
||||
- cleanup (Cut 본 v0.3.2): 잠재 bug 회귀 X 확인
|
||||
- soak 후 신규 발견 + data-dependent 9건 일괄 triage → Cut G brainstorm 진입
|
||||
|
||||
**Cut G 가설** (재검증):
|
||||
|
||||
1. inbox 단일 view 의 정보 밀도 한계 (현재 노트 누적 N건 = 스크롤 부담)
|
||||
2. notebook 카테고리 = 분류 hint vs 단일 inbox + 태그 필터로 충분
|
||||
3. 사이드바 = 새 surface = 기존 정책 (트레이 deemphasis / SettingsPage 우선) 재고
|
||||
@@ -3,9 +3,9 @@
|
||||
> 누적 backlog. v0.2.3 cut (7항목 / PR #13~#19) 시점부터 PR review deferred + dogfood 발견 모두 합산. **파일명은 historic** (`v024-backlog.md`) — v0.2.4 ~ v0.2.6 cut 후에도 이어 사용. **v0.2.7 brainstorm 시** 신규 피드백 + 잔여 일괄 triage.
|
||||
|
||||
**누적 시작일:** 2026-05-01 (#7 telemetry skeleton 머지 시점)
|
||||
**최종 갱신:** 2026-05-07 (v0.2.7 cross-platform cut — #45 자동실행 deeper fix)
|
||||
**최종 갱신:** 2026-05-10 (v0.3.2 cleanup cut — 잠재 bug 4 + cosmetic 5 + #20 deferred)
|
||||
**총 항목 수:** 46 (#1 stale 포함)
|
||||
**잔여:** 23건 (=46 − 처리 22 − stale 1)
|
||||
**잔여:** 14건 (=46 − 처리 31 − stale 1)
|
||||
|
||||
## 처리 이력 / 진행 흐름
|
||||
|
||||
@@ -39,6 +39,21 @@
|
||||
|---|---|---|
|
||||
| **B1 production path** (CaptureService.restoreNote 가 옛 `repo.restore` 호출) | ✅ Critical fix (commit `a991008`) | v0.2.6 round 1 |
|
||||
|
||||
### v0.3.2 cleanup cut (2026-05-10)
|
||||
|
||||
| 항목 | 상태 | Cut |
|
||||
|---|---|---|
|
||||
| #14 (탭 ARIA `aria-pressed`→`role="tab"`) | ✅ 처리 | v0.3.2 |
|
||||
| #18 (`loadExpired` 미사용 제거) | ✅ 처리 | v0.3.2 |
|
||||
| #19 (KST inline 5 callsite 잔여 migrate) | ✅ 처리 | v0.3.2 |
|
||||
| #20 (telemetry `.catch` silent → debug log) | 🟡 deferred (CaptureService logger 미주입 — constructor 변경 회피) | v0.3.2 audit |
|
||||
| #31 (vocabSet COLLATE NOCASE 정합) | ✅ 처리 | v0.3.2 |
|
||||
| #32 (per-tag emit `Promise.all` 병렬화) | ✅ 처리 | v0.3.2 |
|
||||
| #36 (recall IPC `handle`→`on`) | ✅ 처리 | v0.3.2 |
|
||||
| #39 (`ollama_unreachable.reason` PII 마스킹) | ✅ 처리 | v0.3.2 |
|
||||
| #41+#42 (`OllamaSettingsModal` 폐기 audit) | ✅ 자연 소멸 (v0.2.7) | v0.3.2 audit |
|
||||
| time-dependent test flake fix | ✅ 처리 | v0.3.2 |
|
||||
|
||||
### v0.2.6 final reviewer + round 1 minors (deferred)
|
||||
|
||||
| 항목 | 상태 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
|
||||
@@ -176,7 +176,8 @@ export class AiWorker {
|
||||
candidatesCount: candidates.length
|
||||
});
|
||||
if (this.telemetry) {
|
||||
await this.telemetry.emit({
|
||||
const telemetry = this.telemetry;
|
||||
await telemetry.emit({
|
||||
kind: 'ai_succeeded',
|
||||
payload: {
|
||||
noteId: job.noteId,
|
||||
@@ -186,23 +187,25 @@ export class AiWorker {
|
||||
}).catch(() => {});
|
||||
// v0.2.3 #3 — per-tag vocab hit/miss 분류 (updateAiResult 후 → tagId 보장)
|
||||
// dedup: AI 응답에 같은 태그 중복 가능 — INSERT OR IGNORE 와 정합한 1-emit/태그 보장
|
||||
const vocabSet = new Set(vocab);
|
||||
for (const tagName of new Set(res.tags)) {
|
||||
if (vocabSet.has(tagName)) {
|
||||
const tagId = this.repo.getTagIdByName(tagName);
|
||||
if (tagId !== null) {
|
||||
await this.telemetry.emit({
|
||||
kind: 'tag_vocab_hit',
|
||||
payload: { tagId, vocabSize: vocab.length }
|
||||
const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
|
||||
await Promise.all(
|
||||
Array.from(new Set(res.tags)).map(async (tagName) => {
|
||||
if (vocabSet.has(tagName.toLowerCase())) {
|
||||
const tagId = this.repo.getTagIdByName(tagName);
|
||||
if (tagId !== null) {
|
||||
await telemetry.emit({
|
||||
kind: 'tag_vocab_hit',
|
||||
payload: { tagId, vocabSize: vocab.length }
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
await telemetry.emit({
|
||||
kind: 'tag_vocab_miss',
|
||||
payload: { vocabSize: vocab.length }
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
await this.telemetry.emit({
|
||||
kind: 'tag_vocab_miss',
|
||||
payload: { vocabSize: vocab.length }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
this.emit(job.noteId);
|
||||
return;
|
||||
|
||||
@@ -5,6 +5,14 @@ import { buildVisionPrompt } from './visionPrompt.js';
|
||||
import type { GenerateInput, GenerateOptions, HealthResult, InferenceProvider } from './InferenceProvider.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../shared/constants.js';
|
||||
|
||||
function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other' {
|
||||
const msg = ((e as Error)?.message ?? '').toLowerCase();
|
||||
if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout';
|
||||
if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network';
|
||||
if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export interface LocalOllamaOptions {
|
||||
endpoint?: string;
|
||||
model?: string;
|
||||
@@ -119,7 +127,8 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
return found ? { ok: true, model: this.model }
|
||||
: { ok: false, reason: `${this.model} not installed` };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: `unreachable: ${(err as Error).message}` };
|
||||
const cls = classifyFetchError(err);
|
||||
return { ok: false, reason: `unreachable:${cls}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,8 +153,8 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
|
||||
ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
|
||||
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
|
||||
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
|
||||
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
|
||||
ipcMain.on('inbox:emitRecallShown', (_e, id: string) => { void deps.capture.emitRecallShown(id); });
|
||||
ipcMain.on('inbox:emitRecallSnoozed', (_e, id: string) => { void deps.capture.emitRecallSnoozed(id); });
|
||||
|
||||
ipcMain.handle('inbox:loadOllamaSettings', async () => {
|
||||
const s = await deps.settings.load();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
|
||||
import type { AiStatus, Note, NoteMedia, NoteRevision, NoteStatus, NoteTag } from '@shared/types';
|
||||
import { kstTodayIso } from '../../shared/util/kstDate.js';
|
||||
import { kstTodayIso, KST_OFFSET_MS } from '../../shared/util/kstDate.js';
|
||||
import { sanitizeFtsQuery, computeCutoff, type ReviewPeriod } from './ftsHelpers.js';
|
||||
|
||||
export interface CreateNoteInput {
|
||||
@@ -79,25 +79,25 @@ const KEBAB_CASE_RE = /^[a-z0-9-]+$/;
|
||||
export class NoteRepository {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
create(input: CreateNoteInput): { id: string } {
|
||||
create(input: CreateNoteInput, now: Date = new Date()): { id: string } {
|
||||
const id = uuidv7();
|
||||
const now = new Date().toISOString();
|
||||
const ts = now.toISOString();
|
||||
const aiStatus: AiStatus = input.aiStatus ?? 'pending';
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(id, input.rawText, aiStatus, now, now);
|
||||
.run(id, input.rawText, aiStatus, ts, ts);
|
||||
this.db
|
||||
.prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'capture')`)
|
||||
.run(id, input.rawText, now);
|
||||
.run(id, input.rawText, ts);
|
||||
// pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함.
|
||||
if (aiStatus === 'pending') {
|
||||
this.db
|
||||
.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
|
||||
VALUES (?, 0, ?)`)
|
||||
.run(id, now);
|
||||
.run(id, ts);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
@@ -1039,7 +1039,6 @@ export class NoteRepository {
|
||||
* and count rows whose UTC ISO `created_at` lies inside.
|
||||
*/
|
||||
countToday(now: Date = new Date()): number {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
const kstYear = kstNow.getUTCFullYear();
|
||||
const kstMonth = kstNow.getUTCMonth();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* v0.2.11 Cut D — FTS5 검색 + 회고 view 의 순수 함수 헬퍼.
|
||||
*/
|
||||
|
||||
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
|
||||
|
||||
const FTS5_SPECIAL_CHARS_RE = /["*():]/g;
|
||||
const WS_COLLAPSE_RE = /\s+/g;
|
||||
|
||||
@@ -15,8 +17,6 @@ export function sanitizeFtsQuery(input: string): string {
|
||||
|
||||
export type ReviewPeriod = 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 회고 cutoff = period 시작점의 KST 자정 (UTC ISO).
|
||||
* daily = 오늘 0시, weekly = 7일 전 0시, monthly = 30일 전 0시.
|
||||
|
||||
@@ -2,8 +2,7 @@ import type Database from 'better-sqlite3';
|
||||
import { mkdir, rename, stat, readdir, unlink, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { applyGfsRetention } from './backupRotation.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
|
||||
const MARKER_FILENAME = '.last-snapshot';
|
||||
|
||||
function toKstDateKey(d: Date): string {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { WeeklyContinuity } from '@shared/types';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const WEEK_TARGET = 7;
|
||||
const RECOVERY_GAP_DAYS = 7;
|
||||
|
||||
@@ -43,8 +43,8 @@ const api: InklingApi = {
|
||||
listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
|
||||
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
|
||||
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
|
||||
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
|
||||
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
|
||||
emitRecallShown: (id: string) => { ipcRenderer.send('inbox:emitRecallShown', id); },
|
||||
emitRecallSnoozed: (id: string) => { ipcRenderer.send('inbox:emitRecallSnoozed', id); },
|
||||
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
|
||||
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
|
||||
// v0.2.7 Task 13 — 외부 (트레이) 에서 view 전환 요청 listener.
|
||||
|
||||
@@ -106,8 +106,10 @@ export function App(): React.ReactElement {
|
||||
).map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === t.key}
|
||||
onClick={() => setView(t.key)}
|
||||
aria-pressed={view === t.key}
|
||||
style={tabBtnStyle(view === t.key)}
|
||||
>
|
||||
{t.label}({t.count})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Note, NoteStatus } from '@shared/types';
|
||||
import { KST_OFFSET_MS } from '@shared/util/kstDate.js';
|
||||
import { inboxApi } from '../api.js';
|
||||
import { useInbox } from '../store.js';
|
||||
import { EditableField } from './EditableField.js';
|
||||
@@ -27,7 +28,6 @@ function isPastDue(iso: string, today: string): boolean {
|
||||
}
|
||||
|
||||
function todayKstIso(): string {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const k = new Date(Date.now() + KST_OFFSET_MS);
|
||||
return k.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ interface InboxState {
|
||||
restoreNote: (id: string) => Promise<void>;
|
||||
permanentDeleteNote: (id: string) => Promise<void>;
|
||||
emptyTrash: () => Promise<void>;
|
||||
loadExpired: () => Promise<void>;
|
||||
trashExpiredBatch: (ids: string[]) => Promise<void>;
|
||||
snoozeExpired: () => void;
|
||||
recheckOllama: () => Promise<void>;
|
||||
@@ -235,10 +234,6 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
set({ trashNotes: [], trashCount: 0 });
|
||||
}
|
||||
},
|
||||
async loadExpired() {
|
||||
const expiredCandidates = await inboxApi.listExpired();
|
||||
set({ expiredCandidates });
|
||||
},
|
||||
async trashExpiredBatch(ids: string[]) {
|
||||
const r = await inboxApi.trashExpiredBatch(ids);
|
||||
if (!r.confirmed) return;
|
||||
@@ -285,7 +280,7 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
|
||||
const candidate = get().recallCandidate;
|
||||
if (candidate) {
|
||||
await inboxApi.emitRecallSnoozed(candidate.id);
|
||||
inboxApi.emitRecallSnoozed(candidate.id);
|
||||
}
|
||||
},
|
||||
// v0.2.11 Cut D — FTS5 search + review aggregate actions.
|
||||
|
||||
@@ -152,8 +152,8 @@ export interface InboxApi {
|
||||
listRecallCandidate(): Promise<Note | null>;
|
||||
markRecallOpened(id: string): Promise<{ note: Note }>;
|
||||
dismissRecall(id: string): Promise<{ note: Note }>;
|
||||
emitRecallShown(id: string): Promise<void>;
|
||||
emitRecallSnoozed(id: string): Promise<void>;
|
||||
emitRecallShown(id: string): void;
|
||||
emitRecallSnoozed(id: string): void;
|
||||
loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>;
|
||||
saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
// v0.2.7 Task 13 — 외부 (트레이 등) 에서 view 전환 요청 구독.
|
||||
|
||||
@@ -560,3 +560,91 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
expect(miss).toHaveLength(1); // 'meeting' 1 miss
|
||||
});
|
||||
});
|
||||
|
||||
describe('vocab COLLATE NOCASE', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('hits when vocab has lowercase and AI returns capital', async () => {
|
||||
// Pre-seed: 'design' in vocab (lowercase)
|
||||
const seed = repo.create({ rawText: 'seed' }).id;
|
||||
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
|
||||
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['Design'], // AI returns capitalized — DB COLLATE NOCASE matches 'design'
|
||||
dueDate: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1);
|
||||
expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('hits when vocab has capital and AI returns lowercase', async () => {
|
||||
// Scenario: vocab contains 'Design' (capital), AI returns 'design' (lowercase).
|
||||
// getTopUsedTags filters via KEBAB_CASE_RE (/^[a-z0-9-]+$/) so 'Design' would be
|
||||
// stripped in production. We stub getTopUsedTags to inject the capital vocab directly,
|
||||
// and pre-seed the DB so getTagIdByName (COLLATE NOCASE) can resolve 'design' → tagId.
|
||||
const seed = repo.create({ rawText: 'seed' }).id;
|
||||
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['Design'], provider: 'p' });
|
||||
// Inject capital vocab bypassing the kebab filter
|
||||
vi.spyOn(repo, 'getTopUsedTags').mockReturnValueOnce(['Design']);
|
||||
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['design'], // AI returns lowercase — DB COLLATE NOCASE matches 'Design'
|
||||
dueDate: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1);
|
||||
expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still hits when both vocab and AI tag are same lowercase (regression)', async () => {
|
||||
// Pre-seed: 'design' in vocab (lowercase)
|
||||
const seed = repo.create({ rawText: 'seed' }).id;
|
||||
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
|
||||
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['design'], // same lowercase — should still hit
|
||||
dueDate: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
const w = new AiWorker(repo, new ProviderHolder(provider), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(1);
|
||||
expect(emits.filter((e) => e.kind === 'tag_vocab_miss')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,25 +130,31 @@ describe('App header — 4 tabs', () => {
|
||||
|
||||
it('renders 4 tabs with counts', async () => {
|
||||
render(<App />);
|
||||
expect(await screen.findByRole('button', { name: /Inbox\(5\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /완료\(3\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /보관\(2\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /휴지통\(1\)/ })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('tab', { name: /Inbox\(5\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /완료\(3\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /보관\(2\)/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /휴지통\(1\)/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking 완료 tab sets view=completed', async () => {
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /완료/ }));
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /완료/ }));
|
||||
expect(useInbox.getState().view).toBe('completed');
|
||||
});
|
||||
|
||||
it('aria-pressed reflects current view', async () => {
|
||||
it('aria-selected reflects current view', async () => {
|
||||
useInbox.setState({ view: 'archived' });
|
||||
render(<App />);
|
||||
const archivedBtn = await screen.findByRole('button', { name: /보관/ });
|
||||
expect(archivedBtn.getAttribute('aria-pressed')).toBe('true');
|
||||
const inboxBtn = screen.getByRole('button', { name: /Inbox/ });
|
||||
expect(inboxBtn.getAttribute('aria-pressed')).toBe('false');
|
||||
const archivedBtn = await screen.findByRole('tab', { name: /보관/ });
|
||||
expect(archivedBtn.getAttribute('aria-selected')).toBe('true');
|
||||
const inboxBtn = screen.getByRole('tab', { name: /Inbox/ });
|
||||
expect(inboxBtn.getAttribute('aria-selected')).toBe('false');
|
||||
});
|
||||
|
||||
it('inbox tab has aria-selected="true" when active', async () => {
|
||||
render(<App />);
|
||||
const inboxTab = await screen.findByRole('tab', { name: /Inbox/ });
|
||||
expect(inboxTab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,7 +181,7 @@ describe('App — onboarding wizard', () => {
|
||||
it('does not render OnboardingWizard when onboarding_completed=true', async () => {
|
||||
vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true });
|
||||
render(<App />);
|
||||
await screen.findByRole('button', { name: /Inbox/ });
|
||||
await screen.findByRole('tab', { name: /Inbox/ });
|
||||
expect(screen.queryByText(/Inkling 사용 시작/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,44 @@ describe('LocalOllamaProvider', () => {
|
||||
expect(provider.name).toBe('local-ollama/gemma4:26b');
|
||||
});
|
||||
|
||||
describe('healthCheck PII reason masking', () => {
|
||||
it('classifies ECONNREFUSED as network', async () => {
|
||||
mock.get('http://192.168.1.5:11434').intercept({ path: '/api/tags', method: 'GET' })
|
||||
.replyWithError(new Error('connect ECONNREFUSED 192.168.1.5:11434'));
|
||||
const provider = new LocalOllamaProvider({ endpoint: 'http://192.168.1.5:11434' });
|
||||
const h = await provider.healthCheck();
|
||||
expect(h.ok).toBe(false);
|
||||
expect(h.reason).toBe('unreachable:network');
|
||||
expect(h.reason).not.toContain('192.168.1.5');
|
||||
});
|
||||
|
||||
it('classifies AbortError/timeout as timeout', async () => {
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' })
|
||||
.replyWithError(new Error('The operation was aborted due to timeout'));
|
||||
const h = await new LocalOllamaProvider().healthCheck();
|
||||
expect(h.ok).toBe(false);
|
||||
expect(h.reason).toBe('unreachable:timeout');
|
||||
});
|
||||
|
||||
it('classifies ENOTFOUND as dns', async () => {
|
||||
mock.get('http://nonexistent.local:11434').intercept({ path: '/api/tags', method: 'GET' })
|
||||
.replyWithError(new Error('getaddrinfo ENOTFOUND nonexistent.local'));
|
||||
const provider = new LocalOllamaProvider({ endpoint: 'http://nonexistent.local:11434' });
|
||||
const h = await provider.healthCheck();
|
||||
expect(h.ok).toBe(false);
|
||||
expect(h.reason).toBe('unreachable:dns');
|
||||
expect(h.reason).not.toContain('nonexistent.local');
|
||||
});
|
||||
|
||||
it('falls back to other for unknown errors', async () => {
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' })
|
||||
.replyWithError(new Error('something weird happened'));
|
||||
const h = await new LocalOllamaProvider().healthCheck();
|
||||
expect(h.ok).toBe(false);
|
||||
expect(h.reason).toBe('unreachable:other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vision path (v0.3.1 Cut F)', () => {
|
||||
it('visionModel + images → body.images + model=visionModel + buildVisionPrompt', async () => {
|
||||
let capturedBody: string = '';
|
||||
|
||||
@@ -310,6 +310,24 @@ describe('NoteRepository', () => {
|
||||
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
|
||||
expect(job).toBeDefined();
|
||||
});
|
||||
|
||||
it('create accepts explicit now param', () => {
|
||||
const fixed = new Date('2026-05-09T10:00:00.000Z');
|
||||
const { id } = repo.create({ rawText: 'hello' }, fixed);
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.createdAt).toBe('2026-05-09T10:00:00.000Z');
|
||||
expect(note.updatedAt).toBe('2026-05-09T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('create defaults now to new Date when omitted', () => {
|
||||
const before = Date.now();
|
||||
const { id } = repo.create({ rawText: 'hello' });
|
||||
const after = Date.now();
|
||||
const note = repo.findById(id)!;
|
||||
const ts = new Date(note.createdAt).getTime();
|
||||
expect(ts).toBeGreaterThanOrEqual(before);
|
||||
expect(ts).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.trash', () => {
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('NoteRepository.upsertFromSync', () => {
|
||||
});
|
||||
|
||||
it('id 있음 + raw_text 동일 + source 더 최신 → metadata 갱신 (status=updated)', () => {
|
||||
const created = repo.create({ rawText: 'sync 본문' });
|
||||
const created = repo.create({ rawText: 'sync 본문' }, new Date('2026-05-09T00:00:00Z'));
|
||||
repo.updateAiResult(created.id, { title: '옛 제목', summary: '옛 요약', tags: ['old'], provider: 'p' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id);
|
||||
const r = repo.upsertFromSync({ ...baseInput, id: created.id });
|
||||
@@ -75,7 +75,7 @@ describe('NoteRepository.upsertFromSync', () => {
|
||||
});
|
||||
|
||||
it('id 있음 + raw_text 다름 + source 더 최신 → updateRawText (status=updated) + new user revision', () => {
|
||||
const created = repo.create({ rawText: 'old text' });
|
||||
const created = repo.create({ rawText: 'old text' }, new Date('2026-05-09T00:00:00Z'));
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id);
|
||||
const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'new sync text' });
|
||||
expect(r.status).toBe('updated');
|
||||
|
||||
@@ -18,7 +18,8 @@ describe('NoteRepository — note_revisions', () => {
|
||||
|
||||
describe('updateRawText', () => {
|
||||
it('notes.raw_text 갱신 + 새 user revision INSERT (single transaction)', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
const v1At = new Date('2026-05-09T00:00:00Z');
|
||||
const { id } = repo.create({ rawText: 'v1' }, v1At);
|
||||
const t = new Date('2026-05-10T00:00:00Z');
|
||||
repo.updateRawText(id, 'v2', t);
|
||||
|
||||
@@ -41,7 +42,8 @@ describe('NoteRepository — note_revisions', () => {
|
||||
});
|
||||
|
||||
it('atomic: 두 번 호출 시 두 revision 모두 누적 (chain history)', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
const v1At = new Date('2026-05-09T00:00:00Z');
|
||||
const { id } = repo.create({ rawText: 'v1' }, v1At);
|
||||
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
|
||||
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
|
||||
const revs = db
|
||||
@@ -53,7 +55,8 @@ describe('NoteRepository — note_revisions', () => {
|
||||
|
||||
describe('listRevisions', () => {
|
||||
it('DESC 순서 + edited_by + camelCase hydrate', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
const v1At = new Date('2026-05-09T00:00:00Z');
|
||||
const { id } = repo.create({ rawText: 'v1' }, v1At);
|
||||
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
|
||||
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
|
||||
|
||||
@@ -73,7 +76,8 @@ describe('NoteRepository — note_revisions', () => {
|
||||
|
||||
describe('restoreRevision', () => {
|
||||
it('옛 raw_text 를 새 user revision 으로 INSERT + notes.raw_text 갱신', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
const v1At = new Date('2026-05-09T00:00:00Z');
|
||||
const { id } = repo.create({ rawText: 'v1' }, v1At);
|
||||
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
|
||||
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
|
||||
|
||||
@@ -101,7 +105,8 @@ describe('NoteRepository — note_revisions', () => {
|
||||
|
||||
describe('AiWorker source 회귀', () => {
|
||||
it('updateRawText 후 findById 가 latest raw_text 반환 (옛 revision 미노출)', () => {
|
||||
const { id } = repo.create({ rawText: 'v1' });
|
||||
const v1At = new Date('2026-05-09T00:00:00Z');
|
||||
const { id } = repo.create({ rawText: 'v1' }, v1At);
|
||||
repo.updateRawText(id, 'v2 corrected', new Date('2026-05-10T00:00:00Z'));
|
||||
const note = repo.findById(id);
|
||||
expect(note?.rawText).toBe('v2 corrected');
|
||||
|
||||
@@ -11,7 +11,8 @@ vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
|
||||
handlers[ch] = fn;
|
||||
}
|
||||
},
|
||||
on: (_ch: string, _fn: unknown) => {}
|
||||
},
|
||||
dialog: {},
|
||||
shell: { openPath: mockOpenPath }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
ipcMain: { handle: vi.fn() }
|
||||
ipcMain: { handle: vi.fn(), on: vi.fn() }
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() } } }));
|
||||
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn(), on: vi.fn() } } }));
|
||||
import electron from 'electron';
|
||||
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
|
||||
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';
|
||||
|
||||
@@ -12,7 +12,8 @@ vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
|
||||
handlers[ch] = fn;
|
||||
}
|
||||
},
|
||||
on: (_ch: string, _fn: unknown) => {}
|
||||
},
|
||||
dialog: {},
|
||||
shell: {}
|
||||
|
||||
65
tests/unit/recall-ipc.test.ts
Normal file
65
tests/unit/recall-ipc.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Track ipcMain.handle and ipcMain.on calls
|
||||
const { handleCalls, onCalls } = vi.hoisted(() => ({
|
||||
handleCalls: [] as string[],
|
||||
onCalls: [] as string[]
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
ipcMain: {
|
||||
handle: (ch: string, _fn: unknown) => { handleCalls.push(ch); },
|
||||
on: (ch: string, _fn: unknown) => { onCalls.push(ch); }
|
||||
},
|
||||
dialog: {},
|
||||
shell: {}
|
||||
}
|
||||
}));
|
||||
|
||||
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
|
||||
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';
|
||||
|
||||
function makeDeps(): InboxIpcDeps {
|
||||
return {
|
||||
repo: {} as never,
|
||||
continuity: {} as never,
|
||||
capture: {
|
||||
emitRecallShown: vi.fn(async () => {}),
|
||||
emitRecallSnoozed: vi.fn(async () => {})
|
||||
} as never,
|
||||
health: {} as never,
|
||||
intent: {} as never,
|
||||
getInboxWindow: () => null,
|
||||
settings: {} as never,
|
||||
providerHolder: {} as never,
|
||||
paths: { profileDir: '/profile' }
|
||||
};
|
||||
}
|
||||
|
||||
describe('recall IPC channels (fire-and-forget)', () => {
|
||||
beforeEach(() => {
|
||||
handleCalls.length = 0;
|
||||
onCalls.length = 0;
|
||||
});
|
||||
|
||||
it('inbox:emitRecallShown is registered with ipcMain.on (not handle)', () => {
|
||||
registerInboxApi(makeDeps());
|
||||
expect(onCalls).toContain('inbox:emitRecallShown');
|
||||
expect(handleCalls).not.toContain('inbox:emitRecallShown');
|
||||
});
|
||||
|
||||
it('inbox:emitRecallSnoozed is registered with ipcMain.on (not handle)', () => {
|
||||
registerInboxApi(makeDeps());
|
||||
expect(onCalls).toContain('inbox:emitRecallSnoozed');
|
||||
expect(handleCalls).not.toContain('inbox:emitRecallSnoozed');
|
||||
});
|
||||
|
||||
it('each recall channel is registered exactly once with ipcMain.on', () => {
|
||||
registerInboxApi(makeDeps());
|
||||
const shownCount = onCalls.filter((ch) => ch === 'inbox:emitRecallShown').length;
|
||||
const snoozedCount = onCalls.filter((ch) => ch === 'inbox:emitRecallSnoozed').length;
|
||||
expect(shownCount).toBe(1);
|
||||
expect(snoozedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -52,15 +52,6 @@ describe('useInbox — expired state (v0.2.3 #5)', () => {
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
it('loadExpired sets expiredCandidates from inboxApi', async () => {
|
||||
mockApi.listExpired.mockResolvedValueOnce([noteStub('n1')]);
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
await useInbox.getState().loadExpired();
|
||||
const s = useInbox.getState();
|
||||
expect(s.expiredCandidates).toHaveLength(1);
|
||||
expect(s.expiredCandidates[0]!.id).toBe('n1');
|
||||
});
|
||||
|
||||
it('trashExpiredBatch removes ids and increments trashCount when confirmed', async () => {
|
||||
mockApi.trashExpiredBatch.mockResolvedValueOnce({ trashedCount: 2, confirmed: true });
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
|
||||
Reference in New Issue
Block a user