diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index 7bb5d02..4132ddb 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to QuickMD will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.14.3] - 2026-11-04 + +Two rendering fixes. + +### Fixed +- A blank line under a heading, or after a paragraph or list before the next heading, no longer shows as an extra empty line. A document looks the same with or without it, as on GitHub. +- A display formula that cannot be typeset (for example one with `\not\le`) shows its LaTeX source as a code block instead of an empty space. Formulas that typeset look as before. + +### Changed +- Mac App Store version: QuickMD may ask for a rating with the standard macOS prompt, at most once per version and no more than every four months. It asks only after documents were opened on at least three different days over a week or more, after a minute of reading, and never while you edit or right after the Mac sat idle. The count stays on your Mac. + ## [1.14.2] - 2026-11-04 Front matter you can read at a glance. diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index d7ad376..286ee44 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -32,6 +32,10 @@ A1B2C3D401000220 /* FrontMatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000220 /* FrontMatter.swift */; }; A1B2C3D401000221 /* FrontMatter.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000220 /* FrontMatter.swift */; }; A1B2C3D401000222 /* FrontMatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000221 /* FrontMatterTests.swift */; }; + A1B2C3D401000230 /* ReviewPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000230 /* ReviewPrompt.swift */; }; + A1B2C3D401000231 /* ReviewPrompt.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000230 /* ReviewPrompt.swift */; }; + A1B2C3D401000232 /* ReviewPromptTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000231 /* ReviewPromptTrigger.swift */; }; + A1B2C3D401000233 /* ReviewPromptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000232 /* ReviewPromptTests.swift */; }; A1B2C3D401000201 /* PrintableTableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000200 /* PrintableTableTests.swift */; }; A1B2C3D4010001C0 /* SelectionRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001C0 /* SelectionRows.swift */; }; A1B2C3D4010001C1 /* SelectionRows.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001C0 /* SelectionRows.swift */; }; @@ -219,6 +223,9 @@ A1B2C3D4000001B2 /* TableTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableTextTests.swift; sourceTree = ""; }; A1B2C3D400000220 /* FrontMatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontMatter.swift; sourceTree = ""; }; A1B2C3D400000221 /* FrontMatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontMatterTests.swift; sourceTree = ""; }; + A1B2C3D400000230 /* ReviewPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPrompt.swift; sourceTree = ""; }; + A1B2C3D400000231 /* ReviewPromptTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptTrigger.swift; sourceTree = ""; }; + A1B2C3D400000232 /* ReviewPromptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptTests.swift; sourceTree = ""; }; A1B2C3D400000200 /* PrintableTableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrintableTableTests.swift; sourceTree = ""; }; A1B2C3D4000001C0 /* SelectionRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRows.swift; sourceTree = ""; }; A1B2C3D4000001C1 /* TableSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableSelectionTests.swift; sourceTree = ""; }; @@ -332,6 +339,7 @@ A1B2C3D4000000F0 /* HeadingLayoutTests.swift */, A1B2C3D4000001B2 /* TableTextTests.swift */, A1B2C3D400000221 /* FrontMatterTests.swift */, + A1B2C3D400000232 /* ReviewPromptTests.swift */, A1B2C3D400000200 /* PrintableTableTests.swift */, A1B2C3D4000001C1 /* TableSelectionTests.swift */, A1B2C3D4000000C1 /* DocumentSelectionTests.swift */, @@ -362,6 +370,8 @@ A1B2C3D400000069 /* BlockHeightMeasurer.swift */, A1B2C3D4000001B0 /* TableTextBuilder.swift */, A1B2C3D400000220 /* FrontMatter.swift */, + A1B2C3D400000230 /* ReviewPrompt.swift */, + A1B2C3D400000231 /* ReviewPromptTrigger.swift */, A1B2C3D40000006E /* SVGImageDecoder.swift */, A1B2C3D400000080 /* DataImageURI.swift */, A1B2C3D400000081 /* ImageLoader.swift */, @@ -654,6 +664,8 @@ A1B2C3D401000069 /* BlockHeightMeasurer.swift in Sources */, A1B2C3D4010001B0 /* TableTextBuilder.swift in Sources */, A1B2C3D401000220 /* FrontMatter.swift in Sources */, + A1B2C3D401000230 /* ReviewPrompt.swift in Sources */, + A1B2C3D401000232 /* ReviewPromptTrigger.swift in Sources */, A1B2C3D4010001B2 /* TableTextView.swift in Sources */, A1B2C3D40100006C /* VirtualBlockList.swift in Sources */, A1B2C3D40100006D /* SVGBlockView.swift in Sources */, @@ -706,6 +718,7 @@ A1B2C3D40100006B /* BlockHeightMeasurer.swift in Sources (Tests) */, A1B2C3D4010001B1 /* TableTextBuilder.swift in Sources (Tests) */, A1B2C3D401000221 /* FrontMatter.swift in Sources (Tests) */, + A1B2C3D401000231 /* ReviewPrompt.swift in Sources (Tests) */, A1B2C3D40100006F /* SVGImageDecoder.swift in Sources (Tests) */, A1B2C3D401000081 /* DataImageURI.swift in Sources (Tests) */, A1B2C3D401000083 /* ImageLoader.swift in Sources (Tests) */, @@ -737,6 +750,7 @@ A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */, A1B2C3D4010001B3 /* TableTextTests.swift in Sources */, A1B2C3D401000222 /* FrontMatterTests.swift in Sources */, + A1B2C3D401000233 /* ReviewPromptTests.swift in Sources */, A1B2C3D401000201 /* PrintableTableTests.swift in Sources */, A1B2C3D4010001C2 /* TableSelectionTests.swift in Sources */, ); @@ -877,7 +891,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = VJ92852M32; ENABLE_APP_SANDBOX = YES; @@ -902,7 +916,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.14.2; + MARKETING_VERSION = 1.14.3; PRODUCT_BUNDLE_IDENTIFIER = pl.falami.studio.QuickMD; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -921,7 +935,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = VJ92852M32; ENABLE_APP_SANDBOX = YES; @@ -946,7 +960,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.14.2; + MARKETING_VERSION = 1.14.3; PRODUCT_BUNDLE_IDENTIFIER = pl.falami.studio.QuickMD; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/QuickMD/QuickMD/BlockHeightMeasurer.swift b/QuickMD/QuickMD/BlockHeightMeasurer.swift index 901f49e..b1f9b5e 100644 --- a/QuickMD/QuickMD/BlockHeightMeasurer.swift +++ b/QuickMD/QuickMD/BlockHeightMeasurer.swift @@ -496,11 +496,43 @@ struct MathRendering { let inlineImage: (_ latex: String, _ theme: MarkdownTheme, _ fontScale: CGFloat) -> NSImage? /// Height `MTMathUILabel` will take for a display-math block. let displayHeight: (_ latex: String, _ theme: MarkdownTheme, _ fontScale: CGFloat) -> CGFloat + /// False when the engine cannot typeset this display formula at all (an + /// unsupported command such as `\not\le`). `MTMathUILabel` would draw an + /// empty row for it, so `substitutingUntypesettableMath` shows the source + /// instead. Defaults to "always typesets" so stubs keep their old behaviour. + var typesets: (_ latex: String) -> Bool = { _ in true } /// No math engine: inline math degrades to the italic-literal fallback and /// display math to zero intrinsic height. Only for tests and previews. static let none = MathRendering(inlineImage: { _, _, _ in nil }, displayHeight: { _, _, _ in 0 }) + + /// `blocks` with every `.mathBlock` this engine cannot typeset turned into a + /// ```` ```latex ```` code block of the same LaTeX — same `id` (ids are + /// positional) and same `sourceLine`. + /// + /// Why a block substitution and not a fallback inside `MathBlockView`: the + /// row would then have to be a code block for the view, the measurer + /// (`.exact` height), search, selection/copy, Source Edit landing and + /// print/PDF alike — six places that would each need their own "failed + /// math" branch. Swapping the block once per parse, before anything is laid + /// out, gives all of them the existing code-block path. + /// + /// Same thread rule as `measure(math:)`: the engine behind `.swiftMath` is + /// main-thread only, so call this on the main thread with it (the parse + /// task's main-actor step, `PDFExportManager.generateMultiPagePDF` for + /// print and PDF). Documents without display math return without consulting + /// the engine. + func substitutingUntypesettableMath(in blocks: [MarkdownBlock]) -> [MarkdownBlock] { + guard blocks.contains(where: { if case .mathBlock = $0.content { return true } else { return false } }) + else { return blocks } + return blocks.map { block in + guard case .mathBlock(let latex) = block.content, !typesets(latex) else { return block } + return MarkdownBlock(id: block.id, + content: .codeBlock(code: latex, language: "latex"), + sourceLine: block.sourceLine) + } + } } // MARK: - AttributedString → NSAttributedString @@ -847,14 +879,10 @@ enum BlockHeightMeasurer { kinds.append(.exact) case .codeBlock(let code, let language): - let ns = BlockTextConverter.plainCode(code, theme: theme, fontScale: fontScale) - let inner = exactHeight(text: ns, width: codeWidth) - let stack = inner - + (language.isEmpty ? codeChromeWithoutLanguage : codeChromeWithLanguage) - // The always-present copy button is the ZStack's other child, so - // it is a floor under the row — see `Code.copyButtonFloorHeight`. - heights.append(max(stack, BlockLayout.Code.copyButtonFloorHeight) - + 2 * BlockLayout.Document.codeOuterVerticalPadding) + heights.append(codeRowHeight(code: code, theme: theme, fontScale: fontScale, + codeWidth: codeWidth, + chrome: language.isEmpty ? codeChromeWithoutLanguage + : codeChromeWithLanguage)) kinds.append(.exact) case .heading(let level, let title, _): @@ -921,6 +949,21 @@ enum BlockHeightMeasurer { mathPendingRows: mathPendingRows) } + /// One `.codeBlock` row: the code laid out at `codeWidth` plus `chrome` + /// (`BlockLayout.Code.verticalChrome` for the block's language state — + /// passed in so `measure` computes it once per document, not per block). + /// Shared by `measure` and `measureMathRows` (a display formula the engine + /// cannot typeset becomes a code row there). + private static func codeRowHeight(code: String, theme: MarkdownTheme, fontScale: CGFloat, + codeWidth: CGFloat, chrome: CGFloat) -> CGFloat { + let ns = BlockTextConverter.plainCode(code, theme: theme, fontScale: fontScale) + let stack = exactHeight(text: ns, width: codeWidth) + chrome + // The always-present copy button is the ZStack's other child, so it is + // a floor under the row — see `Code.copyButtonFloorHeight`. + return max(stack, BlockLayout.Code.copyButtonFloorHeight) + + 2 * BlockLayout.Document.codeOuterVerticalPadding + } + /// Finishes the rows an off-main `measure(math: nil)` deferred. /// /// `@MainActor` because this is the only place in the measurement path @@ -928,8 +971,9 @@ enum BlockHeightMeasurer { /// caller runs it after the background pass lands, before the table is used /// for layout. /// - /// Nothing else about the table changes — same `kinds`, same `contentWidth`, - /// every other height and converted string carried over — so + /// Nothing else about the table changes — same `kinds` (bar a substituted + /// formula, see `blocks`), same `contentWidth`, every other height and + /// converted string carried over — so /// `measureMathRows(m.mathPendingRows, in: m, …, math: engine)` equals a /// straight `measure(…, math: engine)`, row for row. /// @@ -937,7 +981,10 @@ enum BlockHeightMeasurer { /// - pending: normally `measured.mathPendingRows`. Indices outside the /// table, and rows that don't need the engine, are ignored. /// - blocks: the same array `measured` was produced from (row *i* is - /// `blocks[i]`). + /// `blocks[i]`), or that array after + /// `MathRendering.substitutingUntypesettableMath`: a deferred + /// `.mathBlock` row that became a `.codeBlock` is measured as a code row + /// here and its kind turns `.exact`, exactly what `measure` gives it. /// - contentWidth: MUST be the width `measured` was produced at. /// - reusing: strings a PREVIOUS measurement of the same content already /// built, keyed by block id — normally the caller's last @@ -971,6 +1018,7 @@ enum BlockHeightMeasurer { #endif var heights = measured.table.heights + var kinds = measured.table.kinds var converted = measured.converted let textWidth = BlockLayout.clampedWidth(contentWidth) @@ -1003,13 +1051,22 @@ enum BlockHeightMeasurer { + 2 * BlockLayout.Math.verticalPadding + 2 * BlockLayout.Document.mathOuterVerticalPadding + case .codeBlock(let code, let language): + // Only a substituted formula gets here (a parsed code block is + // never pending). Rare, so the chrome is computed per row. + heights[index] = codeRowHeight( + code: code, theme: theme, fontScale: fontScale, + codeWidth: BlockLayout.clampedWidth(contentWidth - 2 * BlockLayout.Code.horizontalPadding), + chrome: BlockLayout.Code.verticalChrome(hasLanguage: !language.isEmpty, fontScale: fontScale)) + kinds[index] = .exact + default: continue } } return MeasuredBlocks( - table: BlockHeightTable(heights: heights, kinds: measured.table.kinds, + table: BlockHeightTable(heights: heights, kinds: kinds, contentWidth: measured.table.contentWidth), converted: converted, tables: measured.tables, diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift index 8ad9ab6..850d4c4 100644 --- a/QuickMD/QuickMD/MarkdownBlockParser.swift +++ b/QuickMD/QuickMD/MarkdownBlockParser.swift @@ -846,7 +846,7 @@ struct MarkdownBlockParser: Sendable { // blockquotes, a heading and a code fence, …) used to flush an EMPTY // text block that rendered as a ~42 pt gap. Drop it: block spacing is // the stack's job, not the parser's. - guard buffer.contains(where: { !$0.line.trimmingCharacters(in: .whitespaces).isEmpty }) else { + guard buffer.contains(where: { !Self.isBlank($0.line) }) else { buffer.removeAll() return } @@ -874,16 +874,28 @@ struct MarkdownBlockParser: Sendable { } let chunk = buffer[chunkStart.. 0 ? BlockHeightMeasurer.measureMathRows(out.measured.mathPendingRows, - in: out.measured, blocks: parsed.blocks, + in: out.measured, blocks: blocks, theme: currentTheme, fontScale: scale, contentWidth: width, math: .swiftMath) : .empty - headings = parsed.blocks.compactMap { block in + headings = blocks.compactMap { block in if case .heading(let level, let title, let sourceLine) = block.content { return ToCEntry(id: block.id, level: level, title: title, sourceLine: sourceLine) } @@ -714,7 +722,7 @@ struct MarkdownView: View { // left before the re-parse arrived): requested in the transaction // that installs the blocks, so it wins over the list's own anchor // restore and resolves against the new ids. - if let target = landing.blocksInstalled(parsed.blocks) { + if let target = landing.blocksInstalled(blocks) { requestScroll(to: target, anchor: .top, animated: false) } // The find bar's matches index the PREVIOUS parse's blocks: count @@ -884,6 +892,15 @@ struct MarkdownView: View { fileWatcher = nil releaseEditSession() } + #if APPSTORE + // Rating prompt (App Store build only): counts this window's open once + // its first parse landed (`contentVersion` only grows — reloads, zoom + // and theme changes don't count again) and may ask after 60 s of + // display. Not while Source Edit is up. + .modifier(ReviewPromptTrigger(isRendered: contentVersion > 0, + isEditing: editSession.isActive, + window: hostWindow)) + #endif } // MARK: - Auto-Reload & External Editor diff --git a/QuickMD/QuickMD/ReviewPrompt.swift b/QuickMD/QuickMD/ReviewPrompt.swift new file mode 100644 index 0000000..985c2bf --- /dev/null +++ b/QuickMD/QuickMD/ReviewPrompt.swift @@ -0,0 +1,220 @@ +import Foundation + +// MARK: - Review prompt policy (App Store build) + +/// When to ask for an App Store rating: only after real, repeated use — never +/// on a first launch, never twice for one version, never more often than every +/// 120 days. Apple throttles `requestReview` on its own (at most three prompts +/// a year), but a prompt it does show at the wrong moment costs goodwill, so +/// the app asks only when a reader has clearly settled in. +/// +/// Pure policy: no StoreKit, no SwiftUI, no clock of its own (`now` is always +/// passed in) and an injectable `UserDefaults`, so every rule is unit-tested. +/// It compiles into both builds, but only the App Store build calls it +/// (`ReviewPromptTrigger`, `#if APPSTORE`); the GitHub/Homebrew build never +/// touches these keys. +/// +/// Anything unreadable in the defaults (wrong type, a hand-edited value, a +/// date from the future) reads as "nothing recorded yet": it can delay a +/// prompt, never cause one or crash. +struct ReviewPrompt { + + /// Every key lives under this prefix. + static let keyPrefix = "reviewPrompt." + + enum Keys { + /// Int — documents that finished their first render, ever. + static let openCount = ReviewPrompt.keyPrefix + "openCount" + /// [String] — local calendar days (`yyyy-MM-dd`) with at least one + /// open, newest `maximumStoredDays` only. + static let openDays = ReviewPrompt.keyPrefix + "openDays" + /// Date — the first recorded open. + static let firstOpen = ReviewPrompt.keyPrefix + "firstOpen" + /// Date — the last time we asked StoreKit for a review. + static let lastRequest = ReviewPrompt.keyPrefix + "lastRequest" + /// String — `CFBundleShortVersionString` of that request. + static let lastRequestVersion = ReviewPrompt.keyPrefix + "lastRequestVersion" + } + + static let minimumOpens = 10 + static let minimumDistinctDays = 3 + static let minimumDaysSinceFirstOpen = 7 + static let minimumDaysBetweenRequests = 120 + /// The day set only has to answer "at least 3 days"; 30 keeps it bounded + /// for a reader who opens documents every day for years. + static let maximumStoredDays = 30 + /// At the moment of asking, the reader must have touched a key, the mouse + /// or the trackpad within this many seconds: a window left open on an + /// unattended Mac is not reading. + static let maximumIdleAtRequest: TimeInterval = 60 + + private static let secondsPerDay: TimeInterval = 86_400 + + let defaults: UserDefaults + /// Decides what "a day" is for the distinct-days rule (the user's local + /// days in the app; a fixed time zone in tests). + let calendar: Calendar + + init(defaults: UserDefaults = .standard, calendar: Calendar = .current) { + self.defaults = defaults + self.calendar = calendar + } + + // MARK: Recording + + /// One document window/tab finished its first successful render. The + /// caller guarantees once per window — reloads, zoom, theme changes and + /// Source Edit saves are not new opens. + func recordDocumentOpened(now: Date) { + let count = openCount + defaults.set(count == Int.max ? count : count + 1, forKey: Keys.openCount) + + // Sorted ascending; the key format sorts chronologically, so the cap + // keeps the newest days even if the clock was once set back. + var days = openDays + days.insert(dayKey(for: now)) + defaults.set(Array(days.sorted().suffix(Self.maximumStoredDays)), forKey: Keys.openDays) + + // A missing, unreadable or future first-open date (clock set forward + // once) would block the age rule for good: restart it from today. + let firstIsUsable = firstOpen.map { $0 <= now } ?? false + if !firstIsUsable { + defaults.set(now, forKey: Keys.firstOpen) + } + } + + // MARK: Deciding + + /// True only when ALL hold: ≥ 10 opens, opens on ≥ 3 distinct days, ≥ 7 + /// days since the first open, no request yet for `appVersion`, and ≥ 120 + /// days since the last request (if there was one). + func shouldRequest(now: Date, appVersion: String) -> Bool { + guard openCount >= Self.minimumOpens else { return false } + guard openDays.count >= Self.minimumDistinctDays else { return false } + guard let first = firstOpen, + now.timeIntervalSince(first) >= Double(Self.minimumDaysSinceFirstOpen) * Self.secondsPerDay + else { return false } + if let version = lastRequestVersion, version == appVersion { return false } + if let last = lastRequest, + now.timeIntervalSince(last) < Double(Self.minimumDaysBetweenRequests) * Self.secondsPerDay { + // Also true for a request date in the future: wait, don't guess. + return false + } + return true + } + + /// Called right after the app asked StoreKit — whether or not the system + /// actually showed the prompt (it never says). + func markRequested(now: Date, appVersion: String) { + defaults.set(now, forKey: Keys.lastRequest) + defaults.set(appVersion, forKey: Keys.lastRequestVersion) + } + + /// True when the last user input (seconds ago, from the system's event + /// source) is recent enough to ask. Negative / NaN / infinite = unknown = + /// not active: an unreadable answer must not cause a prompt. + static func isUserActive(secondsSinceLastInput: TimeInterval) -> Bool { + secondsSinceLastInput.isFinite + && secondsSinceLastInput >= 0 + && secondsSinceLastInput < maximumIdleAtRequest + } + + // MARK: Stored values, garbage-tolerant + + var openCount: Int { + guard let count = defaults.object(forKey: Keys.openCount) as? Int, count > 0 else { return 0 } + return count + } + + /// Valid day keys only — a hand-written "x" must not count as a day. + var openDays: Set { + guard let stored = defaults.object(forKey: Keys.openDays) as? [Any] else { return [] } + return Set(stored.compactMap { $0 as? String }.filter(Self.isDayKey)) + } + + var firstOpen: Date? { Self.validDate(defaults.object(forKey: Keys.firstOpen)) } + var lastRequest: Date? { Self.validDate(defaults.object(forKey: Keys.lastRequest)) } + + var lastRequestVersion: String? { + guard let version = defaults.object(forKey: Keys.lastRequestVersion) as? String, + !version.isEmpty else { return nil } + return version + } + + // MARK: Helpers + + /// `yyyy-MM-dd` in `calendar`'s time zone. Built from components, not a + /// DateFormatter: no locale (or non-Gregorian era) can change the shape. + func dayKey(for date: Date) -> String { + let parts = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04ld-%02ld-%02ld", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0) + } + + static func isDayKey(_ string: String) -> Bool { + let bytes = Array(string.utf8) + guard bytes.count == 10 else { return false } + for (index, byte) in bytes.enumerated() { + if index == 4 || index == 7 { + guard byte == UInt8(ascii: "-") else { return false } + } else { + guard byte >= UInt8(ascii: "0"), byte <= UInt8(ascii: "9") else { return false } + } + } + return true + } + + private static func validDate(_ value: Any?) -> Date? { + guard let date = value as? Date, date.timeIntervalSinceReferenceDate.isFinite else { return nil } + return date + } +} + +// MARK: - Continuous display countdown + +/// "The window has been on screen for 60 s in one go." Fed by a periodic tick +/// (about once a second) with whether the window is displayed right now; any +/// tick that is not resets the run. Pure so the rule is tested without timers. +/// +/// Times are MONOTONIC seconds (`ProcessInfo.systemUptime`: it does not +/// advance while the Mac sleeps, and no clock change moves it). A gap of more +/// than `maximumGap` between two ticks also resets the run: the ticks stopped +/// (sleep, App Nap, a stalled main thread), so nobody can say the window was +/// being read in between. +struct ReviewPromptCountdown { + let required: TimeInterval + let maximumGap: TimeInterval + /// Start of the current uninterrupted run; nil = not displayed. + private(set) var displayedSince: TimeInterval? + /// The previous displayed tick of this run. + private(set) var lastTick: TimeInterval? + + init(required: TimeInterval, maximumGap: TimeInterval = 3) { + self.required = required + self.maximumGap = maximumGap + } + + /// Returns true once `required` seconds of uninterrupted display have + /// passed by `now`. Keeps returning true on later displayed ticks of the + /// same run (the caller may still be waiting for other conditions). + mutating func advance(isDisplayed: Bool, now: TimeInterval) -> Bool { + guard isDisplayed else { + reset() + return false + } + guard let since = displayedSince, let last = lastTick, + now >= last, now - last <= maximumGap else { + displayedSince = now + lastTick = now + return false + } + lastTick = now + return now - since >= required + } + + /// Ends the current run (the window stopped being key); the next + /// displayed tick starts a new one from zero. + mutating func reset() { + displayedSince = nil + lastTick = nil + } +} diff --git a/QuickMD/QuickMD/ReviewPromptTrigger.swift b/QuickMD/QuickMD/ReviewPromptTrigger.swift new file mode 100644 index 0000000..fbb67ce --- /dev/null +++ b/QuickMD/QuickMD/ReviewPromptTrigger.swift @@ -0,0 +1,161 @@ +// App Store build only: the GitHub/Homebrew build has no rating prompt, and +// none of this file is compiled into it (`OTHER_SWIFT_FLAGS="-DAPPSTORE"`). +#if APPSTORE +import SwiftUI +import AppKit +import StoreKit +#if DEBUG +import os + +private let reviewLog = Logger(subsystem: "pl.falami.studio.QuickMD", category: "ReviewPrompt") +#endif + +/// One rating request per app run, across every window and tab. +@MainActor +enum ReviewPromptSession { + static var hasRequested = false +} + +/// Attached to each document window's `MarkdownView`. Records the open once, +/// when the document first rendered, and — if `ReviewPrompt` says the reader +/// has settled in — asks StoreKit for a rating after 60 s of uninterrupted +/// display of this window. +/// +/// Ticks once a second ONLY while the window is key (the reader is looking at +/// THIS tab, the app is frontmost); otherwise it waits for +/// `didBecomeKeyNotification` and costs nothing. "Displayed" per tick: visible, +/// unoccluded, key, no sheet or app-modal panel. A tick that fails it, losing +/// key, or a gap of more than 3 s between ticks (sleep, App Nap, a stalled +/// main thread — measured on the monotonic `systemUptime`) restarts the 60 s. +/// At 60 s it also needs user input within the last 60 s; if the reader is +/// idle the run is reset, so someone returning after a long break is never +/// asked on their first touch — it takes a fresh 60 s of display that ends +/// with recent input. Source Edit stops it (the task id changes and the task returns +/// while editing; leaving starts a fresh 60 s), closing the window cancels it +/// with the view's task. +/// +/// The policy is evaluated when the countdown arms AND when it completes: in +/// the usual case (thresholds not met, or this version already asked) no +/// timer runs at all. +struct ReviewPromptTrigger: ViewModifier { + /// The document finished its first render (`contentVersion > 0`). Never + /// goes back to false, so reloads, zoom and theme changes do not re-arm. + let isRendered: Bool + let isEditing: Bool + /// The hosting window (`MarkdownView.hostWindow`), nil until attached. + let window: NSWindow? + + static let requiredDisplay: TimeInterval = 60 + private static let tick: UInt64 = 1_000_000_000 + + @Environment(\.requestReview) private var requestReview + /// Per window: the open is recorded once even if the task re-runs + /// (Source Edit toggles, the window object arriving, a re-appear). + @State private var didRecordOpen = false + + private struct Arming: Equatable { + let isRendered: Bool + let isEditing: Bool + let window: ObjectIdentifier? + } + + func body(content: Content) -> some View { + content + .task(id: Arming(isRendered: isRendered, isEditing: isEditing, + window: window.map(ObjectIdentifier.init))) { + await run() + } + } + + /// `@MainActor` explicitly: on the CI SDK a View's helpers are + /// nonisolated, and this touches NSWindow / NSApp / `requestReview`. + @MainActor + private func run() async { + guard isRendered else { return } + let policy = ReviewPrompt() + if !didRecordOpen { + didRecordOpen = true + policy.recordDocumentOpened(now: Date()) + } + guard !isEditing, let window, Self.canRequest(policy) else { return } + + var countdown = ReviewPromptCountdown(required: Self.requiredDisplay) + while !Task.isCancelled { + // Another window asked: stop for good, no further ticks. + guard !ReviewPromptSession.hasRequested else { return } + // Not key (background tab, another app or window in front, a + // sheet): no ticking at all — sleep until AppKit says the window + // is key again, then count a fresh 60 s. + if !window.isKeyWindow { + countdown.reset() + guard await Self.waitUntilKey(window) else { return } + continue + } + let displayedLongEnough = countdown.advance(isDisplayed: Self.isDisplayed(window), + now: ProcessInfo.processInfo.systemUptime) + if displayedLongEnough { + // Another window may have asked meanwhile, or the policy changed. + guard Self.canRequest(policy) else { return } + // An unattended screen is not reading. Start over rather than + // keep the run complete: a reader coming back after a long + // idle must never get the prompt on their first touch — it + // takes another 60 s of display that ends with recent input. + guard Self.userIsActive else { + countdown.reset() + try? await Task.sleep(nanoseconds: Self.tick) + continue + } + ReviewPromptSession.hasRequested = true + #if DEBUG + reviewLog.debug("requestReview for \(Self.appVersion, privacy: .public)") + #endif + requestReview() + policy.markRequested(now: Date(), appVersion: Self.appVersion) + return + } + try? await Task.sleep(nanoseconds: Self.tick) + } + } + + /// Suspends until `window` becomes key; false when the task was cancelled + /// (window closed, view gone, Source Edit entered). The notification + /// sequence is created BEFORE the key check, so a window that becomes key + /// in between is not missed. + @MainActor + private static func waitUntilKey(_ window: NSWindow) async -> Bool { + let becameKey = NotificationCenter.default.notifications(named: NSWindow.didBecomeKeyNotification, + object: window) + var iterator = becameKey.makeAsyncIterator() + if window.isKeyWindow { return !Task.isCancelled } + let arrived = await iterator.next() != nil + return arrived && !Task.isCancelled + } + + @MainActor + private static func canRequest(_ policy: ReviewPrompt) -> Bool { + !ReviewPromptSession.hasRequested && policy.shouldRequest(now: Date(), appVersion: appVersion) + } + + /// Any keyboard / mouse / trackpad event within `maximumIdleAtRequest`. + /// `~0` is `kCGAnyInputEventType`; an unconvertible type reads as idle. + private static var userIsActive: Bool { + guard let anyInput = CGEventType(rawValue: ~0) else { return false } + let idle = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyInput) + return ReviewPrompt.isUserActive(secondsSinceLastInput: idle) + } + + @MainActor + private static func isDisplayed(_ window: NSWindow) -> Bool { + window.isVisible + && !window.isMiniaturized + && window.occlusionState.contains(.visible) + && window.isKeyWindow + && window.attachedSheet == nil + && NSApp.modalWindow == nil + } + + private static var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" + } +} +#endif diff --git a/QuickMD/QuickMD/Views/MathBlockView.swift b/QuickMD/QuickMD/Views/MathBlockView.swift index 2d205d3..a61cd39 100644 --- a/QuickMD/QuickMD/Views/MathBlockView.swift +++ b/QuickMD/QuickMD/Views/MathBlockView.swift @@ -54,6 +54,14 @@ extension MathRendering { let (error, image, _) = mathImage.asImage() guard error == nil, let image = image else { return 0 } return image.size.height + }, + // Exactly the test `MTMathUILabel.latex` applies: a parse error is what + // leaves the label (and `MathImage`) with nothing to draw. Parsing only — + // no font, no typesetting, no bitmap — so the per-parse check costs a + // fraction of the `displayHeight` call that follows for the same row. + typesets: { latex in + var error: NSError? + return MTMathListBuilder.build(fromString: latex, error: &error) != nil && error == nil } ) } diff --git a/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift b/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift index bd5cd55..a66c69d 100644 --- a/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift +++ b/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift @@ -106,6 +106,7 @@ final class BlockHeightMeasurerTests: XCTestCase { ("ordered list", "1. first\n2. second item long enough to wrap at the narrow widths under test\n3. third"), ("footnote block", "Text with a ref[^a] and another[^b].\n\n[^a]: First footnote body, long enough to wrap somewhere.\n[^b]: Second footnote body."), ("inline math", "Einstein wrote $E = mc^2$ and also $x^2 + y^2 = z^2$ in one paragraph that wraps at the narrower widths."), + ("blank-separated blocks", "## H\n\n- a\n- b\n\nText after blank.\n\n\n## Next\n\nLast.\n\n"), ] for fixture in fixtures { @@ -137,6 +138,20 @@ final class BlockHeightMeasurerTests: XCTestCase { } } + /// A blank line between a heading and its paragraph or list (or after the + /// list) adds no height: the text rows of the loose document measure + /// exactly like those of the tight one (1.14.3). + func testBlankLinesAroundBlocksAddNoHeight() { + let loose = parse("## Para\n\nText after blank.\n\n## List\n\n- one\n- two\n\n## End") + let tight = parse("## Para\nText after blank.\n## List\n- one\n- two\n## End") + XCTAssertEqual(loose.map(\.id), tight.map(\.id)) + for width in widths { + let looseHeights = measure(loose, width: width).table.heights + let tightHeights = measure(tight, width: width).table.heights + XCTAssertEqual(looseHeights, tightHeights, "@\(width)") + } + } + // MARK: - .blockquote parity func testBlockquoteParityAcrossLevelsAndWidths() { @@ -857,3 +872,115 @@ final class BlockHeightMeasurerTests: XCTestCase { ![alt text](image.png) """ } + +// MARK: - Untypesettable display math (1.14.3): the source as a ```latex block + +extension BlockHeightMeasurerTests { + + /// Stand-in for SwiftMath's parser: refuses `\not` the way the real + /// `MTMathListBuilder` does ("Unsupported \not\le combination"), accepts + /// everything else. + private var refusingNot: MathRendering { + MathRendering(inlineImage: { _, _, _ in NSImage(size: NSSize(width: 24, height: 12)) }, + displayHeight: { _, _, _ in 40 }, + typesets: { !$0.contains("\\not") }) + } + + private static let failingFormulaDocument = """ + Intro paragraph. + + $$ + V(e_1) \\not\\le V(e_2) \\quad \\land \\quad V(e_2) \\not\\le V(e_1) + $$ + + $$x = \\frac{1}{2}$$ + + Outro paragraph. + """ + + func testUntypesettableFormulaBecomesALatexCodeBlockKeepingIdAndSourceLine() { + let parsed = MarkdownBlockParser(theme: MarkdownTheme.cached(for: .light)).parse(Self.failingFormulaDocument) + let blocks = refusingNot.substitutingUntypesettableMath(in: parsed) + + XCTAssertEqual(blocks.count, parsed.count) + XCTAssertEqual(blocks.map(\.id), parsed.map(\.id), "ids are positional and must not change") + XCTAssertEqual(blocks.map(\.sourceLine), parsed.map(\.sourceLine)) + + guard case .mathBlock(let failing) = parsed[1].content else { + return XCTFail("fixture: row 1 is not display math") + } + guard case .codeBlock(let code, let language) = blocks[1].content else { + return XCTFail("the untypesettable formula is still \(blocks[1].content)") + } + XCTAssertEqual(code, failing, "the code block shows the formula's source verbatim") + XCTAssertEqual(language, "latex") + XCTAssertEqual(blocks[1].sourceLine, 2, "the opening $$ line") + + guard case .mathBlock(let working) = blocks[2].content else { + return XCTFail("a formula that typesets must stay display math") + } + XCTAssertEqual(working, "x = \\frac{1}{2}") + } + + /// The default engine stubs (and `.none`) never substitute — nothing that + /// existed before the fallback changes behaviour. + func testEnginesWithoutATypesetCheckKeepEveryFormula() { + let parsed = parse(Self.failingFormulaDocument) + for engine in [math, MathRendering.none] { + let blocks = engine.substitutingUntypesettableMath(in: parsed) + XCTAssertEqual(blocks.filter { if case .mathBlock = $0.content { return true } else { return false } }.count, 2) + } + } + + func testDocumentWithoutDisplayMathNeverConsultsTheEngine() { + let parsed = parse("Text with $x$ inline only.\n\n```latex\n\\not\\le\n```") + let engine = MathRendering(inlineImage: { _, _, _ in nil }, displayHeight: { _, _, _ in 0 }, + typesets: { latex in + XCTFail("asked to typeset \u{201C}\(latex)\u{201D} with no display math present") + return false + }) + XCTAssertEqual(engine.substitutingUntypesettableMath(in: parsed).map(\.id), parsed.map(\.id)) + } + + /// The production order: the off-main pass measured the ORIGINAL blocks (the + /// formula row deferred as math), the main actor substitutes, then + /// `measureMathRows` finishes. The result must equal a straight measurement + /// of the substituted blocks — the formula row an `.exact` code row whose + /// height is what the code-block view lays out. + @MainActor + func testSubstitutedFormulaRowIsMeasuredAsAnExactCodeRow() { + let engine = refusingNot + let parsed = parse(Self.failingFormulaDocument) + let blocks = engine.substitutingUntypesettableMath(in: parsed) + guard case .codeBlock(let code, let language) = blocks[1].content else { + return XCTFail("fixture: row 1 was not substituted") + } + for width in widths { + let deferred = measureWithoutMathEngine(parsed, width: width) + XCTAssertTrue(deferred.mathPendingRows.contains(1), "@\(width)") + XCTAssertEqual(deferred.table.kinds[1], .reported) + + let finished = BlockHeightMeasurer.measureMathRows( + deferred.mathPendingRows, in: deferred, blocks: blocks, theme: theme, + fontScale: 1.0, contentWidth: width, math: engine) + let direct = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1.0, + contentWidth: width, math: engine) + XCTAssertEqual(finished.table, direct.table, "@\(width)") + XCTAssertEqual(finished.table.kinds[1], .exact, "@\(width)") + XCTAssertEqual(finished.table.kinds[2], .reported, "the working formula stays a math row") + + // Independent of the measurer: the string CodeBlockView installs, laid + // out in a live text view, plus the code chrome from BlockLayout. + let ns = BlockTextConverter.plainCode(code, theme: theme, fontScale: 1.0) + let inner = liveTextHeight(ns, width: width - 2 * BlockLayout.Code.horizontalPadding) + XCTAssertFalse(language.isEmpty) + let chrome = BlockLayout.Code.languageLabelHeight(fontScale: 1.0) + + BlockLayout.Code.languageLabelTopPadding + + BlockLayout.Code.languageLabelBottomPadding + + 2 * BlockLayout.Code.verticalPaddingWithLanguage + let expected = max(inner + chrome, BlockLayout.Code.copyButtonFloorHeight) + + 2 * BlockLayout.Document.codeOuterVerticalPadding + XCTAssertEqual(finished.table.heights[1], expected, accuracy: tolerance, "@\(width)") + } + } +} diff --git a/QuickMD/QuickMDTests/ParserTests.swift b/QuickMD/QuickMDTests/ParserTests.swift index 3dfbe10..70c2d95 100644 --- a/QuickMD/QuickMDTests/ParserTests.swift +++ b/QuickMD/QuickMDTests/ParserTests.swift @@ -842,6 +842,107 @@ final class ParserTests: XCTestCase { } } + // MARK: - No blank line drawn above or below a block (1.14.3) + // + // Blank source lines at either end of a text run are block separators. The + // renderer turns each blank line into empty paragraphs, so before 1.14.3 + // `## H\n\ntext` drew a whole empty line above "text" that `## H\ntext` + // did not (GitHub/CommonMark render both alike). + + /// The `.text` blocks of `markdown` as (rendered string, sourceLine). + private func textBlocks(_ markdown: String) -> [(text: String, line: Int)] { + parse(markdown).compactMap { block in + plainText(block).map { ($0, block.sourceLine) } + } + } + + /// The renderer's string for `markdown` — what a text block holding exactly + /// those lines shows. + private func rendered(_ markdown: String) -> String { + String(MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)).render(markdown).characters) + } + + func testBlankLineAfterHeadingAddsNoEmptyParagraph() { + let loose = textBlocks("## Para\n\nText after blank.") + let tight = textBlocks("## Para\nText after blank.") + XCTAssertEqual(loose.map(\.text), tight.map(\.text)) + XCTAssertEqual(loose.map(\.text), [rendered("Text after blank.")]) + XCTAssertEqual(loose.map(\.line), [2], "the paragraph's own line, not the blank above it") + XCTAssertEqual(tight.map(\.line), [1]) + } + + func testBlankLinesAroundAListAddNoEmptyParagraphs() { + let loose = textBlocks("## Tight\n\n- one\n- two\n\n\n## Next") + let tight = textBlocks("## Tight\n- one\n- two\n## Next") + XCTAssertEqual(loose.map(\.text), tight.map(\.text)) + XCTAssertEqual(loose.map(\.text), [rendered("- one\n- two")]) + XCTAssertEqual(loose.map(\.line), [2]) + } + + func testTrailingBlankLinesBeforeTheNextBlockAreDropped() { + // Whitespace-only lines are blank too; the document ends in blanks. + for markdown in ["Text.\n\n## Next", "Text.\n \n\t\n```\ncode\n```", "Text.\n\n\n"] { + let blocks = textBlocks(markdown) + XCTAssertEqual(blocks.map(\.text), [rendered("Text.")], markdown.debugDescription) + XCTAssertEqual(blocks.map(\.line), [0], markdown.debugDescription) + } + } + + func testLeadingWhitespaceOnlyLinesAreDropped() { + let blocks = textBlocks("> quote\n \n\n\t\nText.") + XCTAssertEqual(blocks.map(\.text), [rendered("Text.")]) + XCTAssertEqual(blocks.map(\.line), [4]) + } + + func testBlankLinesInsideATextBlockStay() { + // Paragraph separation inside the block is exactly what it was. + let inner = "First paragraph.\n\nSecond paragraph.\n\n\n- item\n\nLast." + let blocks = textBlocks("# H\n\n" + inner + "\n\n# Next") + XCTAssertEqual(blocks.map(\.text), [rendered(inner)]) + XCTAssertEqual(blocks.map(\.line), [2]) + } + + func testCRLFInputTrimsLikeLF() { + let lf = "## X\n\nText.\n\n- a\n- b\n\n## Y\n" + let crlf = lf.replacingOccurrences(of: "\n", with: "\r\n") + let normalized = textBlocks(MarkdownDocument.normalizeLineEndings(crlf)) + let expected = textBlocks(lf) + XCTAssertEqual(normalized.map(\.text), expected.map(\.text)) + XCTAssertEqual(normalized.map(\.line), expected.map(\.line)) + XCTAssertEqual(expected.map(\.text), [rendered("Text.\n\n- a\n- b")]) + } + + func testDocumentOfOnlyBlankLinesHasNoBlocks() { + for markdown in ["", "\n", "\n\n\n", " \n\t\n ", + MarkdownDocument.normalizeLineEndings("\r\n\r\n")] { + XCTAssertTrue(parse(markdown).isEmpty, markdown.debugDescription) + } + } + + func testChunksNeitherStartNorEndWithBlankLines() { + // 80 paragraphs separated by runs of 1–3 blank lines (some + // whitespace-only): the run is cut into several chunks, the cuts land + // on blank lines, and no chunk may carry them at either end. + var lines: [String] = [] + for n in 1...80 { + lines.append("paragraph \(n)") + for blank in 0..<(n % 3 + 1) { lines.append(blank == 1 ? " " : "") } + } + let blocks = textBlocks("# H\n" + lines.joined(separator: "\n")) + XCTAssertGreaterThan(blocks.count, 1, "the run must be chunked") + for block in blocks { + XCTAssertFalse(block.text.hasPrefix("\n"), "chunk at line \(block.line) starts blank") + XCTAssertFalse(block.text.hasSuffix("\n\n"), "chunk at line \(block.line) ends blank") + XCTAssertTrue(block.text.hasPrefix("paragraph "), "line \(block.line)") + XCTAssertEqual(lines[block.line - 1], block.text.components(separatedBy: "\n")[0], + "sourceLine is the first line the chunk renders") + } + // Every paragraph is still rendered exactly once. + let paragraphs = blocks.flatMap { $0.text.components(separatedBy: "\n") } + .filter { $0.hasPrefix("paragraph ") } + XCTAssertEqual(paragraphs, (1...80).map { "paragraph \($0)" }) + } + func testHeadingStoredSourceLineMatchesAssociatedValue() { // 0 `[ref]: …` (filtered), 1 blank, 2 `# ATX`, 3 `body`, 4 blank, // 5 `Setext`, 6 `------` diff --git a/QuickMD/QuickMDTests/ReviewPromptTests.swift b/QuickMD/QuickMDTests/ReviewPromptTests.swift new file mode 100644 index 0000000..7ee5cf6 --- /dev/null +++ b/QuickMD/QuickMDTests/ReviewPromptTests.swift @@ -0,0 +1,312 @@ +import XCTest +import Foundation + +/// The App Store rating prompt's policy (`ReviewPrompt`) and its continuous +/// display countdown (`ReviewPromptCountdown`). Every date is built from a +/// fixed base in UTC, so nothing depends on the machine's clock, time zone or +/// speed. +final class ReviewPromptTests: XCTestCase { + + private var suiteName: String! + private var defaults: UserDefaults! + private var policy: ReviewPrompt! + private var calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + }() + + /// 2026-01-01 10:00 UTC. + private let base = Date(timeIntervalSince1970: 1_767_261_600) + private let version = "1.15.0" + + /// Per-test directory that holds the suite's plist. + private var suiteDirectory: URL! + + /// The suite name is an ABSOLUTE PATH (CFPreferences then stores the + /// domain at `.plist`), inside a per-test temporary directory. + /// A plain suite name leaks `~/Library/Preferences/.plist`: + /// `removePersistentDomain` makes cfprefsd write an EMPTY plist there + /// seconds later (measured: ~20 s after the test process ended), so + /// neither deleting the file in tearDown nor `synchronize()` first wins + /// the race. A late write here lands in the temporary directory. + override func setUpWithError() throws { + try super.setUpWithError() + suiteDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("QuickMD-ReviewPromptTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: suiteDirectory, withIntermediateDirectories: true) + suiteName = suiteDirectory.appendingPathComponent("defaults").path + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + policy = ReviewPrompt(defaults: defaults, calendar: calendar) + } + + override func tearDownWithError() throws { + defaults.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: suiteDirectory) + defaults = nil + policy = nil + try super.tearDownWithError() + } + + private func day(_ n: Int, hour: Int = 0) -> Date { + base.addingTimeInterval(TimeInterval(n) * 86_400 + TimeInterval(hour) * 3_600) + } + + /// `count` opens spread over days 0, 1 and 2 (the rest on day 2). + private func recordQualifyingOpens(_ count: Int) { + for i in 0.. Bool { + var result = false + var now = start + while now <= end { + result = countdown.advance(isDisplayed: true, now: now) + now += 1 + } + return result + } + + /// Monotonic seconds, like `systemUptime`. + private let uptime: TimeInterval = 5_000 + + func testCountdownNeedsUninterruptedDisplay() { + var countdown = ReviewPromptCountdown(required: 60) + XCTAssertFalse(tick(&countdown, from: uptime, through: uptime + 59)) + XCTAssertTrue(countdown.advance(isDisplayed: true, now: uptime + 60)) + XCTAssertTrue(countdown.advance(isDisplayed: true, now: uptime + 61), "a complete run stays complete") + } + + func testCountdownRestartsAfterAnInterruption() { + var countdown = ReviewPromptCountdown(required: 60) + tick(&countdown, from: uptime, through: uptime + 50) + XCTAssertFalse(countdown.advance(isDisplayed: false, now: uptime + 51)) + XCTAssertNil(countdown.displayedSince) + XCTAssertFalse(tick(&countdown, from: uptime + 52, through: uptime + 111)) + XCTAssertTrue(countdown.advance(isDisplayed: true, now: uptime + 112)) + } + + func testCountdownRestartsAfterAGapBetweenTicks() { + var countdown = ReviewPromptCountdown(required: 60, maximumGap: 3) + tick(&countdown, from: uptime, through: uptime + 40) + // 3 s since the previous tick is still continuous… + XCTAssertFalse(countdown.advance(isDisplayed: true, now: uptime + 43)) + XCTAssertEqual(countdown.displayedSince, uptime) + // …4 s is not (sleep, App Nap, a stalled main thread): a new run. + XCTAssertFalse(countdown.advance(isDisplayed: true, now: uptime + 47)) + XCTAssertEqual(countdown.displayedSince, uptime + 47) + XCTAssertFalse(tick(&countdown, from: uptime + 48, through: uptime + 106)) + XCTAssertTrue(countdown.advance(isDisplayed: true, now: uptime + 107)) + } + + func testCountdownResetStartsFromZero() { + var countdown = ReviewPromptCountdown(required: 60) + tick(&countdown, from: uptime, through: uptime + 59) + countdown.reset() + XCTAssertNil(countdown.displayedSince) + XCTAssertNil(countdown.lastTick) + XCTAssertFalse(countdown.advance(isDisplayed: true, now: uptime + 60)) + } + + /// The trigger's idle path: a run that completed while the reader was + /// idle is reset, so the first tick after they come back does NOT + /// complete — it takes another full 60 s of display. + func testCompletedRunResetForIdleNeedsAFullNewRun() { + var countdown = ReviewPromptCountdown(required: 60) + XCTAssertTrue(tick(&countdown, from: uptime, through: uptime + 60)) + XCTAssertFalse(ReviewPrompt.isUserActive(secondsSinceLastInput: 600)) + countdown.reset() + XCTAssertFalse(countdown.advance(isDisplayed: true, now: uptime + 61), "first tick after idle") + XCTAssertFalse(tick(&countdown, from: uptime + 62, through: uptime + 120)) + XCTAssertTrue(countdown.advance(isDisplayed: true, now: uptime + 121)) + } + + func testCountdownSurvivesTimeGoingBack() { + var countdown = ReviewPromptCountdown(required: 60) + tick(&countdown, from: uptime, through: uptime + 30) + XCTAssertFalse(countdown.advance(isDisplayed: true, now: uptime - 100)) + XCTAssertEqual(countdown.displayedSince, uptime - 100) + XCTAssertTrue(tick(&countdown, from: uptime - 99, through: uptime - 40)) + } + + // MARK: - Idle check at the moment of asking + + func testUserActivityWindow() { + XCTAssertTrue(ReviewPrompt.isUserActive(secondsSinceLastInput: 0)) + XCTAssertTrue(ReviewPrompt.isUserActive(secondsSinceLastInput: 59.9)) + XCTAssertFalse(ReviewPrompt.isUserActive(secondsSinceLastInput: 60)) + XCTAssertFalse(ReviewPrompt.isUserActive(secondsSinceLastInput: 3_600)) + XCTAssertFalse(ReviewPrompt.isUserActive(secondsSinceLastInput: -1)) + XCTAssertFalse(ReviewPrompt.isUserActive(secondsSinceLastInput: .nan)) + XCTAssertFalse(ReviewPrompt.isUserActive(secondsSinceLastInput: .infinity)) + } +} diff --git a/QuickMD/QuickMDTests/SearchTests.swift b/QuickMD/QuickMDTests/SearchTests.swift index a16e147..de3241d 100644 --- a/QuickMD/QuickMDTests/SearchTests.swift +++ b/QuickMD/QuickMDTests/SearchTests.swift @@ -586,3 +586,21 @@ final class SearchTests: XCTestCase { XCTAssertEqual(count("key", in: markdown), 1, "a term only in a key is counted") } } + +// MARK: - Untypesettable display math (1.14.3) + +extension SearchTests { + /// A formula shown as its ```latex source is searched as that code block: + /// each occurrence counted once (not once as math and again as code). + func testUntypesettableFormulaIsCountedOnceAsItsCodeBlock() { + let engine = MathRendering(inlineImage: { _, _, _ in nil }, displayHeight: { _, _, _ in 40 }, + typesets: { !$0.contains("\\not") }) + let markdown = "$$\nV(e_1) \\not\\le V(e_2) \\quad \\land \\quad V(e_2) \\not\\le V(e_1)\n$$\n\nAfter." + let blocks = engine.substitutingUntypesettableMath(in: parse(markdown)) + guard case .codeBlock = blocks[0].content else { return XCTFail("formula was not substituted") } + + let results = DocumentSearch.computeMatches(in: blocks, term: "\\not\\le") + XCTAssertEqual(results.matchBlockIds, [blocks[0].id, blocks[0].id]) + XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: "land").matchBlockIds, [blocks[0].id]) + } +}