Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions tests/renderer/stores/diffStore.diagram.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// The Diagram view: which comparisons offer it, and what it routes to.
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useDiffStore } from '../../../src/renderer/src/stores/diffStore'

beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
window.api = {}
})

// The Diagram toggle reuses the Structure checkbox — a second control would be
// the repo's recurring "second bespoke copy". So the getters have to agree.
describe('diagram comparison', () => {
const mmd = (body) => `flowchart TD\n${body}\n`
const load = (diff, l, r) => {
diff.left = { path: '/a.mmd', name: 'a.mmd', content: l }
diff.right = { path: '/b.mmd', name: 'b.mmd', content: r }
diff.mode = 'files'
}

it('offers the toggle only when both sides look like Mermaid', () => {
const diff = useDiffStore()
load(diff, mmd(' A --> B'), mmd(' A --> C'))
expect(diff.canCompareDiagram).toBe(true)

load(diff, mmd(' A --> B'), 'just some text')
expect(diff.canCompareDiagram).toBe(false)
})

it('calls itself Diagram, not Structure', () => {
const diff = useDiffStore()
load(diff, mmd(' A --> B'), mmd(' A --> C'))
expect(diff.structureLabel).toBe('Diagram')
})

it('routes to the diagram viewer only with the toggle on', () => {
const diff = useDiffStore()
load(diff, mmd(' A --> B'), mmd(' A --> C'))
diff.semanticView = false
expect(diff.comparableKind).toBe('text')
diff.semanticView = true
expect(diff.comparableKind).toBe('diagram')
})

it('never offers it for a streamed comparison', () => {
const diff = useDiffStore()
load(diff, mmd(' A --> B'), mmd(' A --> C'))
diff.left = { ...diff.left, kind: 'streamed' }
expect(diff.canCompareDiagram).toBe(false)
})
})

// Pasted text is a comparison like any other — comparePasted() fills left/right,
// so the Diagram toggle must be offered there too.
describe('diagram comparison from pasted text', () => {
it('offers the diagram view after comparing two pasted diagrams', () => {
const diff = useDiffStore()
diff.mode = 'paste'
diff.pasteLeft = 'flowchart TD\n A --> B'
diff.pasteRight = 'flowchart TD\n A --> C'
diff.comparePasted()
expect(diff.canCompareDiagram).toBe(true)
diff.semanticView = true
expect(diff.comparableKind).toBe('diagram')
})
})
223 changes: 223 additions & 0 deletions tests/renderer/stores/diffStore.disk.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// What happens when the files move under a live comparison.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { DISK_NOTICE_MS, useDiffStore } from '../../../src/renderer/src/stores/diffStore'

beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
window.api = {}
})

const FILE = (name) => ({ path: `/tmp/${name}`, name, content: `content of ${name}` })

// "Saved" is what silences the discard prompts, so a comparison that no longer
// matches the vault copy must stop claiming to be it.
describe('staying honest about what is saved', () => {
const FILE_AT = (name, content) => ({ path: `/tmp/${name}`, name, content })

it('a file changing on disk makes the reloaded comparison unsaved again', async () => {
const store = useDiffStore()
store.left = FILE_AT('a.txt', 'before')
store.right = FILE_AT('b.txt', 'other')
store.markSaved()
window.api = {
readFile: async (path) =>
path.endsWith('a.txt')
? { path, name: 'a.txt', content: 'edited elsewhere' }
: { path, name: 'b.txt', content: 'other' }
}

await store.refreshFromDisk()
expect(store.left.content).toBe('edited elsewhere')
expect(store.diffSaved).toBe(false)
})

it('leaves a diff alone when nothing on disk actually changed', async () => {
const store = useDiffStore()
store.left = FILE_AT('a.txt', 'same')
store.markSaved()
window.api = { readFile: async (path) => ({ path, name: 'a.txt', content: 'same' }) }

await store.refreshFromDisk()
expect(store.diffSaved).toBe(true)
})
})

