Commit b475734cc0
Verified · cmc
Layout: unified · split
gitbay/Discovery/ProfileEdit.swift added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// `profile set` — unlike every other filter or edit in the app, `--link` | |
| 4 | /// replaces the whole set rather than appending, so every link the form | |
| 5 | /// wants to keep is sent on every save. The form is always seeded from | |
| 6 | /// the current profile, so every other field is also sent every time, | |
| 7 | /// including empty ones: `--description ""` clears, omitting the flag | |
| 8 | /// would leave the old value alone. | |
| 9 | nonisolated struct ProfileEdit: Equatable, Sendable { | |
| 10 | ||
| 11 | nonisolated struct Link: Equatable, Sendable { | |
| 12 | var label: String | |
| 13 | var url: String | |
| 14 | } | |
| 15 | ||
| 16 | var description: String | |
| 17 | var website: String | |
| 18 | var about: String | |
| 19 | var aboutFormat: String | |
| 20 | var links: [Link] | |
| 21 | ||
| 22 | init( | |
| 23 | description: String = "", website: String = "", | |
| 24 | about: String = "", aboutFormat: String = "md", | |
| 25 | links: [Link] = [] | |
| 26 | ) { | |
| 27 | self.description = description | |
| 28 | self.website = website | |
| 29 | self.about = about | |
| 30 | self.aboutFormat = aboutFormat | |
| 31 | self.links = links | |
| 32 | } | |
| 33 | ||
| 34 | init(from profile: ProfileViewModel.Profile) { | |
| 35 | description = profile.description ?? "" | |
| 36 | website = profile.website ?? "" | |
| 37 | about = profile.about ?? "" | |
| 38 | aboutFormat = profile.aboutFormat ?? "md" | |
| 39 | links = (profile.links ?? []).map { Link(label: $0.label ?? "", url: $0.url) } | |
| 40 | } | |
| 41 | ||
| 42 | var isEmpty: Bool { | |
| 43 | description.isEmpty && website.isEmpty && about.isEmpty && links.isEmpty | |
| 44 | } | |
| 45 | ||
| 46 | /// At most 5 links, and every link needs a URL — the server enforces | |
| 47 | /// both, but the form should not let a save reach it that way. | |
| 48 | var validationError: String? { | |
| 49 | if links.count > 5 { return "At most 5 links." } | |
| 50 | if links.contains(where: { $0.url.trimmingCharacters(in: .whitespaces).isEmpty }) { | |
| 51 | return "A link needs a URL." | |
| 52 | } | |
| 53 | return nil | |
| 54 | } | |
| 55 | ||
| 56 | /// Everything except the about text, which travels over stdin. | |
| 57 | func flags() -> [String] { | |
| 58 | var flags = ["--description", description, "--website", website] | |
| 59 | if links.isEmpty { | |
| 60 | flags += ["--link", ""] | |
| 61 | } else { | |
| 62 | for link in links { | |
| 63 | let label = link.label.trimmingCharacters(in: .whitespaces) | |
| 64 | flags += ["--link", label.isEmpty ? link.url : "\(label)|\(link.url)"] | |
| 65 | } | |
| 66 | } | |
| 67 | flags += ["--about-format", aboutFormat, "--file", "-"] | |
| 68 | return flags | |
| 69 | } | |
| 70 | ||
| 71 | /// The about text, sent over stdin — never in argv, since it is | |
| 72 | /// long-form and may hold newlines. | |
| 73 | var aboutStdin: String? { about } | |
| 74 | } | |
gitbayTests/ProfileEditTests.swift added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | struct ProfileEditFlagTests { | |
| 6 | ||
| 7 | private func edit( | |
| 8 | description: String = "", website: String = "", | |
| 9 | about: String = "", aboutFormat: String = "md", | |
| 10 | links: [ProfileEdit.Link] = [] | |
| 11 | ) -> ProfileEdit { | |
| 12 | ProfileEdit(description: description, website: website, | |
| 13 | about: about, aboutFormat: aboutFormat, links: links) | |
| 14 | } | |
| 15 | ||
| 16 | @Test func everyFieldSendsItsFlag() { | |
| 17 | let e = edit(description: "hi", website: "https://x.test", | |
| 18 | links: [.init(label: "blog", url: "https://b.test")]) | |
| 19 | let flags = e.flags() | |
| 20 | #expect(flags.contains("--description")) | |
| 21 | #expect(flags.contains("hi")) | |
| 22 | #expect(flags.contains("--website")) | |
| 23 | #expect(flags.contains("https://x.test")) | |
| 24 | #expect(flags.contains("blog|https://b.test")) | |
| 25 | } | |
| 26 | ||
| 27 | /// An emptied field CLEARS it. Omitting the flag would leave the old | |
| 28 | /// value in place, which is a different outcome. | |
| 29 | @Test func anEmptiedFieldSendsAnEmptyValueNotNothing() { | |
| 30 | let flags = edit(description: "", website: "https://x.test").flags() | |
| 31 | let i = try! #require(flags.firstIndex(of: "--description")) | |
| 32 | #expect(flags[i + 1] == "") | |
| 33 | } | |
| 34 | ||
| 35 | /// `--link ""` is how the whole set is cleared. Omitting it would | |
| 36 | /// leave the existing links untouched. | |
| 37 | @Test func noLinksSendsAnEmptyLinkFlag() { | |
| 38 | let flags = edit(description: "hi").flags() | |
| 39 | let i = try! #require(flags.firstIndex(of: "--link")) | |
| 40 | #expect(flags[i + 1] == "") | |
| 41 | } | |
| 42 | ||
| 43 | /// The set replaces wholesale, so every kept link is sent every time. | |
| 44 | @Test func everyLinkIsSentSoTheSetReplaces() { | |
| 45 | let flags = edit(links: [ | |
| 46 | .init(label: "a", url: "https://a.test"), | |
| 47 | .init(label: "b", url: "https://b.test"), | |
| 48 | ]).flags() | |
| 49 | #expect(flags.filter { $0 == "--link" }.count == 2) | |
| 50 | #expect(flags.contains("a|https://a.test")) | |
| 51 | #expect(flags.contains("b|https://b.test")) | |
| 52 | } | |
| 53 | ||
| 54 | /// Without a separator the whole value is the URL, so a label-less | |
| 55 | /// link must not send a leading pipe. | |
| 56 | @Test func aLabellessLinkSendsJustTheUrl() { | |
| 57 | let flags = edit(links: [.init(label: "", url: "https://a.test")]).flags() | |
| 58 | #expect(flags.contains("https://a.test")) | |
| 59 | #expect(flags.contains("|https://a.test") == false) | |
| 60 | } | |
| 61 | ||
| 62 | /// Long-form text with newlines belongs in stdin, not argv. | |
| 63 | @Test func theAboutTextGoesToStdinNotArgv() { | |
| 64 | let e = edit(about: "line one\n\nline two", aboutFormat: "org") | |
| 65 | #expect(e.flags().contains { $0.contains("line one") } == false) | |
| 66 | #expect(e.flags().contains("--file")) | |
| 67 | #expect(e.flags().contains("-")) | |
| 68 | #expect(e.aboutStdin == "line one\n\nline two") | |
| 69 | } | |
| 70 | ||
| 71 | @Test func theAboutFormatAccompaniesTheText() { | |
| 72 | let flags = edit(about: "x", aboutFormat: "org").flags() | |
| 73 | let i = try! #require(flags.firstIndex(of: "--about-format")) | |
| 74 | #expect(flags[i + 1] == "org") | |
| 75 | } | |
| 76 | ||
| 77 | @Test func moreThanFiveLinksIsInvalid() { | |
| 78 | let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") } | |
| 79 | #expect(edit(links: six).validationError != nil) | |
| 80 | let five = (1...5).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") } | |
| 81 | #expect(edit(links: five).validationError == nil) | |
| 82 | } | |
| 83 | ||
| 84 | @Test func aLinkWithoutAUrlIsInvalid() { | |
| 85 | #expect(edit(links: [.init(label: "blog", url: " ")]).validationError != nil) | |
| 86 | } | |
| 87 | } | |