From 9adac09f22e1686ede629c0d2020f911844ee0eb Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:50:05 -0700 Subject: [PATCH 1/6] Fix arrowWidth/arrowHeight mix-up in StatsChartMarker drawTopRightRect was using arrowHeight (8px) for the x-axis calculation instead of arrowWidth (12px), causing a 4px horizontal offset on the tooltip arrow. All other draw*Rect methods correctly use arrowWidth for x-axis positioning. Co-Authored-By: Claude Opus 4.6 --- .../Classes/ViewRelated/Stats/Charts/StatsChartMarker.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/StatsChartMarker.swift b/WordPress/Classes/ViewRelated/Stats/Charts/StatsChartMarker.swift index 32d8273cb082..fb5d16d78e1a 100644 --- a/WordPress/Classes/ViewRelated/Stats/Charts/StatsChartMarker.swift +++ b/WordPress/Classes/ViewRelated/Stats/Charts/StatsChartMarker.swift @@ -207,6 +207,7 @@ class StatsChartMarker: MarkerView { func drawTopRightRect(context: CGContext, x: CGFloat, y: CGFloat, height: CGFloat, width: CGFloat) { let arrowHeight = Constants.arrowSize.height + let arrowWidth = Constants.arrowSize.width drawDot(context: context, xPosition: x + width - Constants.dotRadius, yPosition: y - Constants.dotRadius) @@ -223,7 +224,7 @@ class StatsChartMarker: MarkerView { context.addLine(to: CGPoint(x: x, y: y + arrowHeight + Constants.cornerRadius + Constants.dotRadius)) // Top left corner context.addQuadCurve(to: CGPoint(x: x + Constants.cornerRadius, y: y + arrowHeight + Constants.dotRadius), control: CGPoint(x: x, y: y + arrowHeight + Constants.dotRadius)) - context.addLine(to: CGPoint(x: x + width - arrowHeight / 2.0, y: y + arrowHeight + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width - arrowWidth / 2.0, y: y + arrowHeight + Constants.dotRadius)) context.addLine(to: CGPoint(x: x + width, y: y + Constants.dotRadius)) context.fillPath() } From e4ba1d283eee2b5933deb021caab44325cf0e004 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:50:18 -0700 Subject: [PATCH 2/6] Fix nil-coalescing operator precedence in GutenbergViewController The ?? operator has lower precedence than -, so the expression `height ?? 0 - borderWidth` was parsed as `height ?? (0 - borderWidth)` instead of `(height ?? 0) - borderWidth`. This caused the navigation bar border to be positioned at -borderWidth when navigationController was nil. Co-Authored-By: Claude Opus 4.6 --- .../Classes/ViewRelated/Gutenberg/GutenbergViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift index b7e4eb15fde5..5e740a9390cd 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift @@ -428,7 +428,7 @@ class GutenbergViewController: UIViewController, PostEditor, PublishingEditor { let borderBottom = UIView() borderBottom.backgroundColor = UIColor(cgColor: borderColor) - borderBottom.frame = CGRect(x: 0, y: navigationController?.navigationBar.frame.size.height ?? 0 - borderWidth, width: navigationController?.navigationBar.frame.size.width ?? 0, height: borderWidth) + borderBottom.frame = CGRect(x: 0, y: (navigationController?.navigationBar.frame.size.height ?? 0) - borderWidth, width: navigationController?.navigationBar.frame.size.width ?? 0, height: borderWidth) borderBottom.autoresizingMask = [.flexibleWidth, .flexibleTopMargin] navigationController?.navigationBar.addSubview(borderBottom) From d53bd88c68ec10d8201a79b216daaa7ad3305f05 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:53:18 -0700 Subject: [PATCH 3/6] Fix YYYY (week-year) date format to yyyy (calendar year) The date formatter in PluginDirectoryServiceRemote used "YYYY" (ISO week-year) instead of "yyyy" (calendar year). Near year boundaries (Dec 31 / Jan 1), this caused dates to be parsed with the wrong year. Also fixed the matching format in the test helper. Added a test that verifies Dec 31 2024 parses as year 2024. Co-Authored-By: Claude Opus 4.6 --- .../PluginDirectoryServiceRemote.swift | 2 +- .../PluginDirectoryEntryStateTests.swift | 2 +- .../Tests/PluginDirectoryTests.swift | 25 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/WordPressKit/PluginDirectoryServiceRemote.swift b/Modules/Sources/WordPressKit/PluginDirectoryServiceRemote.swift index abc04b82b3a0..133ebe8cf38f 100644 --- a/Modules/Sources/WordPressKit/PluginDirectoryServiceRemote.swift +++ b/Modules/Sources/WordPressKit/PluginDirectoryServiceRemote.swift @@ -4,7 +4,7 @@ private struct PluginDirectoryRemoteConstants { static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "YYYY-MM-dd h:mma z" + formatter.dateFormat = "yyyy-MM-dd h:mma z" return formatter }() diff --git a/Tests/KeystoneTests/Tests/Services/PluginDirectoryEntryStateTests.swift b/Tests/KeystoneTests/Tests/Services/PluginDirectoryEntryStateTests.swift index c981481f2e98..45d5621443e7 100644 --- a/Tests/KeystoneTests/Tests/Services/PluginDirectoryEntryStateTests.swift +++ b/Tests/KeystoneTests/Tests/Services/PluginDirectoryEntryStateTests.swift @@ -7,7 +7,7 @@ class PluginDirectoryEntryStateTests: XCTestCase { static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "YYYY-MM-dd h:mma z" + formatter.dateFormat = "yyyy-MM-dd h:mma z" return formatter }() diff --git a/Tests/WordPressKitTests/WordPressKitTests/Tests/PluginDirectoryTests.swift b/Tests/WordPressKitTests/WordPressKitTests/Tests/PluginDirectoryTests.swift index fda960f3e5c0..9f6fe40bcfae 100644 --- a/Tests/WordPressKitTests/WordPressKitTests/Tests/PluginDirectoryTests.swift +++ b/Tests/WordPressKitTests/WordPressKitTests/Tests/PluginDirectoryTests.swift @@ -346,6 +346,31 @@ class PluginDirectoryTests: XCTestCase { XCTAssertTrue(lhs == rhs) } + func testDateParsingNearYearBoundary() throws { + // Verify that dates near the year boundary are parsed with the correct calendar year. + // Previously the format used "YYYY" (week-year) instead of "yyyy" (calendar year), + // which caused Dec 31 dates to be parsed as the following year. + let json = """ + { + "name": "Test Plugin", + "slug": "test-plugin", + "version": "1.0", + "last_updated": "2024-12-31 8:00pm GMT", + "author": "Test Author", + "rating": 80, + "icons": {}, + "sections": {} + } + """.data(using: .utf8)! + + let endpoint = PluginDirectoryGetInformationEndpoint(slug: "test-plugin") + let plugin = try endpoint.parseResponse(data: json) + + let calendar = Calendar(identifier: .gregorian) + let year = calendar.component(.year, from: plugin.lastUpdated!) + XCTAssertEqual(year, 2024, "Date near year boundary should parse as 2024, not 2025 (week-year)") + } + func testUnconventionalPluginSlug() async throws { let data = try MockPluginDirectoryProvider.getPluginDirectoryMockData(with: "plugin-directory-rename-xml-rpc", sender: type(of: self)) stub(condition: isHost("api.wordpress.org")) { _ in From 5a29586ae4085add63f3fc51302260d9246e5887 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:53:25 -0700 Subject: [PATCH 4/6] Fix crash in DonutChartView with empty segments When totalCount > 0 but all segments have value 0, normalizedSegments() filters them all out, leaving an empty array. The loop `0.. --- .../Features/Stats/DonutChartViewTests.swift | 48 +++++++++++++++++++ .../Stats/Charts/DonutChartView.swift | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 Tests/KeystoneTests/Tests/Features/Stats/DonutChartViewTests.swift diff --git a/Tests/KeystoneTests/Tests/Features/Stats/DonutChartViewTests.swift b/Tests/KeystoneTests/Tests/Features/Stats/DonutChartViewTests.swift new file mode 100644 index 000000000000..3941961ea37c --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Stats/DonutChartViewTests.swift @@ -0,0 +1,48 @@ +import Testing +import UIKit +@testable import WordPress + +struct DonutChartViewTests { + + @Test + func configureWithEmptySegmentsDoesNotCrash() { + let chartView = DonutChartView(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) + + // When totalCount > 0 but all segment values are 0, normalizedSegments() + // filters them all out, leaving an empty array. Previously this caused + // a crash on `0.. Date: Tue, 3 Mar 2026 21:53:31 -0700 Subject: [PATCH 5/6] Fix plugin directory entry lookup using slug instead of id PluginViewModel was passing plugin.id (e.g. "jetpack/jetpack.php") to getPluginDirectoryEntry(slug:) in 4 places, but directory entries are keyed by plugin.state.slug (e.g. "jetpack"). This caused lookups to always return nil, so directory metadata (descriptions, FAQs, changelogs, ratings) was never populated. Changed all 4 call sites to use plugin.state.slug. Added tests that verify the id/slug distinction and correct lookup behavior. Co-Authored-By: Claude Opus 4.6 --- .../PluginDirectoryEntryLookupTests.swift | 93 +++++++++++++++++++ .../Plugins/ViewModels/PluginViewModel.swift | 8 +- 2 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 Tests/KeystoneTests/Tests/Features/Misc/PluginDirectoryEntryLookupTests.swift diff --git a/Tests/KeystoneTests/Tests/Features/Misc/PluginDirectoryEntryLookupTests.swift b/Tests/KeystoneTests/Tests/Features/Misc/PluginDirectoryEntryLookupTests.swift new file mode 100644 index 000000000000..75ba98e3d42c --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Misc/PluginDirectoryEntryLookupTests.swift @@ -0,0 +1,93 @@ +import Testing +import WordPressKit +@testable import WordPress + +struct PluginDirectoryEntryLookupTests { + + /// The directory entries dictionary in PluginStoreState is keyed by slug. + /// PluginViewModel must use `plugin.state.slug` (not `plugin.id`) to look + /// up entries, because `id` and `slug` are different values. + /// + /// Example: id = "jetpack/jetpack.php", slug = "jetpack" + @Test + func pluginIdDiffersFromSlug() { + let state = PluginState( + id: "jetpack/jetpack.php", + slug: "jetpack", + active: true, + name: "Jetpack", + author: "Automattic", + version: "5.5", + updateState: .updated, + autoupdate: false, + automanaged: false, + url: nil, + settingsURL: nil + ) + let plugin = Plugin(state: state, directoryEntry: nil) + + #expect(plugin.id != plugin.state.slug, + "plugin.id and plugin.state.slug should differ") + #expect(plugin.id == "jetpack/jetpack.php") + #expect(plugin.state.slug == "jetpack") + } + + @Test + func directoryEntryLookupBySlugSucceeds() { + let entry = makeDirectoryEntry(slug: "jetpack") + + // Simulate the directoryEntries dictionary (keyed by slug) + var directoryEntries = [String: PluginDirectoryEntryState]() + directoryEntries["jetpack"] = .present(entry) + + // Looking up by slug succeeds + #expect(directoryEntries["jetpack"]?.entry != nil) + } + + @Test + func directoryEntryLookupByIdFails() { + let entry = makeDirectoryEntry(slug: "jetpack") + + // Simulate the directoryEntries dictionary (keyed by slug) + var directoryEntries = [String: PluginDirectoryEntryState]() + directoryEntries["jetpack"] = .present(entry) + + // Looking up by plugin.id (the bug) fails because id != slug + let pluginId = "jetpack/jetpack.php" + #expect(directoryEntries[pluginId]?.entry == nil, + "Looking up by plugin.id should fail since entries are keyed by slug") + } + + @Test + func pluginStoreGetPluginDirectoryEntryUsesSlugKey() { + let store = PluginStore() + let entry = makeDirectoryEntry(slug: "jetpack") + + // Populate the store's directoryEntries via its state + store.state.directoryEntries["jetpack"] = .present(entry) + + // Lookup by slug succeeds + #expect(store.getPluginDirectoryEntry(slug: "jetpack") != nil) + + // Lookup by id fails + #expect(store.getPluginDirectoryEntry(slug: "jetpack/jetpack.php") == nil) + } + + // MARK: - Helpers + + private func makeDirectoryEntry(slug: String) -> PluginDirectoryEntry { + let json = """ + { + "name": "Test Plugin", + "slug": "\(slug)", + "version": "1.0", + "author": "Test", + "rating": 80, + "icons": {}, + "sections": {} + } + """.data(using: .utf8)! + + return try! JSONDecoder().decode(PluginDirectoryEntry.self, from: json) + } +} diff --git a/WordPress/Classes/ViewRelated/Plugins/ViewModels/PluginViewModel.swift b/WordPress/Classes/ViewRelated/Plugins/ViewModels/PluginViewModel.swift index 1e3dfc21cc3b..9828fe1bd111 100644 --- a/WordPress/Classes/ViewRelated/Plugins/ViewModels/PluginViewModel.swift +++ b/WordPress/Classes/ViewRelated/Plugins/ViewModels/PluginViewModel.swift @@ -71,7 +71,7 @@ class PluginViewModel: Observable { // Self hosted non-Jetpack plugins may not have the directory entry set // attempt to find one for this plugin if updatedPlugin.directoryEntry == nil { - updatedPlugin.directoryEntry = store.getPluginDirectoryEntry(slug: plugin.id) + updatedPlugin.directoryEntry = store.getPluginDirectoryEntry(slug: plugin.state.slug) } self.state = .plugin(updatedPlugin) @@ -88,7 +88,7 @@ class PluginViewModel: Observable { } if plugin.directoryEntry == nil { - plugin.directoryEntry = store.getPluginDirectoryEntry(slug: plugin.id) + plugin.directoryEntry = store.getPluginDirectoryEntry(slug: plugin.state.slug) } self?.state = .plugin(plugin) @@ -114,7 +114,7 @@ class PluginViewModel: Observable { let state: State if var plugin = store.getPlugin(slug: slug, site: site) { if plugin.directoryEntry == nil { - plugin.directoryEntry = store.getPluginDirectoryEntry(slug: plugin.id) + plugin.directoryEntry = store.getPluginDirectoryEntry(slug: plugin.state.slug) } state = .plugin(plugin) @@ -141,7 +141,7 @@ class PluginViewModel: Observable { if var plugin = self?.store.getPlugin(slug: entry.slug, site: site) { if plugin.directoryEntry == nil { - plugin.directoryEntry = store.getPluginDirectoryEntry(slug: plugin.id) + plugin.directoryEntry = store.getPluginDirectoryEntry(slug: plugin.state.slug) } self?.state = .plugin(plugin) From b35cf70dd0ce44c48726441cca1a323f69f2792c Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:57:02 -0700 Subject: [PATCH 6/6] Use production decoder in PluginDirectoryEntryStateTests The test was creating its own DateFormatter with the same format string as production code, so a bug in both would go undetected. Now uses PluginDirectoryGetInformationEndpoint.parseResponse() which exercises the real decoder. Co-Authored-By: Claude Opus 4.6 --- .../Services/PluginDirectoryEntryStateTests.swift | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/Tests/KeystoneTests/Tests/Services/PluginDirectoryEntryStateTests.swift b/Tests/KeystoneTests/Tests/Services/PluginDirectoryEntryStateTests.swift index 45d5621443e7..f291e2c47353 100644 --- a/Tests/KeystoneTests/Tests/Services/PluginDirectoryEntryStateTests.swift +++ b/Tests/KeystoneTests/Tests/Services/PluginDirectoryEntryStateTests.swift @@ -4,21 +4,12 @@ import WordPressKit class PluginDirectoryEntryStateTests: XCTestCase { - static let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd h:mma z" - return formatter - }() - static let jetpackEntry: PluginDirectoryEntry = { let json = Bundle(for: PluginDirectoryEntryStateTests.self).url(forResource: "plugin-directory-jetpack", withExtension: "json")! let data = try! Data(contentsOf: json) - let jsonDecoder = JSONDecoder() - jsonDecoder.dateDecodingStrategy = .formatted(PluginDirectoryEntryStateTests.dateFormatter) - - return try! jsonDecoder.decode(PluginDirectoryEntry.self, from: data) + let endpoint = PluginDirectoryGetInformationEndpoint(slug: "jetpack") + return try! endpoint.parseResponse(data: data) }() func testMoreSpecificDirectoryEntryStateWins() {