From 13e6b8025054e78c23edd3f6c374f0a925d83e10 Mon Sep 17 00:00:00 2001 From: oh in seong <58154638+ois0886@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:53:52 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=90=EA=B5=90=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EC=BD=94=EC=8A=A4=EC=97=90=20=EA=B3=B5=EA=B0=9C=20=EB=B3=B5?= =?UTF-8?q?=EC=8A=B5=20=EB=8F=84=EB=A1=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hangil/FinishSourceReviewRoomFlowTest.kt | 76 +++++++++++++-- .../hangillab/hangil/MapInputLifecycleTest.kt | 7 +- .../core/database/LocalPracticeCatalog.kt | 1 + .../core/database/LocalRoadSnapshots.kt | 92 +++++++++++++++++++ .../core/database/LocalPracticeCatalogTest.kt | 4 + docs/development/course-map-preview.md | 2 + 6 files changed, 175 insertions(+), 7 deletions(-) diff --git a/app/src/androidTest/kotlin/io/github/hangillab/hangil/FinishSourceReviewRoomFlowTest.kt b/app/src/androidTest/kotlin/io/github/hangillab/hangil/FinishSourceReviewRoomFlowTest.kt index 17c9222..8353889 100644 --- a/app/src/androidTest/kotlin/io/github/hangillab/hangil/FinishSourceReviewRoomFlowTest.kt +++ b/app/src/androidTest/kotlin/io/github/hangillab/hangil/FinishSourceReviewRoomFlowTest.kt @@ -76,6 +76,64 @@ import io.github.hangillab.hangil.feature.home.R as HomeR class FinishSourceReviewRoomFlowTest { @get:Rule val compose = createAndroidComposeRule() + @Test fun nativeNavigationCourseKeepsTheSelectedPublicRoadAfterRoomReopen() { + assumeManual() + fixture(courseId = "pleos-pangyo-yangjae") { f -> + val ended = + runBlocking { + val result = f.store.sessions.finish(f.active, TIME + 90_000) { true } + check(result is SessionFinishResult.Finished) + result.session + } + val course = checkNotNull(NextPracticeSeed.resolve(f.store.catalog, ended)).practice.course + check(course.navigationRoute != null) + val road = course.roadSnapshots.single() + check(road.id == "osm-way-619101472-v5") + val before = f.snapshot() + var ui = f.mount(f.store.sessions, f.store.reviewRoads) + ui.openExact(ended) + ui.ready(ended) + ui.touch("history_review_road") + ui.waitTag("review_road_empty") + ui.touch("review_road_candidate_0") + check( + runBlocking { + f.store.reviewRoads + .observe(ended.sessionId) + .first() + } == null, + ) + ui.touch("review_road_save") + ui.waitTag("review_road_stored_title") + check(ui.text("review_road_stored_title") == road.title) + val saved = + checkNotNull( + runBlocking { + f.store.reviewRoads + .observe(ended.sessionId) + .first() + }, + ) + check(saved.source == ended && saved.road == road && saved.origin == "USER_SELECTION" && saved.sampleAnchor == null) + ui.dispose() + runBlocking { f.store.reopen() } + ui = f.mount(f.store.sessions, f.store.reviewRoads) + ui.openExact(ended) + ui.ready(ended) + ui.touch("history_review_road") + ui.waitTag("review_road_stored_title") + check(ui.text("review_road_stored_title") == road.title) + check( + runBlocking { + f.store.reviewRoads + .observe(ended.sessionId) + .first() + } == saved, + ) + check(f.snapshot() == before) + } + } + @Test fun committedFlowBeforeAcknowledgementReviewsTheFinishedSourceEvenAfterANewerRecordExists() { assumeManual() fixture { f -> @@ -846,11 +904,14 @@ class FinishSourceReviewRoomFlowTest { } } - private fun fixture(body: (Fixture) -> Unit) { + private fun fixture( + courseId: String? = null, + body: (Fixture) -> Unit, + ) { val f = Fixture(OwnedPracticeTestStore.create(InstrumentationRegistry.getInstrumentation().targetContext)) var primary: Throwable? = null try { - f.seed() + f.seed(courseId) body(f) } catch (failure: Throwable) { primary = failure @@ -894,7 +955,7 @@ class FinishSourceReviewRoomFlowTest { var ui: FinishSourceReviewBoundaryTest.Driver? = null val memoQueries = ConcurrentLinkedQueue() - fun seed() = + fun seed(courseId: String?) = runBlocking { check( store.schemaVersion() == SCHEMA_VERSION && @@ -904,7 +965,7 @@ class FinishSourceReviewRoomFlowTest { .isEmpty(), ) check(store.preparation.observeSelection().first() == null) - val practice = prepared(0) + val practice = prepared(0, courseId) val start = store.sessions.start(practice, TIME + 1_000) { true } check(start is SessionStartResult.Started) active = start.session @@ -917,8 +978,11 @@ class FinishSourceReviewRoomFlowTest { check(store.courseCompletions.observe(active.sessionId).first() == null) } - private suspend fun prepared(taskIndex: Int): io.github.hangillab.hangil.core.domain.PreparedPractice { - val course = store.catalog.courses.first() + private suspend fun prepared( + taskIndex: Int, + courseId: String? = null, + ): io.github.hangillab.hangil.core.domain.PreparedPractice { + val course = if (courseId == null) store.catalog.courses.first() else store.catalog.courses.single { it.id == courseId } val goal = store.catalog.goals.first { it.id in course.goalIds } check(course.tasks.size > taskIndex) val selection = PracticeSelection(goal.id, course.id, course.version, course.briefingVersion, course.tasks[taskIndex].id) diff --git a/app/src/androidTest/kotlin/io/github/hangillab/hangil/MapInputLifecycleTest.kt b/app/src/androidTest/kotlin/io/github/hangillab/hangil/MapInputLifecycleTest.kt index 755cb5a..e59c12b 100644 --- a/app/src/androidTest/kotlin/io/github/hangillab/hangil/MapInputLifecycleTest.kt +++ b/app/src/androidTest/kotlin/io/github/hangillab/hangil/MapInputLifecycleTest.kt @@ -142,7 +142,7 @@ class MapInputLifecycleTest { val catalog = LocalPracticeCatalog() val course = catalog.courses.single { it.id == "pleos-pangyo-yangjae" } val route = checkNotNull(course.navigationRoute) - assertTrue("시연 preview로 우회하지 않는 현재 nav-only 원본이어야 합니다", course.mapPreview == null) + assertTrue("시연 지도 대신 내비 요청 장소 원본을 사용해야 합니다", course.mapPreview == null) val source = fixture.preview.source as MapPreview.Source.NavigationRequest assertTrue( "전체 요청 원본과 코스 버전을 보존해야 합니다", @@ -172,6 +172,11 @@ class MapInputLifecycleTest { assertTrue("추가 시연 지점을 만들면 안 됩니다", nodes("map_point_2").isEmpty() && nodes("map_demo_badge").isEmpty()) assertPoiText("map_navigation_request_badge", "내비 요청 장소") assertPoiText("map_navigation_request_scope", "내비에 요청할 장소이며 실제 안내 경로와 현재 위치는 표시하지 않아요.") + assertEquals(course.roadSnapshots, fixture.preview.roads) + assertPoiText("map_road_badge", "공개 OSM 도로 구간") + assertPoiText("map_road_0", "1. 창업로40번길") + assertPoiText("map_road_attribution", "도로 자료: © OpenStreetMap 기여자, ODbL 1.0") + awaitRoadLines(fixture.preview, initial.view, null) val directory = File(instrumentation.targetContext.cacheDir, "screenshots/navigation-request-pois-${UUID.randomUUID()}") check(directory.mkdirs()) { "POI_MAP_PRIVATE_CACHE" } capturePoiRegion(directory, "pois-map.png", "map_view") diff --git a/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/LocalPracticeCatalog.kt b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/LocalPracticeCatalog.kt index 5e79793..ffb5da0 100644 --- a/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/LocalPracticeCatalog.kt +++ b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/LocalPracticeCatalog.kt @@ -88,6 +88,7 @@ class LocalPracticeCatalog PracticeTask("stops", "경유지 순서 확인하기", "출발 전에 경유지와 최종 목적지의 순서를 확인해 보세요."), ), goalIds = setOf("commute", "shopping"), + roadSnapshots = listOf(LocalRoadSnapshots.pangyoWaypoint), // POI identifiers and routing coordinates from the official NaviHelper waypoint example. // Pleos resolves the waypoint ID to the Software Dream Center in active guidance. // https://document.pleos.ai/docs/pleos-only/connect-sdk-pleos/NaviHelper/route-with-waypoints diff --git a/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/LocalRoadSnapshots.kt b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/LocalRoadSnapshots.kt index 37751f4..e512eb4 100644 --- a/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/LocalRoadSnapshots.kt +++ b/core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/LocalRoadSnapshots.kt @@ -67,4 +67,96 @@ internal object LocalRoadSnapshots { CourseRoadSnapshot.Node(436850150L, 6, 37.5756733, 126.9742918), ), ) + + // Copyright OpenStreetMap contributors, ODbL 1.0. + // https://api.openstreetmap.org/api/0.6/way/619101472/5.json + // Independent public road snapshot. The current access and Pleos guidance path are unverified. + val pangyoWaypoint = + CourseRoadSnapshot( + id = "osm-way-619101472-v5", + title = "창업로40번길", + highway = "residential", + osmWayId = 619101472L, + osmWayVersion = 5, + capturedAtEpochMillis = 1794440075031L, + nodes = + listOf( + CourseRoadSnapshot.Node(5785292092L, 3, 37.4107925, 127.0948633), + CourseRoadSnapshot.Node(14081613437L, 2, 37.4108116, 127.0946083), + CourseRoadSnapshot.Node(14084804748L, 1, 37.4108137, 127.0945693), + CourseRoadSnapshot.Node(14081613445L, 2, 37.4108163, 127.0945144), + CourseRoadSnapshot.Node(14081613438L, 2, 37.4108204, 127.0944642), + CourseRoadSnapshot.Node(14081613444L, 1, 37.4108273, 127.0944065), + CourseRoadSnapshot.Node(14081613436L, 1, 37.4108413, 127.0943284), + CourseRoadSnapshot.Node(5850817444L, 2, 37.4108636, 127.0942273), + CourseRoadSnapshot.Node(14081613443L, 1, 37.4108929, 127.0941207), + CourseRoadSnapshot.Node(5850817406L, 2, 37.4109122, 127.0940621), + CourseRoadSnapshot.Node(14081613442L, 1, 37.4109331, 127.0940148), + CourseRoadSnapshot.Node(14081613441L, 1, 37.4109541, 127.0939743), + CourseRoadSnapshot.Node(14081613440L, 1, 37.4109864, 127.0939267), + CourseRoadSnapshot.Node(14081613439L, 1, 37.4112145, 127.0936002), + CourseRoadSnapshot.Node(14081613446L, 1, 37.4112843, 127.0934963), + CourseRoadSnapshot.Node(14081613452L, 1, 37.4114015, 127.0933045), + CourseRoadSnapshot.Node(14081613451L, 1, 37.4114194, 127.0932782), + CourseRoadSnapshot.Node(14081613450L, 1, 37.4114463, 127.0932455), + CourseRoadSnapshot.Node(14081613449L, 1, 37.4114745, 127.0932153), + CourseRoadSnapshot.Node(14081613453L, 1, 37.4114906, 127.0932006), + CourseRoadSnapshot.Node(14081613448L, 1, 37.4115056, 127.0931871), + CourseRoadSnapshot.Node(14081613447L, 1, 37.4115421, 127.0931627), + CourseRoadSnapshot.Node(14081613457L, 1, 37.4116208, 127.0931224), + CourseRoadSnapshot.Node(14081613456L, 1, 37.4116475, 127.0931104), + CourseRoadSnapshot.Node(14081613455L, 1, 37.4116789, 127.0931008), + CourseRoadSnapshot.Node(14081613454L, 1, 37.4117096, 127.0930944), + CourseRoadSnapshot.Node(14081613458L, 1, 37.4117465, 127.0930891), + CourseRoadSnapshot.Node(14081613459L, 1, 37.4117700, 127.0930881), + CourseRoadSnapshot.Node(14081613460L, 1, 37.4117931, 127.0930892), + CourseRoadSnapshot.Node(14081613461L, 1, 37.4118213, 127.0930916), + CourseRoadSnapshot.Node(5850817408L, 2, 37.4118498, 127.0930967), + CourseRoadSnapshot.Node(14081613462L, 1, 37.4118737, 127.0931027), + CourseRoadSnapshot.Node(14081613463L, 1, 37.4119014, 127.0931110), + CourseRoadSnapshot.Node(14081613465L, 1, 37.4119312, 127.0931224), + CourseRoadSnapshot.Node(14081613466L, 1, 37.4119675, 127.0931373), + CourseRoadSnapshot.Node(14081613467L, 1, 37.4119980, 127.0931515), + CourseRoadSnapshot.Node(14081613469L, 1, 37.4120159, 127.0931603), + CourseRoadSnapshot.Node(14081613468L, 1, 37.4120362, 127.0931719), + CourseRoadSnapshot.Node(5850817409L, 2, 37.4120621, 127.0931903), + CourseRoadSnapshot.Node(14081613470L, 1, 37.4120882, 127.0932112), + CourseRoadSnapshot.Node(14081613471L, 1, 37.4121197, 127.0932390), + CourseRoadSnapshot.Node(14081613472L, 1, 37.4121524, 127.0932713), + CourseRoadSnapshot.Node(14081613473L, 1, 37.4121787, 127.0932981), + CourseRoadSnapshot.Node(5850817423L, 2, 37.4122059, 127.0933281), + CourseRoadSnapshot.Node(14081613474L, 1, 37.4122283, 127.0933563), + CourseRoadSnapshot.Node(14081613475L, 1, 37.4122497, 127.0933852), + CourseRoadSnapshot.Node(14081613476L, 1, 37.4122696, 127.0934135), + CourseRoadSnapshot.Node(14081613477L, 1, 37.4122863, 127.0934406), + CourseRoadSnapshot.Node(14081613478L, 1, 37.4123029, 127.0934713), + CourseRoadSnapshot.Node(14081613479L, 1, 37.4123179, 127.0935012), + CourseRoadSnapshot.Node(14081613480L, 1, 37.4123334, 127.0935326), + CourseRoadSnapshot.Node(14081613481L, 1, 37.4123500, 127.0935689), + CourseRoadSnapshot.Node(5850817410L, 2, 37.4123665, 127.0936044), + CourseRoadSnapshot.Node(5850817424L, 2, 37.4124143, 127.0937125), + CourseRoadSnapshot.Node(5850817425L, 2, 37.4124909, 127.0938837), + CourseRoadSnapshot.Node(5850817419L, 2, 37.4126072, 127.0941395), + CourseRoadSnapshot.Node(5850817422L, 2, 37.4126413, 127.0942155), + CourseRoadSnapshot.Node(5850817427L, 2, 37.4126690, 127.0942869), + CourseRoadSnapshot.Node(5850817411L, 2, 37.4126962, 127.0943601), + CourseRoadSnapshot.Node(14081613500L, 1, 37.4127069, 127.0944013), + CourseRoadSnapshot.Node(5850817418L, 2, 37.4127145, 127.0944425), + CourseRoadSnapshot.Node(14081613501L, 1, 37.4127188, 127.0944854), + CourseRoadSnapshot.Node(5850817428L, 2, 37.4127231, 127.0945359), + CourseRoadSnapshot.Node(5850817414L, 2, 37.4127238, 127.0945930), + CourseRoadSnapshot.Node(14081613502L, 1, 37.4127233, 127.0946550), + CourseRoadSnapshot.Node(5850817420L, 2, 37.4127173, 127.0947090), + CourseRoadSnapshot.Node(14081613503L, 1, 37.4127111, 127.0947516), + CourseRoadSnapshot.Node(5850817415L, 2, 37.4126995, 127.0947994), + CourseRoadSnapshot.Node(5850817421L, 2, 37.4126786, 127.0948755), + CourseRoadSnapshot.Node(14081613504L, 1, 37.4126644, 127.0949111), + CourseRoadSnapshot.Node(5850817412L, 2, 37.4126448, 127.0949482), + CourseRoadSnapshot.Node(5850817416L, 2, 37.4126005, 127.0950242), + CourseRoadSnapshot.Node(5850817417L, 2, 37.4125500, 127.0951028), + CourseRoadSnapshot.Node(5850817413L, 2, 37.4124724, 127.0952163), + CourseRoadSnapshot.Node(14081613499L, 1, 37.4122801, 127.0954777), + CourseRoadSnapshot.Node(5850817399L, 2, 37.4121218, 127.0956758), + ), + ) } diff --git a/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/LocalPracticeCatalogTest.kt b/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/LocalPracticeCatalogTest.kt index 67e1038..cac78bc 100644 --- a/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/LocalPracticeCatalogTest.kt +++ b/core/database/src/test/kotlin/io/github/hangillab/hangil/core/database/LocalPracticeCatalogTest.kt @@ -73,6 +73,10 @@ class LocalPracticeCatalogTest { assertEquals("36khp37hh8gqcm5nqhe5", route.destination.poiId) assertEquals(listOf("36khp2hdj2cfcm5nqhe3"), route.waypoints.map { it.poiId }) assertEquals(null, prepared.course.mapPreview) + val road = prepared.course.roadSnapshots.single() + assertEquals("osm-way-619101472-v5", road.id) + assertEquals("창업로40번길", road.title) + assertEquals(76, road.nodes.size) assertTrue(catalog.courses.filter { it.mapPreview != null }.all { it.navigationRoute == null }) } diff --git a/docs/development/course-map-preview.md b/docs/development/course-map-preview.md index b7a195c..a9e6a47 100644 --- a/docs/development/course-map-preview.md +++ b/docs/development/course-map-preview.md @@ -78,6 +78,8 @@ 생활권 코스의 도로선은 장소 대표점을 잇는 선이 아니다. [고정 도로 자료](../../core/database/src/main/kotlin/io/github/hangillab/hangil/core/database/LocalRoadSnapshots.kt)의 way 184443139 버전13과 431903253 버전5를 [버전별 API](https://wiki.openstreetmap.org/wiki/API_v0.6#Version:_GET_/api/0.6/[node|way|relation]/#id/#version)로 읽고, 참조한 노드13개의 버전과 좌표를 개별 버전 응답으로 대조했다. way의 노드 순서를 그대로 보존하며 각 노드 버전도 자료에 포함한다. 이는 way 변경 시각의 역사적 도로 상태나 현재 통행 조건을 확인했다는 뜻은 아니다. +판교를 거쳐 양재까지 코스에서는 내비 요청 지점과 별도로 경유지 인근의 [창업로40번길 공개 도로 원형](https://api.openstreetmap.org/api/0.6/way/619101472/5.json)을 지도와 복습 후보로 표시한다. way 버전 5의 순서와 노드 76개의 개별 버전, 좌표를 공식 API에서 대조했다. 내비 경유지와 목적지 요청 값은 그대로 쓰며 실제 안내 경로의 일치는 MAP-05 실기에서 확인한다. + 준비 지도에는 생활권의 세종대로20길과 교차로 코스의 새문안로5길을 각각 연습 예시 구간으로 표시한다. 각 공개 도로의 첫 노드와 마지막 노드가 지도에서 구간 시작과 끝이 된다. 이 지점은 도로선의 범위를 나타내며 정차나 주차 지점으로 사용하지 않는다. 새문안로5길은 [OSM way 66662159 버전 10](https://api.openstreetmap.org/api/0.6/way/66662159/10.json)의 일방통행 노드 순서를 따른다. [서울시 본청 진입 안내](https://www.seoul.go.kr/seoul/map.do)와 [세종문화회관 차량 접근 안내](https://www.sejongpac.or.kr/portal/main/contents.do?menuNo=200390)의 주변 진행 방향을 대조했다. 실제 길안내와 당일 통제는 별도로 확인한다. [도로 모델](../../core/domain/src/main/kotlin/io/github/hangillab/hangil/core/domain/CourseRoadSnapshot.kt)은 도로 식별자, way 버전, 수집 시각과 ordered node 스냅샷을 보존한다. 지도에는 별도 GeoJSON LineString과 LineLayer로 표시하고, 목록에서 선택한 선만 강조한다. 점과 도로는 한 선택의 서로 다른 target이며 준비 지도에서의 선택은 현재 지도에만 유지된다. 종료 연습의 복습 화면에서는 별도의 사용자 선택 원형을 저장한다. 실제 SDK 표본의 도로 매칭은 연결하지 않았다.