// The change check compared `content`, which a spreadsheet has none of, so no
// workbook ever reloaded.
describe('following a spreadsheet on disk', () => {
const book = (v) => ({
path: '/tmp/book.xlsx',
name: 'book.xlsx',
kind: 'spreadsheet',
sheets: [{ name: 'S1', rows: [['a', v]] }]
})

it('reloads a workbook whose grid changed, and says so', async () => {
const store = useDiffStore()
store.left = book(1)
store.markSaved()
window.api = { readFile: async () => book(2) }

await store.refreshFromDisk()
expect(store.left.sheets[0].rows[0][1]).toBe(2)
expect(store.diskNotice).toContain('changed on disk')
expect(store.diffSaved).toBe(false)
})

it('leaves an untouched workbook alone', async () => {
const store = useDiffStore()
store.left = book(1)
store.markSaved()
window.api = { readFile: async () => book(1) }

await store.refreshFromDisk()
expect(store.diskNotice).toBeNull()
expect(store.diffSaved).toBe(true)
})
})

// A second save adds nothing but a duplicate row, so it is not offered.
describe('saving the same comparison twice', () => {
it('is not offered while the comparison on screen is already saved', () => {
const store = useDiffStore()
store.left = FILE('a.txt')
store.right = FILE('b.txt')
expect(store.hasUnsavedWork).toBe(true)

store.markSaved()
expect(store.canSave).toBe(true) // there is still a comparison to share
expect(store.hasUnsavedWork).toBe(false)

store.handleMenuAction('save')
expect(store.showSaveDialog).toBe(false)
})

it('is offered again the moment the comparison changes', () => {
const store = useDiffStore()
store.left = FILE('a.txt')
store.right = FILE('b.txt')
store.markSaved()

store.swap()
expect(store.hasUnsavedWork).toBe(true)
})

it('is never offered for an empty comparison', () => {
expect(useDiffStore().hasUnsavedWork).toBe(false)
})
})

// Format rewrites a side in memory, so the app's copy and the file diverge. The
// focus re-read saw a difference it had caused itself, threw the formatting
// away, and reported a disk change that never happened.
describe('when the app and the disk have both moved', () => {
const UGLY = '{"a":1}'
const onDisk = (content) => ({ path: '/tmp/a.json', name: 'a.json', content })

it('keeps a side the app reformatted, and does not claim the disk changed', async () => {
const store = useDiffStore()
store.left = onDisk(UGLY)
store.formatSide('left')
const formatted = store.left.content
expect(formatted).not.toBe(UGLY)

window.api = { readFile: async () => onDisk(UGLY) }
await store.refreshFromDisk()

expect(store.left.content).toBe(formatted)
expect(store.diskNotice).toBeNull()
})

it('holds the app’s copy when the file ALSO changed, and says which', async () => {
const store = useDiffStore()
store.left = onDisk(UGLY)
store.formatSide('left')
const formatted = store.left.content

window.api = { readFile: async () => onDisk('{"a":2}') }
await store.refreshFromDisk()

expect(store.left.content).toBe(formatted)
expect(store.diskNotice).toContain('a.json')
expect(store.diskNotice).toContain('changed on disk')
expect(store.diskNotice).toContain('kept')
})

it('follows the disk again once the side is reloaded from it', async () => {
const store = useDiffStore()
store.left = onDisk(UGLY)
store.formatSide('left')

// Re-picking the file is the deliberate "take theirs".
store.receive('left', onDisk('{"a":2}'))
window.api = { readFile: async () => onDisk('{"a":3}') }
await store.refreshFromDisk()

expect(store.left.content).toBe('{"a":3}')
expect(store.diskNotice).toContain('diff reloaded')
})

it('still reloads an untouched side while another is held back', async () => {
const store = useDiffStore()
store.left = onDisk(UGLY)
store.formatSide('left')
store.right = { path: '/tmp/b.json', name: 'b.json', content: 'old' }

window.api = {
readFile: async (path) =>
path.endsWith('a.json') ? onDisk('{"a":9}') : { ...store.right, content: 'new' }
}
await store.refreshFromDisk()

expect(store.right.content).toBe('new')
expect(store.diskNotice).toContain('a.json')
expect(store.diskNotice).toContain('b.json')
})
})

