diff --git a/src-tauri/src/vault/cache.rs b/src-tauri/src/vault/cache.rs index 8d59df0a..5da332d3 100644 --- a/src-tauri/src/vault/cache.rs +++ b/src-tauri/src/vault/cache.rs @@ -8,7 +8,7 @@ use super::{parse_md_file, scan_vault, VaultEntry}; // --- Vault Cache --- /// Bump this when VaultEntry fields change to force a full rescan. -const CACHE_VERSION: u32 = 5; +const CACHE_VERSION: u32 = 6; #[derive(Debug, Serialize, Deserialize)] struct VaultCache { @@ -870,4 +870,88 @@ mod tests { "note must be trashed after invalidate + rescan" ); } + + /// Integration test: a note with `Archived: Yes` (string, not boolean) + /// must be recognized as archived through the full cached vault load path. + /// This catches the scenario where a stale cache stores `archived: false` + /// and the cache version bump forces a correct re-parse. + #[test] + fn test_cached_vault_archived_yes_string() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file( + vault, + "archived-note.md", + "---\nArchived: Yes\n---\n# Old Note\n", + ); + git_add_commit(vault, "init"); + + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + assert!( + entries[0].archived, + "'Archived: Yes' must be parsed as true through the cached vault path" + ); + } + + /// Integration test: `Trashed: Yes` (string) through full cached path. + #[test] + fn test_cached_vault_trashed_yes_string() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file( + vault, + "trashed-note.md", + "---\nTrashed: Yes\n---\n# Gone\n", + ); + git_add_commit(vault, "init"); + + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + assert!( + entries[0].trashed, + "'Trashed: Yes' must be parsed as true through the cached vault path" + ); + } + + /// Integration test: stale cache with old version is invalidated and + /// re-parses `Archived: Yes` correctly after cache version bump. + #[test] + fn test_stale_cache_version_forces_rescan_of_archived_yes() { + let (_lock, _cache_tmp, dir) = setup_git_vault(); + let vault = dir.path(); + + create_test_file( + vault, + "note.md", + "---\nArchived: Yes\n---\n# Note\n", + ); + git_add_commit(vault, "init"); + + let hash = git_head_hash(vault).unwrap(); + + // Simulate a stale cache written by old code that parsed Archived: Yes as false + let stale_entry = { + let mut e = parse_md_file(&vault.join("note.md")).unwrap(); + e.archived = false; // simulate old parser behavior + e + }; + let stale_cache = VaultCache { + version: CACHE_VERSION - 1, // old version + vault_path: vault.to_string_lossy().to_string(), + commit_hash: hash, + entries: vec![stale_entry], + }; + write_cache(vault, &stale_cache); + + // Load via cached path — stale version must trigger full rescan + let entries = scan_vault_cached(vault).unwrap(); + assert_eq!(entries.len(), 1); + assert!( + entries[0].archived, + "stale cache with old version must be invalidated, re-parsing 'Archived: Yes' as true" + ); + } } diff --git a/src/utils/frontmatter.test.ts b/src/utils/frontmatter.test.ts new file mode 100644 index 00000000..4b5c4f9a --- /dev/null +++ b/src/utils/frontmatter.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { parseFrontmatter } from './frontmatter' + +describe('parseFrontmatter', () => { + describe('boolean-like Yes/No values', () => { + it('parses Archived: Yes as true', () => { + const fm = parseFrontmatter('---\nArchived: Yes\n---\nBody') + expect(fm['Archived']).toBe(true) + }) + + it('parses Archived: No as false', () => { + const fm = parseFrontmatter('---\nArchived: No\n---\nBody') + expect(fm['Archived']).toBe(false) + }) + + it('parses Trashed: Yes as true', () => { + const fm = parseFrontmatter('---\nTrashed: Yes\n---\nBody') + expect(fm['Trashed']).toBe(true) + }) + + it('parses Trashed: No as false', () => { + const fm = parseFrontmatter('---\nTrashed: No\n---\nBody') + expect(fm['Trashed']).toBe(false) + }) + + it('parses yes (lowercase) as true', () => { + const fm = parseFrontmatter('---\nArchived: yes\n---\nBody') + expect(fm['Archived']).toBe(true) + }) + + it('parses no (lowercase) as false', () => { + const fm = parseFrontmatter('---\nArchived: no\n---\nBody') + expect(fm['Archived']).toBe(false) + }) + + it('still parses true as true', () => { + const fm = parseFrontmatter('---\nArchived: true\n---\nBody') + expect(fm['Archived']).toBe(true) + }) + + it('still parses false as false', () => { + const fm = parseFrontmatter('---\nArchived: false\n---\nBody') + expect(fm['Archived']).toBe(false) + }) + }) +}) diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index bea126c7..38b7dee2 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -23,8 +23,9 @@ function parseInlineArray(value: string): FrontmatterValue { function parseScalar(value: string): FrontmatterValue { const clean = unquote(value) - if (clean.toLowerCase() === 'true') return true - if (clean.toLowerCase() === 'false') return false + const lower = clean.toLowerCase() + if (lower === 'true' || lower === 'yes') return true + if (lower === 'false' || lower === 'no') return false return clean } diff --git a/tests/smoke/fix-archived-trashed-detection-v2.spec.ts b/tests/smoke/fix-archived-trashed-detection-v2.spec.ts new file mode 100644 index 00000000..3f0a0176 --- /dev/null +++ b/tests/smoke/fix-archived-trashed-detection-v2.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from '@playwright/test' +import { sendShortcut } from './helpers' + +const QUICK_OPEN_INPUT = 'input[placeholder="Search notes..."]' + +/** Known archived note titles from mock data */ +const ARCHIVED_TITLES = ['Website Redesign', 'Twitter Thread Growth Experiment'] + +async function openQuickOpen(page: import('@playwright/test').Page) { + await page.locator('body').click() + await sendShortcut(page, 'p', ['Control']) + await expect(page.locator(QUICK_OPEN_INPUT)).toBeVisible() +} + +function quickOpenPanel(page: import('@playwright/test').Page) { + return page.locator('.fixed.inset-0').filter({ has: page.locator(QUICK_OPEN_INPUT) }) +} + +function getResultTitles(container: import('@playwright/test').Locator) { + return container.locator('span.truncate').allTextContents() +} + +test.describe('Archived/Trashed Yes/No detection', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('archived notes are filtered out of the default sidebar', async ({ page }) => { + // In the default sidebar view (non-archived section), archived notes must not appear + const noteItems = page.locator('[data-testid="note-item"]') + const count = await noteItems.count() + for (let i = 0; i < count; i++) { + const text = await noteItems.nth(i).textContent() + for (const title of ARCHIVED_TITLES) { + expect(text, `archived note "${title}" should not be in default sidebar`).not.toContain(title) + } + } + }) + + test('archived note shows ArchivedNoteBanner in the editor', async ({ page }) => { + // Open quick open and navigate to an archived note + await openQuickOpen(page) + await page.locator(QUICK_OPEN_INPUT).fill('Website Redesign') + await page.waitForTimeout(400) + + // The archived note might not appear in quick open (filtered), so try direct URL + const panel = quickOpenPanel(page) + const titles = await getResultTitles(panel) + if (titles.some(t => t.includes('Website Redesign'))) { + // If it appears in quick open, click it + const result = panel.locator('span.truncate').filter({ hasText: 'Website Redesign' }).first() + await result.click() + } else { + // Close quick open and use sidebar Archived section if available + await page.keyboard.press('Escape') + // Click the Archived section header to expand it + const archivedSection = page.locator('text=Archived').first() + if (await archivedSection.isVisible({ timeout: 1000 }).catch(() => false)) { + await archivedSection.click() + await page.waitForTimeout(300) + const archivedNote = page.locator('[data-testid="note-item"]').filter({ hasText: 'Website Redesign' }) + if (await archivedNote.isVisible({ timeout: 1000 }).catch(() => false)) { + await archivedNote.click() + } else { + test.skip() + return + } + } else { + test.skip() + return + } + } + + await page.waitForTimeout(500) + // Verify the archived banner is visible + const banner = page.locator('[data-testid="archived-note-banner"]') + await expect(banner).toBeVisible({ timeout: 3000 }) + await expect(banner).toContainText('Archived') + }) + + test('archived note shows archived badge in the note list', async ({ page }) => { + // Navigate to the Archived section in the sidebar + const archivedSection = page.locator('button, [role="button"], span').filter({ hasText: /^Archived/ }).first() + if (!await archivedSection.isVisible({ timeout: 2000 }).catch(() => false)) { + test.skip() + return + } + await archivedSection.click() + await page.waitForTimeout(300) + + // Look for archived badge on note items + const badges = page.locator('[data-testid="state-badge"]') + const badgeCount = await badges.count() + expect(badgeCount).toBeGreaterThan(0) + }) + + test('archived notes do not appear in Quick Open search', async ({ page }) => { + await openQuickOpen(page) + const panel = quickOpenPanel(page) + for (const title of ARCHIVED_TITLES) { + const query = title.split(' ')[0] + await page.locator(QUICK_OPEN_INPUT).fill(query) + await page.waitForTimeout(400) + const titles = await getResultTitles(panel) + expect(titles, `"${title}" should not appear in Quick Open`).not.toContain(title) + } + }) +})