From 3855fcc17362b455f66f7b224c6c571578022591 Mon Sep 17 00:00:00 2001 From: Stephen Williams <107999+onato@users.noreply.github.com> Date: Fri, 26 Apr 2024 11:59:55 +1200 Subject: [PATCH] refactor: Decouple settings vm from user defaults --- TypeReader.xcodeproj/project.pbxproj | 20 +++++++++++ .../Settings/UserDefaultsSettings.swift | 36 +++++++++++++++++++ .../ViewModels/SpeechSettingsViewModel.swift | 29 ++++----------- TypeReaderTests/Mocks/AppSettingsMock.swift | 23 ++++++++++++ .../SpeechSettingsViewModelTests.swift | 32 +++-------------- .../UserDefaultsAppSettingsTests.swift | 26 ++++++++++++++ 6 files changed, 116 insertions(+), 50 deletions(-) create mode 100644 TypeReader/Settings/UserDefaultsSettings.swift create mode 100644 TypeReaderTests/Mocks/AppSettingsMock.swift create mode 100644 TypeReaderTests/UserDefaultsAppSettingsTests.swift diff --git a/TypeReader.xcodeproj/project.pbxproj b/TypeReader.xcodeproj/project.pbxproj index 2269541..484dc9c 100644 --- a/TypeReader.xcodeproj/project.pbxproj +++ b/TypeReader.xcodeproj/project.pbxproj @@ -25,6 +25,9 @@ A792D69E2BCE30FE00D3ABDB /* SpeechSettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A792D69D2BCE30FE00D3ABDB /* SpeechSettingsViewModelTests.swift */; }; A792D6A02BCF5C8E00D3ABDB /* PDFViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A792D69F2BCF5C8E00D3ABDB /* PDFViewer.swift */; }; A792D6DB2BD5C20200D3ABDB /* PDFViewerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A792D6DA2BD5C20200D3ABDB /* PDFViewerTests.swift */; }; + A79FA4302BDB1BC900CF949D /* AppSettingsMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79FA42F2BDB1BC900CF949D /* AppSettingsMock.swift */; }; + A79FA4322BDB1F8700CF949D /* UserDefaultsAppSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79FA4312BDB1F8700CF949D /* UserDefaultsAppSettingsTests.swift */; }; + A79FA4352BDB253700CF949D /* UserDefaultsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79FA4342BDB253700CF949D /* UserDefaultsSettings.swift */; }; A7DF8BD92BD5CECA0061A2B5 /* PDFHighlighter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7DF8BD82BD5CECA0061A2B5 /* PDFHighlighter.swift */; }; A7F073DA2BD5E7A2002E06B3 /* SentenceFindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F073D92BD5E7A2002E06B3 /* SentenceFindingTests.swift */; }; A7F073DD2BD5FA63002E06B3 /* String+SentenceFinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F073DC2BD5FA63002E06B3 /* String+SentenceFinding.swift */; }; @@ -62,6 +65,9 @@ A792D69D2BCE30FE00D3ABDB /* SpeechSettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechSettingsViewModelTests.swift; sourceTree = ""; }; A792D69F2BCF5C8E00D3ABDB /* PDFViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFViewer.swift; sourceTree = ""; }; A792D6DA2BD5C20200D3ABDB /* PDFViewerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFViewerTests.swift; sourceTree = ""; }; + A79FA42F2BDB1BC900CF949D /* AppSettingsMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsMock.swift; sourceTree = ""; }; + A79FA4312BDB1F8700CF949D /* UserDefaultsAppSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsAppSettingsTests.swift; sourceTree = ""; }; + A79FA4342BDB253700CF949D /* UserDefaultsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsSettings.swift; sourceTree = ""; }; A7DF8BD82BD5CECA0061A2B5 /* PDFHighlighter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFHighlighter.swift; sourceTree = ""; }; A7F073D92BD5E7A2002E06B3 /* SentenceFindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceFindingTests.swift; sourceTree = ""; }; A7F073DC2BD5FA63002E06B3 /* String+SentenceFinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SentenceFinding.swift"; sourceTree = ""; }; @@ -108,6 +114,7 @@ A78F726C2BC3827C000919DE /* TypeReader */ = { isa = PBXGroup; children = ( + A79FA4332BDB252700CF949D /* Settings */, A7F073DB2BD5FA46002E06B3 /* StringParsing */, A7DF8BD72BD5CEAD0061A2B5 /* PDF */, A78F72AA2BC4BC7F000919DE /* Views */, @@ -141,6 +148,7 @@ A792D69D2BCE30FE00D3ABDB /* SpeechSettingsViewModelTests.swift */, A792D6DA2BD5C20200D3ABDB /* PDFViewerTests.swift */, A7F073D92BD5E7A2002E06B3 /* SentenceFindingTests.swift */, + A79FA4312BDB1F8700CF949D /* UserDefaultsAppSettingsTests.swift */, ); path = TypeReaderTests; sourceTree = ""; @@ -185,10 +193,19 @@ isa = PBXGroup; children = ( A792D69B2BCE2B7800D3ABDB /* UserSettingsMock.swift */, + A79FA42F2BDB1BC900CF949D /* AppSettingsMock.swift */, ); path = Mocks; sourceTree = ""; }; + A79FA4332BDB252700CF949D /* Settings */ = { + isa = PBXGroup; + children = ( + A79FA4342BDB253700CF949D /* UserDefaultsSettings.swift */, + ); + path = Settings; + sourceTree = ""; + }; A7DF8BD72BD5CEAD0061A2B5 /* PDF */ = { isa = PBXGroup; children = ( @@ -316,6 +333,7 @@ A792D6A02BCF5C8E00D3ABDB /* PDFViewer.swift in Sources */, A78F72A52BC39B12000919DE /* SpeechSynthesizer.swift in Sources */, A78F72A92BC4BC65000919DE /* SpeechSettingsViewModel.swift in Sources */, + A79FA4352BDB253700CF949D /* UserDefaultsSettings.swift in Sources */, A7F073DF2BD74F52002E06B3 /* ErrorMessage.swift in Sources */, A78F729A2BC39267000919DE /* PDFTextExtractor.swift in Sources */, A78F72982BC3840E000919DE /* DocumentPicker.swift in Sources */, @@ -336,7 +354,9 @@ A792D6DB2BD5C20200D3ABDB /* PDFViewerTests.swift in Sources */, A792D69E2BCE30FE00D3ABDB /* SpeechSettingsViewModelTests.swift in Sources */, A792D69C2BCE2B7800D3ABDB /* UserSettingsMock.swift in Sources */, + A79FA4322BDB1F8700CF949D /* UserDefaultsAppSettingsTests.swift in Sources */, A78F729C2BC393B7000919DE /* PDFTextExtractorTests.swift in Sources */, + A79FA4302BDB1BC900CF949D /* AppSettingsMock.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TypeReader/Settings/UserDefaultsSettings.swift b/TypeReader/Settings/UserDefaultsSettings.swift new file mode 100644 index 0000000..9625119 --- /dev/null +++ b/TypeReader/Settings/UserDefaultsSettings.swift @@ -0,0 +1,36 @@ +import Foundation +import AVFoundation + +protocol AppSettings { + var selectedVoiceIdentifier: String { get set } + var speechRate: Float { get set } +} + +class UserDefaultsSettings: AppSettings { + private let userSettings: UserSettings + + var selectedVoiceIdentifier: String { + didSet { + userSettings.set(selectedVoiceIdentifier, forKey: "selectedVoiceIdentifier") + } + } + var speechRate: Float { + didSet { + userSettings.set(speechRate, forKey: "speechRate") + } + } + init(userDefaults: UserSettings = UserDefaults.standard) { + self.userSettings = userDefaults + if let voiceIdentifier = userSettings.string(forKey: "selectedVoiceIdentifier") { + selectedVoiceIdentifier = voiceIdentifier + } else { + selectedVoiceIdentifier = (AVSpeechSynthesisVoice.speechVoices().first { voice in + voice.language == AVSpeechSynthesisVoice.currentLanguageCode() + })?.identifier ?? "There are no voices installed" + } + self.speechRate = userSettings.float(forKey: "speechRate") + if self.speechRate == 0 { + self.speechRate = AVSpeechUtteranceDefaultSpeechRate + } + } +} diff --git a/TypeReader/ViewModels/SpeechSettingsViewModel.swift b/TypeReader/ViewModels/SpeechSettingsViewModel.swift index 9f895b1..2f1a8a6 100644 --- a/TypeReader/ViewModels/SpeechSettingsViewModel.swift +++ b/TypeReader/ViewModels/SpeechSettingsViewModel.swift @@ -2,32 +2,15 @@ import Foundation import AVFoundation class SpeechSettingsViewModel: ObservableObject { - private let userSettings: UserSettings + @Published var selectedVoiceIdentifier: String - @Published var selectedVoiceIdentifier: String { - didSet { - userSettings.set(selectedVoiceIdentifier, forKey: "selectedVoiceIdentifier") - } - } - - @Published var speechRate: Float { - didSet { - userSettings.set(speechRate, forKey: "speechRate") - } - } + @Published var speechRate: Float var voices: [AVSpeechSynthesisVoice] = [] - init(userDefaults: UserSettings = UserDefaults.standard) { - self.userSettings = userDefaults - // Load saved settings or defaults - self.selectedVoiceIdentifier = userSettings.string(forKey: "selectedVoiceIdentifier") ?? AVSpeechSynthesisVoice.currentLanguageCode() - self.speechRate = userSettings.float(forKey: "speechRate") - if self.speechRate == 0 { - self.speechRate = AVSpeechUtteranceDefaultSpeechRate - } - - // Fetch available voices - self.voices = AVSpeechSynthesisVoice.speechVoices() + init(settings: AppSettings = UserDefaultsSettings()) { + selectedVoiceIdentifier = settings.selectedVoiceIdentifier + speechRate = settings.speechRate + voices = AVSpeechSynthesisVoice.speechVoices() } } diff --git a/TypeReaderTests/Mocks/AppSettingsMock.swift b/TypeReaderTests/Mocks/AppSettingsMock.swift new file mode 100644 index 0000000..7a00386 --- /dev/null +++ b/TypeReaderTests/Mocks/AppSettingsMock.swift @@ -0,0 +1,23 @@ +import Foundation +@testable import TypeReader + +// MARK: - AppSettingsMock - + +final class AppSettingsMock: AppSettings { + + // MARK: - selectedVoiceIdentifier + + var selectedVoiceIdentifier: String { + get { underlyingSelectedVoiceIdentifier } + set(value) { underlyingSelectedVoiceIdentifier = value } + } + internal var underlyingSelectedVoiceIdentifier: String! + + // MARK: - speechRate + + var speechRate: Float { + get { underlyingSpeechRate } + set(value) { underlyingSpeechRate = value } + } + internal var underlyingSpeechRate: Float! +} diff --git a/TypeReaderTests/SpeechSettingsViewModelTests.swift b/TypeReaderTests/SpeechSettingsViewModelTests.swift index 8a28207..07d0a58 100644 --- a/TypeReaderTests/SpeechSettingsViewModelTests.swift +++ b/TypeReaderTests/SpeechSettingsViewModelTests.swift @@ -4,35 +4,13 @@ import Nimble final class SpeechSettingsViewModelTests: XCTestCase { func test_SettingsViewModel_whenSettings_shouldSetRate() throws { - let mockUserSettings = UserSettingsMock() - mockUserSettings.floatForKeyReturnValue = 1.5 - mockUserSettings.stringForKeyReturnValue = "com.apple.speech.synthesis.voice.Princess" - - let sut = SpeechSettingsViewModel(userDefaults: mockUserSettings) + let mockSettings = AppSettingsMock() + mockSettings.speechRate = 1.5 + mockSettings.selectedVoiceIdentifier = "com.apple.speech.synthesis.voice.Princess" + let sut = SpeechSettingsViewModel(settings: mockSettings) + expect(sut.speechRate) == 1.5 expect(sut.selectedVoiceIdentifier) == "com.apple.speech.synthesis.voice.Princess" } - - func test_SettingsViewModel_whenNoSettings_shouldSetRate() throws { - let mockUserSettings = UserSettingsMock() - mockUserSettings.floatForKeyReturnValue = 0 - - let sut = SpeechSettingsViewModel(userDefaults: mockUserSettings) - - expect(sut.speechRate) == 0.5 - } - - func test_SettingsViewModel_whenSetID_shouldSaveID() throws { - let mockUserSettings = UserSettingsMock() - mockUserSettings.floatForKeyReturnValue = 0 - - let sut = SpeechSettingsViewModel(userDefaults: mockUserSettings) - sut.selectedVoiceIdentifier = "com.apple.speech.synthesis.voice.Princess" - sut.speechRate = 0.6 - - let identifier = try XCTUnwrap(mockUserSettings.setForKeyReceivedArguments?.value as? String) - expect(identifier) == "com.apple.speech.synthesis.voice.Princess" - expect(mockUserSettings.setFloatForKeyReceivedArguments?.value) == 0.6 - } } diff --git a/TypeReaderTests/UserDefaultsAppSettingsTests.swift b/TypeReaderTests/UserDefaultsAppSettingsTests.swift new file mode 100644 index 0000000..6e21926 --- /dev/null +++ b/TypeReaderTests/UserDefaultsAppSettingsTests.swift @@ -0,0 +1,26 @@ +@testable import TypeReader +import XCTest +import Nimble + +final class UserDefaultsAppSettingsTests: XCTestCase { + func test_SettingsViewModel_whenSettings_shouldSetDefaults() throws { + let defaults = UserDefaults() + let sut = UserDefaultsSettings(userDefaults: defaults) + + sut.speechRate = 1.5 + sut.selectedVoiceIdentifier = "com.apple.speech.synthesis.voice.Princess" + + expect(defaults.string(forKey: "selectedVoiceIdentifier")) == "com.apple.speech.synthesis.voice.Princess" + expect(defaults.float(forKey: "speechRate")) == 1.5 + } + + func test_SettingsViewModel_whenNoDefaults_shouldSetRate() throws { + let mockUserSettings = UserSettingsMock() + mockUserSettings.floatForKeyReturnValue = 0 + + let sut = UserDefaultsSettings(userDefaults: mockUserSettings) + + expect(sut.speechRate) == 0.5 + expect(sut.selectedVoiceIdentifier) != nil + } +}