// A held, dismissible label — not a toast that clears itself out from under you.
describe('the file-changed label', () => {
const onDisk = (content) => ({ path: '/tmp/a.txt', name: 'a.txt', content })

it('goes up on a disk change and clears itself after its window', async () => {
vi.useFakeTimers()
try {
const store = useDiffStore()
store.left = onDisk('before')
window.api = { readFile: async () => onDisk('after') }

await store.refreshFromDisk()
expect(store.diskNotice).toContain('a.txt')

vi.advanceTimersByTime(DISK_NOTICE_MS - 1)
expect(store.diskNotice).not.toBeNull()
vi.advanceTimersByTime(1)
expect(store.diskNotice).toBeNull()
} finally {
vi.useRealTimers()
}
})

it('outlives the ordinary toast, which would have cleared first', () => {
expect(DISK_NOTICE_MS).toBeGreaterThan(5000)
})

it('can be dismissed by hand, and stays dismissed', () => {
vi.useFakeTimers()
try {
const store = useDiffStore()
store.showDiskNotice('"a.txt" changed on disk — diff reloaded.')
store.dismissDiskNotice()
expect(store.diskNotice).toBeNull()

// The timer it cancelled cannot come back and blank a later one.
store.showDiskNotice('second')
vi.advanceTimersByTime(DISK_NOTICE_MS - 1)
expect(store.diskNotice).toBe('second')
} finally {
vi.useRealTimers()
}
})
})
96 changes: 96 additions & 0 deletions tests/renderer/stores/diffStore.export.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Getting a comparison back out: patches, HTML, and the config backup bundle.
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useDiffStore } from '../../../src/renderer/src/stores/diffStore'
import { loadPersisted, savePersisted } from '../../../src/renderer/src/persist'

beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
window.api = {}
})

describe('applyPatch', () => {
const PATCH = '--- original\n+++ changed\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n'
const pick = (base, patch) => async (side) =>
side === 'base'
? { path: '/tmp/config.js', name: 'config.js', content: base }
: { name: 'change.patch', content: patch }

it('opens base ↔ patched from the chosen files', async () => {
const store = useDiffStore()
window.api.openFile = pick('a\nb\nc\n', PATCH)
await store.applyPatch()
expect(store.left).toEqual({ path: '/tmp/config.js', name: 'config.js', content: 'a\nb\nc\n' })
expect(store.right).toEqual({ path: null, name: 'config.js (patched)', content: 'a\nB\nc\n' })
expect(store.mode).toBe('files')
})

it('does nothing when the base pick is cancelled', async () => {
const store = useDiffStore()
window.api.openFile = async () => null
await store.applyPatch()
expect(store.left).toBeNull()
expect(store.right).toBeNull()
})

it('rejects a file that is not a unified diff without loading anything', async () => {
const store = useDiffStore()
window.api.openFile = pick('a\nb\nc\n', 'not a patch')
await store.applyPatch()
expect(store.left).toBeNull()
expect(store.right).toBeNull()
})
})

describe('exportDiff', () => {
it('builds a self-contained HTML doc and hands it to the save IPC', async () => {
const store = useDiffStore()
store.left = { path: null, name: 'a.js', content: 'a\nb\n' }
store.right = { path: null, name: 'b.js', content: 'a\nB\n' }
let sent = null
window.api.exportDiffFile = async (payload) => {
sent = payload
return { ok: true, path: '/tmp/out.html' }
}
await store.exportDiff()
expect(sent.name).toBe('a.js-vs-b.js')
expect(sent.format).toBe('html')
expect(sent.text).toContain('<!doctype html>')
expect(sent.text).toContain('a.js ↔ b.js')
})

it('does nothing (no IPC) when there is nothing to compare', async () => {
const store = useDiffStore()
let called = false
window.api.exportDiffFile = async () => {
called = true
return { ok: true }
}
await store.exportDiff()
expect(called).toBe(false)
})
})

// The bundle carried `session` from the start; without this it was sealed into
// the archive and silently dropped on the way back.
describe('config backup — session round trip', () => {
it('collects the session into the bundle and writes it back on restore', async () => {
const diff = useDiffStore()
savePersisted('session', '{"tabs":["a"]}')
let sent = null
window.api.backupConfig = async (bundle) => {
sent = bundle
return { ok: true, path: '/tmp/x' }
}
await diff.runConfigBackup('passphrase-long-enough')
expect(sent.session).toBe('{"tabs":["a"]}')

savePersisted('session', '{"tabs":["different"]}')
window.api.restoreConfig = async () => ({ ok: true, session: sent.session })
await diff.runConfigRestore('passphrase-long-enough')
// Written to persistence, not applied live: replacing the comparisons the
// reader is looking at mid-restore is not what they asked for.
expect(loadPersisted('session')).toBe('{"tabs":["a"]}')
})
})
Loading
Loading