diff --git a/src/StateFile.cxx b/src/StateFile.cxx index 398e7f0845..77c979df69 100644 --- a/src/StateFile.cxx +++ b/src/StateFile.cxx @@ -10,13 +10,17 @@ #include "io/BufferedOutputStream.hxx" #include "storage/StorageState.hxx" #include "Partition.hxx" +#include "config/PartitionConfig.hxx" #include "Instance.hxx" #include "SongLoader.hxx" #include "util/Domain.hxx" #include "Log.hxx" +#include "util/StringCompare.hxx" #include +#define PARTITION_STATE "partition: " + static constexpr Domain state_file_domain("state_file"); StateFile::StateFile(StateFileConfig &&_config, @@ -55,14 +59,23 @@ StateFile::IsModified() const noexcept inline void StateFile::Write(BufferedOutputStream &os) { - partition.mixer_memento.SaveSoftwareVolumeState(os); - audio_output_state_save(os, partition.outputs); + for (auto ¤t_partition : partition.instance.partitions) { + const bool is_default_partition = + ¤t_partition == &partition.instance.partitions.front(); + if (!is_default_partition) // Write partition header except for default partition + os.Fmt(PARTITION_STATE "{}\n", current_partition.name); + current_partition.mixer_memento.SaveSoftwareVolumeState(os); + audio_output_state_save(os, current_partition.outputs); + playlist_state_save(os, current_partition.playlist, current_partition.pc); #ifdef ENABLE_DATABASE - storage_state_save(os, partition.instance); + if (is_default_partition) { + // Only save storage state once and do it in the default partition + storage_state_save(os, partition.instance); + } #endif + } - playlist_state_save(os, partition.playlist, partition.pc); } inline void @@ -106,13 +119,18 @@ try { const SongLoader song_loader(nullptr, nullptr); #endif + Partition *current_partition = &partition; + const char *line; while ((line = file.ReadLine()) != nullptr) { - success = partition.mixer_memento.LoadSoftwareVolumeState(line, partition.outputs) || - audio_output_state_read(line, partition.outputs) || + success = current_partition->mixer_memento.LoadSoftwareVolumeState(line, + partition.outputs) || + audio_output_state_read(line, partition.outputs, current_partition) || playlist_state_restore(config, line, file, song_loader, - partition.playlist, - partition.pc); + current_partition->playlist, + current_partition->pc) || + PartitionSwitch(line, current_partition); + #ifdef ENABLE_DATABASE success = success || storage_state_restore(line, file, partition.instance); #endif @@ -140,3 +158,39 @@ StateFile::OnTimeout() noexcept { Write(); } + +/** + * Attempts to switch the current partition based on a state file line. + * + * @param line The line from the state file to parse + * @param current_partition Reference to pointer that will be updated to point + * to the target partition + * @return true if the line was a partition switch command, false otherwise + */ +bool StateFile::PartitionSwitch(const char *line, + Partition *¤t_partition) noexcept { + // Check if this line contains a partition switch command + line = StringAfterPrefix(line, PARTITION_STATE); + if (line == nullptr) + return false; + + // Try to find existing partition + Partition *new_partition = partition.instance.FindPartition(line); + if (new_partition != nullptr) { + current_partition = new_partition; + FmtDebug(state_file_domain, "Switched to existing partition '{}'", + current_partition->name); + return true; + } + + // Partition doesn't exist, create it + partition.instance.partitions.emplace_back(partition.instance, line, + PartitionConfig{}); + current_partition = &partition.instance.partitions.back(); + current_partition->UpdateEffectiveReplayGainMode(); + + FmtDebug(state_file_domain, "Created partition '{}' and switched to it", + current_partition->name); + + return true; +} diff --git a/src/StateFile.hxx b/src/StateFile.hxx index badacf8913..101832e27f 100644 --- a/src/StateFile.hxx +++ b/src/StateFile.hxx @@ -63,4 +63,10 @@ private: /* callback for #timer_event */ void OnTimeout() noexcept; + + /** + * Handle partition lines in the state file + */ + bool PartitionSwitch(const char *line, + Partition *¤t_partition) noexcept; }; diff --git a/src/output/State.cxx b/src/output/State.cxx index 5933e57522..12b9bbdd50 100644 --- a/src/output/State.cxx +++ b/src/output/State.cxx @@ -12,15 +12,35 @@ #include "Log.hxx" #include "io/BufferedOutputStream.hxx" #include "util/StringCompare.hxx" +#include "Partition.hxx" +#include "config/PartitionConfig.hxx" #include #include #define AUDIO_DEVICE_STATE "audio_device_state:" +#define DEFAULT_PARTITION "default" unsigned audio_output_state_version; +/** + * Iterate the instance and save audio output state configuration. + * + * Writes the name of the audio outputs, the partitions they are assigned to, + * and the state of the output. + * + * Writes a configuration line this format: + * AUDIO_DEVICE_STATE:: + * + * Where: + * = 0 (disabled) or 1 (enabled) + * = output device name + * + * @param os The output stream + * @param outputs The outputs assigned to this partition + * @return nothing + */ void audio_output_state_save(BufferedOutputStream &os, const MultipleOutputs &outputs) @@ -34,8 +54,25 @@ audio_output_state_save(BufferedOutputStream &os, } } +/** + * Parse and apply audio output state configuration. + * + * Reads a configuration line in one of these formats: + * AUDIO_DEVICE_STATE: + * AUDIO_DEVICE_STATE:: + * + * Where: + * = 0 (disabled) or 1 (enabled) + * = output device name + * = optional partition name + * + * @param line The configuration line to parse + * @param outputs The collection of audio outputs to modify + * @param current_partition The partition to which the outputs belong + * @return true if the line was valid and processed, false on parse error + */ bool -audio_output_state_read(const char *line, MultipleOutputs &outputs) +audio_output_state_read(const char *line, MultipleOutputs &outputs, Partition *current_partition) { long value; char *endptr; @@ -49,10 +86,6 @@ audio_output_state_read(const char *line, MultipleOutputs &outputs) if (*endptr != ':' || (value != 0 && value != 1)) return false; - if (value != 0) - /* state is "enabled": no-op */ - return true; - name = endptr + 1; auto *ao = outputs.FindByName(name); if (ao == nullptr) { @@ -61,7 +94,16 @@ audio_output_state_read(const char *line, MultipleOutputs &outputs) return true; } - ao->LockSetEnabled(false); + if (current_partition->name != DEFAULT_PARTITION) { + // Move the output to this partition + FmtDebug(output_domain, + "Moving device {:?} from default to partition {:?}", + name, current_partition->name); + current_partition->outputs.AddMoveFrom(std::move(*ao), value != 0); + return true; + } + + ao->LockSetEnabled(value != 0); return true; } diff --git a/src/output/State.hxx b/src/output/State.hxx index 8a1d222408..475c0ca4c9 100644 --- a/src/output/State.hxx +++ b/src/output/State.hxx @@ -11,9 +11,10 @@ class MultipleOutputs; class BufferedOutputStream; +struct Partition; bool -audio_output_state_read(const char *line, MultipleOutputs &outputs); +audio_output_state_read(const char *line, MultipleOutputs &outputs, Partition *partition); void audio_output_state_save(BufferedOutputStream &os, diff --git a/src/queue/PlaylistState.cxx b/src/queue/PlaylistState.cxx index d1785dccaf..517641aa9d 100644 --- a/src/queue/PlaylistState.cxx +++ b/src/queue/PlaylistState.cxx @@ -168,6 +168,7 @@ playlist_state_restore(const StateFileConfig &config, } else if (StringStartsWith(line, PLAYLIST_STATE_FILE_PLAYLIST_BEGIN)) { playlist_state_load(file, song_loader, playlist); + break; } } diff --git a/test/MockPlaylistSong.cxx b/test/MockPlaylistSong.cxx new file mode 100644 index 0000000000..e6b0c20882 --- /dev/null +++ b/test/MockPlaylistSong.cxx @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +/** + * Mock implementation of playlist_check_translate_song for testing. + * + * This minimal mock allows songs to be loaded into the queue during tests + * without requiring a real database or file system. It simply returns true + * for all songs, bypassing the normal validation that would fail in a test + * environment. + */ + +#include "playlist/PlaylistSong.hxx" +#include "song/DetachedSong.hxx" + +/** + * Mock implementation that always allows songs to load. + * + * In production, this function validates that: + * - Songs exist in the database or filesystem + * - URIs are properly formatted + * - Files are accessible + * + * For testing, we bypass all validation and allow any song to load. + * This enables testing of state file read/write logic without needing + * a real music database. + * + * @param song The song to validate (modified in place if needed) + * @param base_uri Base URI for resolving relative paths (unused in mock) + * @param loader Song loader for database access (unused in mock) + * @return Always returns true to allow the song to be added to the queue + */ +bool +playlist_check_translate_song(DetachedSong &song [[maybe_unused]], + [[maybe_unused]] std::string_view base_uri, + [[maybe_unused]] const SongLoader &loader) noexcept +{ + // Always return true - allow all songs to load in tests + return true; +} diff --git a/test/MockStorage.cxx b/test/MockStorage.cxx new file mode 100644 index 0000000000..e80c0ad6cb --- /dev/null +++ b/test/MockStorage.cxx @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +/** + * Mock storage implementation for testing. + */ + +#include "MockStorage.hxx" +#include "util/StringCompare.hxx" + +static std::unique_ptr +CreateMockStorageURI([[maybe_unused]] EventLoop &event_loop, const char *uri) +{ + // Accept any URI starting with "mock://" + if (!StringStartsWith(uri, "mock://")) + return nullptr; + + return std::make_unique(uri); +} + +// Make prefixes array external linkage so it's accessible +const char *const mock_storage_prefixes[] = { + "mock://", + nullptr +}; + +const StoragePlugin mock_storage_plugin = { + .name = "mock", + .prefixes = mock_storage_prefixes, + .create_uri = CreateMockStorageURI, +}; diff --git a/test/MockStorage.hxx b/test/MockStorage.hxx new file mode 100644 index 0000000000..89bb1eeef4 --- /dev/null +++ b/test/MockStorage.hxx @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +/** + * Mock storage implementation for testing. + * + * This provides a minimal in-memory storage that can be mounted and + * unmounted during tests without requiring actual filesystem or network + * resources. + */ + +#ifndef MPD_MOCK_STORAGE_HXX +#define MPD_MOCK_STORAGE_HXX + +#include "storage/StorageInterface.hxx" +#include "storage/StoragePlugin.hxx" +#include "storage/FileInfo.hxx" +#include "fs/AllocatedPath.hxx" +#include "input/InputStream.hxx" + +/** + * A minimal mock storage implementation that stores only its URI. + * + * This is sufficient for testing state file read/write operations, + * which only need to serialize and deserialize mount points. + */ +class MockStorage final : public Storage { + std::string uri; + +public: + explicit MockStorage(std::string_view _uri) noexcept + : uri(_uri) {} + + /* virtual methods from class Storage */ + StorageFileInfo GetInfo(std::string_view, bool) override { + throw std::runtime_error("Not implemented in mock"); + } + + std::unique_ptr OpenDirectory(std::string_view) override { + throw std::runtime_error("Not implemented in mock"); + } + + std::string MapUTF8(std::string_view) const noexcept override { + return uri; + } + + AllocatedPath MapFS(std::string_view) const noexcept override { + return nullptr; + } + + std::string_view MapToRelativeUTF8(std::string_view) const noexcept override { + return {}; + } + + InputStreamPtr OpenFile(std::string_view, Mutex &) override { + return nullptr; + } +}; + +/** + * Storage plugin for creating mock storage instances. + */ +extern const StoragePlugin mock_storage_plugin; + +#endif diff --git a/test/TestRegistry.cxx b/test/TestRegistry.cxx new file mode 100644 index 0000000000..7b32657ecb --- /dev/null +++ b/test/TestRegistry.cxx @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +// Test version of Registry.cxx that uses mock storage plugins + +#include "storage/Registry.hxx" +#include "MockStorage.hxx" + +// Define the storage_plugins array with only the mock plugin +constinit const StoragePlugin *const storage_plugins[] = { + &mock_storage_plugin, + nullptr +}; + +const StoragePlugin * +GetStoragePluginByName(const char *name) noexcept +{ + for (auto i = storage_plugins; *i != nullptr; ++i) { + const StoragePlugin &plugin = **i; + if (strcmp(plugin.name, name) == 0) + return *i; + } + + return nullptr; +} + +const StoragePlugin * +GetStoragePluginByUri(const char *uri) noexcept +{ + for (auto i = storage_plugins; *i != nullptr; ++i) { + const StoragePlugin &plugin = **i; + if (plugin.SupportsUri(uri)) + return *i; + } + + return nullptr; +} + +std::unique_ptr +CreateStorageURI(EventLoop &event_loop, const char *uri) +{ + for (auto i = storage_plugins; *i != nullptr; ++i) { + const StoragePlugin &plugin = **i; + + if (plugin.create_uri == nullptr || !plugin.SupportsUri(uri)) + continue; + + auto storage = plugin.create_uri(event_loop, uri); + if (storage != nullptr) + return storage; + } + + return nullptr; +} diff --git a/test/TestStateFile.cxx b/test/TestStateFile.cxx new file mode 100644 index 0000000000..818d7e0187 --- /dev/null +++ b/test/TestStateFile.cxx @@ -0,0 +1,907 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +#include "StateFile.hxx" +#include "Instance.hxx" +#include "Partition.hxx" +#include "output/Filtered.hxx" +#include "song/DetachedSong.hxx" +#include "config/Data.hxx" +#include "config/PartitionConfig.hxx" +#include "fs/FileSystem.hxx" +#include "io/FileOutputStream.hxx" +#include "io/FileLineReader.hxx" +#include "storage/CompositeStorage.hxx" +#include "storage/StoragePlugin.hxx" +#include "Log.hxx" +#include "LogBackend.hxx" +#include "util/StringCompare.hxx" +#include "util/StringStrip.hxx" +#include "event/FineTimerEvent.hxx" + +#include +#include + +#define PARTITION_STATE "partition: " +#define MOUNT_STATE_BEGIN "mount_begin" +#define MOUNT_STATE_END "mount_end" +#define MOUNT_STATE_STORAGE_URI "uri: " +#define MOUNT_STATE_MOUNTED_URL "mounted_url: " + +Instance *global_instance = nullptr; + +/** + * Global test environment to initialize logging subsystem. + * This allows FmtDebug/FmtError messages to be visible during test execution. + */ +class TestEnvironment final : public ::testing::Environment { +public: + void SetUp() override { + // Check if verbose logging is requested via environment variable + const char *verbose = std::getenv("MPD_TEST_VERBOSE"); + if (verbose != nullptr && + (std::string_view{verbose} == "1" || + std::string_view{verbose} == "true")) { + SetLogThreshold(verbose ? LogLevel::DEBUG : LogLevel::INFO); + } + + } +}; + +/** + * Test fixture for StateFile read/write operations. + * + * This fixture creates a minimal MPD instance with a temporary state file + * for isolated testing of StateFile functionality. + */ +class TestStateFile : public ::testing::Test { +protected: + TestStateFile() + : temp_state_file(nullptr){} + + std::unique_ptr instance; + std::unique_ptr state_file; + AllocatedPath temp_state_file; + + /** + * Set up test environment: create instance, partition, and state file. + * Called before each test case. + */ + void SetUp() override { + // Create instance + instance = std::make_unique(); + global_instance = instance.get(); + + // Generate unique temporary file path + temp_state_file = GenerateTempFilePath(); + + // Create configuration with audio output + ConfigData config_data; + + // Add null audio output for testing + ConfigBlock audio_output_block{1}; + audio_output_block.AddBlockParam("type", "null"); + audio_output_block.AddBlockParam("name", "MyTestOutput"); + audio_output_block.AddBlockParam("mixer_type", "null"); + config_data.AddBlock(ConfigBlockOption::AUDIO_OUTPUT, + std::move(audio_output_block)); + + // Create partition and add to instance + PartitionConfig partition_config{config_data}; + instance->partitions.emplace_back( + *instance, + "default", + partition_config + ); + instance->partitions.emplace_back( + *instance, + "ExistingPartition", + partition_config + ); + + // Get reference to the partition + Partition &default_partition = instance->partitions.front(); + + // Configure outputs from config data + // Note: ReplayGainConfig needs to be created + ReplayGainConfig replay_gain_config; + replay_gain_config.preamp = 1.0; + replay_gain_config.missing_preamp = 1.0; + replay_gain_config.limit = true; + + default_partition.outputs.Configure( + instance->event_loop, + instance->rtio_thread.GetEventLoop(), + config_data, + replay_gain_config + ); + + // Set up composite storage for mount testing + instance->storage = new CompositeStorage(); + + // Create StateFile configuration with our temporary file + StateFileConfig state_config{config_data}; + state_config.path = temp_state_file; + + // Create the StateFile for testing + state_file = std::make_unique( + std::move(state_config), + instance->partitions.front(), + instance->event_loop + ); + } + + /** + * Clean up test environment: destroy state file and remove temp files. + * Called after each test case. + */ + void TearDown() override { + // Destroy state file first + state_file.reset(); + + // Clean up storage if it was allocated + if (instance->storage != nullptr) { + delete instance->storage; + instance->storage = nullptr; + } + + // Clear global instance + global_instance = nullptr; + instance.reset(); + + // Remove temporary file if it exists + if (!temp_state_file.IsNull() && PathExists(temp_state_file)) { + try { + RemoveFile(temp_state_file); + } catch (...) { + // Ignore cleanup errors + } + } + } + + /** + * Generate a unique temporary file path for state file testing. + * Uses timestamp and process ID to ensure uniqueness. + * + * @return AllocatedPath pointing to unique temporary file location + */ + [[nodiscard]] + static AllocatedPath GenerateTempFilePath() noexcept { + // Get current timestamp as nanoseconds since epoch + const auto now = std::chrono::system_clock::now(); + const auto timestamp = std::chrono::duration_cast( + now.time_since_epoch() + ).count(); + const std::string temp_dir = testing::TempDir(); + + const auto base_path = AllocatedPath::FromFS(temp_dir); + + const auto filename = fmt::format("state_{}_{}", + timestamp, getpid()); + + return AllocatedPath::Build(base_path, + AllocatedPath::FromFS(filename.c_str())); + } + + /** + * Write test content to the temporary state file. + * + * @param content The content to write (must be valid UTF-8) + * @throws std::exception on I/O error + */ + void WriteStateFile(std::string_view content) { + FileOutputStream file{temp_state_file}; + + // Convert string_view to span for Write() + const auto bytes = std::as_bytes(std::span{content}); + file.Write(bytes); + + file.Commit(); + } + + /** + * Get a reference to a partition by name. + * + * @param name Partition name to find (default: "default") + * @return Reference to the partition + * @throws Assertion failure if partition not found + */ + [[nodiscard]] + Partition &GetPartition(std::string_view name = "default") noexcept { + auto *partition = instance->FindPartition(name.data()); + assert(partition != nullptr); + return *partition; + } + + /** + * Get the value of a state file entry for a specific partition. + * + * This function reads the temporary state file and returns the value + * associated with the given key in the specified partition's section. + * + * @param partition_name Name of the partition ("default" for the first/unlabeled partition) + * @param key The state key to search for (e.g., "sw_volume", "state") + * @return The value if found, empty string if not found or on error + */ + [[nodiscard]] + std::string GetStateFileEntry(std::string_view partition_name, + std::string_view key) const { + if (!FileExists(temp_state_file)) { + return {}; + } + + try { + // Open and read the state file + FileLineReader reader{temp_state_file}; + + // Track current partition (start with "default") + std::string current_partition = "default"; + + // Search key with colon separator + const auto search_key = std::string{key} + ":"; + + // Read file line by line + const char *line; + while ((line = reader.ReadLine()) != nullptr) { + const char *value; + + // Check for partition switch + if ((value = StringAfterPrefix(line, PARTITION_STATE)) != nullptr) { + // Extract partition name after "partition: " + current_partition = value; + continue; + } + + // Skip if we're not in the target partition + if (current_partition != partition_name) { + continue; + } + + // Check if line matches our key + if ((value = StringAfterPrefix(line, search_key)) != nullptr) { + return std::string{StripLeft(value)}; + } + } + + return {}; + } catch (...) { + // File read error + return {}; + } + } + + /** + * Get all mounts from the state file. + * + * Mounts are global and not partition-specific. They appear in the + * default partition section of the state file. + * + * @return Vector of maps, where each map contains the key-value pairs for one mount + */ + [[nodiscard]] + std::vector> + GetStateFileMounts() const { + std::vector> mounts; + + if (!FileExists(temp_state_file)) { + return mounts; + } + + try { + FileLineReader reader{temp_state_file}; + std::map current_mount; + bool in_mount = false; + const char *line; + while ((line = reader.ReadLine()) != nullptr) { + // Mounts should only in default partition section + if (StringAfterPrefix(line, PARTITION_STATE) != nullptr) { + break; + } + + if (StringStartsWith(line, MOUNT_STATE_BEGIN)) { + in_mount = true; + current_mount.clear(); + continue; + } + + if (!in_mount) + continue; + + const char *value; + if ((value = StringAfterPrefix(line, MOUNT_STATE_MOUNTED_URL)) != nullptr) { + current_mount["mounted_url"] = StripLeft(value); + } else if ((value = StringAfterPrefix(line, MOUNT_STATE_STORAGE_URI)) != nullptr) { + current_mount["uri"] = StripLeft(value); + } else if (StringStartsWith(line, MOUNT_STATE_END)) { + if (!current_mount.empty()) { + mounts.push_back(current_mount); + } + in_mount = false; + } + } + + return mounts; + } catch (...) { + return {}; + } + } + + /** + * Print the contents of the state file for debugging. + */ + void DumpStateFile() const { + if (!FileExists(temp_state_file)) { + std::cerr << "State file does not exist\n"; + return; + } + + std::cerr << "\n=== State File Contents ===\n"; + try { + FileLineReader reader{temp_state_file}; + const char *line; + while ((line = reader.ReadLine()) != nullptr) { + std::cerr << line << "\n"; + } + } catch (...) { + std::cerr << "Error reading state file\n"; + } + std::cerr << "=== End State File ===\n\n"; + } + + /** + * Helper to break the event loop. + */ + void BreakLoop() noexcept { + instance->event_loop.Break(); + } +}; + +/** + * Test that audio output configuration was properly loaded in SetUp(). + */ +TEST_F(TestStateFile, AudioOutputLoadedFromConfig) { + // Get the partition + Partition &partition = GetPartition(); + + // Verify exactly one audio output was configured + ASSERT_EQ(partition.outputs.Size(), 1); + + // Get the output and verify its properties + const auto &output = partition.outputs.Get(0); + EXPECT_EQ(output.GetName(), "MyTestOutput"); + EXPECT_STREQ(output.GetPluginName(), "null"); +} + +/** + * Test that partition configuration was properly loaded in SetUp(). + */ +TEST_F(TestStateFile, PartitionLoadedFromConfig) { + ASSERT_EQ(instance->partitions.size(), 2); + ASSERT_NE(instance->FindPartition("ExistingPartition"), nullptr); +} + +/** + * Test that StateFile handles empty state file file gracefully. + */ +TEST_F(TestStateFile, ReadEmptyStateFile) { + // Create an empty state file + WriteStateFile(""); + + // Should handle empty file without throwing + state_file->Read(); + + SUCCEED(); +} + +/** + * Test that StateFile handles missing state file gracefully. + * + * Reading a non-existent file should log an error. + */ +TEST_F(TestStateFile, ReadNonExistentFile) { + testing::internal::CaptureStderr(); + + state_file->Read(); + + const std::string output = testing::internal::GetCapturedStderr(); + + EXPECT_THAT(output, testing::HasSubstr("Failed to open")); +} + +/** + * Test that StateFile can successfully read a valid state file that contains the + * default partition only. + * + * There is no writing of the state file in this test. + */ +TEST_F(TestStateFile, ReadValidStateFile) { + // Create a state file with valid content + WriteStateFile( + "sw_volume: 80\n" + "state: stop\n" + "random: 1\n" + "repeat: 0\n" + ); + + state_file->Read(); + + // Verify internal state + EXPECT_NE(instance->FindPartition("default"), nullptr) + << "Default partition should have been created"; + EXPECT_EQ(GetPartition().mixer_memento.GetVolume(GetPartition().outputs), 80); + EXPECT_EQ(GetPartition().pc.GetState(), PlayerState::STOP); + EXPECT_TRUE(GetPartition().playlist.GetRandom()); + EXPECT_FALSE(GetPartition().playlist.GetRepeat()); +} + +/** + * Test that StateFile correctly handles partition switching. + * + * The state file format supports multiple partitions using "partition:" lines. + */ +TEST_F(TestStateFile, MultiplePartitions) { + // Create a state file with multiple partitions + WriteStateFile( + "partition: secondary\n" + ); + + // Read the state file + state_file->Read(); + + // Verify that multiple partitions exist + ASSERT_GE(instance->partitions.size(), 1); + + // The "secondary" partition should have been created (internal state) + EXPECT_NE(instance->FindPartition("secondary"), nullptr) + << "Secondary partition should have been created"; + + state_file->Write(); + + // Check for anything in the secondary partition (state file on disk) + EXPECT_EQ(GetStateFileEntry("secondary", "state"), "stop"); + + // Check default partition also written + EXPECT_EQ(GetStateFileEntry("default", "state"), "stop"); +} + +/** + * Test reading and writing volume in two partitions. + */ +TEST_F(TestStateFile, VolumeMultiplePartitions) { + // Create initial state file + WriteStateFile( + "sw_volume: 75\n" + "partition: secondary\n" + "sw_volume: 40\n" + ); + + state_file->Read(); + state_file->Write(); + + // Validate specific entries were written + EXPECT_EQ(GetStateFileEntry("default", "sw_volume"), "75"); + EXPECT_EQ(GetStateFileEntry("secondary", "sw_volume"), "40"); +} + +/** + * Test reading and writing enabled audio output of a second partition. + */ +TEST_F(TestStateFile, AudioOutputSecondPartitionEnabled) { + // Create initial state file + WriteStateFile( + "audio_device_state:0:MyTestOutput\n" + "partition: secondary\n" + "audio_device_state:1:MyTestOutput\n" + ); + + state_file->Read(); + state_file->Write(); + + EXPECT_EQ(GetStateFileEntry("default", "audio_device_state:0"), "MyTestOutput"); + EXPECT_EQ(GetStateFileEntry("secondary", "audio_device_state:1"), "MyTestOutput"); +} + +/** + * Test reading and writing disabled audio output of a second partition. + */ +TEST_F(TestStateFile, AudioOutputSecondPartitionDisabled) { + // Create initial state file + WriteStateFile( + "audio_device_state:0:MyTestOutput\n" + "partition: secondary\n" + "audio_device_state:0:MyTestOutput\n" + ); + + state_file->Read(); + state_file->Write(); + + EXPECT_EQ(GetStateFileEntry("default", "audio_device_state:0"), "MyTestOutput"); + EXPECT_EQ(GetStateFileEntry("secondary", "audio_device_state:0"), "MyTestOutput"); +} + +/** + * Test reading and writing audio output of an existing partition. + */ +TEST_F(TestStateFile, AudioOutputExistingPartition) { + // Create initial state file + WriteStateFile( + "audio_device_state:0:MyTestOutput\n" + "partition: ExistingPartition\n" + "audio_device_state:1:MyTestOutput\n" + ); + + state_file->Read(); + state_file->Write(); + + EXPECT_EQ(GetStateFileEntry("default", "audio_device_state:0"), "MyTestOutput"); + EXPECT_EQ(GetStateFileEntry("ExistingPartition", "audio_device_state:1"), "MyTestOutput"); +} + +/** + * Move audio output to an existing partition. + */ +TEST_F(TestStateFile, AudioOutputMoveToExistingPartition) { + // Create initial state file + WriteStateFile( + "audio_device_state:1:MyTestOutput\n" // start in default partition + ); + + state_file->Read(); + + // Simulate a user moving output to the existing partition + Partition &default_partition = GetPartition("default"); + Partition &existing_partition = GetPartition("ExistingPartition"); + auto *ao = default_partition.outputs.FindByName("MyTestOutput"); + existing_partition.outputs.AddMoveFrom(std::move(*ao), 1); + + state_file->Write(); + + EXPECT_EQ(GetStateFileEntry("default", "audio_device_state:0"), "MyTestOutput"); + EXPECT_EQ(GetStateFileEntry("ExistingPartition", "audio_device_state:1"), "MyTestOutput"); +} + +/** + * Move audio output from existing partition to default partition. + */ +TEST_F(TestStateFile, AudioOutputMoveToDefaultPartition) { + // Create initial state file + WriteStateFile( + "partition: ExistingPartition\n" + "audio_device_state:1:MyTestOutput\n" // start in default partition + ); + + state_file->Read(); + + // Simulate a user moving output to the default partition + Partition &default_partition = GetPartition("default"); + auto *existing_output = default_partition.outputs.FindByName("MyTestOutput"); + auto *output = instance->FindOutput("MyTestOutput", default_partition); + const bool was_enabled = output->IsEnabled(); + existing_output->ReplaceDummy(output->Steal(), was_enabled); + + state_file->Write(); + + EXPECT_EQ(GetStateFileEntry("default", "audio_device_state:1"), "MyTestOutput"); + EXPECT_EQ(GetStateFileEntry("ExistingPartition", "audio_device_state:0"), "MyTestOutput"); +} + +/** + * Test reading and writing playlist state across multiple partitions. + */ +TEST_F(TestStateFile, PlaylistStateMultiplePartitions) { + // Write a known state file + WriteStateFile( + "state: stop\n" + "random: 1\n" + "repeat: 0\n" + "playlist_begin\n" + "playlist_end\n" + "partition: secondary\n" + "state: stop\n" + "random: 0\n" + "repeat: 1\n" + "playlist_begin\n" + "playlist_end\n" + ); + + state_file->Read(); + state_file->Write(); + + EXPECT_EQ(GetStateFileEntry("default", "state"), "stop"); + EXPECT_EQ(GetStateFileEntry("default", "random"), "1"); + EXPECT_EQ(GetStateFileEntry("default", "repeat"), "0"); + EXPECT_EQ(GetStateFileEntry("secondary", "state"), "stop"); + EXPECT_EQ(GetStateFileEntry("secondary", "random"), "0"); + EXPECT_EQ(GetStateFileEntry("secondary", "repeat"), "1"); +} + +/** + * Test reading and writing playlist songs in state across multiple partitions. + * + * Use a mock of playlist_check_translate_song to allow songs to load in all cases. + * playlist_check_translate_song functionality is tested in test/test_translate_song.cxx. + */ +TEST_F(TestStateFile, PlaylistSongStateMultiplePartitions) { + // Write a known state file with playlist songs + WriteStateFile( + "state: stop\n" + "playlist_begin\n" + "0:song1.mp3\n" + "1:dir1/song2.mp3\n" + "playlist_end\n" + "partition: secondary\n" + "state: stop\n" + "playlist_begin\n" + "0:secondary_song.mp3\n" + "playlist_end\n" + ); + + state_file->Read(); + + // Verify songs were loaded into default partition's queue + Partition &default_partition = GetPartition("default"); + ASSERT_EQ(default_partition.playlist.queue.GetLength(), 2); + EXPECT_STREQ(default_partition.playlist.queue.Get(0).GetURI(), "song1.mp3"); + EXPECT_STREQ(default_partition.playlist.queue.Get(1).GetURI(), "dir1/song2.mp3"); + + // Verify song was loaded into secondary partition's queue + const auto *secondary = instance->FindPartition("secondary"); + ASSERT_NE(secondary, nullptr); + ASSERT_EQ(secondary->playlist.queue.GetLength(), 1); + EXPECT_STREQ(secondary->playlist.queue.Get(0).GetURI(), "secondary_song.mp3"); + + state_file->Write(); + + EXPECT_EQ(GetStateFileEntry("default", "0"), "song1.mp3"); + EXPECT_EQ(GetStateFileEntry("default", "1"), "dir1/song2.mp3"); + + EXPECT_EQ(GetStateFileEntry("secondary", "0"), "secondary_song.mp3"); +} + +/** + * Test reading and writing storage mount state. + */ +TEST_F(TestStateFile, MountState) { + // Create initial state file with a mount + WriteStateFile( + "mount_begin\n" + "uri: music\n" + "mounted_url: mock://server1/music\n" + "mount_end\n" + ); + + state_file->Read(); + + // Verify the mount was created + auto *storage = instance->storage; + ASSERT_NE(storage, nullptr); + auto *composite = dynamic_cast(storage); + ASSERT_NE(composite, nullptr); + + auto *mounted = composite->GetMount("music"); + EXPECT_NE(mounted, nullptr); + + state_file->Write(); + + // Verify mount was written back + auto mounts = GetStateFileMounts(); + + // Check number of mounts + EXPECT_EQ(mounts.size(), 1); + + // Check mount details + EXPECT_EQ(mounts[0].at("uri"), "music"); + EXPECT_EQ(mounts[0].at("mounted_url"), "mock://server1/music"); +} + +/** + * Test reading and writing multiple storage mounts. + */ +TEST_F(TestStateFile,MultipleMounts) { + WriteStateFile( + "mount_begin\n" + "uri: music\n" + "mounted_url: mock://server1/music\n" + "mount_end\n" + "mount_begin\n" + "uri: podcasts\n" + "mounted_url: mock://server2/podcasts\n" + "mount_end\n" + ); + + state_file->Read(); + + auto *composite = dynamic_cast(instance->storage); + ASSERT_NE(composite, nullptr); + + // Verify both mounts exist (internal state) + EXPECT_NE(composite->GetMount("music"), nullptr); + EXPECT_NE(composite->GetMount("podcasts"), nullptr); + + state_file->Write(); + + auto mounts = GetStateFileMounts(); + + EXPECT_EQ(mounts.size(), 2); + + EXPECT_EQ(mounts[0].at("uri"), "music"); + EXPECT_EQ(mounts[0].at("mounted_url"), "mock://server1/music"); + + EXPECT_EQ(mounts[1].at("uri"), "podcasts"); + EXPECT_EQ(mounts[1].at("mounted_url"), "mock://server2/podcasts"); +} + +/** + * Test that malformed mount state is handled gracefully. + * + * Errors should be logged. + */ +TEST_F(TestStateFile, MalformedMountState) { + WriteStateFile( + "mount_begin\n" + "uri: incomplete\n" + // Missing mounted_url and mount_end + "state: stop\n" + ); + + testing::internal::CaptureStderr(); + + // Should log error but not crash + state_file->Read(); + + const std::string output = testing::internal::GetCapturedStderr(); + + EXPECT_THAT(output, testing::HasSubstr("Unrecognized line in mountpoint state: state: stop")); + EXPECT_THAT(output, testing::HasSubstr("Missing value in mountpoint state.")); +} + +/** + * Test unmount storage. + * + * When a mount is unmounted, it should be removed from the state file. + */ +TEST_F(TestStateFile, UnmountRemovesMount) { + // Initial mount + WriteStateFile( + "mount_begin\n" + "uri: temp\n" + "mounted_url: mock://temp/storage\n" + "mount_end\n" + ); + + state_file->Read(); + + // Verify mount exists (internal state) + auto *composite = dynamic_cast(instance->storage); + ASSERT_NE(composite, nullptr); + ASSERT_NE(composite->GetMount("temp"), nullptr); + + // Unmount + bool unmounted = composite->Unmount("temp"); + EXPECT_TRUE(unmounted); + EXPECT_EQ(composite->GetMount("temp"), nullptr); + + state_file->Write(); + + auto mounts = GetStateFileMounts(); + + EXPECT_EQ(mounts.size(), 0); +} + +/** + * Test storage state with nested mount paths. + * + * URIs with slashes should be handled correctly. + */ +TEST_F(TestStateFile, NestedMountPaths) { + WriteStateFile( + "mount_begin\n" + "uri: music/classical\n" + "mounted_url: mock://server/classical\n" + "mount_end\n" + ); + + state_file->Read(); + state_file->Write(); + + auto mounts = GetStateFileMounts(); + + EXPECT_EQ(mounts.size(), 1); + + EXPECT_EQ(mounts[0].at("uri"), "music/classical"); + EXPECT_EQ(mounts[0].at("mounted_url"), "mock://server/classical"); +} + +/** + * Test that StateFile handles malformed content gracefully. + * + * Errors should be logged for malformed lines. + */ +TEST_F(TestStateFile, ReadMalformedStateFile) { + // Create a state file with various malformed lines + WriteStateFile( + "invalid line without colon\n" + ":::too:many:colons:::\n" + "incomplete:" + ); + + testing::internal::CaptureStderr(); + + // Should handle malformed file gracefully (logs errors internally) + state_file->Read(); + + const std::string output = testing::internal::GetCapturedStderr(); + + EXPECT_THAT(output, testing::HasSubstr("Unrecognized line in state file: invalid line without colon")); + EXPECT_THAT(output, testing::HasSubstr("Unrecognized line in state file: :::too:many:colons:::")); + EXPECT_THAT(output, testing::HasSubstr("Unrecognized line in state file: incomplete:")); +} + +/** + * Test that empty lines and whitespace-only lines are handled gracefully. + * + * Errors should be logged for whitespace-only lines. + */ +TEST_F(TestStateFile, ReadWithEmptyLines) { + WriteStateFile( + "\n" + "sw_volume: 100\n" + "\n" + " \n" + "state: play\n" + ); + + testing::internal::CaptureStderr(); + + // Should skip empty/whitespace lines without error + state_file->Read(); + + const std::string output = testing::internal::GetCapturedStderr(); + + EXPECT_THAT(output, testing::HasSubstr("Unrecognized line in state file:")); + EXPECT_THAT(output, testing::HasSubstr("Unrecognized line in state file:")); + EXPECT_THAT(output, testing::HasSubstr("Unrecognized line in state file:")); +} + +/** + * Test that CheckModified triggers a write when state has changed. + */ +TEST_F(TestStateFile, CheckModified) { + // Create new config data with short save interval for testing + ConfigData config_data; + StateFileConfig state_config{config_data}; + state_config.path = temp_state_file; + state_config.interval = std::chrono::milliseconds(10); + + state_file = std::make_unique(std::move(state_config), + GetPartition(), + instance->event_loop); + + // Initial write + state_file->Write(); + + // Verify initial state on disk (default is 100) + EXPECT_EQ(GetStateFileEntry("default", "sw_volume"), "100"); + + // Modify volume in partition + GetPartition().mixer_memento.SetVolume(GetPartition().outputs, 50); + + // Trigger check - should schedule timer + state_file->CheckModified(); + + // Setup a timer to break the loop after 50ms (giving enough time for 10ms timer to fire) + FineTimerEvent break_timer(instance->event_loop, BIND_THIS_METHOD(BreakLoop)); + break_timer.Schedule(std::chrono::milliseconds(50)); + + // Run loop + instance->event_loop.Run(); + + // Check if file contains the new volume + EXPECT_EQ(GetStateFileEntry("default", "sw_volume"), "50"); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + ::testing::AddGlobalTestEnvironment(new TestEnvironment); + return RUN_ALL_TESTS(); +} diff --git a/test/meson.build b/test/meson.build index 84c52b0a31..ee84269d0b 100644 --- a/test/meson.build +++ b/test/meson.build @@ -645,3 +645,82 @@ if alsa_dep.found() endif subdir('fs') + +# +# StateFile +# + +# Build mock storage as a static library +mock_storage_lib = static_library( + 'mock_storage', + 'MockStorage.cxx', + 'TestRegistry.cxx', # Includes storage_plugins array with mock plugin + '../src/storage/StoragePlugin.cxx', + '../src/storage/CompositeStorage.cxx', + '../src/storage/StorageState.cxx', + include_directories: inc, + dependencies: [ + util_dep, + log_dep, + fmt_dep, + ], +) + +# Build state_file_lib for testing +state_file_lib_sources = [] +foreach src : sources + # Exclude main entry points - tests will provide their own + src_str = '@0@'.format(src) + if not src_str.contains('Main.cxx') and not src_str.contains('Win32Main.cxx') + if src_str.startswith('src/') + state_file_lib_sources += '..' / src + else + # Handle generated sources (like version_cxx) which are build-relative + state_file_lib_sources += src + endif + endif +endforeach + +state_file_lib_dependencies = [ + basic_dep, + neighbor_glue_dep, + output_glue_dep, + mixer_glue_dep, + decoder_glue_dep, + encoder_glue_dep, + playlist_glue_dep, + db_glue_dep, + storage_glue_dep, + song_dep, + systemd_dep, + sqlite_dep, +] + +state_file_lib = static_library( + 'state_file_lib', + state_file_lib_sources, + include_directories: inc, + dependencies: state_file_lib_dependencies, +) + +state_file_lib_dep = declare_dependency( + link_with: state_file_lib, + dependencies: state_file_lib_dependencies, +) + +test( + 'TestStateFile', + executable( + 'TestStateFile', + 'TestStateFile.cxx', + 'MockPlaylistSong.cxx', + include_directories: inc, + dependencies: [ + gtest_dep, + state_file_lib_dep, + ], + link_whole: mock_storage_lib, + link_args: ['-Wl,--allow-multiple-definition'], + ), + protocol: 'gtest', +)