krz/orgstar

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

Tests/OrgAppTests/UserConfigTests.swift

191ddb506f3a91b93c755ba6e8123a4e8ac4f90d
orgstar/Tests/OrgAppTests/UserConfigTests.swift history · blame · raw

189 lines · 11164 bytes

  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}