Tests/OrgAppTests/UserConfigTests.swift
189 lines · 11164 bytes
16 symbols in this file
UserConfigTestsdefaultstemplateRoundTripsproblemsAndRemovedLineseditsKeepCommentsAndOrdermissingSectionsAreAddedlegacyFilesAreRewrittenlegacyNamesMoveInPlacesyncsBothWaysThemeConfigTeststhemeTablesAreNotSettingsProblemsthemeFileInASubfolderIsRecognizedThemeReadingTestsconfigColorsOverThemeFileOverDefaultnoThemeFileIsTheDefaultWithConfigColorsproblems
1import Foundation
2import OrgCore
3import OrgPresentation
4import Testing
5@testable import OrgApp
6
7@MainActor
8struct UserConfigTests {
9 func defaults() -> UserDefaults {
10 let name = "orgstar-config-\(UUID().uuidString)"
11 let defaults = UserDefaults(suiteName: name)!
12 defaults.removePersistentDomain(forName: name)
13 return defaults
14 }
15
16 @Test func templateRoundTrips() {
17 let source = defaults()
18 source.set("doom", forKey: "keymap")
19 source.set(3, forKey: "optionAsMeta")
20 source.set(72, forKey: "fillColumn")
21 source.set(false, forKey: "showLineNumbers")
22 let text = UserConfig.template(from: source)
23 #expect(text.contains("keymap = \"doom\""))
24 #expect(text.contains("\nfill-column = 72"))
25 #expect(text.contains("org-agenda-start-day = \"-3d\""))
26 #expect(text.contains("option-as-meta = \"both\""))
27 let target = defaults()
28 #expect(UserConfig.apply(text, to: target).isEmpty)
29 #expect(target.string(forKey: "keymap") == "doom")
30 #expect(target.integer(forKey: "optionAsMeta") == 3)
31 #expect(target.integer(forKey: "fillColumn") == 72)
32 #expect(target.bool(forKey: "showLineNumbers") == false)
33 }
34
35 @Test func problemsAndRemovedLines() {
36 let target = defaults()
37 target.set(60, forKey: "fillColumn")
38 let problems = UserConfig.apply("fill-column = \"wide\"\norg-agenda-start-day = \"soon\"\n[orgstar]\nkeymap = \"vim\"\ncolour = 1\n", to: target)
39 #expect(problems.count == 4)
40 #expect(target.integer(forKey: "fillColumn") == 60)
41 #expect(UserConfig.apply("[orgstar]\n", to: target).isEmpty)
42 #expect(target.object(forKey: "fillColumn") == nil)
43 }
44
45 @Test func editsKeepCommentsAndOrder() {
46 let text = "# mine\n[editor]\nfill-column = 80 # wide\nline-numbers = true\n\n[agenda]\nspan = 10\n"
47 #expect(UserConfig.setting(text, section: "editor", key: "fill-column", to: .integer(72))
48 == "# mine\n[editor]\nfill-column = 72 # wide\nline-numbers = true\n\n[agenda]\nspan = 10\n")
49 #expect(UserConfig.setting(text, section: "editor", key: "show-markup", to: .bool(true))
50 == "# mine\n[editor]\nfill-column = 80 # wide\nline-numbers = true\nshow-markup = true\n\n[agenda]\nspan = 10\n")
51 #expect(UserConfig.setting(text, section: "capture", key: "global-hotkey", to: .bool(false))
52 == "# mine\n[editor]\nfill-column = 80 # wide\nline-numbers = true\n\n[agenda]\nspan = 10\n\n[capture]\nglobal-hotkey = false\n")
53 #expect(UserConfig.setting("[general]\nkeymap = \"emacs#1\" # c\n", section: "general", key: "keymap", to: .string("doom"))
54 == "[general]\nkeymap = \"doom\" # c\n")
55 #expect(UserConfig.setting("# top\n\n[orgstar]\nsave = \"explicit\"\n", section: "", key: "fill-column", to: .integer(70))
56 == "# top\nfill-column = 70\n\n[orgstar]\nsave = \"explicit\"\n")
57 }
58
59 @Test func missingSectionsAreAdded() {
60 let text = "# mine\nfill-column = 70\n\n[orgstar]\nsave = \"explicit\"\n"
61 let completed = UserConfig.addingMissingSections(text, from: defaults())
62 #expect(completed.hasPrefix(text))
63 #expect(completed.contains("\n[theme]\nfont = \"\"") && !completed.contains("keymap"))
64 #expect(UserConfig.addingMissingSections(completed, from: defaults()) == completed)
65 }
66
67 @Test func legacyFilesAreRewritten() throws {
68 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)")
69 defer { try? FileManager.default.removeItem(at: folder) }
70 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
71 let url = folder.appendingPathComponent("config.toml")
72 try "[editor]\nfill-column = 64\n[general]\nkeymap = \"doom\"\n".write(to: url, atomically: true, encoding: .utf8)
73 let store = defaults()
74 ConfigSync(url: url, defaults: store).start(watch: false)
75 #expect(store.integer(forKey: "fillColumn") == 64)
76 let text = try String(contentsOf: url, encoding: .utf8)
77 #expect(text.contains("\nfill-column = 64") && text.contains("keymap = \"doom\"") && !text.contains("[editor]"))
78 }
79
80 @Test func legacyNamesMoveInPlace() {
81 let text = "# mine\n[editor]\n# my editor\nfill-column = 64 # narrow\n\n[agenda]\nspan = 7\n\n[theme]\nfont-size = 15\n"
82 let migrated = UserConfig.migratingLegacyNames(text)
83 #expect(migrated == "# mine\nfill-column = 64 # narrow\norg-agenda-span = 7\n[editor]\n# my editor\n\n[theme]\nfont-size = 15\n")
84 #expect(!UserConfig.usesLegacyNames(migrated))
85 let store = defaults()
86 #expect(UserConfig.apply(migrated, to: store).isEmpty)
87 #expect(store.integer(forKey: "fillColumn") == 64 && store.integer(forKey: "agendaSpan") == 7 && store.integer(forKey: "themeFontSize") == 15)
88 // The new name wins over the old one.
89 #expect(UserConfig.migratingLegacyNames("fill-column = 70\n[editor]\nfill-column = 64\n") == "fill-column = 70\n")
90 }
91
92 @Test func syncsBothWays() throws {
93 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)")
94 defer { try? FileManager.default.removeItem(at: folder) }
95 let url = folder.appendingPathComponent("config.toml")
96 let store = defaults()
97 let sync = ConfigSync(url: url, defaults: store)
98 sync.start(watch: false)
99 #expect(try String(contentsOf: url, encoding: .utf8).contains("\nfill-column = 80"))
100
101 store.set(66, forKey: "fillColumn")
102 #expect(try String(contentsOf: url, encoding: .utf8).contains("fill-column = 66"))
103
104 var text = try String(contentsOf: url, encoding: .utf8)
105 text = text.replacingOccurrences(of: "org-agenda-span = 10", with: "org-agenda-span = 7")
106 .replacingOccurrences(of: "org-agenda-start-day = \"-3d\"", with: "org-agenda-start-day = \"+0d\"")
107 try text.write(to: url, atomically: true, encoding: .utf8)
108 sync.reload()
109 #expect(store.integer(forKey: "agendaSpan") == 7)
110 #expect(store.integer(forKey: "agendaStartOffset") == 0)
111 #expect(sync.problems.isEmpty)
112 }
113}
114
115@MainActor
116struct ThemeConfigTests {
117 @Test func themeTablesAreNotSettingsProblems() throws {
118 let name = "orgstar-theme-\(UUID().uuidString)"
119 let defaults = UserDefaults(suiteName: name)!
120 defer { defaults.removePersistentDomain(forName: name) }
121 let problems = UserConfig.apply("[theme]\nfont-size = 15\nlink = \"#123456\"\n[theme.dark]\nlink = \"#654321\"\n", to: defaults)
122 #expect(problems.isEmpty)
123 #expect(defaults.integer(forKey: "themeFontSize") == 15)
124
125 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-theme-\(UUID().uuidString)")
126 defer { try? FileManager.default.removeItem(at: folder) }
127 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
128 try "[theme.light]\nlink = \"#000001\"\ntodo = \"#000002\"\n".write(to: folder.appendingPathComponent("mine.toml"), atomically: true, encoding: .utf8)
129 try "[theme]\ntheme-file = \"mine.toml\"\nlink = \"#000003\"\n".write(to: folder.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8)
130 let sync = ConfigSync(url: folder.appendingPathComponent("config.toml"), defaults: defaults)
131 sync.start(watch: false)
132 #expect(sync.problems.isEmpty, "\(sync.problems)")
133 #expect(sync.theme.light["link"]?.hex == "#000003")
134 #expect(sync.theme.light["todo"]?.hex == "#000002")
135 #expect(FileManager.default.fileExists(atPath: folder.appendingPathComponent("default-theme.toml").path))
136 }
137
138 @Test func themeFileInASubfolderIsRecognized() throws {
139 let name = "orgstar-theme-\(UUID().uuidString)"
140 let defaults = UserDefaults(suiteName: name)!
141 defer { defaults.removePersistentDomain(forName: name) }
142 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-theme-\(UUID().uuidString)")
143 defer { try? FileManager.default.removeItem(at: folder) }
144 try FileManager.default.createDirectory(at: folder.appendingPathComponent("themes"), withIntermediateDirectories: true)
145 try "[theme]\ntheme-file = \"themes/x.toml\"\n".write(to: folder.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8)
146 let sync = ConfigSync(url: folder.appendingPathComponent("config.toml"), defaults: defaults)
147 sync.start(watch: false)
148 // The watcher reports paths under the folder with symlinks resolved (/private/var/…).
149 let reported = folder.resolvingSymlinksInPath().appendingPathComponent("themes/x.toml")
150 #expect(sync.isThemeFile(reported))
151 #expect(!sync.isThemeFile(folder.appendingPathComponent("x.toml")))
152 }
153}
154
155struct ThemeReadingTests {
156 let folder = URL(fileURLWithPath: "/config")
157
158 @Test func configColorsOverThemeFileOverDefault() throws {
159 var read: [String] = []
160 let config = "[theme]\ntheme-file = \"themes/mine.toml\"\nlink = \"#000003\"\n[theme.dark]\nbackground = \"#000004\"\n"
161 let result = try #require(UserConfig.theme(config: config, themeFile: "themes/mine.toml", in: folder) { url in
162 read.append(url.path)
163 return "[theme.light]\nlink = \"#000001\"\ntodo = \"#000002\"\n[theme.todo]\nWAIT = \"#000005\"\n"
164 })
165 #expect(read == ["/config/themes/mine.toml"])
166 #expect(result.problems.isEmpty, "\(result.problems)")
167 #expect(result.theme.light["link"]?.hex == "#000003" && result.theme.dark["link"]?.hex == "#000003")
168 #expect(result.theme.light["todo"]?.hex == "#000002")
169 #expect(result.theme.dark["todo"] == ThemeSpec.default.dark["todo"])
170 #expect(result.theme.dark["background"]?.hex == "#000004" && result.theme.light["background"] == ThemeSpec.default.light["background"])
171 #expect(result.theme.todo["WAIT"]?.hex == "#000005")
172 }
173
174 @Test func noThemeFileIsTheDefaultWithConfigColors() throws {
175 let result = try #require(UserConfig.theme(config: "[theme]\nfont-size = 15\n", themeFile: "", in: folder) { _ in
176 Issue.record("read a theme file")
177 return nil
178 })
179 #expect(result.theme == ThemeSpec.default && result.problems.isEmpty)
180 }
181
182 @Test func problems() throws {
183 let missing = try #require(UserConfig.theme(config: "", themeFile: "gone.toml", in: folder) { _ in nil })
184 #expect(missing.problems == ["config.toml: theme-file gone.toml can't be read"])
185 let bad = try #require(UserConfig.theme(config: "[theme]\nlink = \"blue\"\n", themeFile: "t.toml", in: folder) { _ in "[theme]\nnope = \"#000000\"\n" })
186 #expect(bad.problems == ["t.toml: unknown theme color nope", "config.toml: theme.link must be a color such as \"#1f2328\""])
187 #expect(UserConfig.theme(config: "[theme", themeFile: nil, in: folder)?.theme == nil)
188 }
189}