import Foundation import Testing @testable import Kanban /// The board background's **mapping form** — the write side that has to preserve what the app has no /// control over, and the path rule that decides where an `image:` may point (01-storage-format.md § /// Frontmatter; 03-board-ui.md § Styling ▸ Capabilities). /// /// The *readings* live with their siblings in `FrontmatterTests`; what is here is everything that is /// new machinery rather than a new value: `FrontmatterDocument.setStyleValue` (BackgroundField.swift) /// and `BoardBackdrop.imageURL(named:inBoardRoot:)`. // MARK: - Fixtures private func document(_ frontmatter: String) throws -> FrontmatterDocument { try FrontmatterDocument.parse("---\n\(frontmatter)\n---\nbody\n") } /// The `background:` line as it now reads on disk, or `nil` when the key is gone. private func backgroundLine(_ document: FrontmatterDocument) -> String? { document.serialized() .split(separator: "\n", omittingEmptySubsequences: false) .first { $0.hasPrefix("background:") } .map(String.init) } // MARK: - Writing into the mapping @Suite("Board background ▸ the write preserves the mapping") struct BackgroundWriteTests { /// The whole point of the field: the app owns the colour well and nothing else, so a colour /// change on a board carrying an image has to come back still carrying it. @Test("Setting a colour replaces the subkey and keeps the image") func setKeepsTheImage() throws { var document = try document("background: {color: fern, image: sunset.jpg}") document.setStyleValue("dark-teal", for: FrontmatterKeys.background) #expect(document.background == .valid("dark-teal")) #expect(document.backgroundImage == .valid("sunset.jpg")) #expect(backgroundLine(document) == "background: {color: \"dark-teal\", image: \"sunset.jpg\"}") } /// A colour written into a mapping that had none joins it rather than replacing it — the /// image-only board is exactly the board the style editor is most likely to be opened on. @Test("Setting a colour on an image-only background adds the subkey") func setAddsTheSubkeyToAnImageOnlyMapping() throws { var document = try document("background: {image: sunset.jpg}") document.setStyleValue("chalk", for: FrontmatterKeys.background) #expect(document.background == .valid("chalk")) #expect(document.backgroundImage == .valid("sunset.jpg")) } /// Unknown subkeys ride along like unknown keys do — nothing in the app knows what `blend:` /// means and nothing in the app is entitled to drop it. @Test("Unknown subkeys survive a colour change, in their own positions") func setPreservesUnknownSubkeys() throws { var document = try document("background: {blend: multiply, color: fern, opacity: 0.5, tags: [a, b]}") document.setStyleValue("obsidian", for: FrontmatterKeys.background) #expect(backgroundLine(document) == "background: {blend: \"multiply\", color: \"obsidian\", opacity: 0.5, tags: [\"a\", \"b\"]}") } /// The None well removes the *colour*, not the background: an image the user never chose in the /// app must not disappear because they cleared a colour (03-board-ui.md § Styling ▸ Controls). @Test("The None well drops the colour subkey alone") func removeDropsOnlyTheColour() throws { var document = try document("background: {color: fern, image: sunset.jpg}") document.setStyleValue(nil, for: FrontmatterKeys.background) #expect(document.background == .missing) #expect(document.backgroundImage == .valid("sunset.jpg")) #expect(backgroundLine(document) == "background: {image: \"sunset.jpg\"}") } /// A mapping the removal empties takes the key with it — `background: {}` is a key that says /// nothing, and the removal's contract is that the field is gone. @Test("A mapping emptied by the removal takes the key with it") func removeDropsAnEmptiedKey() throws { var document = try document("background: {color: fern}") document.setStyleValue(nil, for: FrontmatterKeys.background) #expect(document.background == .missing) #expect(!document.contains(FrontmatterKeys.background)) #expect(backgroundLine(document) == nil) } /// A block mapping is the same value as a flow one and is merged the same way. The spelling is /// what does not survive — the span editor rewrites a key's value as one line — which is the /// documented limit of the verbatim promise on the one key being written. @Test("A block mapping merges, collapsing to flow form") func blockMappingCollapsesToFlow() throws { var document = try FrontmatterDocument.parse( "---\nschema: 1\nbackground:\n color: fern\n image: sunset.jpg\nicon: tray\n---\nbody\n" ) document.setStyleValue("chalk", for: FrontmatterKeys.background) #expect(document.background == .valid("chalk")) #expect(document.backgroundImage == .valid("sunset.jpg")) #expect(document.serialized() == """ --- schema: 1 background: {color: "chalk", image: "sunset.jpg"} icon: tray --- body """) } /// **The app always writes the mapping** (01-storage-format.md § Frontmatter): a key that was /// absent gets one, and a key holding a shape the schema cannot read — the retired scalar, a /// sequence — is *replaced* by one, which is the malformed-value-cleared posture ("choosing any /// well replaces it"). @Test("A colour written onto an absent or unreadable key lands as a mapping") func alwaysWritesTheMapping() throws { var absent = try document("schema: 1") absent.setStyleValue("chalk", for: FrontmatterKeys.background) #expect(backgroundLine(absent) == "background: {color: \"chalk\"}") #expect(absent.background == .valid("chalk")) var scalar = try document("background: fern") scalar.setStyleValue("chalk", for: FrontmatterKeys.background) #expect(backgroundLine(scalar) == "background: {color: \"chalk\"}") var sequence = try document("background: [a, b]") sequence.setStyleValue("chalk", for: FrontmatterKeys.background) #expect(backgroundLine(sequence) == "background: {color: \"chalk\"}") } /// A removal over a shape with no subkeys to keep is simply a removal — there is no mapping to /// preserve half of, and nothing was readable to begin with. @Test("A removal over a scalar or an absent key just removes it") func removalOverANonMappingRemovesTheKey() throws { var scalar = try document("background: fern") scalar.setStyleValue(nil, for: FrontmatterKeys.background) #expect(!scalar.contains(FrontmatterKeys.background)) var absent = try document("schema: 1") absent.setStyleValue(nil, for: FrontmatterKeys.background) #expect(!absent.contains(FrontmatterKeys.background)) #expect(absent.serialized() == "---\nschema: 1\n---\nbody\n") } /// The other style keys are untouched by any of this: their values *are* strings, and a /// hand-written `icon: {a: 1}` is a malformed value the write exists to clear — never something /// to merge a `color:` subkey into. @Test("Title and icon keep the plain scalar path") func otherKeysStayScalar() throws { var titled = try document("title: Old") titled.setStyleValue("New", for: FrontmatterKeys.title) #expect(titled.title == .valid("New")) #expect(titled.serialized() == "---\ntitle: New\n---\nbody\n") var mappedIcon = try document("icon: {a: 1}") mappedIcon.setStyleValue("tray", for: FrontmatterKeys.icon) #expect(mappedIcon.icon == .valid("tray")) #expect(mappedIcon.serialized() == "---\nicon: tray\n---\nbody\n") } /// The emitted mapping is read back by the very reader the app uses, values with YAML-significant /// characters included — the reason strings are always quoted in flow context. @Test("An awkward colour and path round-trip through the emitted mapping") func awkwardValuesRoundTrip() throws { var document = try document("background: {image: \"a, b}.jpg\"}") document.setStyleValue("#ff8800", for: FrontmatterKeys.background) let reparsed = try FrontmatterDocument.parse(document.serialized()) #expect(reparsed.background == .valid("#ff8800")) #expect(reparsed.backgroundImage == .valid("a, b}.jpg")) } } // MARK: - Writing the image subkey /// `FrontmatterDocument.setBackgroundImage` — the colour write's sibling, one subkey over /// (BackgroundField.swift), and what `BoardStore.applyGeneratedBackground` points at the PNG it just /// wrote. Every rule the colour write obeys, this one obeys too: that is the whole reason they share /// a merge. @Suite("Board background ▸ the image subkey") struct BackgroundImageWriteTests { /// The mirror of `setKeepsTheImage`: the app now writes both subkeys, and neither may take the /// other with it. @Test("Setting an image replaces the subkey and keeps the colour") func setKeepsTheColour() throws { var document = try document("background: {color: fern, image: sunset.jpg}") document.setBackgroundImage("facets.png") #expect(document.background == .valid("fern")) #expect(document.backgroundImage == .valid("facets.png")) #expect(backgroundLine(document) == "background: {color: \"fern\", image: \"facets.png\"}") } /// A colour-only board — every board styled from the wells — is the one the generator is most /// likely to be pointed at. @Test("Setting an image on a colour-only background adds the subkey") func setAddsTheSubkeyToAColourOnlyMapping() throws { var document = try document("background: {color: fern}") document.setBackgroundImage("facets.png") #expect(backgroundLine(document) == "background: {color: \"fern\", image: \"facets.png\"}") } /// Unknown subkeys ride along here exactly as they do through a colour change — the merge is /// literally the same one. @Test("Unknown subkeys survive an image change, in their own positions") func setPreservesUnknownSubkeys() throws { var document = try document("background: {blend: multiply, image: old.png, opacity: 0.5}") document.setBackgroundImage("facets.png") #expect(backgroundLine(document) == "background: {blend: \"multiply\", image: \"facets.png\", opacity: 0.5}") } /// The undo of a first generation: the image subkey goes and the colour the board had — or did /// not have — is left to the colour write. @Test("Removing the image drops that subkey alone") func removeDropsOnlyTheImage() throws { var document = try document("background: {color: fern, image: facets.png}") document.setBackgroundImage(nil) #expect(document.background == .valid("fern")) #expect(document.backgroundImage == .missing) #expect(backgroundLine(document) == "background: {color: \"fern\"}") } /// `background: {}` is a key that says nothing — the removal's contract is that the field is /// gone, whichever subkey emptied it. @Test("A mapping emptied by the removal takes the key with it") func removeDropsAnEmptiedKey() throws { var document = try document("background: {image: facets.png}") document.setBackgroundImage(nil) #expect(!document.contains(FrontmatterKeys.background)) #expect(backgroundLine(document) == nil) } /// The malformed-value-cleared posture, on this subkey: a shape the schema cannot read has no /// subkeys to preserve and is replaced by the mapping the app writes. @Test("An image written onto an absent or unreadable key lands as a mapping") func alwaysWritesTheMapping() throws { var absent = try document("schema: 1") absent.setBackgroundImage("facets.png") #expect(backgroundLine(absent) == "background: {image: \"facets.png\"}") var scalar = try document("background: fern") scalar.setBackgroundImage("facets.png") #expect(backgroundLine(scalar) == "background: {image: \"facets.png\"}") var sequence = try document("background: [a, b]") sequence.setBackgroundImage("facets.png") #expect(backgroundLine(sequence) == "background: {image: \"facets.png\"}") } /// Both subkeys written in one edit, which is the shape every generated background lands in — /// and the order the schema spells it in, colour first. @Test("A colour and an image written together land as one mapping") func bothSubkeysTogether() throws { var document = try document("schema: 1") document.setStyleValue("#E0E5EB", for: FrontmatterKeys.background) document.setBackgroundImage("facets.png") #expect(backgroundLine(document) == "background: {color: \"#E0E5EB\", image: \"facets.png\"}") #expect(document.background == .valid("#E0E5EB")) #expect(document.backgroundImage == .valid("facets.png")) } /// Everything outside the key survives byte for byte, including the comment on the unknown key /// beside it — the verbatim promise, which yields on the one key being rewritten and nowhere else. @Test("Nothing but the background line moves") func leavesEverythingElseAlone() throws { var document = try FrontmatterDocument.parse(""" --- schema: 1 title: Work project: lanework # agent overlay background: {color: fern} icon: tray --- Board description. """) document.setBackgroundImage("facets.png") #expect(document.serialized() == """ --- schema: 1 title: Work project: lanework # agent overlay background: {color: "fern", image: "facets.png"} icon: tray --- Board description. """) } /// The emitted mapping is read back by the reader the app uses — a name with YAML-significant /// characters in it included, which is the reason strings are always quoted in flow context. @Test("An awkward file name round-trips through the emitted mapping") func awkwardNamesRoundTrip() throws { var document = try document("background: {color: fern}") document.setBackgroundImage("a, b}.png") let reparsed = try FrontmatterDocument.parse(document.serialized()) #expect(reparsed.backgroundImage == .valid("a, b}.png")) #expect(reparsed.background == .valid("fern")) } } // MARK: - Where an image may point @Suite("Board background ▸ the image path stays inside the board") struct BackgroundImagePathTests { private let root = URL(fileURLWithPath: "/Users/someone/Boards/Work.kanban", isDirectory: true) @Test("A plain name and a nested path resolve inside the board") func resolvesRelativePaths() { #expect(BoardBackdrop.imageURL(named: "sunset.jpg", inBoardRoot: root)?.path == "/Users/someone/Boards/Work.kanban/sunset.jpg") #expect(BoardBackdrop.imageURL(named: "art/backdrops/sunset.png", inBoardRoot: root)?.path == "/Users/someone/Boards/Work.kanban/art/backdrops/sunset.png") } /// The check is about where the path *ends up*, not how it is spelled: a climb that lands back /// inside the board is an ordinary file in it. @Test("A path that climbs and returns is still inside") func resolvesPathsThatStandardizeBackInside() { #expect(BoardBackdrop.imageURL(named: "art/../sunset.jpg", inBoardRoot: root)?.path == "/Users/someone/Boards/Work.kanban/sunset.jpg") #expect(BoardBackdrop.imageURL(named: "./sunset.jpg", inBoardRoot: root)?.path == "/Users/someone/Boards/Work.kanban/sunset.jpg") } /// A `.kanban` folder is a document — it gets copied, zipped and handed to somebody else — so a /// background that only works on the Mac it was written on resolves to nothing at all. @Test("Escapes and absolute paths resolve to nothing") func rejectsEscapes() { #expect(BoardBackdrop.imageURL(named: "../escape.jpg", inBoardRoot: root) == nil) #expect(BoardBackdrop.imageURL(named: "art/../../escape.jpg", inBoardRoot: root) == nil) #expect(BoardBackdrop.imageURL(named: "/etc/passwd", inBoardRoot: root) == nil) // Appended rather than rejected, an absolute path would land as `/etc/passwd` and pass // containment while naming a file the author plainly did not mean. #expect(BoardBackdrop.imageURL(named: "/sunset.jpg", inBoardRoot: root) == nil) #expect(BoardBackdrop.imageURL(named: "", inBoardRoot: root) == nil) #expect(BoardBackdrop.imageURL(named: ".", inBoardRoot: root) == nil) } /// The trailing separator in the containment test, doing its job: a sibling whose name merely /// starts with this board's is a different board. @Test("A sibling folder with a prefixed name is not inside") func rejectsAPrefixedSibling() { #expect(BoardBackdrop.imageURL(named: "../Work.kanban.backup/sunset.jpg", inBoardRoot: root) == nil) } /// `~` is not expanded and is not special: only a shell ever meant a home folder by it, and a /// file honestly named that way sits in the board like any other. @Test("A tilde is an ordinary character in a file name") func treatsTildeAsAnOrdinaryCharacter() { #expect(BoardBackdrop.imageURL(named: "~notes.png", inBoardRoot: root)?.path == "/Users/someone/Boards/Work.kanban/~notes.png") } /// The whole-model reading, which is what the view and the window chrome ask: no key, an /// unresolvable one, and a good one. @Test("The board-level reading follows the field and the path rule") func readsTheBoardsOwnField() throws { func board(_ frontmatter: String) throws -> BoardModel { BoardModel( rootURL: root, schema: 1, title: .missing, created: .missing, modified: .missing, modifiedBy: .missing, deleted: .missing, background: .missing, backgroundImage: try document(frontmatter).backgroundImage, icon: .missing, iconColor: .missing, template: nil, lanes: [], document: try document(frontmatter) ) } #expect(BoardBackdrop.imageURL(for: try board("background: {color: fern}"), root: root) == nil) #expect(BoardBackdrop.imageURL(for: try board("background: {image: ../x.jpg}"), root: root) == nil) #expect(BoardBackdrop.imageURL(for: try board("background: {image: sunset.jpg}"), root: root)?.path == "/Users/someone/Boards/Work.kanban/sunset.jpg") } /// **The window-chrome predicate** (`BoardWindowHost`): a board paints a background of its own /// when a colour resolves or an image path lands inside the board — a path that could never /// paint anything leaves the standard chrome alone. @Test("The chrome predicate answers for colour, image, both and neither") func answersTheChromePredicate() throws { func board(_ frontmatter: String) throws -> BoardModel { let parsed = try document(frontmatter) return BoardModel( rootURL: root, schema: 1, title: .missing, created: .missing, modified: .missing, modifiedBy: .missing, deleted: .missing, background: parsed.background, backgroundImage: parsed.backgroundImage, icon: .missing, iconColor: .missing, template: nil, lanes: [], document: parsed ) } #expect(BoardBackdrop.isCustom(try board("schema: 1"), root: root) == false) #expect(BoardBackdrop.isCustom(try board("background: {color: fern}"), root: root)) #expect(BoardBackdrop.isCustom(try board("background: {image: sunset.jpg}"), root: root)) #expect(BoardBackdrop.isCustom(try board("background: {color: fern, image: sunset.jpg}"), root: root)) // Neither half resolves: an unrecognized colour name and a path that leaves the board. #expect(BoardBackdrop.isCustom(try board("background: {color: mauve, image: /tmp/x.jpg}"), root: root) == false) } }