From 03316824650f170758ef129f31f02e0d335ce73d Mon Sep 17 00:00:00 2001 From: UllashPodder Date: Tue, 21 Jul 2026 12:49:28 +0100 Subject: [PATCH] added CharacterListViewModel and states with tests --- .DS_Store | Bin 8196 -> 8196 bytes Stream-iOS/.DS_Store | Bin 6148 -> 6148 bytes .../Characters/CharacterListState.swift | 12 ++++ .../Characters/CharacterListView.swift | 7 +++ .../Characters/CharacterListViewModel.swift | 47 ++++++++++++++ .../Characters/CharacterRowView.swift | 7 +++ .../CharacterDecodingTests.swift | 58 ++++++++++++++++++ .../Mocks/MockCharacterRepository.swift | 1 + 8 files changed, 132 insertions(+) create mode 100644 Stream-iOS/Stream-iOS/Features/Characters/CharacterListState.swift create mode 100644 Stream-iOS/Stream-iOS/Features/Characters/CharacterListView.swift create mode 100644 Stream-iOS/Stream-iOS/Features/Characters/CharacterListViewModel.swift create mode 100644 Stream-iOS/Stream-iOS/Features/Characters/CharacterRowView.swift diff --git a/.DS_Store b/.DS_Store index c4b606e14ba79723dc75aac05fa47319e45310b5..a476d1a4626dbf1bbb3475715ab3fd6cb363f54a 100644 GIT binary patch delta 30 kcmZp1XmOa}&&azmU^hP_?`9qWR`!V{-!`*LZ~^Jh0F?y_00000 delta 136 zcmZp1XmOa}&&aniU^hP_-)0^GR(3{_$%-N(dJK9D6%5G?`3xxxsSE`SML<@TXHI@{ gQcivn0|SEq0|QeLkkB)qu~2NHo+2aL#DLw4A22d9vQ6e;te@=6s40@0@8Xh_lb^)Ez`(Kd zME_24_7jei9T;_h5-Hdv3Nj3Xlk;;6fI1i$7#Dy@!N~%QBAXX5u4dWH&cV+Cv~cr9 X#_!CN`9&-_7=emFYBmRmY+(if&u}JK delta 73 zcmZoMXfc=|#>B`mu~2NHo+2aj#DLw5%#(ST>KO$m3owanj$vNSvUvgX9>&e=9Q+(W cg_|cbe`lV|FJj5T00fK-3{0B?M7A&k0JFgnVgLXD diff --git a/Stream-iOS/Stream-iOS/Features/Characters/CharacterListState.swift b/Stream-iOS/Stream-iOS/Features/Characters/CharacterListState.swift new file mode 100644 index 0000000..5a08b11 --- /dev/null +++ b/Stream-iOS/Stream-iOS/Features/Characters/CharacterListState.swift @@ -0,0 +1,12 @@ +// +// CharacterListState.swift +// Stream-iOS +// +// Created by Ullash Podder on 20/07/2026. +// +enum CharacterListState: Equatable { + case idle + case loading + case loaded + case error(String) +} diff --git a/Stream-iOS/Stream-iOS/Features/Characters/CharacterListView.swift b/Stream-iOS/Stream-iOS/Features/Characters/CharacterListView.swift new file mode 100644 index 0000000..0ca58ff --- /dev/null +++ b/Stream-iOS/Stream-iOS/Features/Characters/CharacterListView.swift @@ -0,0 +1,7 @@ +// +// CharacterListView.swift +// Stream-iOS +// +// Created by Ullash Podder on 20/07/2026. +// + diff --git a/Stream-iOS/Stream-iOS/Features/Characters/CharacterListViewModel.swift b/Stream-iOS/Stream-iOS/Features/Characters/CharacterListViewModel.swift new file mode 100644 index 0000000..47528ba --- /dev/null +++ b/Stream-iOS/Stream-iOS/Features/Characters/CharacterListViewModel.swift @@ -0,0 +1,47 @@ +// +// CharacterListViewModel.swift +// Stream-iOS +// +// Created by Ullash Podder on 20/07/2026. +// + +import Foundation + +@MainActor +@Observable +final class CharacterListViewModel { + private(set) var characters: [CharacterDTO] = [] + private(set) var state: CharacterListState = .idle + + private let repository: CharacterRepository + private var currentPage = 0 + private var totalPages = Int.max // unknown until first response + private var isLoading = false // the dedupe guard + + init(repository: CharacterRepository) { + self.repository = repository + } + + func loadNextPageIfNeeded() async { + // Guard 1: don't start a fetch while one is running + guard !isLoading else { return } + // Guard 2: don't fetch past the last page + guard currentPage < totalPages else { return } + + isLoading = true + state = .loading + let nextPage = currentPage + 1 + + do { + let response = try await repository.fetchCharacters(page: nextPage) + characters.append(contentsOf: response.results) + currentPage = nextPage + totalPages = response.info.pages + state = .loaded + } catch { + state = .error("Could not load characters.") + } + + isLoading = false + } +} diff --git a/Stream-iOS/Stream-iOS/Features/Characters/CharacterRowView.swift b/Stream-iOS/Stream-iOS/Features/Characters/CharacterRowView.swift new file mode 100644 index 0000000..b9551ec --- /dev/null +++ b/Stream-iOS/Stream-iOS/Features/Characters/CharacterRowView.swift @@ -0,0 +1,7 @@ +// +// CharacterRowView.swift +// Stream-iOS +// +// Created by Ullash Podder on 20/07/2026. +// + diff --git a/Stream-iOS/Stream-iOSTests/CharacterDecodingTests.swift b/Stream-iOS/Stream-iOSTests/CharacterDecodingTests.swift index e48badb..bd3c8fb 100644 --- a/Stream-iOS/Stream-iOSTests/CharacterDecodingTests.swift +++ b/Stream-iOS/Stream-iOSTests/CharacterDecodingTests.swift @@ -40,5 +40,63 @@ struct CharacterDecodingTests { #expect(dto.status == .unknown) } + func decodedFixturePage() throws -> PagedResponse { + let data = try Fixture.load("character_page1") + let response = try JSONDecoder().decode(PagedResponse.self, from: data) + return response + } +// when several calls to loadNextPageIfNeeded() overlap in time, exactly one fetch reaches the repository. + @MainActor @Test func loadsFirstPageAndPopulatesCharacters() async throws{ +// arrange + let mockRepository = MockCharacterRepository() + mockRepository.result = .success( try decodedFixturePage()) + let sut = CharacterListViewModel(repository: mockRepository) +// act + await sut.loadNextPageIfNeeded() +// assert + #expect(sut.state == .loaded) + #expect(sut.characters.count == 20) + #expect(mockRepository.requestedPages == [1]) + } + @MainActor @Test func loadsSecondPageAndAppends() async throws{ +// arrange + let mockRepository = MockCharacterRepository() + mockRepository.result = .success( try decodedFixturePage()) + let sut = CharacterListViewModel(repository: mockRepository) +// act + await sut.loadNextPageIfNeeded() +// second call + await sut.loadNextPageIfNeeded() +// assert + #expect(sut.state == .loaded) + #expect(sut.characters.count == 40) + #expect(mockRepository.requestedPages == [1,2]) + } + @MainActor @Test func concurrentLoadsRequestPageOnlyOnce() async throws{ +// arrange + let mockRepository = MockCharacterRepository() + mockRepository.result = .success( try decodedFixturePage()) + let sut = CharacterListViewModel(repository: mockRepository) +// act + await withTaskGroup (of:Void.self) { group in + for _ in 0..<3 { + group.addTask { await sut.loadNextPageIfNeeded() } + } + } +// assert + #expect(sut.state == .loaded) + #expect(sut.characters.count == 20) + #expect(mockRepository.requestedPages == [1]) + } + @MainActor @Test func failedLoadSetsErrorStateAndKeepsCharactersEmpty() async throws { + let mockRepository = MockCharacterRepository() + mockRepository.result = .failure(APIError.httpStatus(500)) + let sut = CharacterListViewModel(repository: mockRepository) + + await sut.loadNextPageIfNeeded() + + #expect(sut.characters.isEmpty) + // assert state is .error + } } diff --git a/Stream-iOS/Stream-iOSTests/Mocks/MockCharacterRepository.swift b/Stream-iOS/Stream-iOSTests/Mocks/MockCharacterRepository.swift index dbd7308..a096daa 100644 --- a/Stream-iOS/Stream-iOSTests/Mocks/MockCharacterRepository.swift +++ b/Stream-iOS/Stream-iOSTests/Mocks/MockCharacterRepository.swift @@ -14,6 +14,7 @@ final class MockCharacterRepository: CharacterRepository, @unchecked Sendable { func fetchCharacters(page: Int) async throws -> PagedResponse { requestedPages.append(page) + try await Task.sleep(for: .milliseconds(50)) return try result.get() } }