diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt index 5f6b872e..03a9e3ec 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt @@ -12,6 +12,9 @@ import androidx.recyclerview.widget.RecyclerView import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.base.BaseFragment import kr.co.vividnext.sodalive.common.LoadingDialog +import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.settings.notification.MemberRole +import kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerActivity import kr.co.vividnext.sodalive.databinding.FragmentV2MainChatBinding import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity import kr.co.vividnext.sodalive.search.SearchActivity @@ -51,12 +54,19 @@ class ChatMainFragment : BaseFragment( private var isChatPullRefreshing = false private var refreshingChatFilter: ChatRoomFilter? = null private var lastRenderedChatContentState: ChatRoomListUiState.Content? = null + private var shouldRefreshChatRoomsOnResume = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + shouldRefreshChatRoomsOnResume = false loadingDialog = LoadingDialog(requireActivity(), layoutInflater) setupTitleBar() + val isCreator = SharedPreferenceManager.role == MemberRole.CREATOR.name + binding.btnNewDm.isVisible = isCreator + binding.btnNewDm.setOnClickListener { + if (isCreator) openWithLoginGuard(DmRecipientPickerActivity::class.java) + } val initialFilter = getInitialFilter() ?: ChatRoomFilter.ALL selectedFilter = initialFilter setupFilterTabs(initialFilter) @@ -67,6 +77,17 @@ class ChatMainFragment : BaseFragment( viewModel.loadFirstPage(initialFilter) } + override fun onResume() { + super.onResume() + if (!shouldRefreshChatRoomsOnResume) { + shouldRefreshChatRoomsOnResume = true + return + } + cancelChatPullRefresh() + bindChatRooms(emptyList(), showEmpty = false) + viewModel.loadFirstPage(selectedFilter) + } + fun selectFilter(filter: ChatRoomFilter) { if (refreshingChatFilter != null && refreshingChatFilter != filter) { cancelChatPullRefresh() diff --git a/app/src/main/res/layout/fragment_v2_main_chat.xml b/app/src/main/res/layout/fragment_v2_main_chat.xml index a246b433..40d591c1 100644 --- a/app/src/main/res/layout/fragment_v2_main_chat.xml +++ b/app/src/main/res/layout/fragment_v2_main_chat.xml @@ -58,4 +58,17 @@ tools:text="아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요." /> + diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt index 95fdaa8a..9b6ea24e 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt @@ -110,6 +110,28 @@ class ChatMainFragmentLayoutTest { assertTrue(source.contains("binding.swipeChatRooms.isRefreshing = false")) } + @Test + fun `채팅방에서 복귀하면 현재 filter 첫 페이지로 목록을 갱신한다`() { + val source = chatMainFragmentSource() + val onResume = source.substringBetween( + "override fun onResume()", + "private fun setupTitleBar()" + ) + + assertTrue(source.contains("private var shouldRefreshChatRoomsOnResume = false")) + val onViewCreated = source.substringBetween( + "override fun onViewCreated", + "viewModel.loadFirstPage(initialFilter)" + ) + assertTrue(onViewCreated.contains("shouldRefreshChatRoomsOnResume = false")) + assertTrue(onResume.contains("super.onResume()")) + assertTrue(onResume.contains("if (!shouldRefreshChatRoomsOnResume)")) + assertTrue(onResume.contains("shouldRefreshChatRoomsOnResume = true")) + assertTrue(onResume.contains("cancelChatPullRefresh()")) + assertTrue(onResume.contains("bindChatRooms(emptyList(), showEmpty = false)")) + assertTrue(onResume.contains("viewModel.loadFirstPage(selectedFilter)")) + } + @Test fun `채팅 empty 상태에서 pull refresh loading은 empty message를 숨기지 않는다`() { val source = chatMainFragmentSource() diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerSourceTest.kt new file mode 100644 index 00000000..b737a9e9 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/recipient/DmRecipientPickerSourceTest.kt @@ -0,0 +1,128 @@ +package kr.co.vividnext.sodalive.v2.main.chat.recipient + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +class DmRecipientPickerSourceTest { + @Test + fun `대화 탭은 크리에이터에게만 수신자 선택 진입을 연결한다`() { + val source = source("main/chat/ChatMainFragment.kt") + assertTrue(source.contains("SharedPreferenceManager.role == MemberRole.CREATOR.name")) + assertTrue(source.contains("binding.btnNewDm.isVisible = isCreator")) + assertTrue(source.contains("if (isCreator)")) + assertTrue(source.contains("openWithLoginGuard(DmRecipientPickerActivity::class.java)")) + } + + @Test + fun `수신자 화면은 팔로워와 검색 및 빈 결과 상태를 연결한다`() { + val source = source("main/chat/recipient/DmRecipientPickerActivity.kt") + assertTrue(source.contains("viewModel.enter()")) + assertTrue(source.contains("viewModel.onSearchQueryChanged")) + assertTrue(source.contains("DmRecipientPickerMode.FOLLOWERS")) + assertTrue(source.contains("state.searchResultCount")) + assertTrue(source.contains("state.isSearchEmpty")) + assertTrue(source.contains("adapter.submitItems(state.recipients)")) + val factory = source("main/chat/recipient/DmRecipientPickerFactory.kt") + assertTrue(factory.contains("getFollowerList(")) + assertTrue(factory.contains("SharedPreferenceManager.userId")) + assertTrue(factory.contains("searchUser(")) + assertTrue(factory.contains("observeOn(AndroidSchedulers.mainThread())")) + } + + @Test + fun `수신자 선택은 공통 확인 팝업에서만 요청하고 취소는 부작용이 없다`() { + val source = source("main/chat/recipient/DmRecipientPickerActivity.kt") + val confirmation = source.substringAfter("private fun confirmRecipient(").substringBefore("private fun openRoom(") + assertTrue(confirmation.contains("V2ModalDialog(")) + assertTrue(confirmation.contains("R.string.dm_recipient_confirm_title")) + assertTrue(confirmation.contains("getString(R.string.dm_recipient_confirm_message, recipient.nickname)")) + assertTrue(confirmation.contains("R.string.cancel")) + assertTrue(confirmation.contains("R.string.dm_recipient_send")) + assertTrue(confirmation.contains("confirmButtonClick = { openRoom(recipient.id) }")) + assertFalse(confirmation.contains("cancelButtonClick")) + assertFalse(confirmation.contains("createOrGetRoom")) + } + + @Test + fun `보내기는 recipient 경로로 생성한 방 아이디로 기존 DM 화면을 연다`() { + val source = source("main/chat/recipient/DmRecipientPickerActivity.kt") + assertTrue(source.contains("createOrGetRoomByRecipientId(SharedPreferenceManager.token, recipientId)")) + assertTrue(source.contains("response.success && data != null")) + assertTrue(source.contains("DmChatRoomActivity.newIntentByRoomId(this, data.roomId)")) + assertFalse(source.contains("newIntentByCreatorId")) + assertTrue( + file("app/src/main/AndroidManifest.xml").readText().contains( + "kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerActivity" + ) + ) + } + + @Test + fun `수신자 화면 layout은 Figma 검색 toolbar와 검색결과 count를 사용한다`() { + val layout = file("app/src/main/res/layout/activity_dm_recipient_picker.xml").readText() + + assertFalse(layout.contains("layout=\"@layout/detail_toolbar\"")) + assertTrue(layout.contains("android:id=\"@+id/btn_back\"")) + assertTrue(layout.contains("android:id=\"@+id/search_container\"")) + assertTrue(layout.contains("android:id=\"@+id/iv_search\"")) + assertTrue(layout.contains("android:id=\"@+id/et_search_nickname\"")) + assertTrue(layout.contains("android:id=\"@+id/tv_search_result_count\"")) + assertTrue(layout.contains("@string/dm_recipient_search_result_count")) + } + + @Test + fun `검색 empty 문구는 목록 콘텐츠 영역에서 전환된다`() { + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(file("app/src/main/res/layout/activity_dm_recipient_picker.xml")) + val content = requireNotNull(document.findById("recipient_content_container")) + val empty = requireNotNull(document.findById("tv_search_empty")) + val list = requireNotNull(document.findById("rv_recipients")) + + assertSame(content, empty.parentNode) + assertSame(content, list.parentNode) + } + + @Test + fun `새 수신자 문구는 지원 언어 전체에 정의된다`() { + val names = listOf( + "dm_recipient_search_hint", + "dm_recipient_followers", + "dm_recipient_search_result_count", + "dm_recipient_search_empty", + "dm_recipient_confirm_title", + "dm_recipient_confirm_message", + "dm_recipient_send" + ) + for (directory in listOf("values", "values-en", "values-ja")) { + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(file("app/src/main/res/$directory/strings.xml")) + val strings = document.getElementsByTagName("string") + val available = (0 until strings.length).map { + strings.item(it).attributes.getNamedItem("name").nodeValue + } + names.forEach { assertTrue("$directory/$it", it in available) } + } + } + + private fun source(path: String): String { + val file = file("app/src/main/java/kr/co/vividnext/sodalive/v2/$path") + assertTrue("수신자 연결 소스가 필요함: $path", file.exists()) + return file.readText() + } + + private fun file(path: String): File = + if (File("app").isDirectory) File(path) else File("../$path") + + private fun org.w3c.dom.Document.findById(id: String): org.w3c.dom.Node? { + val nodes = getElementsByTagName("*") + return (0 until nodes.length).map { nodes.item(it) } + .firstOrNull { node -> + val value = node.attributes?.getNamedItem("android:id")?.nodeValue + value == "@+id/$id" || value == "@id/$id" + } + } +}