krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Tests/OrgDocumentTests/SaveTests.swift

15f6b0709d88971fb62ed432c3e5b8032643670a
orgstar/Tests/OrgDocumentTests/SaveTests.swift history · blame · raw

204 lines · 8237 bytes

  1import Foundation
  2import OrgCore
  3import Testing
  4@testable import OrgDocument
  5
  6/// An in-memory file with hooks that let another writer change it at each step of a save.
  7final class FaultyFileSystem: FileSystem, @unchecked Sendable {
  8    var file: [UInt8]?
  9    var reads = 0
 10    /// Content another writer puts in place just before the n-th read (1-based).
 11    var beforeRead: [Int: [UInt8]] = [:]
 12    /// Content another writer puts in place just before our replace.
 13    var beforeReplace: [UInt8]?
 14
 15    init(_ text: String?) {
 16        file = text.map { Array($0.utf8) }
 17    }
 18
 19    func read(_ url: URL) throws -> [UInt8]? {
 20        reads += 1
 21        if let injected = beforeRead[reads] { file = injected }
 22        return file
 23    }
 24
 25    func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]? {
 26        if let injected = beforeReplace { file = injected }
 27        let replaced = file
 28        file = bytes
 29        return replaced
 30    }
 31
 32    var text: String? { file.map { String(decoding: $0, as: UTF8.self) } }
 33}
 34
 35final class MemoryRecovery: RecoveryStore, @unchecked Sendable {
 36    var kept: [(label: String, text: String)] = []
 37
 38    func keep(_ bytes: [UInt8], for url: URL, label: String) throws {
 39        kept.append((label, String(decoding: bytes, as: UTF8.self)))
 40    }
 41
 42    func contains(_ text: String) -> Bool { kept.contains { $0.text == text } }
 43}
 44
 45let url = URL(fileURLWithPath: "/notes/a.org")
 46
 47/// A buffer loaded from "a\nb\nc\n" with its first line changed to "A".
 48func editedState() throws -> DocumentState {
 49    var doc = state("a\nb\nc\n")
 50    try doc.apply([TextEdit(range: 0..<1, replacement: "A")], baseRevision: 0)
 51    return doc
 52}
 53
 54struct SaveTests {
 55    @Test func plainSave() throws {
 56        let files = FaultyFileSystem("a\nb\nc\n")
 57        let recovery = MemoryRecovery()
 58        var doc = try editedState()
 59        #expect(try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) == .saved)
 60        #expect(files.text == "A\nb\nc\n")
 61        #expect(!doc.isDirty)
 62        #expect(recovery.kept.isEmpty)
 63    }
 64
 65    @Test func missingFileIsCreated() throws {
 66        let files = FaultyFileSystem(nil)
 67        var doc = try editedState()
 68        #expect(try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) == .saved)
 69        #expect(files.text == "A\nb\nc\n")
 70    }
 71
 72    @Test func readOnlyDocumentsAreNotSaved() {
 73        var doc = DocumentState(bytes: [0x61, 0xFF])
 74        #expect(throws: DocumentState.EditError.readOnly) {
 75            try Saver(fileSystem: FaultyFileSystem("x"), recovery: MemoryRecovery()).save(&doc, to: url)
 76        }
 77    }
 78
 79    // MARK: - Another writer at each step
 80
 81    @Test func changedBeforeSaveMerges() throws {
 82        let files = FaultyFileSystem("a\nb\nc\n")
 83        files.beforeRead[1] = Array("a\nb\nC\n".utf8)
 84        let recovery = MemoryRecovery()
 85        var doc = try editedState()
 86        guard case .mergedAndSaved = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else {
 87            Issue.record("expected a merge")
 88            return
 89        }
 90        #expect(files.text == "A\nb\nC\n")
 91        #expect(recovery.contains("a\nb\nC\n") && recovery.contains("A\nb\nc\n"))
 92    }
 93
 94    @Test func conflictingChangeWritesNothing() throws {
 95        let files = FaultyFileSystem("a\nb\nc\n")
 96        files.beforeRead[1] = Array("Z\nb\nc\n".utf8)
 97        var doc = try editedState()
 98        guard case .conflict = try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) else {
 99            Issue.record("expected a conflict")
100            return
101        }
102        #expect(files.text == "Z\nb\nc\n")
103        #expect(doc.text == "A\nb\nc\n")
104    }
105
106    @Test func overwriteKeepsTheReplacedVersion() throws {
107        let files = FaultyFileSystem("Z\nb\nc\n")
108        let recovery = MemoryRecovery()
109        var doc = try editedState()
110        try Saver(fileSystem: files, recovery: recovery).overwrite(&doc, to: url)
111        #expect(files.text == "A\nb\nc\n")
112        #expect(!doc.isDirty)
113        #expect(recovery.contains("Z\nb\nc\n"))
114    }
115
116    @Test func changedBetweenReadAndCheckRetries() throws {
117        let files = FaultyFileSystem("a\nb\nc\n")
118        files.beforeRead[2] = Array("a\nb\nC\n".utf8)
119        var doc = try editedState()
120        guard case .mergedAndSaved = try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) else {
121            Issue.record("expected a merge on the second attempt")
122            return
123        }
124        #expect(files.text == "A\nb\nC\n")
125    }
126
127    @Test func changedJustBeforeReplaceIsRecovered() throws {
128        let files = FaultyFileSystem("a\nb\nc\n")
129        files.beforeReplace = Array("a\nb\nC\n".utf8)
130        let recovery = MemoryRecovery()
131        var doc = try editedState()
132        guard case .overwroteExternalChange(.merged) = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else {
133            Issue.record("expected their change merged into the buffer")
134            return
135        }
136        #expect(files.text == "A\nb\nc\n")
137        #expect(recovery.contains("a\nb\nC\n"))
138        #expect(doc.text == "A\nb\nC\n")
139        #expect(doc.isDirty)
140    }
141
142    @Test func changedRightAfterWriteKeepsOursInRecovery() throws {
143        let files = FaultyFileSystem("a\nb\nc\n")
144        files.beforeRead[3] = Array("A\nb\nc\nD\n".utf8)
145        let recovery = MemoryRecovery()
146        var doc = try editedState()
147        guard case .changedAfterWrite(.reloaded) = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else {
148            Issue.record("expected a reload of their version")
149            return
150        }
151        #expect(recovery.contains("A\nb\nc\n"))
152        #expect(doc.text == "A\nb\nc\nD\n")
153    }
154
155    @Test func keepsChangingGivesUp() throws {
156        let files = FaultyFileSystem("a\nb\nc\n")
157        for n in stride(from: 2, through: 6, by: 2) { files.beforeRead[n] = Array("a\nb\nc\n\(n)\n".utf8) }
158        var doc = try editedState()
159        #expect(throws: SaveError.fileKeepsChanging) {
160            try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url)
161        }
162    }
163}
164
165struct FileStorageTests {
166    func temporaryFolder() throws -> URL {
167        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)")
168        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
169        return folder
170    }
171
172    @Test func readAndReplace() throws {
173        let folder = try temporaryFolder()
174        defer { try? FileManager.default.removeItem(at: folder) }
175        let file = folder.appendingPathComponent("a.org")
176        let files = CoordinatedFileSystem()
177        #expect(try files.read(file) == nil)
178        #expect(try files.replace(file, with: Array("one\n".utf8)) == nil)
179        #expect(try files.replace(file, with: Array("two\n".utf8)) == Array("one\n".utf8))
180        #expect(try files.read(file) == Array("two\n".utf8))
181        #expect(try FileManager.default.contentsOfDirectory(atPath: folder.path) == ["a.org"])
182    }
183
184    @Test func savesThroughTheRealFileSystem() throws {
185        let folder = try temporaryFolder()
186        defer { try? FileManager.default.removeItem(at: folder) }
187        let file = folder.appendingPathComponent("a.org")
188        try Data("a\nb\nc\n".utf8).write(to: file)
189        let recovery = FileRecoveryStore(directory: folder.appendingPathComponent("recovery"))
190        var doc = try editedState()
191        #expect(try Saver(fileSystem: CoordinatedFileSystem(), recovery: recovery).save(&doc, to: file) == .saved)
192        #expect(try String(contentsOf: file, encoding: .utf8) == "A\nb\nc\n")
193    }
194
195    @Test func recoveryKeepsTheNewestVersions() throws {
196        let folder = try temporaryFolder()
197        defer { try? FileManager.default.removeItem(at: folder) }
198        let store = FileRecoveryStore(directory: folder, limit: 3)
199        for n in 0..<5 { try store.keep(Array("v\(n)".utf8), for: url, label: "local") }
200        let versions = try store.versions(for: url)
201        #expect(versions.count == 3)
202        #expect(try versions.map { try String(contentsOf: $0, encoding: .utf8) } == ["v2", "v3", "v4"])
203    }
204}