diff --git a/Sources/MapConductorCore/marker/StrategyMarkerController.swift b/Sources/MapConductorCore/marker/StrategyMarkerController.swift index d1653e4..0a7d32c 100644 --- a/Sources/MapConductorCore/marker/StrategyMarkerController.swift +++ b/Sources/MapConductorCore/marker/StrategyMarkerController.swift @@ -138,8 +138,27 @@ where Strategy.ActualMarker == ActualMarker, Renderer.ActualMarker == ActualMark } } + /// 他の 5 種類・``AbstractMarkerController`` の clear() と同じく + /// 「onRemove(全部) → 空にする → onPostProcess」の順に呼ぶ。 + /// + /// 以前は `strategy.clear()` で manager を空にするだけだった。GL 系の描画担当は + /// onRemove では地図に触らず onPostProcess で manager から描き直すので、呼ばないと + /// clear() の後もマーカーが地図に残る。空にするのは `strategy.clear()` のままにして、 + /// ストラテジ固有の後片付け(クラスタの状態など)を保つ。 + /// + /// **permit の中で行う。** `pendingStates` を触るため(上の ★ を参照)と、 + /// add / カメラ変更のストラテジ処理と交錯させないため。 public func clear() async { - strategy.clear() + await semaphore.withPermit { + let entities = markerManager.allEntities() + if !entities.isEmpty { + await renderer.onRemove(data: entities) + } + strategy.clear() + // 最初のカメラ待ちで保留していたものも消す。残すと次のカメラで描かれてしまう。 + pendingStates = nil + await renderer.onPostProcess() + } } public func find(position: GeoPointProtocol) -> MarkerEntity? { diff --git a/Tests/MapConductorCoreTests/StrategyMarkerClearOrderTests.swift b/Tests/MapConductorCoreTests/StrategyMarkerClearOrderTests.swift new file mode 100644 index 0000000..6f88800 --- /dev/null +++ b/Tests/MapConductorCoreTests/StrategyMarkerClearOrderTests.swift @@ -0,0 +1,118 @@ +import XCTest + +@testable import MapConductorCore + +/// ``StrategyMarkerController/clear()`` は他のコントローラの clear() と同じく +/// 「onRemove(全部) → 空にする → onPostProcess」で、何も描かれていない状態で終わること。 +/// +/// 以前は `strategy.clear()` で manager を空にするだけで、onRemove も onPostProcess も +/// 呼ばなかった。GL 系の描画担当は onPostProcess で manager から描き直すので、 +/// clear() の後もマーカーが地図に残る。android-sdk-core / js-sdk-core の同名テストと同じ。 +final class StrategyMarkerClearOrderTests: XCTestCase { + private let camera = MapCameraPosition( + position: GeoPoint(latitude: 35.0, longitude: 139.0), + zoom: 10, + visibleRegion: VisibleRegion(bounds: GeoRectBounds( + southWest: GeoPoint(latitude: 34.0, longitude: 138.0), + northEast: GeoPoint(latitude: 36.0, longitude: 140.0) + )) + ) + + private func markers() -> [MarkerState] { + [ + MarkerState(position: GeoPoint(latitude: 35.0, longitude: 139.0), id: "m1"), + MarkerState(position: GeoPoint(latitude: 35.1, longitude: 139.1), id: "m2"), + ] + } + + /// GL 系の描画担当: onRemove では地図に触らず、onPostProcess で manager の全部を描き直す。 + @MainActor + private final class GlRenderer: MarkerOverlayRendererProtocol { + typealias ActualMarker = String + let manager: MarkerManager + var drawn: [String] = [] + var removed: [String] = [] + var animateStartListener: OnMarkerEventHandler? + var animateEndListener: OnMarkerEventHandler? + init(_ manager: MarkerManager) { self.manager = manager } + func onAdd(data: [MarkerOverlayAddParams]) async -> [String?] { data.map { $0.state.id } } + func onChange(data: [MarkerOverlayChangeParams]) async -> [String?] { data.map { $0.current.state.id } } + func onRemove(data: [MarkerEntity]) async { removed += data.map { $0.state.id } } + func onAnimate(entity: MarkerEntity) async {} + func onPostProcess() async { drawn = manager.allEntities().map { $0.state.id }.sorted() } + } + + /// 渡されたマーカーを全部描くストラテジ。描いた状態は manager へ直接登録する。 + private final class DrawAllStrategy: MarkerRenderingStrategyProtocol { + typealias ActualMarker = String + let markerManager = MarkerManager() + var cleared = 0 + + func clear() { + cleared += 1 + markerManager.clear() + } + + func onAdd( + data: [MarkerState], + viewport _: GeoRectBounds, + renderer: Renderer + ) async -> Bool where Renderer.ActualMarker == String { + for state in data { + markerManager.registerEntity(MarkerEntity(marker: state.id, state: state, isRendered: true)) + } + await renderer.onPostProcess() + return true + } + + func onUpdate( + state _: MarkerState, + viewport _: GeoRectBounds, + renderer _: Renderer + ) async -> Bool where Renderer.ActualMarker == String { true } + + func onCameraChanged( + mapCameraPosition _: MapCameraPosition, + renderer _: Renderer + ) async where Renderer.ActualMarker == String {} + } + + @MainActor + func testClearLeavesNothingDrawnAndKeepsTheStrategyReset() async { + let strategy = DrawAllStrategy() + let renderer = GlRenderer(strategy.markerManager) + let controller = StrategyMarkerController(strategy: strategy, renderer: renderer) + await controller.onCameraChanged(mapCameraPosition: camera) + await controller.add(data: markers()) + XCTAssertEqual(renderer.drawn, ["m1", "m2"]) + + await controller.clear() + + XCTAssertEqual(renderer.removed.sorted(), ["m1", "m2"]) + XCTAssertEqual(strategy.cleared, 1) + XCTAssertEqual(renderer.drawn, []) + } + + @MainActor + func testClearDropsMarkersWaitingForTheFirstCamera() async { + let strategy = DrawAllStrategy() + let renderer = GlRenderer(strategy.markerManager) + let controller = StrategyMarkerController(strategy: strategy, renderer: renderer) + await controller.add(data: markers()) // カメラ前なので保留 + await controller.clear() + await controller.onCameraChanged(mapCameraPosition: camera) + XCTAssertEqual(renderer.drawn, []) + } + + @MainActor + func testMarkersSentAgainAfterClearAreDrawnAgain() async { + let strategy = DrawAllStrategy() + let renderer = GlRenderer(strategy.markerManager) + let controller = StrategyMarkerController(strategy: strategy, renderer: renderer) + await controller.onCameraChanged(mapCameraPosition: camera) + await controller.add(data: markers()) + await controller.clear() + await controller.add(data: markers()) + XCTAssertEqual(renderer.drawn, ["m1", "m2"]) + } +}