Files
conjure/Tests/ConjureKitTests/NotesMarkupTests.swift
T

327 lines
18 KiB
Swift

import AppKit
import ConjureKit
import Testing
@testable import Conjure
@Test @MainActor
func noteFormattingHidesSyntaxButPreservesMarkdownAndLiteralCode() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 600, height: 800))
let source = "# Heading\n## Subheading\n*italic* **bold** _emphasis_ __strong__ ~~gone~~ <u>underlined</u> `*literal*`\n> Quote\n[Label](https://example.com)\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*"
editor.setMarkdownContents(NSAttributedString(string: source))
MarkdownHighlighting.apply(to: editor)
editor.refreshTasks()
let layout = try #require(editor.layoutManager)
let container = try #require(editor.textContainer)
layout.ensureLayout(for: container)
func visibleText() -> String {
var result = ""
let text = editor.string as NSString
for index in 0..<text.length {
let glyph = layout.glyphIndexForCharacter(at: index)
if !layout.propertyForGlyph(at: glyph).contains(.null) {
result += text.substring(with: NSRange(location: index, length: 1))
}
}
return result
}
#expect(visibleText() == "Heading\nSubheading\nitalic bold emphasis strong gone underlined *literal*\nQuote\nLabel\n```text\n# Code *literal*\n```\nUnmatched * and \\*escaped*")
#expect(editor.markdownString == source)
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
let pasteboard = NSPasteboard.withUniqueName()
defer { pasteboard.releaseGlobally() }
#expect(editor.writeSelection(to: pasteboard, types: editor.writablePasteboardTypes))
#expect(pasteboard.string(forType: .string) == source)
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let store = try NotesStore(directory: directory)
let id = try store.create()
try store.update(id: id, contents: editor.markdownContents)
let reloaded = try #require(NotesStore(directory: directory).notes.first)
#expect(reloaded.contents.string == source)
}
private final class NotesLayoutProbe: NSLayoutManager {
var observeFormatting: ((NSTextStorage) -> Void)?
override func processEditing(for textStorage: NSTextStorage, edited editMask: NSTextStorageEditActions,
range newCharRange: NSRange, changeInLength delta: Int,
invalidatedRange invalidatedCharRange: NSRange) {
observeFormatting?(textStorage)
super.processEditing(for: textStorage, edited: editMask, range: newCharRange,
changeInLength: delta, invalidatedRange: invalidatedCharRange)
}
}
@Test @MainActor
func typingDoesNotExposeUnformattedExistingTextToLayout() throws {
_ = NSApplication.shared
let suite = "NotesFlashing.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
defer {
defaults.removePersistentDomain(forName: suite)
try? FileManager.default.removeItem(at: directory)
}
let store = try NotesStore(directory: directory)
let id = try store.create()
try store.update(id: id, contents: NSAttributedString(string: "# Heading\n- [ ] Task\nPlain"))
defaults.set(id.uuidString, forKey: "selectedNote")
let controller = NotesController(defaults: defaults, directory: directory)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
defer { _ = controller.close(); window.orderOut(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
let layout = NotesLayoutProbe()
try #require(editor.textContainer).replaceLayoutManager(layout)
editor.refreshTasks()
window.makeFirstResponder(editor)
layout.observeFormatting = { storage in
let text = storage.string as NSString
let heading = text.range(of: "Heading")
let task = text.range(of: "Task")
#expect((storage.attribute(.font, at: heading.location, effectiveRange: nil) as? NSFont)?.pointSize == 26)
#expect((storage.attribute(.paragraphStyle, at: task.location, effectiveRange: nil) as? NSParagraphStyle)?.headIndent == 22)
#expect(storage.attribute(MarkdownHighlighting.hiddenMarkup, at: 0, effectiveRange: nil) as? Bool == true)
}
defer { layout.observeFormatting = nil }
editor.insertText("!", replacementRange: NSRange(location: editor.string.utf16.count, length: 0))
#expect(editor.markdownString == "# Heading\n- [ ] Task\nPlain!")
#expect(try NotesStore(directory: directory).notes.first?.contents.string == editor.markdownString)
}
@Test @MainActor
func noteImagesRenderRoundedWithoutChangingSavedImageData() throws {
_ = NSApplication.shared
let image = NSImage(size: NSSize(width: 80, height: 48), flipped: false) { rect in
NSColor.red.setFill()
rect.fill()
return true
}
let tiff = try #require(image.tiffRepresentation)
let bitmap = try #require(NSBitmapImageRep(data: tiff))
let png = try #require(bitmap.representation(using: .png, properties: [:]))
let pasteboard = NSPasteboard.withUniqueName()
defer { pasteboard.releaseGlobally() }
pasteboard.setData(png, forType: .png)
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
editor.isRichText = true
editor.importsGraphics = true
#expect(editor.readSelection(from: pasteboard))
func checkRoundedDrawing() throws {
MarkdownHighlighting.apply(to: editor)
let layout = try #require(editor.layoutManager)
layout.ensureLayout(for: try #require(editor.textContainer))
let output = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 200, pixelsHigh: 120,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
let context = try #require(NSGraphicsContext(bitmapImageRep: output))
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
NSGraphicsContext.current = context
context.cgContext.clear(CGRect(x: 0, y: 0, width: 200, height: 120))
layout.drawGlyphs(forGlyphRange: layout.glyphRange(forCharacterRange: NSRange(location: 0, length: 1),
actualCharacterRange: nil), at: .zero)
var left = 200, right = -1, top = 120, bottom = -1
for y in 0..<120 {
for x in 0..<200 where (output.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.5 {
left = min(left, x); right = max(right, x)
top = min(top, y); bottom = max(bottom, y)
}
}
try #require(right > left && bottom > top)
for x in [left, right] {
for y in [top, bottom] {
#expect(try #require(output.colorAt(x: x, y: y)).alphaComponent < 0.1)
}
}
let center = try #require(output.colorAt(x: (left + right) / 2, y: (top + bottom) / 2)?.usingColorSpace(.deviceRGB))
#expect(center.redComponent > 0.9 && center.greenComponent < 0.1 && center.alphaComponent > 0.9)
}
try checkRoundedDrawing()
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let store = try NotesStore(directory: directory)
let id = try store.create()
try store.update(id: id, contents: editor.markdownContents)
let reloaded = try #require(NotesStore(directory: directory).notes.first)
let attachment = try #require(reloaded.contents.attribute(.attachment, at: 0, effectiveRange: nil) as? NSTextAttachment)
#expect(attachment.fileWrapper?.regularFileContents == png)
editor.setMarkdownContents(reloaded.contents)
try checkRoundedDrawing()
}
@Test @MainActor
func hiddenNoteMarkupKeepsTypingDeletionFormattingAndUndoAtVisibleBoundaries() throws {
_ = NSApplication.shared
let suite = "NotesMarkup.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
defer {
defaults.removePersistentDomain(forName: suite)
try? FileManager.default.removeItem(at: directory)
}
let controller = NotesController(defaults: defaults, directory: directory)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 570, height: 404),
styleMask: .borderless, backing: .buffered, defer: false)
window.contentViewController = controller
defer { _ = controller.close(); window.orderOut(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let editor = try #require(descendants(controller.view).compactMap { $0 as? NotesTextView }.first)
window.makeFirstResponder(editor)
editor.insertText("# Title\n*word*", replacementRange: NSRange(location: 0, length: 0))
editor.setSelectedRange(NSRange(location: 0, length: 0))
editor.insertText("A", replacementRange: editor.selectedRange())
#expect(editor.markdownString == "# ATitle\n*word*")
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
editor.undoManager?.beginUndoGrouping()
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
editor.deleteBackward(nil)
editor.undoManager?.endUndoGrouping()
#expect(editor.markdownString == "# ATitle\n*wor*")
editor.undoManager?.undo()
#expect(editor.markdownString == "# ATitle\n*word*")
let word = (editor.string as NSString).range(of: "word")
editor.setSelectedRange(NSRange(location: word.location, length: 0))
editor.moveRight(nil)
#expect(editor.selectedRange().location == word.location + 1)
editor.setSelectedRange(NSRange(location: NSMaxRange(word), length: 0))
editor.moveRight(nil)
#expect(editor.selectedRange().location == editor.string.utf16.count)
editor.setSelectedRange(NSRange(location: word.location, length: 0))
let italic = try #require(descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Italic" })
// Selecting the body and toggling formatting must remove its hidden delimiters.
editor.setSelectedRange(word)
italic.performClick(nil)
#expect(editor.markdownString == "# ATitle\nword")
#expect(controller.save())
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "# ATitle\nword")
}
@Test @MainActor
func notesFormattingShortcutsToggleCombinedStylesAndRespectPopupFocus() throws {
_ = NSApplication.shared
let suite = "NotesFormattingKeys.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
defer {
defaults.removePersistentDomain(forName: suite)
try? FileManager.default.removeItem(at: directory)
}
let launcher = LauncherWindowController(
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
defaults: defaults, notesDirectory: directory)
let window = try #require(launcher.window)
defer { launcher.dismiss(restoringFocus: false) }
launcher.present(command: .notes)
let content = try #require(window.contentView)
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first)
let controller = try #require(editor.delegate as? NotesController)
func shortcut(_ key: String, shift: Bool = false, option: Bool = false) throws -> Bool {
var flags: NSEvent.ModifierFlags = .command
if shift { flags.insert(.shift) }
if option { flags.insert(.option) }
let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags,
timestamp: 1, windowNumber: window.windowNumber, context: nil,
characters: shift ? key.uppercased() : key, charactersIgnoringModifiers: shift ? key.uppercased() : key,
isARepeat: false, keyCode: 0))
return window.performKeyEquivalent(with: event)
}
editor.insertText("fox 🦊", replacementRange: editor.selectedRange())
editor.setSelectedRange(NSRange(location: 0, length: editor.string.utf16.count))
#expect(try shortcut("b"))
#expect(editor.markdownString == "**fox 🦊**")
#expect(try shortcut("i"))
#expect(editor.markdownString == "***fox 🦊***")
let font = try #require(editor.textStorage?.attribute(.font, at: editor.selectedRange().location,
effectiveRange: nil) as? NSFont)
let traits = NSFontManager.shared.traits(of: font)
#expect(traits.contains(.boldFontMask) && traits.contains(.italicFontMask))
#expect(try shortcut("i"))
#expect(editor.markdownString == "**fox 🦊**")
#expect(try shortcut("b"))
#expect(editor.markdownString == "fox 🦊")
#expect(try shortcut("s", shift: true))
#expect(editor.markdownString == "~~fox 🦊~~")
#expect(editor.textStorage?.attribute(.strikethroughStyle, at: editor.selectedRange().location,
effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
#expect(try shortcut("s", shift: true))
#expect(editor.markdownString == "fox 🦊")
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
editor.undoManager?.beginUndoGrouping()
#expect(try shortcut("u", shift: true))
editor.undoManager?.endUndoGrouping()
#expect(editor.markdownString == "<u>fox 🦊</u>")
#expect(editor.textStorage?.attribute(.underlineStyle, at: editor.selectedRange().location,
effectiveRange: nil) as? Int == NSUnderlineStyle.single.rawValue)
#expect(controller.save())
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "<u>fox 🦊</u>")
#expect(try NoteMarkdownExport(contents: editor.markdownContents).clipboardText == "<u>fox 🦊</u>")
editor.undoManager?.undo()
#expect(editor.markdownString == "fox 🦊")
#expect(try shortcut("u", shift: true))
#expect(try shortcut("u", shift: true))
#expect(editor.markdownString == "fox 🦊")
#expect(editor.textStorage?.attribute(.underlineStyle, at: 0, effectiveRange: nil) == nil)
#expect(try shortcut("l", shift: true))
#expect(editor.markdownString == "- fox 🦊")
#expect(try shortcut("l", shift: true))
#expect(editor.markdownString == "fox 🦊")
#expect(!(try shortcut("b", option: true)))
#expect(editor.markdownString == "fox 🦊")
editor.breakUndoCoalescing()
editor.undoManager?.removeAllActions()
editor.undoManager?.beginUndoGrouping()
#expect(try shortcut("e"))
editor.undoManager?.endUndoGrouping()
#expect(editor.markdownString == "`fox 🦊`")
editor.undoManager?.undo()
#expect(editor.markdownString == "fox 🦊")
#expect(try shortcut("k"))
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes actions" })
#expect(window.firstResponder !== editor)
_ = try shortcut("b")
#expect(editor.markdownString == "fox 🦊")
#expect(search.stringValue.isEmpty)
#expect(controller.handle(#selector(NSResponder.cancelOperation(_:))))
#expect(controller.save())
#expect(try NotesStore(directory: directory).notes.first?.contents.string == "fox 🦊")
}
@Test @MainActor
func inlineCodeContainerStaysWithinCodeAndDisappearsWhenDelimitersAreRemoved() throws {
_ = NSApplication.shared
let editor = NotesTextView(frame: NSRect(x: 0, y: 0, width: 200, height: 300))
let source = "Before `some *literal* code 🦊` after\n```text\n`not inline` <u>literal</u>\n```"
editor.setMarkdownContents(NSAttributedString(string: source))
let storage = try #require(editor.textStorage)
let layout = try #require(editor.layoutManager)
layout.ensureLayout(for: try #require(editor.textContainer))
let body = (source as NSString).range(of: "some *literal* code 🦊")
for index in body.location..<NSMaxRange(body) {
#expect(storage.attribute(.foregroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.accent)
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) as? NSColor == SunsetPalette.selectionFill)
}
for token in ["Before", "after", "not inline"] {
let index = (source as NSString).range(of: token).location
#expect(storage.attribute(.backgroundColor, at: index, effectiveRange: nil) == nil)
}
let literalUnderline = (source as NSString).range(of: "<u>literal</u>")
#expect(storage.attribute(.underlineStyle, at: literalUnderline.location, effectiveRange: nil) == nil)
#expect(editor.markdownString == source)
#expect(editor.markdownContents.attribute(.backgroundColor, at: body.location, effectiveRange: nil) == nil)
editor.replaceMarkdown("some *literal* code 🦊",
range: NSRange(location: body.location - 1, length: body.length + 2))
let plain = (editor.string as NSString).range(of: "some")
#expect(storage.attribute(.backgroundColor, at: plain.location, effectiveRange: nil) == nil)
#expect(storage.attribute(.foregroundColor, at: plain.location, effectiveRange: nil) as? NSColor == SunsetPalette.text)
}