From d42ff834fa7a9b17f301c05c554c6e72ce0bebf0 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 12 Nov 2026 22:27:16 -0800 Subject: [PATCH] Add onAddStart/onAddProgress/onAddEnd to Markers, Circles, Polylines, Polygons A list of thousands shows nothing until it is drawn; these say the work is going on. They count the states a list brings by the same moment as each state's onAdded, report progress at most every 100 ms, and start over when a new list adds states, the same as js-sdk-react and android-sdk-compose. The collections are values rebuilt on every content pass, so the counts live in MapViewBase (a StateObject per map view), keyed by where each collection is written; an unchanged list is recognised by its storage and costs nothing. OverlayAddedListeners lets them hear the states OverlayAddedTracker reports. StrategyMarkerController now calls onAdded too, as on the web and Android. --- .../MapConductorCore/MapCollectionItems.swift | 80 +++++- Sources/MapConductorCore/MapViewBase.swift | 4 + Sources/MapConductorCore/MapViewContent.swift | 3 + .../marker/StrategyMarkerController.swift | 17 +- .../overlay/AddProgress.swift | 237 ++++++++++++++++++ .../overlay/OverlayAddedTracker.swift | 40 ++- .../AddProgressTests.swift | 184 ++++++++++++++ .../StrategyMarkerClearOrderTests.swift | 21 ++ 8 files changed, 577 insertions(+), 9 deletions(-) create mode 100644 Sources/MapConductorCore/overlay/AddProgress.swift create mode 100644 Tests/MapConductorCoreTests/AddProgressTests.swift diff --git a/Sources/MapConductorCore/MapCollectionItems.swift b/Sources/MapConductorCore/MapCollectionItems.swift index bdb592a..cc52b76 100644 --- a/Sources/MapConductorCore/MapCollectionItems.swift +++ b/Sources/MapConductorCore/MapCollectionItems.swift @@ -7,27 +7,63 @@ import UIKit /// ビューを作らないので、`MapViewContentBuilder` の中でそのまま使える。 public struct Markers: MapOverlayItemProtocol { private let states: [MarkerState] + private let progress: AddProgressRequest? - public init(_ states: [MarkerState]) { + /// `onAddStart`, `onAddProgress` and `onAddEnd` say how far the states this + /// list adds are with reaching the map; see ``AddProgressCallbacks``. Leave + /// `fileID`, `line` and `column` to their defaults: they tell this + /// collection apart from the others on the map. + public init( + _ states: [MarkerState], + onAddStart: ((_ total: Int) -> Void)? = nil, + onAddProgress: ((_ added: Int, _ total: Int) -> Void)? = nil, + onAddEnd: ((_ total: Int) -> Void)? = nil, + fileID: StaticString = #fileID, + line: UInt = #line, + column: UInt = #column + ) { self.states = states + let callbacks = AddProgressCallbacks(onAddStart: onAddStart, onAddProgress: onAddProgress, onAddEnd: onAddEnd) + progress = callbacks.isEmpty + ? nil + : AddProgressRequest(kind: "markers", states: states, callbacks: callbacks, fileID: fileID, line: line, column: column) } public func append(to content: inout MapViewContent) { let items = states.map { Marker(state: $0) } if content.markers.isEmpty { content.markers = items } else { content.markers.append(contentsOf: items) } + if let progress { content.addProgressRequests.append(progress) } } } public struct Circles: MapOverlayItemProtocol { private let states: [CircleState] + private let progress: AddProgressRequest? - public init(_ states: [CircleState]) { + /// `onAddStart`, `onAddProgress` and `onAddEnd` say how far the states this + /// list adds are with reaching the map; see ``AddProgressCallbacks``. Leave + /// `fileID`, `line` and `column` to their defaults: they tell this + /// collection apart from the others on the map. + public init( + _ states: [CircleState], + onAddStart: ((_ total: Int) -> Void)? = nil, + onAddProgress: ((_ added: Int, _ total: Int) -> Void)? = nil, + onAddEnd: ((_ total: Int) -> Void)? = nil, + fileID: StaticString = #fileID, + line: UInt = #line, + column: UInt = #column + ) { self.states = states + let callbacks = AddProgressCallbacks(onAddStart: onAddStart, onAddProgress: onAddProgress, onAddEnd: onAddEnd) + progress = callbacks.isEmpty + ? nil + : AddProgressRequest(kind: "circles", states: states, callbacks: callbacks, fileID: fileID, line: line, column: column) } public func append(to content: inout MapViewContent) { let items = states.map { Circle(state: $0) } if content.circles.isEmpty { content.circles = items } else { content.circles.append(contentsOf: items) } + if let progress { content.addProgressRequests.append(progress) } } } @@ -54,14 +90,32 @@ public struct CircleTiling: MapOverlayItemProtocol { public struct Polylines: MapOverlayItemProtocol { private let states: [PolylineState] + private let progress: AddProgressRequest? - public init(_ states: [PolylineState]) { + /// `onAddStart`, `onAddProgress` and `onAddEnd` say how far the states this + /// list adds are with reaching the map; see ``AddProgressCallbacks``. Leave + /// `fileID`, `line` and `column` to their defaults: they tell this + /// collection apart from the others on the map. + public init( + _ states: [PolylineState], + onAddStart: ((_ total: Int) -> Void)? = nil, + onAddProgress: ((_ added: Int, _ total: Int) -> Void)? = nil, + onAddEnd: ((_ total: Int) -> Void)? = nil, + fileID: StaticString = #fileID, + line: UInt = #line, + column: UInt = #column + ) { self.states = states + let callbacks = AddProgressCallbacks(onAddStart: onAddStart, onAddProgress: onAddProgress, onAddEnd: onAddEnd) + progress = callbacks.isEmpty + ? nil + : AddProgressRequest(kind: "polylines", states: states, callbacks: callbacks, fileID: fileID, line: line, column: column) } public func append(to content: inout MapViewContent) { let items = states.map { Polyline(state: $0) } if content.polylines.isEmpty { content.polylines = items } else { content.polylines.append(contentsOf: items) } + if let progress { content.addProgressRequests.append(progress) } } } @@ -88,14 +142,32 @@ public struct PolylineTiling: MapOverlayItemProtocol { public struct Polygons: MapOverlayItemProtocol { private let states: [PolygonState] + private let progress: AddProgressRequest? - public init(_ states: [PolygonState]) { + /// `onAddStart`, `onAddProgress` and `onAddEnd` say how far the states this + /// list adds are with reaching the map; see ``AddProgressCallbacks``. Leave + /// `fileID`, `line` and `column` to their defaults: they tell this + /// collection apart from the others on the map. + public init( + _ states: [PolygonState], + onAddStart: ((_ total: Int) -> Void)? = nil, + onAddProgress: ((_ added: Int, _ total: Int) -> Void)? = nil, + onAddEnd: ((_ total: Int) -> Void)? = nil, + fileID: StaticString = #fileID, + line: UInt = #line, + column: UInt = #column + ) { self.states = states + let callbacks = AddProgressCallbacks(onAddStart: onAddStart, onAddProgress: onAddProgress, onAddEnd: onAddEnd) + progress = callbacks.isEmpty + ? nil + : AddProgressRequest(kind: "polygons", states: states, callbacks: callbacks, fileID: fileID, line: line, column: column) } public func append(to content: inout MapViewContent) { let items = states.map { Polygon(state: $0) } if content.polygons.isEmpty { content.polygons = items } else { content.polygons.append(contentsOf: items) } + if let progress { content.addProgressRequests.append(progress) } } } diff --git a/Sources/MapConductorCore/MapViewBase.swift b/Sources/MapConductorCore/MapViewBase.swift index 6482fab..5400cb8 100644 --- a/Sources/MapConductorCore/MapViewBase.swift +++ b/Sources/MapConductorCore/MapViewBase.swift @@ -49,6 +49,7 @@ public struct MapViewBase: View { private let content: MapViewContent private let mapContent: () -> MapContent private let topContent: () -> TopContent + @StateObject private var addProgress = AddProgressHost() public init( attributionRules: [AttributionRule], @@ -65,6 +66,9 @@ public struct MapViewBase: View { } public var body: some View { + // Before the map below is handed the content, so that the collections + // know their lists ahead of the first arrival. + let _ = addProgress.sync(content.addProgressRequests) ZStack { mapContent() ForEach(0.. [MarkerState] { guard let bounds = mapCameraPosition?.visibleRegion?.bounds ?? lastKnownBounds else { pendingStates = data - return + return [] } + let fresh = data.filter { !markerManager.hasEntity($0.id) } _ = await strategy.onAdd( data: data, viewport: bounds, renderer: renderer ) + return fresh.filter { markerManager.hasEntity($0.id) } } public func update(state: MarkerState) async { @@ -206,6 +213,7 @@ where Strategy.ActualMarker == ActualMarker, Renderer.ActualMarker == ActualMark /// ではなくなる)。終了時の見た目は変わらない。 public func onCameraChanged(mapCameraPosition: MapCameraPosition) async { await latestCamera.store(mapCameraPosition) + var landed: [MarkerState] = [] await semaphore.withPermit { if let camera = await latestCamera.take() { self.mapCameraPosition = camera @@ -229,9 +237,10 @@ where Strategy.ActualMarker == ActualMarker, Renderer.ActualMarker == ActualMark // 流したいのであって、どのタスクが流すかは問わない。 if let pending = pendingStates { pendingStates = nil - await addLocked(data: pending) + landed = await addLocked(data: pending) } } + await OverlayAddedTracker.notify(landed) } public func destroy() { diff --git a/Sources/MapConductorCore/overlay/AddProgress.swift b/Sources/MapConductorCore/overlay/AddProgress.swift new file mode 100644 index 0000000..06dd4ee --- /dev/null +++ b/Sources/MapConductorCore/overlay/AddProgress.swift @@ -0,0 +1,237 @@ +import Foundation + +/// How far a collection (``Markers``, ``Circles``, ``Polylines``, ``Polygons``) +/// is with putting its states on the map: for a list of thousands the screen +/// shows nothing until they are drawn, and these say that work is going on. +/// +/// "On the map" is what each state's `onAdded` means: drawn natively, or, for a +/// state drawn in tiles, its tiles handed to the map (their images may still be +/// loading). +/// +/// - `onAddStart(total)`: a list brought states that are not on the map yet; +/// `total` is how many. +/// - `onAddProgress(added, total)`: how many of them are on the map so far, at +/// most every 100 ms, and once more with `added == total` before the end. +/// - `onAddEnd(total)`: all of them are on the map. A state taken out of the +/// list before it got there no longer counts against the end. +/// +/// A list that brings new states while an earlier one is still being added +/// starts over: a new `onAddStart` with only the new states, and no `onAddEnd` +/// for the earlier list. A list that only updates or removes states calls +/// nothing. All three run on the main actor, never during a view update. +/// +/// A collection is told apart from the others on the same map by where it is +/// written in the content closure, the way SwiftUI tells views apart. It +/// starts from nothing when it leaves the content and comes back, and when the +/// map is recreated. +public struct AddProgressCallbacks { + public var onAddStart: ((_ total: Int) -> Void)? + public var onAddProgress: ((_ added: Int, _ total: Int) -> Void)? + public var onAddEnd: ((_ total: Int) -> Void)? + + public init( + onAddStart: ((_ total: Int) -> Void)? = nil, + onAddProgress: ((_ added: Int, _ total: Int) -> Void)? = nil, + onAddEnd: ((_ total: Int) -> Void)? = nil + ) { + self.onAddStart = onAddStart + self.onAddProgress = onAddProgress + self.onAddEnd = onAddEnd + } + + var isEmpty: Bool { onAddStart == nil && onAddProgress == nil && onAddEnd == nil } +} + +/// One collection's list as it was put into a ``MapViewContent``. +struct AddProgressRequest { + /// Where the collection is written (and what kind it is). + let site: String + let callbacks: AddProgressCallbacks + /// The list's storage, so that an unchanged list costs nothing to recognise: + /// the content is rebuilt on every camera frame. + let identity: UnsafeRawPointer? + let count: Int + /// The list's ids and state objects. Walks the whole list; called only when + /// the list changed. Holds the list, so its storage cannot be reused while + /// ``identity`` is remembered. + let entries: () -> [(id: String, state: AnyObject)] + + init( + kind: String, + states: [S], + callbacks: AddProgressCallbacks, + fileID: StaticString, + line: UInt, + column: UInt + ) where S.ID == String { + site = "\(kind) \(fileID):\(line):\(column)" + self.callbacks = callbacks + identity = states.withUnsafeBufferPointer { UnsafeRawPointer($0.baseAddress) } + count = states.count + entries = { states.map { (id: $0.id, state: $0 as AnyObject) } } + } +} + +/// The states one collection handed over and how many of them are on the map. +@MainActor +final class AddProgressTracker { + private static let progressInterval: TimeInterval = 0.1 + + private var callbacks = AddProgressCallbacks() + private var last: AddProgressRequest? + private var ids: Set = [] + /// State objects handed over and not on the map yet. + private var pending: [ObjectIdentifier: (id: String, state: AnyObject)] = [:] + private var total = 0 + private var added = 0 + private var started = true + private var generation = 0 + private var lastProgressAt = -TimeInterval.infinity + private var progressScheduled = false + private let now: () -> TimeInterval + + init(now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { + self.now = now + } + + /// The collection's list in this content pass. Calls nothing itself (this + /// runs during a view update); the start and an end it causes come right after. + func sync(_ request: AddProgressRequest) { + callbacks = request.callbacks + if let last, last.identity == request.identity, last.count == request.count { return } + last = request + let entries = request.entries() + let nextIds = Set(entries.map(\.id)) + let fresh = entries.filter { !ids.contains($0.id) } + ids = nextIds + if !pending.isEmpty { + let before = pending.count + pending = pending.filter { nextIds.contains($0.value.id) } + total -= before - pending.count + if fresh.isEmpty { + // A state that is still coming may be handed over again as a new + // object with the same id; the map reports that object. + let pendingIds = Set(pending.values.map(\.id)) + var rekeyed: [ObjectIdentifier: (id: String, state: AnyObject)] = [:] + for entry in entries where pendingIds.contains(entry.id) { + rekeyed[ObjectIdentifier(entry.state)] = entry + } + pending = rekeyed + if pending.isEmpty { later { $0.finishIfDone() } } + } + } + guard !fresh.isEmpty else { return } + generation += 1 + pending = Dictionary(fresh.map { (ObjectIdentifier($0.state), $0) }, uniquingKeysWith: { a, _ in a }) + total = pending.count + added = 0 + started = false + lastProgressAt = -.infinity + later { $0.announceStart() } + } + + /// States reached the map (any collection's, any map's: only ours count). + func arrived(_ states: [AnyObject]) { + if pending.isEmpty { return } + var hit = false + for state in states where pending.removeValue(forKey: ObjectIdentifier(state)) != nil { + added += 1 + hit = true + } + guard hit else { return } + announceStart() + if pending.isEmpty { finishIfDone() } else { reportProgress() } + } + + func dispose() { + generation += 1 + pending = [:] + } + + private func later(_ body: @escaping @MainActor (AddProgressTracker) -> Void) { + let generation = generation + DispatchQueue.main.async { [weak self] in + guard let self, self.generation == generation else { return } + body(self) + } + } + + private func announceStart() { + if started { return } + started = true + callbacks.onAddStart?(total) + } + + private func finishIfDone() { + guard pending.isEmpty else { return } + announceStart() + let total = self.total + generation += 1 + callbacks.onAddProgress?(total, total) + callbacks.onAddEnd?(total) + } + + private func reportProgress() { + if progressScheduled { return } + let wait = lastProgressAt + Self.progressInterval - now() + if wait <= 0 { + emitProgress() + return + } + progressScheduled = true + let generation = generation + DispatchQueue.main.asyncAfter(deadline: .now() + wait) { [weak self] in + guard let self else { return } + self.progressScheduled = false + guard self.generation == generation else { return } + self.emitProgress() + } + } + + private func emitProgress() { + if pending.isEmpty { return } + lastProgressAt = now() + callbacks.onAddProgress?(added, total) + } +} + +/// The trackers of one map view's collections, kept for as long as the map view. +@MainActor +final class AddProgressHost: ObservableObject { + private var trackers: [String: AddProgressTracker] = [:] + private var removeListener: (() -> Void)? + + /// The collections in this content pass. Ones no longer there are dropped. + func sync(_ requests: [AddProgressRequest]) { + if requests.isEmpty && trackers.isEmpty { return } + var occurrences: [String: Int] = [:] + var live: Set = [] + for request in requests { + // The same line in a loop: tell them apart by order. + let n = occurrences[request.site, default: 0] + occurrences[request.site] = n + 1 + let key = "\(request.site)#\(n)" + live.insert(key) + let tracker = trackers[key] ?? AddProgressTracker() + trackers[key] = tracker + tracker.sync(request) + } + for (key, tracker) in trackers where !live.contains(key) { + tracker.dispose() + trackers[key] = nil + } + if trackers.isEmpty { + removeListener?() + removeListener = nil + } else if removeListener == nil { + removeListener = OverlayAddedListeners.add { [weak self] states in + guard let self else { return } + for tracker in self.trackers.values { tracker.arrived(states) } + } + } + } + + deinit { + removeListener?() + } +} diff --git a/Sources/MapConductorCore/overlay/OverlayAddedTracker.swift b/Sources/MapConductorCore/overlay/OverlayAddedTracker.swift index 07a6546..ac191fa 100644 --- a/Sources/MapConductorCore/overlay/OverlayAddedTracker.swift +++ b/Sources/MapConductorCore/overlay/OverlayAddedTracker.swift @@ -74,9 +74,11 @@ public final class OverlayAddedTracker { /// way an exception could on Android or the web. public static func notify(_ states: [S]) async { if states.isEmpty { return } + let listening = OverlayAddedListeners.isActive // Skip the hop when nobody listens (the common case with markers). - guard states.contains(where: { $0.hasOnAdded }) else { return } + guard listening || states.contains(where: { $0.hasOnAdded }) else { return } await MainActor.run { + if listening { OverlayAddedListeners.notify(states.map { $0 as AnyObject }) } for state in states { state.callOnAdded() } } } @@ -87,6 +89,42 @@ public final class OverlayAddedTracker { } } +/// Hears every batch of states ``OverlayAddedTracker/notify(_:)`` reports, on the +/// main actor, before their `onAdded` calls. For the framework layer (the +/// collections count their own states with it), not for apps. +@_spi(MapConductorDriver) +public enum OverlayAddedListeners { + private static let lock = NSLock() + nonisolated(unsafe) private static var listeners: [UUID: @MainActor ([AnyObject]) -> Void] = [:] + + static var isActive: Bool { + lock.lock() + defer { lock.unlock() } + return !listeners.isEmpty + } + + /// Adds `listener`; call the returned function (from any thread) to remove it. + public static func add(_ listener: @escaping @MainActor ([AnyObject]) -> Void) -> () -> Void { + let key = UUID() + lock.lock() + listeners[key] = listener + lock.unlock() + return { + lock.lock() + listeners[key] = nil + lock.unlock() + } + } + + @MainActor + static func notify(_ states: [AnyObject]) { + lock.lock() + let current = Array(listeners.values) + lock.unlock() + for listener in current { listener(states) } + } +} + /// An overlay state with an `onAdded` handler. Lets ``OverlayAddedTracker`` /// call it without knowing the overlay type. @_spi(MapConductorDriver) diff --git a/Tests/MapConductorCoreTests/AddProgressTests.swift b/Tests/MapConductorCoreTests/AddProgressTests.swift new file mode 100644 index 0000000..2b6464f --- /dev/null +++ b/Tests/MapConductorCoreTests/AddProgressTests.swift @@ -0,0 +1,184 @@ +import XCTest + +@_spi(MapConductorDriver) @testable import MapConductorCore + +/// `Markers` / `Circles` / `Polylines` / `Polygons` の onAddStart / onAddProgress / onAddEnd +/// (AddProgressTracker / AddProgressHost)。js-sdk-react の `addProgress.test.mjs`、 +/// android-sdk-compose の `AddProgressTrackerTest` と同じ内容に、iOS だけの事情を足す: +/// +/// - content は描き直しのたびに作り直される。同じ配列なら何もしない。 +/// - コレクションは書かれた場所で見分ける。content から消えたら数え直し。 +/// - コールバックはビューの更新中には呼ばない(start と end はその直後)。 +@MainActor +final class AddProgressTests: XCTestCase { + private var calls: [String] = [] + private var clock: TimeInterval = 1000 + + private func circle(_ id: String) -> CircleState { + CircleState(center: GeoPoint(latitude: 42.35, longitude: -71.06), radiusMeters: 5, id: id) + } + + private var callbacks: AddProgressCallbacks { + AddProgressCallbacks( + onAddStart: { [weak self] in self?.calls.append("start \($0)") }, + onAddProgress: { [weak self] in self?.calls.append("progress \($0)/\($1)") }, + onAddEnd: { [weak self] in self?.calls.append("end \($0)") } + ) + } + + private func request(_ states: [CircleState], line: UInt = 1) -> AddProgressRequest { + AddProgressRequest(kind: "circles", states: states, callbacks: callbacks, fileID: "Test.swift", line: line, column: 1) + } + + private func tracker() -> AddProgressTracker { + AddProgressTracker(now: { [unowned self] in self.clock }) + } + + /// Lets the calls deferred out of the view update run. + private func drain(_ seconds: TimeInterval = 0) { + let done = expectation(description: "main queue") + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { done.fulfill() } + wait(for: [done], timeout: seconds + 2) + } + + private func drainAsync() async { + let done = expectation(description: "main queue") + DispatchQueue.main.async { done.fulfill() } + await fulfillment(of: [done], timeout: 2) + } + + func testAllOnTheMapStartProgressEnd() { + let tracker = tracker() + let states = [circle("a"), circle("b"), circle("c")] + tracker.sync(request(states)) + XCTAssertEqual(calls, [], "nothing during the view update") + drain() + XCTAssertEqual(calls, ["start 3"]) + tracker.arrived([states[0]]) + tracker.arrived([states[1], states[2]]) + XCTAssertEqual(calls, ["start 3", "progress 1/3", "progress 3/3", "end 3"]) + } + + func testAnArrivalAheadOfTheDeferredStartStillStartsFirst() { + let tracker = tracker() + let states = [circle("a")] + tracker.sync(request(states)) + tracker.arrived(states) + drain() + XCTAssertEqual(calls, ["start 1", "progress 1/1", "end 1"]) + } + + func testProgressAtMostEvery100ms() { + let tracker = tracker() + let states = (0..<5).map { circle("s\($0)") } + tracker.sync(request(states)) + drain() + tracker.arrived([states[0]]) + clock += 0.01 + tracker.arrived([states[1]]) + tracker.arrived([states[2]]) + XCTAssertEqual(calls, ["start 5", "progress 1/5"]) + clock += 0.1 + drain(0.15) + XCTAssertEqual(calls, ["start 5", "progress 1/5", "progress 3/5"]) + tracker.arrived([states[3], states[4]]) + XCTAssertEqual(calls.last, "end 5") + } + + func testTheSameArrayAgainDoesNothing() { + let tracker = tracker() + let states = [circle("a"), circle("b")] + tracker.sync(request(states)) + drain() + tracker.arrived(states) + calls.removeAll() + for _ in 0..<3 { tracker.sync(request(states)) } + drain() + XCTAssertEqual(calls, []) + } + + func testAStateRemovedBeforeItArrivedNoLongerCounts() { + let tracker = tracker() + let (a, b, c) = (circle("a"), circle("b"), circle("c")) + tracker.sync(request([a, b, c])) + drain() + tracker.arrived([a]) + tracker.sync(request([a, b])) + tracker.arrived([b]) + XCTAssertEqual(Array(calls.suffix(2)), ["progress 2/2", "end 2"]) + } + + func testRemovingTheRestEndsRightAfter() { + let tracker = tracker() + let (a, b) = (circle("a"), circle("b")) + tracker.sync(request([a, b])) + drain() + tracker.arrived([a]) + tracker.sync(request([a])) + drain() + XCTAssertEqual(Array(calls.suffix(2)), ["progress 1/1", "end 1"]) + } + + func testAStillComingStateHandedOverAsANewObjectIsCountedByTheNewOne() { + let tracker = tracker() + tracker.sync(request([circle("a")])) + let a2 = circle("a") + tracker.sync(request([a2])) + tracker.arrived([a2]) + drain() + XCTAssertEqual(calls, ["start 1", "progress 1/1", "end 1"]) + } + + func testNewStatesWhileOthersAreComingStartOverWithoutAnEnd() { + let tracker = tracker() + let (a, b, c) = (circle("a"), circle("b"), circle("c")) + tracker.sync(request([a, b])) + drain() + tracker.sync(request([a, b, c])) + drain() + XCTAssertEqual(calls, ["start 2", "start 1"]) + tracker.arrived([a, b]) + tracker.arrived([c]) + XCTAssertEqual(calls, ["start 2", "start 1", "progress 1/1", "end 1"]) + } + + func testUpdatesRemovalsAndOtherStatesCallNothing() { + let tracker = tracker() + let (a, b) = (circle("a"), circle("b")) + tracker.sync(request([a, b])) + drain() + tracker.arrived([a, b]) + calls.removeAll() + tracker.sync(request([circle("a")])) + tracker.arrived([circle("other"), circle("a")]) + drain() + XCTAssertEqual(calls, []) + } + + /// The host: one tracker per place in the content; arrivals come through the listener. + func testHostHearsArrivalsAndStartsOverWhenACollectionComesBack() async { + let host = AddProgressHost() + let states = [circle("a"), circle("b")] + host.sync([request(states, line: 10)]) + await drainAsync() + await OverlayAddedTracker.notify(states) + XCTAssertEqual(calls, ["start 2", "progress 2/2", "end 2"]) + + calls.removeAll() + host.sync([]) // the collection left the content + host.sync([request(states, line: 10)]) // and came back with the same array + await drainAsync() + XCTAssertEqual(calls, ["start 2"]) + + host.sync([]) + XCTAssertFalse(OverlayAddedListeners.isActive, "no collection, no listener") + } + + func testCollectionsWithoutCallbacksAddNoRequest() { + var content = MapViewContent() + Circles([circle("a")]).append(to: &content) + XCTAssertTrue(content.addProgressRequests.isEmpty) + Circles([circle("a")], onAddEnd: { _ in }).append(to: &content) + XCTAssertEqual(content.addProgressRequests.count, 1) + } +} diff --git a/Tests/MapConductorCoreTests/StrategyMarkerClearOrderTests.swift b/Tests/MapConductorCoreTests/StrategyMarkerClearOrderTests.swift index 6f88800..0256ab4 100644 --- a/Tests/MapConductorCoreTests/StrategyMarkerClearOrderTests.swift +++ b/Tests/MapConductorCoreTests/StrategyMarkerClearOrderTests.swift @@ -115,4 +115,25 @@ final class StrategyMarkerClearOrderTests: XCTestCase { await controller.add(data: markers()) XCTAssertEqual(renderer.drawn, ["m1", "m2"]) } + + /// `onAdded` comes once each, for markers added directly and for those held for the + /// first camera, as js-sdk-core's and android-sdk's StrategyMarkerController do. + @MainActor + func testOnAddedForDirectAndHeldMarkers() async { + var added: [String] = [] + func marker(_ id: String) -> MarkerState { + MarkerState(position: GeoPoint(latitude: 35.0, longitude: 139.0), id: id, onAdded: { added.append($0.id) }) + } + let strategy = DrawAllStrategy() + let renderer = GlRenderer(strategy.markerManager) + let controller = StrategyMarkerController(strategy: strategy, renderer: renderer) + let held = [marker("h1"), marker("h2")] + await controller.add(data: held) // before the first camera: held + XCTAssertEqual(added, []) + await controller.onCameraChanged(mapCameraPosition: camera) + XCTAssertEqual(added, ["h1", "h2"]) + + await controller.add(data: held + [marker("d1")]) + XCTAssertEqual(added, ["h1", "h2", "d1"], "only the new one") + } }