Tests/OrgDocumentTests/SaveTests.swift
204 lines · 8237 bytes
23 symbols in this file
FaultyFileSystemreadreplaceMemoryRecoverykeepcontainseditedStateSaveTestsplainSavemissingFileIsCreatedreadOnlyDocumentsAreNotSavedchangedBeforeSaveMergesconflictingChangeWritesNothingoverwriteKeepsTheReplacedVersionchangedBetweenReadAndCheckRetrieschangedJustBeforeReplaceIsRecoveredchangedRightAfterWriteKeepsOursInRecoverykeepsChangingGivesUpFileStorageTeststemporaryFolderreadAndReplacesavesThroughTheRealFileSystemrecoveryKeepsTheNewestVersions
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}