diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md deleted file mode 100644 index 2ef0b4a2..00000000 --- a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md +++ /dev/null @@ -1,11 +0,0 @@ -### AI assistance (if any): -- List tools here and files touched by them - -### Authorship & Understanding - -- [ ] I wrote or heavily modified this code myself -- [ ] I understand how it works end-to-end -- [ ] I can maintain this code in the future -- [ ] No undisclosed AI-generated code was used -- [ ] If AI assistance was used, it is documented below - diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..29d50735 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +### Description + + +### Alternatives Considered (Optional) + + +### AI assistance (if any) + + + +### Authorship & Understanding + +- [ ] I wrote or heavily modified this code myself. If I didn't, I gave proper attribution to AI assistance above. +- [ ] I understand how it works end-to-end +- [ ] I can maintain this code in the future +- [ ] No undisclosed AI-generated code was used +- [ ] If AI assistance was used, it is documented above + diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index e7848f84..7a0aa037 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -46,7 +46,7 @@ jobs: toolchain: [stable, beta] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -54,7 +54,7 @@ jobs: components: clippy - name: Run clippy action - uses: clechasseur/rs-clippy-check@v3 + uses: clechasseur/rs-clippy-check@v5 - name: Cache Cargo dependencies uses: Swatinem/rust-cache@v2 @@ -65,7 +65,7 @@ jobs: name: nightly / doc runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Rust nightly uses: dtolnay/rust-toolchain@nightly @@ -79,7 +79,7 @@ jobs: name: 1.95.0 / check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install 1.95.0 uses: dtolnay/rust-toolchain@master diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e89bfb1..3e9bc214 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,19 +32,19 @@ jobs: - name: Checkout repository if: env.ACT != 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v5 with: ssh-key: ${{ secrets.RELEASE_BOT_SSH_KEY }} fetch-depth: 0 - name: Checkout repository if: env.ACT == 'true' - uses: actions/checkout@v3 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: '3.10' @@ -219,13 +219,13 @@ jobs: git push origin --follow-tags - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: path: artifacts - name: Upload the changed Cargo files (Act) if: env.ACT == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: bumped-cargo-files path: | @@ -267,7 +267,7 @@ jobs: exit 1 - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 @@ -279,18 +279,18 @@ jobs: - name: Get bumped Cargo files (Act) if: env.ACT == 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: bumped-cargo-files path: ${{ github.workspace }} - - uses: actions/cache@v3 + - uses: actions/cache@v5 name: Cache Cargo registry with: path: ~/.cargo/registry key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }} - - uses: actions/cache@v3 + - uses: actions/cache@v5 if: startsWith(matrix.job.name, 'linux-') with: path: ~/.cargo/bin @@ -375,7 +375,7 @@ jobs: cp target/${{ matrix.job.target }}/release/${{ env.RELEASE_NAME }}.sha256 artifacts/ - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: artifacts-${{ env.RELEASE_NAME }} path: artifacts @@ -393,12 +393,12 @@ jobs: exit 1 - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: artifacts merge-multiple: true @@ -421,7 +421,7 @@ jobs: - name: Create a GitHub Release if: env.ACT != 'true' - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -457,7 +457,7 @@ jobs: prerelease: false - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: path: artifacts overwrite: true @@ -474,12 +474,12 @@ jobs: exit 1 - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 - name: Get release artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: artifacts merge-multiple: true @@ -528,12 +528,12 @@ jobs: exit 1 - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 - name: Get release artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: artifacts merge-multiple: true @@ -592,12 +592,12 @@ jobs: exit 1 - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 - name: Get release artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: artifacts merge-multiple: true @@ -618,20 +618,20 @@ jobs: echo "Release version: ${{ env.version }}" - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub if: env.ACT != 'true' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Push to Docker Hub - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: Dockerfile @@ -651,13 +651,13 @@ jobs: exit 1 - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Get bumped Cargo files (Act) if: env.ACT == 'true' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: bumped-cargo-files path: ${{ github.workspace }} @@ -668,13 +668,13 @@ jobs: git fetch --all git pull - - uses: actions/cache@v3 + - uses: actions/cache@v5 name: Cache Cargo registry with: path: ~/.cargo/registry key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }} - - uses: actions/cache@v3 + - uses: actions/cache@v5 with: path: ~/.cargo/bin key: ${{ runner.os }}-cargo-bin-${{ hashFiles('.github/workflows/release.yml') }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 226c2a9c..9ccc1307 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: # toolchain toolchain: [stable, beta] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install ${{ matrix.toolchain }} uses: dtolnay/rust-toolchain@master @@ -74,7 +74,7 @@ jobs: # runs-on: ubuntu-latest # name: ubuntu / stable / minimal-versions # steps: - # - uses: actions/checkout@v4 + # - uses: actions/checkout@v5 # - name: Install Rust stable # uses: dtolnay/rust-toolchain@stable @@ -110,7 +110,7 @@ jobs: # - run: vcpkg install openssl:x64-windows-static-md # if: runner.os == 'Windows' - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -151,7 +151,7 @@ jobs: name: ubuntu / stable / coverage steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -176,7 +176,7 @@ jobs: - name: Upload to codecov.io if: env.ACT != 'true' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 5ebe2421..e154748f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /.idea/ /.scannerwork/ /.act/ +/.sisyphus/ +/.coyote/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6551cd0..a58e07ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,8 +93,8 @@ act -W .github/workflows/release.yml --input_type bump=minor ## Authorship Policy -All code in this repository is written and reviewed by humans. AI-generated code (e.g., Copilot, ChatGPT, -Claude, etc.) is not permitted unless explicitly disclosed and approved. +All code in this repository is written and reviewed by humans, or AI-generated where explicitly disclosed. AI-generated code (e.g., Copilot, ChatGPT, +Claude, etc.) is permitted only when explicitly disclosed and approved. For example: disclosing the use of AI-generated code in a PR description. Submissions must certify that the contributor understands and can maintain the code they submit. diff --git a/Cargo.lock b/Cargo.lock index 7ca21474..e94e6cc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -34,9 +34,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -108,9 +108,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -152,13 +152,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -184,9 +184,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -194,14 +194,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -225,6 +226,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bimap" version = "0.6.3" @@ -272,9 +279,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -296,9 +303,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -319,15 +326,15 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cargo-husky" @@ -346,9 +353,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.65" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", @@ -358,15 +365,26 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] [[package]] name = "chrono" @@ -384,9 +402,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -394,9 +412,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -407,30 +425,30 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.5" +version = "4.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cmake" @@ -458,9 +476,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -490,7 +508,7 @@ dependencies = [ "lazy_static", "serde", "serde_yaml", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -508,9 +526,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" dependencies = [ "encode_unicode", "libc", @@ -552,6 +570,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -561,6 +585,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -573,7 +606,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "crossterm_winapi", "mio", "parking_lot", @@ -589,13 +622,13 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "crossterm_winapi", "derive_more", "document-features", "mio", "parking_lot", - "rustix 1.1.4", + "rustix 1.1.5", "signal-hook", "signal-hook-mio", "winapi", @@ -663,12 +696,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -682,7 +715,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -696,20 +729,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -720,7 +753,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -731,18 +764,18 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -787,7 +820,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", "unicode-xid", ] @@ -800,7 +833,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -864,7 +897,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -872,13 +905,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -904,9 +937,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "encode_unicode" @@ -916,11 +949,16 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -931,7 +969,7 @@ checksum = "583f1f514d2754010ff71ed6853068cacbe43cc142cc076aa1b871d9754efc48" dependencies = [ "darling 0.20.11", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -940,7 +978,7 @@ version = "0.6.1" dependencies = [ "darling 0.20.11", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -989,17 +1027,11 @@ dependencies = [ "regex", ] -[[package]] -name = "fast-srgb8" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" - [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "filedescriptor" @@ -1014,15 +1046,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "finl_unicode" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" [[package]] name = "fixedbitset" @@ -1083,24 +1115,24 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1109,26 +1141,26 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-timer" @@ -1138,9 +1170,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", @@ -1179,11 +1211,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1193,8 +1223,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1205,15 +1238,15 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "h2" -version = "0.4.15" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -1273,9 +1306,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1283,9 +1316,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1293,9 +1326,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1334,15 +1367,15 @@ dependencies = [ [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -1362,9 +1395,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -1377,16 +1410,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -1426,9 +1460,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1440,9 +1474,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1453,9 +1487,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1467,16 +1501,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1487,15 +1522,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1535,9 +1570,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -1571,7 +1606,7 @@ version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ - "console 0.16.3", + "console 0.16.6", "once_cell", "similar", "tempfile", @@ -1579,22 +1614,22 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "indoc", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is_terminal_polyfill" @@ -1629,7 +1664,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -1644,7 +1679,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1663,24 +1698,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -1695,7 +1730,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -1712,9 +1747,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -1724,20 +1759,20 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] [[package]] name = "line-clipping" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", ] [[package]] @@ -1754,9 +1789,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -1775,9 +1810,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "serde_core", ] @@ -1805,12 +1840,12 @@ dependencies = [ "log-mdc", "mock_instant", "parking_lot", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde-value", "serde_json", "serde_yaml", - "thiserror 2.0.18", + "thiserror 2.0.21", "thread-id", "typemap-ors", "unicode-segmentation", @@ -1819,18 +1854,18 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.0" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ "hashbrown 0.17.1", ] [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "mac_address" @@ -1907,9 +1942,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmem" @@ -1949,9 +1984,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -1988,7 +2023,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2008,7 +2043,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand 0.9.4", + "rand 0.9.5", "regex", "serde_json", "serde_urlencoded", @@ -2016,13 +2051,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -2035,7 +2076,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -2074,7 +2115,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2116,7 +2157,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", ] [[package]] @@ -2162,7 +2203,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cfg-if", "foreign-types", "libc", @@ -2178,7 +2219,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2229,26 +2270,35 @@ dependencies = [ [[package]] name = "palette" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" dependencies = [ "approx", - "fast-srgb8", "libm", "palette_derive", + "palette_math", ] [[package]] name = "palette_derive" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", ] [[package]] @@ -2282,9 +2332,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" dependencies = [ "memchr", "ucd-trie", @@ -2292,9 +2342,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" dependencies = [ "pest", "pest_generator", @@ -2302,25 +2352,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" dependencies = [ "pest", - "sha2", ] [[package]] @@ -2350,7 +2399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -2363,7 +2412,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2383,21 +2432,21 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2465,9 +2514,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2480,9 +2529,9 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", - "bitflags 2.13.0", + "bitflags 2.13.2", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -2499,9 +2548,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -2511,7 +2560,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -2519,21 +2568,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.3", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -2541,23 +2591,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2576,23 +2626,34 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2618,6 +2679,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -2649,7 +2725,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "compact_str", "critical-section", "hashbrown 0.17.1", @@ -2659,7 +2735,7 @@ dependencies = [ "palette", "serde", "strum 0.28.0", - "thiserror 2.0.18", + "thiserror 2.0.21", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -2714,7 +2790,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "hashbrown 0.17.1", "indoc", "instability", @@ -2734,7 +2810,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", ] [[package]] @@ -2750,9 +2826,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2762,9 +2838,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2785,11 +2861,11 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "encoding_rs", "futures-core", @@ -2863,21 +2939,21 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", "unicode-ident", ] [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -2894,7 +2970,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2903,11 +2979,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -2916,9 +2992,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", @@ -2942,9 +3018,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -2952,9 +3028,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -2973,15 +3049,15 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -2991,9 +3067,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -3043,7 +3119,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3068,9 +3144,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3088,29 +3164,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -3175,7 +3251,7 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3185,7 +3261,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3228,9 +3304,9 @@ dependencies = [ [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -3250,9 +3326,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -3262,15 +3338,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3322,7 +3398,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3334,7 +3410,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3356,9 +3432,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -3376,13 +3463,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -3405,7 +3492,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3429,7 +3516,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -3439,9 +3526,9 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "parking_lot", - "rustix 1.1.4", + "rustix 1.1.5", "signal-hook", "windows-sys 0.61.2", ] @@ -3452,7 +3539,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -3490,8 +3577,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", - "bitflags 2.13.0", + "base64 0.22.1", + "bitflags 2.13.2", "fancy-regex", "filedescriptor", "finl_unicode", @@ -3536,11 +3623,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.21", ] [[package]] @@ -3551,18 +3638,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -3577,9 +3664,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -3598,9 +3685,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -3608,24 +3695,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3640,20 +3718,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -3661,22 +3739,23 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "serde_core", "serde_spanned", @@ -3695,9 +3774,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap", "toml_datetime", @@ -3707,18 +3786,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -3741,7 +3820,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -3819,9 +3898,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" @@ -3905,9 +3984,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "atomic", "getrandom 0.4.3", @@ -3922,7 +4001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40630259c022600210096da9538abcb992b801e30b464cb9d19f19ef0e0d09b9" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3931,7 +4010,7 @@ version = "0.6.1" dependencies = [ "log", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3958,7 +4037,7 @@ checksum = "5b2d5567b6fbd34e8f0488d56b648e67c0d999535f4af2060d14f9074b43e833" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4021,9 +4100,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -4034,19 +4113,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4054,31 +4134,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -4096,9 +4176,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -4259,7 +4339,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4270,7 +4350,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4324,7 +4404,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4333,16 +4413,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -4360,31 +4431,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -4402,101 +4456,53 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -4509,9 +4515,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yansi" @@ -4532,34 +4538,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4573,13 +4579,13 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "synstructure", ] @@ -4591,9 +4597,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -4602,9 +4608,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -4613,17 +4619,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/README.md b/README.md index 73067db5..d80e4055 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,17 @@ Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built - [x] ![radarr_logo](logos/radarr.png) [Radarr](https://wiki.servarr.com/radarr) - [x] ![sonarr_logo](logos/sonarr.png) [Sonarr](https://wiki.servarr.com/en/sonarr) - [x] ![lidarr_logo](logos/lidarr.png) [Lidarr](https://wiki.servarr.com/en/lidarr) -- [ ] ![readarr_logo](logos/readarr.png) [Readarr](https://wiki.servarr.com/en/readarr) +- [x] ![readarr_logo](logos/readarr.png) [Readarr](https://wiki.servarr.com/en/readarr) - [ ] ![prowlarr_logo](logos/prowlarr.png) [Prowlarr](https://wiki.servarr.com/en/prowlarr) - [ ] ![whisparr_logo](logos/whisparr.png) [Whisparr](https://wiki.servarr.com/whisparr) - [ ] ![bazarr_logo](logos/bazarr.png) [Bazarr](https://www.bazarr.media/) - [ ] ![tautulli_logo](logos/tautulli.png) [Tautulli](https://tautulli.com/) +> [!NOTE] +> The official Readarr repository has been archived, but Managarr's Readarr support is compatible with any Readarr forks +> that maintain compatibility with the official Readarr v1 API. See [Tested Readarr Forks](#tested-readarr-forks) below +> for forks that have been explicitly tested with Managarr, and any oddities in configuring them to work with Managarr. + ## Try Out the Demo To try out Managarr before linking it to your HTPC, you can use the purpose built [managarr-demo](https://github.com/Dark-Alex-17/managarr-demo) repository. Simply run the following command to start a demo: @@ -134,8 +139,8 @@ Key: | Symbol | Status | |--------|-----------| -| ✅ | Supported | -| ❌ | Missing | +| ✅ | Supported | +| ❌ | Missing | | 🕒 | Planned | | 🚫 | Won't Add | @@ -143,66 +148,82 @@ Key: | TUI | CLI | Feature | |-----|-----|----------------------------------------------------------------------------------------------------------------| -| ✅ | ✅ | View your library, downloads, collections, and blocklist | -| ✅ | ✅ | View details of a specific movie including description, history, downloaded file info, or the credits | -| ✅ | ✅ | View details of any collection and the movies in them | -| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | -| ✅ | ✅ | Search your library or collections | -| ✅ | ✅ | Add movies to your library | -| ✅ | ✅ | Delete movies, downloads, and indexers | -| ✅ | ✅ | Trigger automatic searches for movies | -| ✅ | ✅ | Trigger refresh and disk scan for movies, downloads, and collections | -| ✅ | ✅ | Manually search for movies | -| ✅ | ✅ | Edit your movies, collections, and indexers | -| ✅ | ✅ | Manage your tags | -| ✅ | ✅ | Manage your root folders | -| ✅ | ✅ | Manage your blocklist | -| ✅ | ✅ | View and browse logs, tasks, events queues, and updates | -| ✅ | ✅ | Manually trigger scheduled tasks | +| ✅ | ✅ | View your library, downloads, collections, and blocklist | +| ✅ | ✅ | View details of a specific movie including description, history, downloaded file info, or the credits | +| ✅ | ✅ | View details of any collection and the movies in them | +| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| ✅ | ✅ | Search your library or collections | +| ✅ | ✅ | Add movies to your library | +| ✅ | ✅ | Delete movies, downloads, and indexers | +| ✅ | ✅ | Trigger automatic searches for movies | +| ✅ | ✅ | Trigger refresh and disk scan for movies, downloads, and collections | +| ✅ | ✅ | Manually search for movies | +| ✅ | ✅ | Edit your movies, collections, and indexers | +| ✅ | ✅ | Manage your tags | +| ✅ | ✅ | Manage your root folders | +| ✅ | ✅ | Manage your blocklist | +| ✅ | ✅ | View and browse logs, tasks, events queues, and updates | +| ✅ | ✅ | Manually trigger scheduled tasks | ### Sonarr | TUI | CLI | Feature | |-----|-----|--------------------------------------------------------------------------------------------------------------------| -| ✅ | ✅ | View your library, downloads, blocklist, episodes | -| ✅ | ✅ | View details of a specific series, or episode including description, history, downloaded file info, or the credits | -| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | -| ✅ | ✅ | Search your library | -| ✅ | ✅ | Add series to your library | -| ✅ | ✅ | Delete series, downloads, indexers, root folders, and episode files | -| ✅ | ✅ | Trigger automatic searches for series, seasons, or episodes | -| ✅ | ✅ | Trigger refresh and disk scan for series and downloads | -| ✅ | ✅ | Manually search for series, seasons, or episodes | -| ✅ | ✅ | Edit your series and indexers | -| ✅ | ✅ | Manage your tags | -| ✅ | ✅ | Manage your root folders | -| ✅ | ✅ | Manage your blocklist | -| ✅ | ✅ | View and browse logs, tasks, events queues, and updates | -| ✅ | ✅ | Manually trigger scheduled tasks | +| ✅ | ✅ | View your library, downloads, blocklist, episodes | +| ✅ | ✅ | View details of a specific series, or episode including description, history, downloaded file info, or the credits | +| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| ✅ | ✅ | Search your library | +| ✅ | ✅ | Add series to your library | +| ✅ | ✅ | Delete series, downloads, indexers, root folders, and episode files | +| ✅ | ✅ | Trigger automatic searches for series, seasons, or episodes | +| ✅ | ✅ | Trigger refresh and disk scan for series and downloads | +| ✅ | ✅ | Manually search for series, seasons, or episodes | +| ✅ | ✅ | Edit your series and indexers | +| ✅ | ✅ | Manage your tags | +| ✅ | ✅ | Manage your root folders | +| ✅ | ✅ | Manage your blocklist | +| ✅ | ✅ | View and browse logs, tasks, events queues, and updates | +| ✅ | ✅ | Manually trigger scheduled tasks | ### Lidarr | TUI | CLI | Feature | |-----|-----|----------------------------------------------------------------------------------------------------------------| -| ✅ | ✅ | View your library, downloads, blocklist, tracks | -| ✅ | ✅ | View details of a specific artists, albums, or tracks including description, history, downloaded file info | -| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | -| ✅ | ✅ | Search your library | -| ✅ | ✅ | Add artists to your library | -| ✅ | ✅ | Delete artists, downloads, indexers, root folders, and track files | -| ✅ | ✅ | Trigger automatic searches for artists or albums | -| ✅ | ✅ | Trigger refresh and disk scan for artists and downloads | -| ✅ | ✅ | Manually search for full artist discographies or albums | -| ✅ | ✅ | Edit your artists and indexers | -| ✅ | ✅ | Manage your tags | -| ✅ | ✅ | Manage your root folders | -| ✅ | ✅ | Manage your blocklist | -| ✅ | ✅ | View and browse logs, tasks, events queues, and updates | -| ✅ | ✅ | Manually trigger scheduled tasks | +| ✅ | ✅ | View your library, downloads, blocklist, tracks | +| ✅ | ✅ | View details of a specific artists, albums, or tracks including description, history, downloaded file info | +| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| ✅ | ✅ | Search your library | +| ✅ | ✅ | Add artists to your library | +| ✅ | ✅ | Delete artists, downloads, indexers, root folders, and track files | +| ✅ | ✅ | Trigger automatic searches for artists or albums | +| ✅ | ✅ | Trigger refresh and disk scan for artists and downloads | +| ✅ | ✅ | Manually search for full artist discographies or albums | +| ✅ | ✅ | Edit your artists and indexers | +| ✅ | ✅ | Manage your tags | +| ✅ | ✅ | Manage your root folders | +| ✅ | ✅ | Manage your blocklist | +| ✅ | ✅ | View and browse logs, tasks, events queues, and updates | +| ✅ | ✅ | Manually trigger scheduled tasks | ### Readarr -- [ ] Support for Readarr +| TUI | CLI | Feature | +|-----|-----|----------------------------------------------------------------------------------------------------------------| +| ✅ | ✅ | View your library, downloads, blocklist, editions | +| ✅ | ✅ | View details of a specific author, book, or edition including description, history, downloaded file info | +| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| ✅ | ✅ | Search your library | +| ✅ | ✅ | Add authors to your library | +| ✅ | ✅ | Delete authors, books, downloads, indexers, root folders, and book files | +| ✅ | ✅ | Trigger automatic searches for authors or books | +| ✅ | ✅ | Trigger refresh and disk scan for authors and downloads | +| ✅ | ✅ | Manually search for author releases or books | +| ✅ | ✅ | Edit your authors and indexers | +| ✅ | ✅ | Manage your tags | +| ✅ | ✅ | Manage your root folders | +| ✅ | ✅ | Manage your blocklist | +| ✅ | ✅ | View and browse logs, tasks, events queues, and updates | +| ✅ | ✅ | Manually trigger scheduled tasks | ### Whisparr @@ -245,7 +266,7 @@ To see all available commands, simply run `managarr --help`: ```shell $ managarr --help -managarr 0.7.0 +managarr 0.8.0 Alex Clarke A TUI and CLI to manage your Servarrs @@ -256,6 +277,7 @@ Commands: radarr Commands for manging your Radarr instance sonarr Commands for manging your Sonarr instance lidarr Commands for manging your Lidarr instance + readarr Commands for managing your Readarr instance completions Generate shell completions for the Managarr CLI tail-logs Tail Managarr logs config-path Print the full path to the default configuration file. @@ -347,6 +369,7 @@ default port for that respective service. That is: | Radarr | `localhost` | 7878 | | Sonarr | `localhost` | 8989 | | Lidarr | `localhost` | 8686 | +| Readarr | `localhost` | 8787 | > [!TIP] > In general, all Servarrs store their API tokens under Settings -> General -> Security -> API Key in their web UIs. @@ -367,6 +390,10 @@ sonarr: lidarr: # Connect to lidarr running on localhost:8686 - api_token: + +readarr: + # Connect to readarr running on localhost:8787 + - api_token: ``` ## Configuration File Location @@ -437,6 +464,11 @@ lidarr: custom_headers: # Example of adding custom headers to all requests to the Servarr instance traefik-auth-bypass-key: someBypassKey1234567890 SOME-OTHER-CUSTOM-HEADER: ${MY_CUSTOM_HEADER_VALUE} + +readarr: + - name: Chaptarr (Ebooks) # Example of how to use the Readarr support with a Readarr fork like Chaptarr + uri: http://192.168.0.87:8789/readarr/gr/ebook + api_token: someApiToken1234567890 ``` ### SSL Configuration @@ -546,6 +578,46 @@ Managarr supports using environment variables on startup so you don't have to al | `MANAGARR_CONFIG_FILE` | Set the path to the config file | `--config` | | `MANAGARR_DISABLE_SPINNER` | Disable the CLI spinner (this can be useful when scripting and parsing output) | `--disable-spinner` | +## Tested Readarr Forks + +The following Readarr forks have been explicitly tested with Managarr: + +* [Bookshelf](https://github.com/pennydreadful/bookshelf) +* [Readarr-Resurrected](https://github.com/ricetim/readarr-rresurrected) +* [Librarr](https://github.com/Rorqualx/Librarr) +* [Chaptarr](https://github.com/Chaptarr/Chaptarr) + +> [!IMPORTANT] +> Chaptarr houses a dedicated Readarr facade middleware that activates on a path prefix, meaning a bare config doesn't +> work. Instead, users need to configure both Audiobooks and Ebooks separately, and based on their desired metadata +> fetcher (i.e. Goodreads and Hardcover IDs). Because of this, Chaptarr-based configurations must use the `uri` key +> rather than the `host` and `port` keys. The below configuration file demonstrates all the supported variations for +> connecting Managarr to Chaptarr. + +### Chaptarr Example Configuration +```yaml +readarr: + # Because Chaptarr supports both eBooks and audiobooks, the same instance must be provisioned twice if you're using + # both so Managarr can differentiate between the two. Thus, for Chaptarr, you need an eBook server configured and an + # audiobook server configured like below. + + - name: eBooks (Goodreads) # Readarr originally used Goodreads as its metadata provider + uri: http://192.168.0.87:8789/readarr/gr/ebook # Note the `gr` in the path prefix indicating "GoodReads" + api_token: someApiToken1234567890 + + - name: Chaptarr Audiobooks (Goodreads) + uri: http://192.168.0.105:8789/readarr/gr/audiobook + api_token: someApiToken1234567890 + + - name: Chaptarr eBooks (Hardcover) + uri: http://192.168.0.105:8789/readarr/hc/ebook # Note the `hc` in the path prefix indicating "HardCover" + api_token: someApiToken1234567890 + + - name: Chaptarr Audiobooks (Hardcover) + uri: http://192.168.0.105:8789/readarr/hc/audiobook + api_token: someApiToken1234567890 +``` + ## Screenshots ### Radarr @@ -568,6 +640,13 @@ Managarr supports using environment variables on startup so you don't have to al ![artist_discography_search](screenshots/lidarr/artist_discography_search.png) ![manual_album_search](screenshots/lidarr/manual_album_search.png) +### Readarr +![readarr_library](screenshots/readarr/readarr_library.png) +![author_details](screenshots/readarr/author_details.png) +![book_details](screenshots/readarr/book_details.png) +![manual_author_search](screenshots/readarr/manual_author_search.png) +![manual_album_search](screenshots/readarr/manual_book_search.png) + ### General ![logs](screenshots/radarr/logs.png) ![indexers](screenshots/radarr/indexers.png) @@ -591,4 +670,4 @@ Managarr supports using environment variables on startup so you don't have to al * [Tautulli >= v2](https://github.com/Tautulli/Tautulli/wiki/Tautulli-API-Reference) ## Creator -* [Alex Clarke](https://github.com/Dark-Alex-17) +* [Alex Clarke](https://github.com/Dark-Alex-17) \ No newline at end of file diff --git a/justfile b/justfile index e59dd0c7..72333f1a 100644 --- a/justfile +++ b/justfile @@ -49,7 +49,7 @@ doctest: # Run all proptests [group: 'test'] proptest: - @cargo test proptest + @cargo test -- property_tests proptest alias test-snapshots := snapshot-tests # Run all snapshot tests diff --git a/proptest-regressions/handlers/handler_proptest.txt b/proptest-regressions/handlers/handler_proptest.txt index d088279f..ea581daa 100644 --- a/proptest-regressions/handlers/handler_proptest.txt +++ b/proptest-regressions/handlers/handler_proptest.txt @@ -5,3 +5,4 @@ # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc 56330c025ad79db641d0eb9f429ab74e95822e1fb015b58f0e158ea674cd42a1 # shrinks to list_size = 1, page_ops = 1 +cc 29df5ff471eb388b0ffde573a959a539e4cf32acd9e1cea5187bba2268dd57f7 # shrinks to list_size = 1, filter_term = "𑛀" diff --git a/screenshots/lidarr/lidarr_library.png b/screenshots/lidarr/lidarr_library.png index e1f7c608..0f9dc0c4 100644 Binary files a/screenshots/lidarr/lidarr_library.png and b/screenshots/lidarr/lidarr_library.png differ diff --git a/screenshots/readarr/author_details.png b/screenshots/readarr/author_details.png new file mode 100644 index 00000000..5dc323ad Binary files /dev/null and b/screenshots/readarr/author_details.png differ diff --git a/screenshots/readarr/book_details.png b/screenshots/readarr/book_details.png new file mode 100644 index 00000000..e0d57b3a Binary files /dev/null and b/screenshots/readarr/book_details.png differ diff --git a/screenshots/readarr/manual_author_search.png b/screenshots/readarr/manual_author_search.png new file mode 100644 index 00000000..b8c01815 Binary files /dev/null and b/screenshots/readarr/manual_author_search.png differ diff --git a/screenshots/readarr/manual_book_search.png b/screenshots/readarr/manual_book_search.png new file mode 100644 index 00000000..f5a80fc5 Binary files /dev/null and b/screenshots/readarr/manual_book_search.png differ diff --git a/screenshots/readarr/readarr_library.png b/screenshots/readarr/readarr_library.png new file mode 100644 index 00000000..3731851e Binary files /dev/null and b/screenshots/readarr/readarr_library.png differ diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index abe7c1d0..a7a3a7e8 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -11,6 +11,7 @@ mod tests { use crate::app::{App, AppConfig, Data, ServarrConfig, interpolate_env_vars}; use crate::models::servarr_data::lidarr::lidarr_data::LidarrData; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, ReadarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::{HorizontallyScrollableText, TabRoute}; use crate::network::NetworkEvent; @@ -34,11 +35,18 @@ mod tests { ..ServarrConfig::default() }; let sonarr_config_2 = ServarrConfig::default(); + let readarr_config_1 = ServarrConfig { + name: Some("Readarr Test".to_owned()), + weight: Some(2), + ..ServarrConfig::default() + }; + let readarr_config_2 = ServarrConfig::default(); let config = AppConfig { theme: None, radarr: Some(vec![radarr_config_1.clone(), radarr_config_2.clone()]), sonarr: Some(vec![sonarr_config_1.clone(), sonarr_config_2.clone()]), lidarr: None, + readarr: Some(vec![readarr_config_1.clone(), readarr_config_2.clone()]), }; let expected_tab_routes = vec![ TabRoute { @@ -47,6 +55,12 @@ mod tests { contextual_help: None, config: Some(sonarr_config_1), }, + TabRoute { + title: "Readarr Test".to_owned(), + route: ActiveReadarrBlock::default().into(), + contextual_help: None, + config: Some(readarr_config_1), + }, TabRoute { title: "Radarr 1".to_owned(), route: ActiveRadarrBlock::default().into(), @@ -65,6 +79,12 @@ mod tests { contextual_help: None, config: Some(sonarr_config_2), }, + TabRoute { + title: "Readarr 1".to_owned(), + route: ActiveReadarrBlock::default().into(), + contextual_help: None, + config: Some(readarr_config_2), + }, ]; let app = App::new( @@ -220,6 +240,7 @@ mod tests { let data = Data { lidarr_data: LidarrData::default(), radarr_data, + readarr_data: ReadarrData::default(), sonarr_data, }; let mut app = App { diff --git a/src/app/context_clues.rs b/src/app/context_clues.rs index d7ec1b2e..f870f1c6 100644 --- a/src/app/context_clues.rs +++ b/src/app/context_clues.rs @@ -2,6 +2,7 @@ use crate::app::App; use crate::app::key_binding::{DEFAULT_KEYBINDINGS, KeyBinding}; use crate::app::lidarr::lidarr_context_clues::LidarrContextClueProvider; use crate::app::radarr::radarr_context_clues::RadarrContextClueProvider; +use crate::app::readarr::readarr_context_clues::ReadarrContextClueProvider; use crate::app::sonarr::sonarr_context_clues::SonarrContextClueProvider; use crate::models::Route; @@ -23,6 +24,7 @@ impl ContextClueProvider for ServarrContextClueProvider { Route::Radarr(_, _) => RadarrContextClueProvider::get_context_clues(app), Route::Sonarr(_, _) => SonarrContextClueProvider::get_context_clues(app), Route::Lidarr(_, _) => LidarrContextClueProvider::get_context_clues(app), + Route::Readarr(_, _) => ReadarrContextClueProvider::get_context_clues(app), _ => None, } } diff --git a/src/app/context_clues_tests.rs b/src/app/context_clues_tests.rs index a10881e9..a1a6f244 100644 --- a/src/app/context_clues_tests.rs +++ b/src/app/context_clues_tests.rs @@ -9,6 +9,7 @@ mod test { use crate::app::{App, key_binding::DEFAULT_KEYBINDINGS}; use crate::models::servarr_data::ActiveKeybindingBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; #[test] @@ -303,6 +304,16 @@ mod test { assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES,); } + #[test] + fn test_servarr_context_clue_provider_delegates_to_readarr_provider() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + + let context_clues = ServarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES,); + } + #[test] fn test_servarr_context_clue_provider_unsupported_route_returns_none() { let mut app = App::test_default(); diff --git a/src/app/key_binding.rs b/src/app/key_binding.rs index d63f9e85..6ddbf1b8 100644 --- a/src/app/key_binding.rs +++ b/src/app/key_binding.rs @@ -27,6 +27,7 @@ generate_keybindings! { filter, sort, edit, + view, logs, tasks, test, @@ -142,6 +143,11 @@ pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { alt: None, desc: "edit", }, + view: KeyBinding { + key: Key::Char('v'), + alt: None, + desc: "view", + }, events: KeyBinding { key: Key::Char('e'), alt: None, diff --git a/src/app/key_binding_tests.rs b/src/app/key_binding_tests.rs index 7db65011..7186305a 100644 --- a/src/app/key_binding_tests.rs +++ b/src/app/key_binding_tests.rs @@ -26,6 +26,7 @@ mod test { #[case(DEFAULT_KEYBINDINGS.filter, Key::Char('f'), None, "filter")] #[case(DEFAULT_KEYBINDINGS.sort, Key::Char('o'), None, "sort")] #[case(DEFAULT_KEYBINDINGS.edit, Key::Char('e'), None, "edit")] + #[case(DEFAULT_KEYBINDINGS.view, Key::Char('v'), None, "view")] #[case(DEFAULT_KEYBINDINGS.events, Key::Char('e'), None, "events")] #[case(DEFAULT_KEYBINDINGS.logs, Key::Char('L'), None, "logs")] #[case(DEFAULT_KEYBINDINGS.tasks, Key::Char('t'), None, "tasks")] diff --git a/src/app/lidarr/lidarr_context_clues.rs b/src/app/lidarr/lidarr_context_clues.rs index 2b734f4a..27737fe5 100644 --- a/src/app/lidarr/lidarr_context_clues.rs +++ b/src/app/lidarr/lidarr_context_clues.rs @@ -7,8 +7,8 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::models::Route; use crate::models::servarr_data::lidarr::lidarr_data::{ ADD_ARTIST_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, - ActiveLidarrBlock, EDIT_ARTIST_BLOCKS, EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, - TRACK_DETAILS_BLOCKS, + ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock, DELETE_ALBUM_BLOCKS, DELETE_ARTIST_BLOCKS, + EDIT_ARTIST_BLOCKS, EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, TRACK_DETAILS_BLOCKS, }; #[cfg(test)] @@ -39,7 +39,7 @@ pub static ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [ (DEFAULT_KEYBINDINGS.esc, "edit search"), ]; -pub static ARTIST_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ +pub static ARTIST_DETAILS_CONTEXT_CLUES: [ContextClue; 9] = [ ( DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, @@ -56,6 +56,15 @@ pub static ARTIST_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ DEFAULT_KEYBINDINGS.auto_search, DEFAULT_KEYBINDINGS.auto_search.desc, ), + (DEFAULT_KEYBINDINGS.view, "view overview"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static ARTIST_OVERVIEW_CONTEXT_CLUES: [ContextClue; 2] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; @@ -172,6 +181,9 @@ impl ContextClueProvider for LidarrContextClueProvider { .lidarr_data .artist_info_tabs .get_active_route_contextual_help(), + _ if ARTIST_OVERVIEW_BLOCKS.contains(&active_lidarr_block) => { + Some(&ARTIST_OVERVIEW_CONTEXT_CLUES) + } _ if ALBUM_DETAILS_BLOCKS.contains(&active_lidarr_block) => app .data .lidarr_data @@ -203,6 +215,11 @@ impl ContextClueProvider for LidarrContextClueProvider { { Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) } + _ if DELETE_ARTIST_BLOCKS.contains(&active_lidarr_block) + || DELETE_ALBUM_BLOCKS.contains(&active_lidarr_block) => + { + Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) + } ActiveLidarrBlock::AddArtistPrompt | ActiveLidarrBlock::AddArtistSelectMonitor | ActiveLidarrBlock::AddArtistSelectMonitorNewItems diff --git a/src/app/lidarr/lidarr_context_clues_tests.rs b/src/app/lidarr/lidarr_context_clues_tests.rs index 92ef2e6f..b5f178eb 100644 --- a/src/app/lidarr/lidarr_context_clues_tests.rs +++ b/src/app/lidarr/lidarr_context_clues_tests.rs @@ -9,8 +9,9 @@ mod tests { use crate::app::lidarr::lidarr_context_clues::{ ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES, ALBUM_DETAILS_CONTEXT_CLUES, ALBUM_HISTORY_CONTEXT_CLUES, ARTIST_DETAILS_CONTEXT_CLUES, ARTIST_HISTORY_CONTEXT_CLUES, - ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider, MANUAL_ALBUM_SEARCH_CONTEXT_CLUES, - MANUAL_ARTIST_SEARCH_CONTEXT_CLUES, TRACK_DETAILS_CONTEXT_CLUES, TRACK_HISTORY_CONTEXT_CLUES, + ARTIST_OVERVIEW_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider, + MANUAL_ALBUM_SEARCH_CONTEXT_CLUES, MANUAL_ARTIST_SEARCH_CONTEXT_CLUES, + TRACK_DETAILS_CONTEXT_CLUES, TRACK_HISTORY_CONTEXT_CLUES, }; use crate::models::servarr_data::lidarr::lidarr_data::{ ADD_ROOT_FOLDER_BLOCKS, ActiveLidarrBlock, EDIT_ARTIST_BLOCKS, EDIT_INDEXER_BLOCKS, @@ -114,6 +115,10 @@ mod tests { DEFAULT_KEYBINDINGS.auto_search.desc, ) ); + assert_some_eq_x!( + artist_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.view, "view overview") + ); assert_some_eq_x!( artist_details_context_clues_iter.next(), &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) @@ -121,6 +126,24 @@ mod tests { assert_none!(artist_details_context_clues_iter.next()); } + #[test] + fn test_artist_overview_context_clues() { + let mut artist_overview_context_clues_iter = ARTIST_OVERVIEW_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + artist_overview_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + artist_overview_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(artist_overview_context_clues_iter.next()); + } + #[test] fn test_add_artist_search_results_context_clues() { let mut add_artist_search_results_context_clues_iter = @@ -471,6 +494,16 @@ mod tests { assert_some_eq_x!(context_clues, expected_context_clues); } + #[test] + fn test_lidarr_context_clue_provider_artist_overview_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ARTIST_OVERVIEW_CONTEXT_CLUES); + } + #[test] fn test_lidarr_context_clue_provider_artists_block() { let mut app = App::test_default(); @@ -577,6 +610,42 @@ mod tests { assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); } + #[rstest] + fn test_lidarr_context_clue_provider_confirmation_prompt_context_clues_delete_artist_blocks( + #[values( + ActiveLidarrBlock::DeleteArtistPrompt, + ActiveLidarrBlock::DeleteArtistConfirmPrompt, + ActiveLidarrBlock::DeleteArtistToggleAddListExclusion, + ActiveLidarrBlock::DeleteArtistToggleDeleteFile + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_lidarr_block.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + + #[rstest] + fn test_lidarr_context_clue_provider_confirmation_prompt_context_clues_delete_album_blocks( + #[values( + ActiveLidarrBlock::DeleteAlbumPrompt, + ActiveLidarrBlock::DeleteAlbumConfirmPrompt, + ActiveLidarrBlock::DeleteAlbumToggleAddListExclusion, + ActiveLidarrBlock::DeleteAlbumToggleDeleteFile + )] + active_lidarr_block: ActiveLidarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_lidarr_block.into()); + + let context_clues = LidarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + #[test] fn test_lidarr_context_clue_provider_system_tasks_clues() { let mut app = App::test_default(); diff --git a/src/app/lidarr/lidarr_tests.rs b/src/app/lidarr/lidarr_tests.rs index 4b530139..0ea48a8b 100644 --- a/src/app/lidarr/lidarr_tests.rs +++ b/src/app/lidarr/lidarr_tests.rs @@ -498,7 +498,7 @@ mod tests { assert!(app.is_loading); assert_eq!( rx.recv().await.unwrap(), - LidarrEvent::GetTrackDetails(1).into() + LidarrEvent::GetTrackDetails(5).into() ); assert!(!app.data.lidarr_data.prompt_confirm); assert_eq!(app.tick_count, 0); @@ -518,7 +518,7 @@ mod tests { assert!(app.is_loading); assert_eq!( rx.recv().await.unwrap(), - LidarrEvent::GetTrackHistory(1, 1, 1).into() + LidarrEvent::GetTrackHistory(1, 1, 5).into() ); assert!(!app.data.lidarr_data.prompt_confirm); assert_eq!(app.tick_count, 0); @@ -759,7 +759,7 @@ mod tests { album_details_modal.tracks.set_items(vec![track()]); app.data.lidarr_data.album_details_modal = Some(album_details_modal); - assert_eq!(app.extract_track_id().await, 1); + assert_eq!(app.extract_track_id().await, 5); } #[tokio::test] diff --git a/src/app/mod.rs b/src/app/mod.rs index d511fcf4..24c7eff4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -17,6 +17,7 @@ use crate::cli::Command; use crate::models::servarr_data::Notification; use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, ReadarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::servarr_models::KeybindingItem; use crate::models::stateful_table::StatefulTable; @@ -30,6 +31,7 @@ pub mod key_binding; mod key_binding_tests; pub mod lidarr; pub mod radarr; +pub mod readarr; pub mod sonarr; pub struct App<'a> { @@ -122,6 +124,26 @@ impl App<'_> { server_tabs.extend(lidarr_tabs); } + if let Some(readarr_configs) = config.readarr { + let mut unnamed_idx = 0; + let readarr_tabs = readarr_configs.into_iter().map(|readarr_config| { + let name = if let Some(name) = readarr_config.name.clone() { + name + } else { + unnamed_idx += 1; + format!("Readarr {unnamed_idx}") + }; + + TabRoute { + title: name, + route: ActiveReadarrBlock::Authors.into(), + contextual_help: None, + config: Some(readarr_config), + } + }); + server_tabs.extend(readarr_tabs); + } + let weight_sorted_tabs = server_tabs .into_iter() .sorted_by(|tab1, tab2| { @@ -204,6 +226,7 @@ impl App<'_> { Route::Radarr(active_radarr_block, _) => self.radarr_on_tick(active_radarr_block).await, Route::Sonarr(active_sonarr_block, _) => self.sonarr_on_tick(active_sonarr_block).await, Route::Lidarr(active_lidarr_block, _) => self.lidarr_on_tick(active_lidarr_block).await, + Route::Readarr(active_readarr_block, _) => self.readarr_on_tick(active_readarr_block).await, _ => (), } @@ -297,6 +320,12 @@ impl App<'_> { contextual_help: None, config: Some(ServarrConfig::default()), }, + TabRoute { + title: "Readarr".to_owned(), + route: ActiveReadarrBlock::Authors.into(), + contextual_help: None, + config: Some(ServarrConfig::default()), + }, ]), ..App::default() } @@ -307,6 +336,7 @@ impl App<'_> { data: Data { lidarr_data: LidarrData::test_default_fully_populated(), radarr_data: RadarrData::test_default_fully_populated(), + readarr_data: ReadarrData::test_default_fully_populated(), sonarr_data: SonarrData::test_default_fully_populated(), }, server_tabs: TabState::new(vec![ @@ -328,16 +358,27 @@ impl App<'_> { contextual_help: None, config: Some(ServarrConfig::default()), }, + TabRoute { + title: "Readarr".to_owned(), + route: ActiveReadarrBlock::Authors.into(), + contextual_help: None, + config: Some(ServarrConfig::default()), + }, ]), ..App::default() } } + + pub fn get_navigation_stack_depth(&self) -> usize { + self.navigation_stack.len() + } } #[derive(Default)] pub struct Data<'a> { pub lidarr_data: LidarrData<'a>, pub radarr_data: RadarrData<'a>, + pub readarr_data: ReadarrData<'a>, pub sonarr_data: SonarrData<'a>, } @@ -346,12 +387,17 @@ pub struct AppConfig { pub theme: Option, pub lidarr: Option>, pub radarr: Option>, + pub readarr: Option>, pub sonarr: Option>, } impl AppConfig { pub fn validate(&self, config_path: &str) { - if self.lidarr.is_none() && self.radarr.is_none() && self.sonarr.is_none() { + if self.lidarr.is_none() + && self.radarr.is_none() + && self.sonarr.is_none() + && self.readarr.is_none() + { log_and_print_error(format!( "No Servarrs are configured in the file: {config_path}" )); @@ -369,6 +415,10 @@ impl AppConfig { if let Some(lidarr_configs) = &self.lidarr { lidarr_configs.iter().for_each(|config| config.validate()); } + + if let Some(readarr_configs) = &self.readarr { + readarr_configs.iter().for_each(|config| config.validate()); + } } pub fn verify_config_present_for_cli(&self, command: &Command) { @@ -390,6 +440,10 @@ impl AppConfig { msg("Lidarr"); process::exit(1); } + Command::Readarr(_) if self.readarr.is_none() => { + msg("Readarr"); + process::exit(1); + } _ => (), } } @@ -412,6 +466,12 @@ impl AppConfig { lidarr_config.post_process_initialization(); } } + + if let Some(readarr_configs) = self.readarr.as_mut() { + for readarr_config in readarr_configs { + readarr_config.post_process_initialization(); + } + } } } diff --git a/src/app/radarr/radarr_context_clues.rs b/src/app/radarr/radarr_context_clues.rs index 32221877..e5310f53 100644 --- a/src/app/radarr/radarr_context_clues.rs +++ b/src/app/radarr/radarr_context_clues.rs @@ -6,8 +6,8 @@ use crate::app::context_clues::{ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::models::Route; use crate::models::servarr_data::radarr::radarr_data::{ - ADD_MOVIE_BLOCKS, ActiveRadarrBlock, EDIT_COLLECTION_BLOCKS, EDIT_INDEXER_BLOCKS, - EDIT_MOVIE_BLOCKS, INDEXER_SETTINGS_BLOCKS, MOVIE_DETAILS_BLOCKS, + ADD_MOVIE_BLOCKS, ActiveRadarrBlock, DELETE_MOVIE_BLOCKS, EDIT_COLLECTION_BLOCKS, + EDIT_INDEXER_BLOCKS, EDIT_MOVIE_BLOCKS, INDEXER_SETTINGS_BLOCKS, MOVIE_DETAILS_BLOCKS, }; #[cfg(test)] @@ -120,6 +120,9 @@ impl ContextClueProvider for RadarrContextClueProvider { { Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) } + _ if DELETE_MOVIE_BLOCKS.contains(&active_radarr_block) => { + Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) + } ActiveRadarrBlock::AddMoviePrompt | ActiveRadarrBlock::AddMovieSelectMonitor | ActiveRadarrBlock::AddMovieSelectMinimumAvailability diff --git a/src/app/radarr/radarr_context_clues_tests.rs b/src/app/radarr/radarr_context_clues_tests.rs index a4b4f10a..29ff2922 100644 --- a/src/app/radarr/radarr_context_clues_tests.rs +++ b/src/app/radarr/radarr_context_clues_tests.rs @@ -415,6 +415,25 @@ mod tests { assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); } + #[rstest] + fn test_radarr_context_clue_provider_confirmation_prompt_context_clues_delete_movie_blocks( + #[values( + ActiveRadarrBlock::DeleteMoviePrompt, + ActiveRadarrBlock::DeleteMovieConfirmPrompt, + ActiveRadarrBlock::DeleteMovieToggleAddListExclusion, + ActiveRadarrBlock::DeleteMovieToggleDeleteFile + )] + active_radarr_block: ActiveRadarrBlock, + ) { + let mut app = App::test_default(); + app.data.radarr_data = RadarrData::default(); + app.push_navigation_stack(active_radarr_block.into()); + + let context_clues = RadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + #[rstest] fn test_radarr_context_clue_provider_add_movie_search_results_context_clues( #[values( diff --git a/src/app/readarr/mod.rs b/src/app/readarr/mod.rs new file mode 100644 index 00000000..ce719e24 --- /dev/null +++ b/src/app/readarr/mod.rs @@ -0,0 +1,248 @@ +use super::App; +use crate::{ + models::servarr_data::readarr::readarr_data::ActiveReadarrBlock, + network::readarr_network::ReadarrEvent, +}; + +pub mod readarr_context_clues; + +#[cfg(test)] +#[path = "readarr_tests.rs"] +mod readarr_tests; + +impl App<'_> { + pub(super) async fn dispatch_by_readarr_block( + &mut self, + active_readarr_block: &ActiveReadarrBlock, + ) { + match active_readarr_block { + ActiveReadarrBlock::Authors => { + self + .dispatch_network_event(ReadarrEvent::GetQualityProfiles.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetMetadataProfiles.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetTags.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::ListAuthors.into()) + .await; + } + ActiveReadarrBlock::Blocklist => { + self + .dispatch_network_event(ReadarrEvent::GetBlocklist.into()) + .await; + } + ActiveReadarrBlock::Downloads => { + self + .dispatch_network_event(ReadarrEvent::GetDownloads(500).into()) + .await; + } + ActiveReadarrBlock::AuthorDetails => { + self + .dispatch_network_event(ReadarrEvent::GetBooks(self.extract_author_id().await).into()) + .await; + } + ActiveReadarrBlock::AuthorHistory => { + self + .dispatch_network_event( + ReadarrEvent::GetAuthorHistory(self.extract_author_id().await).into(), + ) + .await; + } + ActiveReadarrBlock::ManualAuthorSearch => { + if self.data.readarr_data.author_releases.is_empty() { + self + .dispatch_network_event( + ReadarrEvent::GetAuthorReleases(self.extract_author_id().await).into(), + ) + .await; + } + } + ActiveReadarrBlock::BookDetails => { + let book_id = self.extract_book_id().await; + self + .dispatch_network_event(ReadarrEvent::GetBookEditions(book_id).into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetBookFiles(book_id).into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetDownloads(500).into()) + .await; + } + ActiveReadarrBlock::BookHistory => { + if !self.data.readarr_data.books.is_empty() { + self + .dispatch_network_event( + ReadarrEvent::GetBookHistory( + self.extract_author_id().await, + self.extract_book_id().await, + ) + .into(), + ) + .await; + } + } + ActiveReadarrBlock::ManualBookSearch => { + match self.data.readarr_data.book_details_modal.as_ref() { + Some(book_details_modal) if book_details_modal.book_releases.is_empty() => { + self + .dispatch_network_event( + ReadarrEvent::GetBookReleases(self.extract_book_id().await).into(), + ) + .await; + } + _ => (), + } + } + ActiveReadarrBlock::AddAuthorSearchResults => { + self + .dispatch_network_event( + ReadarrEvent::SearchNewAuthor(self.extract_add_new_author_search_query().await).into(), + ) + .await; + } + ActiveReadarrBlock::History => { + self + .dispatch_network_event(ReadarrEvent::GetHistory(500).into()) + .await + } + ActiveReadarrBlock::RootFolders => { + self + .dispatch_network_event(ReadarrEvent::GetRootFolders.into()) + .await; + } + ActiveReadarrBlock::Indexers => { + self + .dispatch_network_event(ReadarrEvent::GetTags.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetIndexers.into()) + .await; + } + ActiveReadarrBlock::AllIndexerSettingsPrompt => { + self + .dispatch_network_event(ReadarrEvent::GetAllIndexerSettings.into()) + .await; + } + ActiveReadarrBlock::TestIndexer => { + self + .dispatch_network_event( + ReadarrEvent::TestIndexer(self.extract_readarr_indexer_id().await).into(), + ) + .await; + } + ActiveReadarrBlock::TestAllIndexers => { + self + .dispatch_network_event(ReadarrEvent::TestAllIndexers.into()) + .await; + } + ActiveReadarrBlock::System => { + self + .dispatch_network_event(ReadarrEvent::GetTasks.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetQueuedEvents.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetLogs(500).into()) + .await; + } + ActiveReadarrBlock::SystemUpdates => { + self + .dispatch_network_event(ReadarrEvent::GetUpdates.into()) + .await; + } + _ => (), + } + + self.check_for_readarr_prompt_action().await; + self.reset_tick_count(); + } + + async fn extract_add_new_author_search_query(&self) -> String { + self + .data + .readarr_data + .add_author_search + .as_ref() + .expect("Add author search is empty") + .text + .clone() + } + + async fn extract_author_id(&self) -> i64 { + self.data.readarr_data.authors.current_selection().id + } + + async fn extract_book_id(&self) -> i64 { + self.data.readarr_data.books.current_selection().id + } + + async fn extract_readarr_indexer_id(&self) -> i64 { + self.data.readarr_data.indexers.current_selection().id + } + + async fn check_for_readarr_prompt_action(&mut self) { + if self.data.readarr_data.prompt_confirm { + self.data.readarr_data.prompt_confirm = false; + if let Some(readarr_event) = self.data.readarr_data.prompt_confirm_action.take() { + self.dispatch_network_event(readarr_event.into()).await; + self.should_refresh = true; + } + } + } + + pub(super) async fn readarr_on_tick(&mut self, active_readarr_block: ActiveReadarrBlock) { + if self.is_first_render { + self.refresh_readarr_metadata().await; + self.dispatch_by_readarr_block(&active_readarr_block).await; + self.is_first_render = false; + return; + } + + if self.should_refresh { + self.dispatch_by_readarr_block(&active_readarr_block).await; + self.refresh_readarr_metadata().await; + } + + if self.is_routing { + if !self.should_refresh { + self.cancellation_token.cancel(); + } else { + self.dispatch_by_readarr_block(&active_readarr_block).await; + } + } + + if self.tick_count.is_multiple_of(self.tick_until_poll) { + self.refresh_readarr_metadata().await; + } + } + + async fn refresh_readarr_metadata(&mut self) { + self + .dispatch_network_event(ReadarrEvent::GetQualityProfiles.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetMetadataProfiles.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetTags.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetRootFolders.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetDownloads(500).into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetDiskSpace.into()) + .await; + self + .dispatch_network_event(ReadarrEvent::GetStatus.into()) + .await; + } +} diff --git a/src/app/readarr/readarr_context_clues.rs b/src/app/readarr/readarr_context_clues.rs new file mode 100644 index 00000000..25b1d179 --- /dev/null +++ b/src/app/readarr/readarr_context_clues.rs @@ -0,0 +1,230 @@ +use crate::app::App; +use crate::app::context_clues::{ + BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClue, ContextClueProvider, + SYSTEM_TASKS_CONTEXT_CLUES, +}; +use crate::app::key_binding::DEFAULT_KEYBINDINGS; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ + ADD_AUTHOR_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, AUTHOR_DETAILS_BLOCKS, AUTHOR_OVERVIEW_BLOCKS, + ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, DELETE_AUTHOR_BLOCKS, DELETE_BOOK_BLOCKS, + EDIT_AUTHOR_BLOCKS, EDIT_INDEXER_BLOCKS, EDITION_DETAILS_BLOCKS, INDEXER_SETTINGS_BLOCKS, +}; + +#[cfg(test)] +#[path = "readarr_context_clues_tests.rs"] +mod readarr_context_clues_tests; + +pub static AUTHORS_CONTEXT_CLUES: [ContextClue; 10] = [ + (DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc), + ( + DEFAULT_KEYBINDINGS.toggle_monitoring, + DEFAULT_KEYBINDINGS.toggle_monitoring.desc, + ), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc), + (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.update, "update all"), + (DEFAULT_KEYBINDINGS.esc, "cancel filter"), +]; + +pub static ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [ + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, "edit search"), +]; + +pub static AUTHOR_DETAILS_CONTEXT_CLUES: [ContextClue; 9] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.edit, "edit author"), + (DEFAULT_KEYBINDINGS.delete, "delete book"), + ( + DEFAULT_KEYBINDINGS.toggle_monitoring, + "toggle book monitoring", + ), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.view, "view overview"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static AUTHOR_OVERVIEW_CONTEXT_CLUES: [ContextClue; 2] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static AUTHOR_HISTORY_CONTEXT_CLUES: [ContextClue; 9] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.edit, "edit author"), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.esc, "cancel filter/close"), +]; + +pub static MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES: [ContextClue; 7] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.edit, "edit author"), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static BOOK_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), + (DEFAULT_KEYBINDINGS.submit, "edition details"), +]; + +pub static BOOK_HISTORY_CONTEXT_CLUES: [ContextClue; 7] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), + (DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, "cancel filter/close"), +]; + +pub static BOOK_FILE_CONTEXT_CLUES: [ContextClue; 3] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.delete, "delete book file"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static MANUAL_BOOK_SEARCH_CONTEXT_CLUES: [ContextClue; 5] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + ( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ), + (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), + (DEFAULT_KEYBINDINGS.submit, "details"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static EDITION_DETAILS_CONTEXT_CLUES: [ContextClue; 2] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub(in crate::app) struct ReadarrContextClueProvider; + +impl ContextClueProvider for ReadarrContextClueProvider { + fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> { + let Route::Readarr(active_readarr_block, _context_option) = app.get_current_route() else { + panic!("ReadarrContextClueProvider::get_context_clues called with non-Readarr route"); + }; + + match active_readarr_block { + _ if AUTHOR_DETAILS_BLOCKS.contains(&active_readarr_block) => app + .data + .readarr_data + .author_info_tabs + .get_active_route_contextual_help(), + _ if AUTHOR_OVERVIEW_BLOCKS.contains(&active_readarr_block) => { + Some(&AUTHOR_OVERVIEW_CONTEXT_CLUES) + } + _ if BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) => app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("book_details_modal is empty") + .book_details_tabs + .get_active_route_contextual_help(), + _ if EDITION_DETAILS_BLOCKS.contains(&active_readarr_block) => { + Some(&EDITION_DETAILS_CONTEXT_CLUES) + } + ActiveReadarrBlock::AddAuthorSearchInput + | ActiveReadarrBlock::AddAuthorEmptySearchResults + | ActiveReadarrBlock::TestAllIndexers + | ActiveReadarrBlock::SystemLogs + | ActiveReadarrBlock::SystemUpdates => Some(&BARE_POPUP_CONTEXT_CLUES), + _ if EDIT_AUTHOR_BLOCKS.contains(&active_readarr_block) + || EDIT_INDEXER_BLOCKS.contains(&active_readarr_block) + || INDEXER_SETTINGS_BLOCKS.contains(&active_readarr_block) + || ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) => + { + Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) + } + _ if DELETE_AUTHOR_BLOCKS.contains(&active_readarr_block) + || DELETE_BOOK_BLOCKS.contains(&active_readarr_block) => + { + Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) + } + ActiveReadarrBlock::AddAuthorPrompt + | ActiveReadarrBlock::AddAuthorSelectMonitor + | ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + | ActiveReadarrBlock::AddAuthorSelectQualityProfile + | ActiveReadarrBlock::AddAuthorSelectMetadataProfile + | ActiveReadarrBlock::AddAuthorSelectRootFolder + | ActiveReadarrBlock::AddAuthorTagsInput + | ActiveReadarrBlock::AddAuthorAlreadyInLibrary => Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES), + _ if ADD_AUTHOR_BLOCKS.contains(&active_readarr_block) => { + Some(&ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES) + } + ActiveReadarrBlock::SystemTasks => Some(&SYSTEM_TASKS_CONTEXT_CLUES), + _ => app + .data + .readarr_data + .main_tabs + .get_active_route_contextual_help(), + } + } +} diff --git a/src/app/readarr/readarr_context_clues_tests.rs b/src/app/readarr/readarr_context_clues_tests.rs new file mode 100644 index 00000000..f9020633 --- /dev/null +++ b/src/app/readarr/readarr_context_clues_tests.rs @@ -0,0 +1,628 @@ +#[cfg(test)] +mod tests { + use crate::app::App; + use crate::app::context_clues::{ + BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClue, ContextClueProvider, + SYSTEM_TASKS_CONTEXT_CLUES, + }; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::app::readarr::readarr_context_clues::{ + ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES, AUTHOR_DETAILS_CONTEXT_CLUES, + AUTHOR_HISTORY_CONTEXT_CLUES, AUTHOR_OVERVIEW_CONTEXT_CLUES, AUTHORS_CONTEXT_CLUES, + BOOK_DETAILS_CONTEXT_CLUES, BOOK_FILE_CONTEXT_CLUES, BOOK_HISTORY_CONTEXT_CLUES, + EDITION_DETAILS_CONTEXT_CLUES, MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES, + MANUAL_BOOK_SEARCH_CONTEXT_CLUES, ReadarrContextClueProvider, + }; + use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::servarr_data::readarr::modals::BookDetailsModal; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, EDIT_AUTHOR_BLOCKS, EDIT_INDEXER_BLOCKS, + INDEXER_SETTINGS_BLOCKS, ReadarrData, + }; + use rstest::rstest; + + #[test] + fn test_authors_context_clues() { + let mut authors_context_clues_iter = AUTHORS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.toggle_monitoring, + DEFAULT_KEYBINDINGS.toggle_monitoring.desc + ) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.update, "update all") + ); + assert_some_eq_x!( + authors_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "cancel filter") + ); + assert_none!(authors_context_clues_iter.next()); + } + + #[test] + fn test_author_details_context_clues() { + let mut author_details_context_clues_iter = AUTHOR_DETAILS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ) + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.edit, "edit author") + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.delete, "delete book") + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.toggle_monitoring, + "toggle book monitoring", + ) + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc) + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc, + ) + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.view, "view overview") + ); + assert_some_eq_x!( + author_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(author_details_context_clues_iter.next()); + } + + #[test] + fn test_author_overview_context_clues() { + let mut author_overview_context_clues_iter = AUTHOR_OVERVIEW_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + author_overview_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + author_overview_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(author_overview_context_clues_iter.next()); + } + + #[test] + fn test_add_author_search_results_context_clues() { + let mut add_author_search_results_context_clues_iter = + ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + add_author_search_results_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + add_author_search_results_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "edit search") + ); + assert_none!(add_author_search_results_context_clues_iter.next()); + } + + #[test] + #[should_panic( + expected = "ReadarrContextClueProvider::get_context_clues called with non-Readarr route" + )] + fn test_readarr_context_clue_provider_get_context_clues_non_readarr_route() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::default().into()); + + ReadarrContextClueProvider::get_context_clues(&mut app); + } + + #[test] + fn test_author_history_context_clues() { + let mut author_history_context_clues_iter = AUTHOR_HISTORY_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.edit, "edit author") + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc) + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc) + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc + ) + ); + assert_some_eq_x!( + author_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "cancel filter/close") + ); + assert_none!(author_history_context_clues_iter.next()); + } + + #[test] + fn test_manual_author_search_context_clues() { + let mut manual_author_search_context_clues_iter = MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.edit, "edit author") + ); + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc) + ); + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc + ) + ); + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + manual_author_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(manual_author_search_context_clues_iter.next()); + } + + #[test] + fn test_book_details_context_clues() { + let mut book_details_context_clues_iter = BOOK_DETAILS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + book_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + book_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + book_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc + ) + ); + assert_some_eq_x!( + book_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_some_eq_x!( + book_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "edition details") + ); + assert_none!(book_details_context_clues_iter.next()); + } + + #[test] + fn test_book_history_context_clues() { + let mut book_history_context_clues_iter = BOOK_HISTORY_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc) + ); + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc) + ); + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc + ) + ); + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + book_history_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, "cancel filter/close") + ); + assert_none!(book_history_context_clues_iter.next()); + } + + #[test] + fn test_book_file_context_clues() { + let mut book_file_context_clues_iter = BOOK_FILE_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + book_file_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + book_file_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.delete, "delete book file") + ); + assert_some_eq_x!( + book_file_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(book_file_context_clues_iter.next()); + } + + #[test] + fn test_manual_book_search_context_clues() { + let mut manual_book_search_context_clues_iter = MANUAL_BOOK_SEARCH_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + manual_book_search_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + manual_book_search_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.auto_search, + DEFAULT_KEYBINDINGS.auto_search.desc + ) + ); + assert_some_eq_x!( + manual_book_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc) + ); + assert_some_eq_x!( + manual_book_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.submit, "details") + ); + assert_some_eq_x!( + manual_book_search_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(manual_book_search_context_clues_iter.next()); + } + + #[test] + fn test_edition_details_context_clues() { + let mut edition_details_context_clues_iter = EDITION_DETAILS_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + edition_details_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + edition_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(edition_details_context_clues_iter.next()); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::AuthorDetails, &AUTHOR_DETAILS_CONTEXT_CLUES)] + #[case(1, ActiveReadarrBlock::AuthorHistory, &AUTHOR_HISTORY_CONTEXT_CLUES)] + #[case(2, ActiveReadarrBlock::ManualAuthorSearch, &MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES)] + fn test_readarr_context_clue_provider_author_info_tabs( + #[case] index: usize, + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] expected_context_clues: &[ContextClue], + ) { + let mut app = App::test_default(); + app.data.readarr_data = ReadarrData::default(); + app.data.readarr_data.author_info_tabs.set_index(index); + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, expected_context_clues); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::BookDetails, &BOOK_DETAILS_CONTEXT_CLUES)] + #[case(1, ActiveReadarrBlock::BookHistory, &BOOK_HISTORY_CONTEXT_CLUES)] + #[case(2, ActiveReadarrBlock::BookFileInfo, &BOOK_FILE_CONTEXT_CLUES)] + #[case(3, ActiveReadarrBlock::ManualBookSearch, &MANUAL_BOOK_SEARCH_CONTEXT_CLUES)] + fn test_readarr_context_clue_provider_book_details_tabs( + #[case] index: usize, + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] expected_context_clues: &[ContextClue], + ) { + let mut app = App::test_default(); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal.book_details_tabs.set_index(index); + let readarr_data = ReadarrData { + book_details_modal: Some(book_details_modal), + ..ReadarrData::default() + }; + app.data.readarr_data = readarr_data; + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, expected_context_clues); + } + + #[test] + fn test_readarr_context_clue_provider_book_edition_details_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &EDITION_DETAILS_CONTEXT_CLUES); + } + + #[test] + fn test_readarr_context_clue_provider_author_overview_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &AUTHOR_OVERVIEW_CONTEXT_CLUES); + } + + #[test] + fn test_readarr_context_clue_provider_authors_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &AUTHORS_CONTEXT_CLUES); + } + + #[test] + fn test_readarr_context_clue_provider_authors_sort_prompt_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorsSortPrompt.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &AUTHORS_CONTEXT_CLUES); + } + + #[test] + fn test_readarr_context_clue_provider_search_authors_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::SearchAuthors.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &AUTHORS_CONTEXT_CLUES); + } + + #[test] + fn test_readarr_context_clue_provider_filter_authors_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::FilterAuthors.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &AUTHORS_CONTEXT_CLUES); + } + + #[rstest] + fn test_readarr_context_clue_provider_bare_popup_context_clues( + #[values( + ActiveReadarrBlock::AddAuthorSearchInput, + ActiveReadarrBlock::AddAuthorEmptySearchResults, + ActiveReadarrBlock::TestAllIndexers, + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &BARE_POPUP_CONTEXT_CLUES); + } + + #[test] + fn test_readarr_context_clue_provider_confirmation_prompt_popup_clues_edit_indexer_blocks() { + let mut blocks = EDIT_AUTHOR_BLOCKS.to_vec(); + blocks.extend(ADD_ROOT_FOLDER_BLOCKS); + blocks.extend(INDEXER_SETTINGS_BLOCKS); + blocks.extend(EDIT_INDEXER_BLOCKS); + + for active_readarr_block in blocks { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + } + + #[test] + fn test_readarr_context_clue_provider_add_author_search_results_context_clues() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES); + } + + #[rstest] + fn test_readarr_context_clue_provider_confirmation_prompt_context_clues_add_author_blocks( + #[values( + ActiveReadarrBlock::AddAuthorPrompt, + ActiveReadarrBlock::AddAuthorSelectMonitor, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + ActiveReadarrBlock::AddAuthorSelectRootFolder, + ActiveReadarrBlock::AddAuthorTagsInput, + ActiveReadarrBlock::AddAuthorAlreadyInLibrary + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + + #[rstest] + fn test_readarr_context_clue_provider_confirmation_prompt_context_clues_delete_author_blocks( + #[values( + ActiveReadarrBlock::DeleteAuthorPrompt, + ActiveReadarrBlock::DeleteAuthorConfirmPrompt, + ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion, + ActiveReadarrBlock::DeleteAuthorToggleDeleteFile + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + + #[rstest] + fn test_readarr_context_clue_provider_confirmation_prompt_context_clues_delete_book_blocks( + #[values( + ActiveReadarrBlock::DeleteBookPrompt, + ActiveReadarrBlock::DeleteBookConfirmPrompt, + ActiveReadarrBlock::DeleteBookToggleAddListExclusion, + ActiveReadarrBlock::DeleteBookToggleDeleteFile + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + + #[test] + fn test_readarr_context_clue_provider_system_tasks_clues() { + let mut app = App::test_default(); + + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES); + } +} diff --git a/src/app/readarr/readarr_tests.rs b/src/app/readarr/readarr_tests.rs new file mode 100644 index 00000000..2b771b4e --- /dev/null +++ b/src/app/readarr/readarr_tests.rs @@ -0,0 +1,798 @@ +#[cfg(test)] +mod tests { + use crate::app::App; + use crate::models::readarr_models::{Author, Book, ReadarrRelease}; + use crate::models::servarr_data::readarr::modals::BookDetailsModal; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::servarr_models::Indexer; + use crate::network::NetworkEvent; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::{assert_eq, assert_str_eq}; + use tokio::sync::mpsc; + + #[tokio::test] + async fn test_dispatch_by_readarr_block_authors() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::Authors) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetMetadataProfiles.into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into()); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::ListAuthors.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_readarr_block_author_details() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::AuthorDetails) + .await; + + assert!(app.is_loading); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetBooks(1).into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_blocklist_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::Blocklist) + .await; + + assert!(app.is_loading); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetBlocklist.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_author_history_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::AuthorHistory) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetAuthorHistory(1).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_author_search_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::ManualAuthorSearch) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetAuthorReleases(1).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_author_search_block_author_releases_non_empty() { + let mut app = App::test_default(); + app + .data + .readarr_data + .author_releases + .set_items(vec![ReadarrRelease::default()]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::ManualAuthorSearch) + .await; + + assert!(!app.is_loading); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_book_details_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.data.readarr_data.books.set_items(vec![Book { + id: 2, + ..Book::default() + }]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::BookDetails) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetBookEditions(2).into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetBookFiles(2).into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_book_history_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.data.readarr_data.books.set_items(vec![Book { + id: 2, + ..Book::default() + }]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::BookHistory) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetBookHistory(1, 2).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_book_history_block_no_op_when_books_table_is_empty() { + let (tx, _) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::BookHistory) + .await; + + assert!(!app.is_loading); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_book_search_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.data.readarr_data.books.set_items(vec![Book { + id: 2, + ..Book::default() + }]); + app.data.readarr_data.book_details_modal = Some(BookDetailsModal::default()); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::ManualBookSearch) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetBookReleases(2).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_book_search_block_is_loading() { + let mut app = App { + is_loading: true, + ..App::test_default() + }; + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::ManualBookSearch) + .await; + + assert!(app.is_loading); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_manual_book_search_block_book_releases_non_empty() { + let mut app = App::test_default(); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .book_releases + .set_items(vec![ReadarrRelease::default()]); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::ManualBookSearch) + .await; + + assert!(!app.is_loading); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_book_edition_details_block_does_not_dispatch() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.prompt_confirm = false; + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::BookEditionDetails) + .await; + + assert!(!app.is_loading); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_downloads_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::Downloads) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_readarr_block_add_author_search_results() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.add_author_search = Some("test author".into()); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::AddAuthorSearchResults) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::SearchNewAuthor("test author".to_owned()).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_history_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::History) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetHistory(500).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_root_folders_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::RootFolders) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetRootFolders.into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_indexers_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::Indexers) + .await; + + assert!(app.is_loading); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into()); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetIndexers.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_all_indexer_settings_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::AllIndexerSettingsPrompt) + .await; + + assert!(app.is_loading); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetAllIndexerSettings.into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_test_indexer_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.indexers.set_items(vec![Indexer { + id: 3, + ..Indexer::default() + }]); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::TestIndexer) + .await; + + assert!(app.is_loading); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::TestIndexer(3).into() + ); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_test_all_indexers_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::TestAllIndexers) + .await; + + assert!(app.is_loading); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::TestAllIndexers.into() + ); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_system_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::System) + .await; + + assert!(app.is_loading); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTasks.into()); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetQueuedEvents.into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetLogs(500).into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_system_updates_block() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::SystemUpdates) + .await; + + assert!(app.is_loading); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetUpdates.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_readarr_block_resets_the_tick_count() { + let mut app = App { + tick_count: 2, + ..App::test_default() + }; + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::Authors) + .await; + + assert_eq!(app.tick_count, 0); + } + + #[tokio::test] + async fn test_dispatch_by_readarr_block_clears_the_pending_prompt_confirmation() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + + app + .dispatch_by_readarr_block(&ActiveReadarrBlock::Authors) + .await; + + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.should_refresh); + } + + #[tokio::test] + async fn test_check_for_readarr_prompt_action_no_prompt_confirm() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = false; + + app.check_for_readarr_prompt_action().await; + + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.should_refresh); + } + + #[tokio::test] + async fn test_check_for_readarr_prompt_action_with_no_prompt_confirm_action() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + + app.check_for_readarr_prompt_action().await; + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + } + + #[tokio::test] + async fn test_check_for_readarr_prompt_action() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::GetStatus); + + app.check_for_readarr_prompt_action().await; + + assert!(!app.data.readarr_data.prompt_confirm); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetStatus.into()); + assert!(app.should_refresh); + assert_eq!(app.data.readarr_data.prompt_confirm_action, None); + } + + #[tokio::test] + async fn test_readarr_refresh_metadata() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.is_routing = true; + + app.refresh_readarr_metadata().await; + + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetMetadataProfiles.into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into()); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetRootFolders.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetDiskSpace.into()); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetStatus.into()); + assert!(app.is_loading); + } + + #[tokio::test] + async fn test_readarr_on_tick_first_render() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.is_first_render = true; + + app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetMetadataProfiles.into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into()); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetRootFolders.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetDiskSpace.into()); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetStatus.into()); + assert!(app.is_loading); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.is_first_render); + } + + #[tokio::test] + async fn test_readarr_on_tick_routing() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.is_routing = true; + app.should_refresh = true; + app.is_first_render = false; + app.tick_count = 1; + + app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[tokio::test] + async fn test_readarr_on_tick_routing_while_long_request_is_running_should_cancel_request() { + let (tx, _) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.is_routing = true; + app.should_refresh = false; + app.is_first_render = false; + app.tick_count = 1; + + app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + + assert!(app.cancellation_token.is_cancelled()); + } + + #[tokio::test] + async fn test_readarr_on_tick_should_refresh() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.should_refresh = true; + app.is_first_render = false; + app.tick_count = 1; + + app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert!(app.should_refresh); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[tokio::test] + async fn test_readarr_on_tick_should_refresh_does_not_cancel_prompt_requests() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.is_loading = true; + app.is_routing = true; + app.should_refresh = true; + app.is_first_render = false; + app.tick_count = 1; + + app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert!(app.is_loading); + assert!(app.should_refresh); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.cancellation_token.is_cancelled()); + } + + #[tokio::test] + async fn test_readarr_on_tick_network_tick_frequency() { + let (tx, mut rx) = mpsc::channel::(500); + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.network_tx = Some(tx); + app.is_first_render = false; + app.tick_count = 2; + app.tick_until_poll = 2; + + app.readarr_on_tick(ActiveReadarrBlock::Downloads).await; + + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetQualityProfiles.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetMetadataProfiles.into() + ); + assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into()); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetRootFolders.into() + ); + assert_eq!( + rx.recv().await.unwrap(), + ReadarrEvent::GetDownloads(500).into() + ); + assert!(app.is_loading); + } + + #[tokio::test] + async fn test_extract_add_new_author_search_query() { + let app = App::test_default_fully_populated(); + + let query = app.extract_add_new_author_search_query().await; + + assert_str_eq!(query, "Test Author"); + } + + #[tokio::test] + #[should_panic(expected = "Add author search is empty")] + async fn test_extract_add_new_author_search_query_panics_when_the_query_is_not_set() { + let app = App::test_default(); + + app.extract_add_new_author_search_query().await; + } + + #[tokio::test] + async fn test_extract_author_id() { + let mut app = App::test_default(); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + + assert_eq!(app.extract_author_id().await, 1); + } + + #[tokio::test] + async fn test_extract_book_id() { + let mut app = App::test_default(); + app.data.readarr_data.books.set_items(vec![Book { + id: 2, + ..Book::default() + }]); + + assert_eq!(app.extract_book_id().await, 2); + } + + #[tokio::test] + async fn test_extract_readarr_indexer_id() { + let mut app = App::test_default(); + app.data.readarr_data.indexers.set_items(vec![Indexer { + id: 3, + ..Indexer::default() + }]); + + assert_eq!(app.extract_readarr_indexer_id().await, 3); + } +} diff --git a/src/app/sonarr/sonarr_context_clues.rs b/src/app/sonarr/sonarr_context_clues.rs index 90d8aada..1ed267a0 100644 --- a/src/app/sonarr/sonarr_context_clues.rs +++ b/src/app/sonarr/sonarr_context_clues.rs @@ -5,8 +5,9 @@ use crate::app::context_clues::{ use crate::app::{App, context_clues::ContextClue, key_binding::DEFAULT_KEYBINDINGS}; use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ - ADD_SERIES_BLOCKS, ActiveSonarrBlock, EDIT_INDEXER_BLOCKS, EDIT_SERIES_BLOCKS, - EPISODE_DETAILS_BLOCKS, INDEXER_SETTINGS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, + ADD_SERIES_BLOCKS, ActiveSonarrBlock, DELETE_SERIES_BLOCKS, EDIT_INDEXER_BLOCKS, + EDIT_SERIES_BLOCKS, EPISODE_DETAILS_BLOCKS, INDEXER_SETTINGS_BLOCKS, SEASON_DETAILS_BLOCKS, + SERIES_DETAILS_BLOCKS, SERIES_OVERVIEW_BLOCKS, }; #[cfg(test)] @@ -38,7 +39,7 @@ pub static SERIES_CONTEXT_CLUES: [ContextClue; 11] = [ (DEFAULT_KEYBINDINGS.esc, "cancel filter"), ]; -pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ +pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 9] = [ ( DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh.desc, @@ -55,6 +56,15 @@ pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ DEFAULT_KEYBINDINGS.auto_search, DEFAULT_KEYBINDINGS.auto_search.desc, ), + (DEFAULT_KEYBINDINGS.view, "view overview"), + (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), +]; + +pub static SERIES_OVERVIEW_CONTEXT_CLUES: [ContextClue; 2] = [ + ( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc, + ), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), ]; @@ -177,6 +187,9 @@ impl ContextClueProvider for SonarrContextClueProvider { .sonarr_data .series_info_tabs .get_active_route_contextual_help(), + _ if SERIES_OVERVIEW_BLOCKS.contains(&active_sonarr_block) => { + Some(&SERIES_OVERVIEW_CONTEXT_CLUES) + } _ if SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block) => app .data .sonarr_data @@ -207,6 +220,9 @@ impl ContextClueProvider for SonarrContextClueProvider { { Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) } + _ if DELETE_SERIES_BLOCKS.contains(&active_sonarr_block) => { + Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES) + } ActiveSonarrBlock::AddSeriesPrompt | ActiveSonarrBlock::AddSeriesSelectMonitor | ActiveSonarrBlock::AddSeriesSelectSeriesType diff --git a/src/app/sonarr/sonarr_context_clues_tests.rs b/src/app/sonarr/sonarr_context_clues_tests.rs index 4f02fc35..09479525 100644 --- a/src/app/sonarr/sonarr_context_clues_tests.rs +++ b/src/app/sonarr/sonarr_context_clues_tests.rs @@ -16,7 +16,7 @@ mod tests { ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES, EPISODE_DETAILS_CONTEXT_CLUES, MANUAL_EPISODE_SEARCH_CONTEXT_CLUES, MANUAL_SEASON_SEARCH_CONTEXT_CLUES, SEASON_DETAILS_CONTEXT_CLUES, SEASON_HISTORY_CONTEXT_CLUES, SERIES_CONTEXT_CLUES, - SERIES_DETAILS_CONTEXT_CLUES, SERIES_HISTORY_CONTEXT_CLUES, + SERIES_DETAILS_CONTEXT_CLUES, SERIES_HISTORY_CONTEXT_CLUES, SERIES_OVERVIEW_CONTEXT_CLUES, }, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; @@ -187,6 +187,10 @@ mod tests { DEFAULT_KEYBINDINGS.auto_search.desc ) ); + assert_some_eq_x!( + series_details_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.view, "view overview") + ); assert_some_eq_x!( series_details_context_clues_iter.next(), &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) @@ -194,6 +198,24 @@ mod tests { assert_none!(series_details_context_clues_iter.next()); } + #[test] + fn test_series_overview_context_clues() { + let mut series_overview_context_clues_iter = SERIES_OVERVIEW_CONTEXT_CLUES.iter(); + + assert_some_eq_x!( + series_overview_context_clues_iter.next(), + &( + DEFAULT_KEYBINDINGS.refresh, + DEFAULT_KEYBINDINGS.refresh.desc + ) + ); + assert_some_eq_x!( + series_overview_context_clues_iter.next(), + &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc) + ); + assert_none!(series_overview_context_clues_iter.next()); + } + #[test] fn test_season_details_context_clues() { let mut season_details_context_clues_iter = SEASON_DETAILS_CONTEXT_CLUES.iter(); @@ -442,6 +464,16 @@ mod tests { assert_some_eq_x!(context_clues, expected_context_clues); } + #[test] + fn test_sonarr_context_clue_provider_series_overview_block() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + + let context_clues = SonarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &SERIES_OVERVIEW_CONTEXT_CLUES); + } + #[rstest] #[case(0, ActiveSonarrBlock::SeasonDetails, &SEASON_DETAILS_CONTEXT_CLUES)] #[case(1, ActiveSonarrBlock::SeasonHistory, &SEASON_HISTORY_CONTEXT_CLUES)] @@ -603,6 +635,24 @@ mod tests { assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); } + #[rstest] + fn test_sonarr_context_clue_provider_confirmation_prompt_popup_clues_delete_series_blocks( + #[values( + ActiveSonarrBlock::DeleteSeriesPrompt, + ActiveSonarrBlock::DeleteSeriesConfirmPrompt, + ActiveSonarrBlock::DeleteSeriesToggleAddListExclusion, + ActiveSonarrBlock::DeleteSeriesToggleDeleteFile + )] + active_sonarr_block: ActiveSonarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_sonarr_block.into()); + + let context_clues = SonarrContextClueProvider::get_context_clues(&mut app); + + assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES); + } + #[rstest] fn test_sonarr_context_clue_provider_add_series_search_results_clues( #[values( diff --git a/src/cli/cli_tests.rs b/src/cli/cli_tests.rs index 08fb2875..6a5c968f 100644 --- a/src/cli/cli_tests.rs +++ b/src/cli/cli_tests.rs @@ -9,7 +9,9 @@ mod tests { use tokio::sync::Mutex; use crate::cli::lidarr::LidarrCommand; + use crate::cli::readarr::ReadarrCommand; use crate::network::lidarr_network::LidarrEvent; + use crate::network::readarr_network::ReadarrEvent; use crate::{ Cli, app::App, @@ -24,6 +26,10 @@ mod tests { BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse, RadarrSerdeable, }, + readarr_models::{ + BlocklistItem as ReadarrBlocklistItem, BlocklistResponse as ReadarrBlocklistResponse, + ReadarrSerdeable, + }, sonarr_models::{ BlocklistItem as SonarrBlocklistItem, BlocklistResponse as SonarrBlocklistResponse, SonarrSerdeable, @@ -68,6 +74,13 @@ mod tests { assert_ok!(&result); } + #[test] + fn test_readarr_subcommand_delegates_to_readarr() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", "authors"]); + + assert_ok!(&result); + } + #[test] fn test_completions_requires_argument() { let result = Cli::command().try_get_matches_from(["managarr", "completions"]); @@ -218,4 +231,35 @@ mod tests { assert_ok!(&result); } + + #[tokio::test] + async fn test_cli_handler_delegates_readarr_commands_to_the_readarr_cli_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::BlocklistResponse( + ReadarrBlocklistResponse { + records: vec![ReadarrBlocklistItem::default()], + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::ClearBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let clear_blocklist_command = ReadarrCommand::ClearBlocklist.into(); + + let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await; + + assert_ok!(&result); + } } diff --git a/src/cli/lidarr/edit_command_handler_tests.rs b/src/cli/lidarr/edit_command_handler_tests.rs index ab44b310..37a9dcf5 100644 --- a/src/cli/lidarr/edit_command_handler_tests.rs +++ b/src/cli/lidarr/edit_command_handler_tests.rs @@ -613,10 +613,10 @@ mod tests { async fn test_handle_edit_all_indexer_settings_command() { let expected_edit_all_indexer_settings = IndexerSettings { id: 1, - maximum_size: 1, - minimum_age: 1, - retention: 1, - rss_sync_interval: 1, + maximum_size: 12345, + minimum_age: 11, + retention: 29, + rss_sync_interval: 60, }; let mut mock_network = MockNetworkTrait::new(); mock_network @@ -628,11 +628,11 @@ mod tests { .returning(|_| { Ok(Serdeable::Lidarr(LidarrSerdeable::IndexerSettings( IndexerSettings { - id: 1, - maximum_size: 2, - minimum_age: 2, - retention: 2, - rss_sync_interval: 2, + id: 5, + maximum_size: 22222, + minimum_age: 34, + retention: 48, + rss_sync_interval: 90, }, ))) }); @@ -649,10 +649,10 @@ mod tests { }); let app_arc = Arc::new(Mutex::new(App::test_default())); let edit_all_indexer_settings_command = LidarrEditCommand::AllIndexerSettings { - maximum_size: Some(1), - minimum_age: Some(1), - retention: Some(1), - rss_sync_interval: Some(1), + maximum_size: Some(12345), + minimum_age: Some(11), + retention: Some(29), + rss_sync_interval: Some(60), }; let result = LidarrEditCommandHandler::with( diff --git a/src/cli/lidarr/lidarr_command_tests.rs b/src/cli/lidarr/lidarr_command_tests.rs index 87dc6546..21c19dbe 100644 --- a/src/cli/lidarr/lidarr_command_tests.rs +++ b/src/cli/lidarr/lidarr_command_tests.rs @@ -284,10 +284,8 @@ mod tests { use crate::cli::lidarr::manual_search_command_handler::LidarrManualSearchCommand; use crate::cli::lidarr::refresh_command_handler::LidarrRefreshCommand; use crate::cli::lidarr::trigger_automatic_search_command_handler::LidarrTriggerAutomaticSearchCommand; - use crate::models::lidarr_models::{ - BlocklistItem, BlocklistResponse, LidarrReleaseDownloadBody, LidarrTaskName, - }; - use crate::models::servarr_models::IndexerSettings; + use crate::models::lidarr_models::{BlocklistItem, BlocklistResponse, LidarrTaskName}; + use crate::models::servarr_models::{IndexerSettings, ReleaseDownloadBody}; use crate::{ app::App, cli::{ @@ -583,7 +581,7 @@ mod tests { #[tokio::test] async fn test_download_release_command() { - let expected_release_download_body = LidarrReleaseDownloadBody { + let expected_release_download_body = ReleaseDownloadBody { guid: "guid".to_owned(), indexer_id: 1, }; diff --git a/src/cli/lidarr/list_command_handler_tests.rs b/src/cli/lidarr/list_command_handler_tests.rs index 07456a1a..e047c412 100644 --- a/src/cli/lidarr/list_command_handler_tests.rs +++ b/src/cli/lidarr/list_command_handler_tests.rs @@ -631,7 +631,7 @@ mod tests { async fn test_handle_list_track_history_command() { let expected_artist_id = 1; let expected_album_id = 1; - let expected_track_id = 1; + let expected_track_id = 5; let mut mock_network = MockNetworkTrait::new(); mock_network .expect_handle_network_event() diff --git a/src/cli/lidarr/mod.rs b/src/cli/lidarr/mod.rs index 7ef4c255..6a3a6643 100644 --- a/src/cli/lidarr/mod.rs +++ b/src/cli/lidarr/mod.rs @@ -18,7 +18,8 @@ use super::{CliCommandHandler, Command}; use crate::cli::lidarr::manual_search_command_handler::{ LidarrManualSearchCommand, LidarrManualSearchCommandHandler, }; -use crate::models::lidarr_models::{LidarrReleaseDownloadBody, LidarrTaskName}; +use crate::models::lidarr_models::LidarrTaskName; +use crate::models::servarr_models::ReleaseDownloadBody; use crate::network::lidarr_network::LidarrEvent; use crate::{app::App, network::NetworkTrait}; @@ -231,7 +232,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, ' serde_json::to_string_pretty(&resp)? } LidarrCommand::DownloadRelease { guid, indexer_id } => { - let params = LidarrReleaseDownloadBody { guid, indexer_id }; + let params = ReleaseDownloadBody { guid, indexer_id }; let resp = self .network .handle_network_event(LidarrEvent::DownloadRelease(params).into()) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 858952bc..1492b53d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,6 +6,7 @@ use clap_complete::Shell; use indoc::indoc; use lidarr::{LidarrCliHandler, LidarrCommand}; use radarr::{RadarrCliHandler, RadarrCommand}; +use readarr::{ReadarrCliHandler, ReadarrCommand}; use sonarr::{SonarrCliHandler, SonarrCommand}; use tokio::sync::Mutex; @@ -13,6 +14,7 @@ use crate::{app::App, network::NetworkTrait}; pub mod lidarr; pub mod radarr; +pub mod readarr; pub mod sonarr; #[cfg(test)] @@ -30,6 +32,9 @@ pub enum Command { #[command(subcommand, about = "Commands for manging your Lidarr instance")] Lidarr(LidarrCommand), + #[command(subcommand, about = "Commands for managing your Readarr instance")] + Readarr(ReadarrCommand), + #[command( arg_required_else_help = true, about = "Generate shell completions for the Managarr CLI" @@ -78,6 +83,11 @@ pub(crate) async fn handle_command( .handle() .await? } + Command::Readarr(readarr_command) => { + ReadarrCliHandler::with(app, readarr_command, network) + .handle() + .await? + } _ => String::new(), }; diff --git a/src/cli/radarr/edit_command_handler_tests.rs b/src/cli/radarr/edit_command_handler_tests.rs index 02a697a3..998f96f8 100644 --- a/src/cli/radarr/edit_command_handler_tests.rs +++ b/src/cli/radarr/edit_command_handler_tests.rs @@ -833,13 +833,13 @@ mod tests { async fn test_handle_edit_all_indexer_settings_command() { let expected_edit_all_indexer_settings = IndexerSettings { allow_hardcoded_subs: true, - availability_delay: 1, + availability_delay: 19, id: 1, - maximum_size: 1, - minimum_age: 1, + maximum_size: 18400, + minimum_age: 27, prefer_indexer_flags: true, - retention: 1, - rss_sync_interval: 1, + retention: 44, + rss_sync_interval: 60, whitelisted_hardcoded_subs: "test".into(), }; let mut mock_network = MockNetworkTrait::new(); @@ -853,13 +853,13 @@ mod tests { Ok(Serdeable::Radarr(RadarrSerdeable::IndexerSettings( IndexerSettings { allow_hardcoded_subs: false, - availability_delay: 2, - id: 1, - maximum_size: 2, - minimum_age: 2, + availability_delay: 31, + id: 8, + maximum_size: 22750, + minimum_age: 52, prefer_indexer_flags: false, - retention: 2, - rss_sync_interval: 2, + retention: 66, + rss_sync_interval: 90, whitelisted_hardcoded_subs: "testing".into(), }, ))) @@ -879,13 +879,13 @@ mod tests { let edit_all_indexer_settings_command = RadarrEditCommand::AllIndexerSettings { allow_hardcoded_subs: true, disable_allow_hardcoded_subs: false, - availability_delay: Some(1), - maximum_size: Some(1), - minimum_age: Some(1), + availability_delay: Some(19), + maximum_size: Some(18400), + minimum_age: Some(27), prefer_indexer_flags: true, disable_prefer_indexer_flags: false, - retention: Some(1), - rss_sync_interval: Some(1), + retention: Some(44), + rss_sync_interval: Some(60), whitelisted_subtitle_tags: Some("test".to_owned()), }; @@ -904,13 +904,13 @@ mod tests { async fn test_handle_edit_all_indexer_settings_command_disable_flags_function_properly() { let expected_edit_all_indexer_settings = IndexerSettings { allow_hardcoded_subs: false, - availability_delay: 1, + availability_delay: 19, id: 1, - maximum_size: 1, - minimum_age: 1, + maximum_size: 18400, + minimum_age: 27, prefer_indexer_flags: false, - retention: 1, - rss_sync_interval: 1, + retention: 44, + rss_sync_interval: 60, whitelisted_hardcoded_subs: "test".into(), }; let mut mock_network = MockNetworkTrait::new(); @@ -924,13 +924,13 @@ mod tests { Ok(Serdeable::Radarr(RadarrSerdeable::IndexerSettings( IndexerSettings { allow_hardcoded_subs: true, - availability_delay: 2, - id: 1, - maximum_size: 2, - minimum_age: 2, + availability_delay: 31, + id: 8, + maximum_size: 22750, + minimum_age: 52, prefer_indexer_flags: true, - retention: 2, - rss_sync_interval: 2, + retention: 66, + rss_sync_interval: 90, whitelisted_hardcoded_subs: "testing".into(), }, ))) @@ -950,13 +950,13 @@ mod tests { let edit_all_indexer_settings_command = RadarrEditCommand::AllIndexerSettings { allow_hardcoded_subs: false, disable_allow_hardcoded_subs: true, - availability_delay: Some(1), - maximum_size: Some(1), - minimum_age: Some(1), + availability_delay: Some(19), + maximum_size: Some(18400), + minimum_age: Some(27), prefer_indexer_flags: false, disable_prefer_indexer_flags: true, - retention: Some(1), - rss_sync_interval: Some(1), + retention: Some(44), + rss_sync_interval: Some(60), whitelisted_subtitle_tags: Some("test".to_owned()), }; @@ -976,13 +976,13 @@ mod tests { { let expected_edit_all_indexer_settings = IndexerSettings { allow_hardcoded_subs: true, - availability_delay: 2, + availability_delay: 31, id: 1, - maximum_size: 2, - minimum_age: 2, - prefer_indexer_flags: true, - retention: 2, - rss_sync_interval: 2, + maximum_size: 22750, + minimum_age: 52, + prefer_indexer_flags: false, + retention: 66, + rss_sync_interval: 90, whitelisted_hardcoded_subs: "testing".into(), }; let mut mock_network = MockNetworkTrait::new(); @@ -996,13 +996,13 @@ mod tests { Ok(Serdeable::Radarr(RadarrSerdeable::IndexerSettings( IndexerSettings { allow_hardcoded_subs: true, - availability_delay: 2, - id: 1, - maximum_size: 2, - minimum_age: 2, - prefer_indexer_flags: true, - retention: 2, - rss_sync_interval: 2, + availability_delay: 31, + id: 8, + maximum_size: 22750, + minimum_age: 52, + prefer_indexer_flags: false, + retention: 66, + rss_sync_interval: 90, whitelisted_hardcoded_subs: "testing".into(), }, ))) diff --git a/src/cli/readarr/add_command_handler.rs b/src/cli/readarr/add_command_handler.rs new file mode 100644 index 00000000..36d6e919 --- /dev/null +++ b/src/cli/readarr/add_command_handler.rs @@ -0,0 +1,224 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::{ArgAction, Subcommand}; +use tokio::sync::Mutex; + +use super::ReadarrCommand; +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + models::readarr_models::{ + AddAuthorBody, AddAuthorOptions, AddReadarrRootFolderBody, MonitorType, NewItemMonitorType, + }, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +#[cfg(test)] +#[path = "add_command_handler_tests.rs"] +mod add_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrAddCommand { + #[command(about = "Add a new author to your Readarr library")] + Author { + #[arg( + long, + help = "The Goodreads foreign author ID of the author you wish to add to your library", + required = true + )] + foreign_author_id: String, + #[arg(long, help = "The name of the author", required = true)] + author_name: String, + #[arg( + long, + help = "The root folder path where all author data and metadata should live", + required = true + )] + root_folder_path: String, + #[arg( + long, + help = "The ID of the quality profile to use for this author", + required = true + )] + quality_profile_id: i64, + #[arg( + long, + help = "The ID of the metadata profile to use for this author", + required = true + )] + metadata_profile_id: i64, + #[arg(long, help = "Disable monitoring for this author")] + disable_monitoring: bool, + #[arg( + long, + help = "Tag IDs to tag the author with", + value_parser, + action = ArgAction::Append + )] + tag: Vec, + #[arg( + long, + help = "What Readarr should monitor for this author", + value_enum, + default_value_t = MonitorType::default() + )] + monitor: MonitorType, + #[arg( + long, + help = "How Readarr should monitor new items for this author", + value_enum, + default_value_t = NewItemMonitorType::default() + )] + monitor_new_items: NewItemMonitorType, + #[arg( + long, + help = "Tell Readarr to not start a search for missing books once the author is added to your library" + )] + no_search_for_missing_books: bool, + }, + #[command(about = "Add a new root folder")] + RootFolder { + #[arg(long, help = "The name of the root folder", required = true)] + name: String, + #[arg(long, help = "The path of the new root folder", required = true)] + root_folder_path: String, + #[arg( + long, + help = "The ID of the default quality profile for authors in this root folder", + required = true + )] + quality_profile_id: i64, + #[arg( + long, + help = "The ID of the default metadata profile for authors in this root folder", + required = true + )] + metadata_profile_id: i64, + #[arg( + long, + help = "The default monitor option for authors in this root folder", + value_enum, + default_value_t = MonitorType::default() + )] + monitor: MonitorType, + #[arg( + long, + help = "The default monitor new items option for authors in this root folder", + value_enum, + default_value_t = NewItemMonitorType::default() + )] + monitor_new_items: NewItemMonitorType, + #[arg( + long, + help = "Default tag IDs for authors in this root folder", + value_parser, + action = ArgAction::Append + )] + tag: Vec, + }, + #[command(about = "Add new tag")] + Tag { + #[arg(long, help = "The name of the tag to be added", required = true)] + name: String, + }, +} + +impl From for Command { + fn from(value: ReadarrAddCommand) -> Self { + Command::Readarr(ReadarrCommand::Add(value)) + } +} + +pub(super) struct ReadarrAddCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrAddCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrAddCommand> for ReadarrAddCommandHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: ReadarrAddCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrAddCommandHandler { + _app: app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrAddCommand::Author { + foreign_author_id, + author_name, + root_folder_path, + quality_profile_id, + metadata_profile_id, + disable_monitoring, + tag: tags, + monitor, + monitor_new_items, + no_search_for_missing_books, + } => { + let body = AddAuthorBody { + foreign_author_id, + author_name, + monitored: !disable_monitoring, + root_folder_path, + quality_profile_id, + metadata_profile_id, + tags, + images: Vec::new(), + tag_input_string: None, + add_options: AddAuthorOptions { + monitor, + monitor_new_items, + search_for_missing_books: !no_search_for_missing_books, + }, + }; + let resp = self + .network + .handle_network_event(ReadarrEvent::AddAuthor(body).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrAddCommand::RootFolder { + name, + root_folder_path, + quality_profile_id, + metadata_profile_id, + monitor, + monitor_new_items, + tag: tags, + } => { + let add_root_folder_body = AddReadarrRootFolderBody { + name, + path: root_folder_path, + default_quality_profile_id: quality_profile_id, + default_metadata_profile_id: metadata_profile_id, + default_monitor_option: monitor, + default_new_item_monitor_option: monitor_new_items, + default_tags: tags, + tag_input_string: None, + }; + let resp = self + .network + .handle_network_event(ReadarrEvent::AddRootFolder(add_root_folder_body).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrAddCommand::Tag { name } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::AddTag(name).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/add_command_handler_tests.rs b/src/cli/readarr/add_command_handler_tests.rs new file mode 100644 index 00000000..76f962cc --- /dev/null +++ b/src/cli/readarr/add_command_handler_tests.rs @@ -0,0 +1,562 @@ +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser, error::ErrorKind}; + + use crate::{ + Cli, + cli::{ + Command, + readarr::{ReadarrCommand, add_command_handler::ReadarrAddCommand}, + }, + models::readarr_models::{MonitorType, NewItemMonitorType}, + }; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_add_command_from() { + let command = ReadarrAddCommand::Tag { + name: String::new(), + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Add(command))); + } + + mod cli { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_add_author_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "add", "author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_foreign_author_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--author-name", + "Test", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_author_name() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_root_folder_path() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_quality_profile_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test", + "--root-folder-path", + "/nfs/books", + "--metadata-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_requires_metadata_profile_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_author_success_with_required_args_only() { + let expected_args = ReadarrAddCommand::Author { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + disable_monitoring: false, + tag: vec![], + monitor: MonitorType::default(), + monitor_new_items: NewItemMonitorType::default(), + no_search_for_missing_books: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type") + }; + assert_eq!(add_command, expected_args); + } + + #[test] + fn test_add_author_success_with_all_args() { + let expected_args = ReadarrAddCommand::Author { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 2, + disable_monitoring: true, + tag: vec![1, 2], + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::New, + no_search_for_missing_books: true, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "2", + "--disable-monitoring", + "--tag", + "1", + "--tag", + "2", + "--monitor", + "future", + "--monitor-new-items", + "new", + "--no-search-for-missing-books", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type") + }; + assert_eq!(add_command, expected_args); + } + + #[test] + fn test_add_author_monitor_type_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "2", + "--monitor", + "test", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_add_author_new_item_monitor_type_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "2", + "--monitor-new-items", + "test", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_add_author_tags_is_repeatable() { + let expected_args = ReadarrAddCommand::Author { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 2, + disable_monitoring: false, + tag: vec![1, 2], + monitor: MonitorType::default(), + monitor_new_items: NewItemMonitorType::default(), + no_search_for_missing_books: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "add", + "author", + "--foreign-author-id", + "test-id", + "--author-name", + "Test Author", + "--root-folder-path", + "/nfs/books", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "2", + "--tag", + "1", + "--tag", + "2", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type") + }; + assert_eq!(add_command, expected_args); + } + + #[test] + fn test_add_root_folder_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "add", "root-folder"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_root_folder_success() { + let expected_args = ReadarrAddCommand::RootFolder { + name: "Books".to_owned(), + root_folder_path: "/nfs/test".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + tag: vec![], + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "add", + "root-folder", + "--name", + "Books", + "--root-folder-path", + "/nfs/test", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type"); + }; + assert_eq!(add_command, expected_args); + } + + #[test] + fn test_add_tag_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "add", "tag"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_tag_success() { + let expected_args = ReadarrAddCommand::Tag { + name: "test".to_owned(), + }; + + let result = Cli::try_parse_from(["managarr", "readarr", "add", "tag", "--name", "test"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Add(add_command))) = result.unwrap().command else { + panic!("Unexpected command type"); + }; + assert_eq!(add_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::CliCommandHandler; + use crate::cli::readarr::add_command_handler::{ReadarrAddCommand, ReadarrAddCommandHandler}; + use crate::models::Serdeable; + use crate::models::readarr_models::{ + AddAuthorBody, AddAuthorOptions, AddReadarrRootFolderBody, MonitorType, NewItemMonitorType, + ReadarrSerdeable, + }; + use crate::network::readarr_network::ReadarrEvent; + use crate::{ + app::App, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_add_author_command() { + let expected_body = AddAuthorBody { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + monitored: false, + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + tags: vec![1, 2], + images: Vec::new(), + tag_input_string: None, + add_options: AddAuthorOptions { + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + search_for_missing_books: false, + }, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::AddAuthor(expected_body).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let add_author_command = ReadarrAddCommand::Author { + foreign_author_id: "test-id".to_owned(), + author_name: "Test Author".to_owned(), + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + disable_monitoring: true, + tag: vec![1, 2], + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + no_search_for_missing_books: true, + }; + + let result = ReadarrAddCommandHandler::with(&app_arc, add_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_add_root_folder_command() { + let expected_root_folder_path = "/nfs/test".to_owned(); + let expected_add_root_folder_body = AddReadarrRootFolderBody { + name: "Books".to_owned(), + path: expected_root_folder_path.clone(), + default_quality_profile_id: 1, + default_metadata_profile_id: 1, + default_monitor_option: MonitorType::All, + default_new_item_monitor_option: NewItemMonitorType::All, + default_tags: vec![1, 2], + tag_input_string: None, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::AddRootFolder(expected_add_root_folder_body.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let add_root_folder_command = ReadarrAddCommand::RootFolder { + name: "Books".to_owned(), + root_folder_path: expected_root_folder_path, + quality_profile_id: 1, + metadata_profile_id: 1, + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + tag: vec![1, 2], + }; + + let result = + ReadarrAddCommandHandler::with(&app_arc, add_root_folder_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_add_tag_command() { + let expected_tag_name = "test".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::AddTag(expected_tag_name.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let add_tag_command = ReadarrAddCommand::Tag { + name: expected_tag_name, + }; + + let result = ReadarrAddCommandHandler::with(&app_arc, add_tag_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/delete_command_handler.rs b/src/cli/readarr/delete_command_handler.rs new file mode 100644 index 00000000..f0edd0c5 --- /dev/null +++ b/src/cli/readarr/delete_command_handler.rs @@ -0,0 +1,182 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use super::ReadarrCommand; +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + models::readarr_models::DeleteParams, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +#[cfg(test)] +#[path = "delete_command_handler_tests.rs"] +mod delete_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrDeleteCommand { + #[command(about = "Delete an author from your Readarr library")] + Author { + #[arg(long, help = "The ID of the author to delete", required = true)] + author_id: i64, + #[arg(long, help = "Delete the author files from disk as well")] + delete_files_from_disk: bool, + #[arg(long, help = "Add a list exclusion for this author")] + add_list_exclusion: bool, + }, + #[command(about = "Delete the specified item from the Readarr blocklist")] + BlocklistItem { + #[arg( + long, + help = "The ID of the blocklist item to remove from the blocklist", + required = true + )] + blocklist_item_id: i64, + }, + #[command(about = "Delete a book from your Readarr library")] + Book { + #[arg(long, help = "The ID of the book to delete", required = true)] + book_id: i64, + #[arg(long, help = "Delete the book files from disk as well")] + delete_files_from_disk: bool, + #[arg(long, help = "Add a list exclusion for this book")] + add_list_exclusion: bool, + }, + #[command(about = "Delete the specified book file from disk")] + BookFile { + #[arg(long, help = "The ID of the book file to delete", required = true)] + book_file_id: i64, + }, + #[command(about = "Delete the specified download")] + Download { + #[arg(long, help = "The ID of the download to delete", required = true)] + download_id: i64, + }, + #[command(about = "Delete the indexer with the given ID")] + Indexer { + #[arg(long, help = "The ID of the indexer to delete", required = true)] + indexer_id: i64, + }, + #[command(about = "Delete the root folder with the given ID")] + RootFolder { + #[arg(long, help = "The ID of the root folder to delete", required = true)] + root_folder_id: i64, + }, + #[command(about = "Delete the tag with the specified ID")] + Tag { + #[arg(long, help = "The ID of the tag to delete", required = true)] + tag_id: i64, + }, +} + +impl From for Command { + fn from(value: ReadarrDeleteCommand) -> Self { + Command::Readarr(ReadarrCommand::Delete(value)) + } +} + +pub(super) struct ReadarrDeleteCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrDeleteCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrDeleteCommand> + for ReadarrDeleteCommandHandler<'a, 'b> +{ + fn with( + app: &'a Arc>>, + command: ReadarrDeleteCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrDeleteCommandHandler { + _app: app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrDeleteCommand::Author { + author_id, + delete_files_from_disk, + add_list_exclusion, + } => { + let delete_author_params = DeleteParams { + id: author_id, + delete_files: delete_files_from_disk, + add_import_list_exclusion: add_list_exclusion, + }; + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteAuthor(delete_author_params).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrDeleteCommand::BlocklistItem { blocklist_item_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteBlocklistItem(blocklist_item_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrDeleteCommand::Book { + book_id, + delete_files_from_disk, + add_list_exclusion, + } => { + let delete_book_params = DeleteParams { + id: book_id, + delete_files: delete_files_from_disk, + add_import_list_exclusion: add_list_exclusion, + }; + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteBook(delete_book_params).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrDeleteCommand::BookFile { book_file_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteBookFile(book_file_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrDeleteCommand::Download { download_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteDownload(download_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrDeleteCommand::Indexer { indexer_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteIndexer(indexer_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrDeleteCommand::RootFolder { root_folder_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteRootFolder(root_folder_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrDeleteCommand::Tag { tag_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::DeleteTag(tag_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/delete_command_handler_tests.rs b/src/cli/readarr/delete_command_handler_tests.rs new file mode 100644 index 00000000..0b1bbe50 --- /dev/null +++ b/src/cli/readarr/delete_command_handler_tests.rs @@ -0,0 +1,701 @@ +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser, error::ErrorKind}; + + use crate::{ + Cli, + cli::{ + Command, + readarr::{ReadarrCommand, delete_command_handler::ReadarrDeleteCommand}, + }, + }; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_delete_command_from() { + let command = ReadarrDeleteCommand::Tag { tag_id: 1 }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Delete(command))); + } + + mod cli { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_delete_author_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_author_author_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "author", + "--author-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_author_defaults() { + let expected_args = ReadarrDeleteCommand::Author { + author_id: 1, + delete_files_from_disk: false, + add_list_exclusion: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "author", + "--author-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + + #[test] + fn test_delete_author_all_args_defined() { + let expected_args = ReadarrDeleteCommand::Author { + author_id: 1, + delete_files_from_disk: true, + add_list_exclusion: true, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "author", + "--author-id", + "1", + "--delete-files-from-disk", + "--add-list-exclusion", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + + #[test] + fn test_delete_blocklist_item_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "blocklist-item"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_blocklist_item_blocklist_item_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "blocklist-item", + "--blocklist-item-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_blocklist_item_success() { + let expected_args = ReadarrDeleteCommand::BlocklistItem { + blocklist_item_id: 7, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "blocklist-item", + "--blocklist-item-id", + "7", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + + #[test] + fn test_delete_book_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "book"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_book_book_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "book", + "--book-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_book_defaults() { + let expected_args = ReadarrDeleteCommand::Book { + book_id: 1, + delete_files_from_disk: false, + add_list_exclusion: false, + }; + + let result = Cli::try_parse_from(["managarr", "readarr", "delete", "book", "--book-id", "1"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + + #[test] + fn test_delete_book_all_args_defined() { + let expected_args = ReadarrDeleteCommand::Book { + book_id: 1, + delete_files_from_disk: true, + add_list_exclusion: true, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "book", + "--book-id", + "1", + "--delete-files-from-disk", + "--add-list-exclusion", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + + #[test] + fn test_delete_book_file_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "book-file"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_book_file_book_file_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "book-file", + "--book-file-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_book_file_success() { + let expected_args = ReadarrDeleteCommand::BookFile { book_file_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "book-file", + "--book-file-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + + #[test] + fn test_delete_download_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "download"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_download_download_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "download", + "--download-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_download_success() { + let expected_args = ReadarrDeleteCommand::Download { download_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "download", + "--download-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + + #[test] + fn test_delete_indexer_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "indexer"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_indexer_indexer_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "indexer", + "--indexer-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_indexer_success() { + let expected_args = ReadarrDeleteCommand::Indexer { indexer_id: 8 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "indexer", + "--indexer-id", + "8", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + + #[test] + fn test_delete_root_folder_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "root-folder"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_root_folder_root_folder_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "root-folder", + "--root-folder-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_root_folder_success() { + let expected_args = ReadarrDeleteCommand::RootFolder { root_folder_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "delete", + "root-folder", + "--root-folder-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + + #[test] + fn test_delete_tag_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "delete", "tag"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_tag_tag_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "delete", + "tag", + "--tag-id", + "not_a_number", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_delete_tag_success() { + let expected_args = ReadarrDeleteCommand::Tag { tag_id: 1 }; + + let result = Cli::try_parse_from(["managarr", "readarr", "delete", "tag", "--tag-id", "1"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Delete(delete_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(delete_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::CliCommandHandler; + use crate::cli::readarr::delete_command_handler::{ + ReadarrDeleteCommand, ReadarrDeleteCommandHandler, + }; + use crate::models::Serdeable; + use crate::models::readarr_models::{DeleteParams, ReadarrSerdeable}; + use crate::network::readarr_network::ReadarrEvent; + use crate::{ + app::App, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_delete_author_command() { + let expected_delete_author_params = DeleteParams { + id: 1, + delete_files: true, + add_import_list_exclusion: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::DeleteAuthor(expected_delete_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_author_command = ReadarrDeleteCommand::Author { + author_id: 1, + delete_files_from_disk: true, + add_list_exclusion: false, + }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_delete_book_command() { + let expected_delete_book_params = DeleteParams { + id: 1, + delete_files: true, + add_import_list_exclusion: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::DeleteBook(expected_delete_book_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_book_command = ReadarrDeleteCommand::Book { + book_id: 1, + delete_files_from_disk: true, + add_list_exclusion: false, + }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_book_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_delete_blocklist_item_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::DeleteBlocklistItem(7).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_blocklist_item_command = ReadarrDeleteCommand::BlocklistItem { + blocklist_item_id: 7, + }; + + let result = ReadarrDeleteCommandHandler::with( + &app_arc, + delete_blocklist_item_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_delete_book_file_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::DeleteBookFile(1).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_book_file_command = ReadarrDeleteCommand::BookFile { book_file_id: 1 }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_book_file_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_delete_download_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::DeleteDownload(1).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_download_command = ReadarrDeleteCommand::Download { download_id: 1 }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_download_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_delete_indexer_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::DeleteIndexer(8).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_indexer_command = ReadarrDeleteCommand::Indexer { indexer_id: 8 }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_indexer_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_delete_root_folder_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::DeleteRootFolder(1).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_root_folder_command = ReadarrDeleteCommand::RootFolder { root_folder_id: 1 }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_root_folder_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_delete_tag_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::DeleteTag(1).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_tag_command = ReadarrDeleteCommand::Tag { tag_id: 1 }; + + let result = + ReadarrDeleteCommandHandler::with(&app_arc, delete_tag_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/edit_command_handler.rs b/src/cli/readarr/edit_command_handler.rs new file mode 100644 index 00000000..3af38b2f --- /dev/null +++ b/src/cli/readarr/edit_command_handler.rs @@ -0,0 +1,344 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::{ArgAction, ArgGroup, Subcommand}; +use tokio::sync::Mutex; + +use super::ReadarrCommand; +use crate::models::Serdeable; +use crate::models::readarr_models::ReadarrSerdeable; +use crate::models::servarr_models::{EditIndexerParams, IndexerSettings}; +use crate::{ + app::App, + cli::{CliCommandHandler, Command, mutex_flags_or_option}, + models::readarr_models::{EditAuthorParams, NewItemMonitorType}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +#[cfg(test)] +#[path = "edit_command_handler_tests.rs"] +mod edit_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrEditCommand { + #[command( + about = "Edit the indexer settings that apply to all indexers", + group( + ArgGroup::new("edit_settings") + .args([ + "maximum_size", + "minimum_age", + "retention", + "rss_sync_interval", + ]).required(true) + .multiple(true)) + )] + AllIndexerSettings { + #[arg( + long, + help = "The maximum size for a release to be grabbed in MB. Set to zero to set to unlimited" + )] + maximum_size: Option, + #[arg( + long, + help = "Usenet only: Minimum age in minutes of NZBs before they are grabbed. Use this to give new releases time to propagate to your usenet provider." + )] + minimum_age: Option, + #[arg( + long, + help = "Usenet only: The retention time in days to retain releases. Set to zero to set for unlimited retention" + )] + retention: Option, + #[arg( + long, + help = "The RSS sync interval in minutes. Set to zero to disable (this will stop all automatic release grabbing)" + )] + rss_sync_interval: Option, + }, + #[command( + about = "Edit preferences for the specified author", + group( + ArgGroup::new("edit_author") + .args([ + "enable_monitoring", + "disable_monitoring", + "monitor_new_items", + "quality_profile_id", + "metadata_profile_id", + "root_folder_path", + "tag", + "clear_tags" + ]).required(true) + .multiple(true)) + )] + Author { + #[arg( + long, + help = "The ID of the author whose settings you want to edit", + required = true + )] + author_id: i64, + #[arg( + long, + help = "Enable monitoring of this author in Readarr so Readarr will automatically download books from this author if they are available", + conflicts_with = "disable_monitoring" + )] + enable_monitoring: bool, + #[arg( + long, + help = "Disable monitoring of this author so Readarr does not automatically download books from this author if they are available", + conflicts_with = "enable_monitoring" + )] + disable_monitoring: bool, + #[arg( + long, + help = "How Readarr should monitor new books from this author", + value_enum + )] + monitor_new_items: Option, + #[arg(long, help = "The ID of the quality profile to use for this author")] + quality_profile_id: Option, + #[arg(long, help = "The ID of the metadata profile to use for this author")] + metadata_profile_id: Option, + #[arg( + long, + help = "The root folder path where all author data and metadata should live" + )] + root_folder_path: Option, + #[arg( + long, + help = "Tag IDs to tag this author with", + value_parser, + action = ArgAction::Append, + conflicts_with = "clear_tags" + )] + tag: Option>, + #[arg(long, help = "Clear all tags on this author", conflicts_with = "tag")] + clear_tags: bool, + }, + #[command( + about = "Edit preferences for the specified indexer", + group( + ArgGroup::new("edit_indexer") + .args([ + "name", + "enable_rss", + "disable_rss", + "enable_automatic_search", + "disable_automatic_search", + "enable_interactive_search", + "disable_interactive_search", + "url", + "api_key", + "seed_ratio", + "tag", + "priority", + "clear_tags" + ]).required(true) + .multiple(true)) + )] + Indexer { + #[arg( + long, + help = "The ID of the indexer whose settings you wish to edit", + required = true + )] + indexer_id: i64, + #[arg(long, help = "The name of the indexer")] + name: Option, + #[arg( + long, + help = "Indicate to Readarr that this indexer should be used when Readarr periodically looks for releases via RSS Sync", + conflicts_with = "disable_rss" + )] + enable_rss: bool, + #[arg( + long, + help = "Disable using this indexer when Readarr periodically looks for releases via RSS Sync", + conflicts_with = "enable_rss" + )] + disable_rss: bool, + #[arg( + long, + help = "Indicate to Readarr that this indexer should be used when automatic searches are performed via the UI or by Readarr", + conflicts_with = "disable_automatic_search" + )] + enable_automatic_search: bool, + #[arg( + long, + help = "Disable using this indexer whenever automatic searches are performed via the UI or by Readarr", + conflicts_with = "enable_automatic_search" + )] + disable_automatic_search: bool, + #[arg( + long, + help = "Indicate to Readarr that this indexer should be used when an interactive search is used", + conflicts_with = "disable_interactive_search" + )] + enable_interactive_search: bool, + #[arg( + long, + help = "Disable using this indexer whenever an interactive search is performed", + conflicts_with = "enable_interactive_search" + )] + disable_interactive_search: bool, + #[arg(long, help = "The URL of the indexer")] + url: Option, + #[arg(long, help = "The API key used to access the indexer's API")] + api_key: Option, + #[arg( + long, + help = "The ratio a torrent should reach before stopping; Empty uses the download client's default. Ratio should be at least 1.0 and follow the indexer's rules" + )] + seed_ratio: Option, + #[arg( + long, + help = "Only use this indexer for authors with at least one matching tag ID. Leave blank to use with all authors.", + value_parser, + action = ArgAction::Append, + conflicts_with = "clear_tags" + )] + tag: Option>, + #[arg( + long, + help = "Indexer Priority from 1 (Highest) to 50 (Lowest). Default: 25. Used when grabbing releases as a tiebreaker for otherwise equal releases, Readarr will still use all enabled indexers for RSS Sync and Searching" + )] + priority: Option, + #[arg(long, help = "Clear all tags on this indexer", conflicts_with = "tag")] + clear_tags: bool, + }, +} + +impl From for Command { + fn from(value: ReadarrEditCommand) -> Self { + Command::Readarr(ReadarrCommand::Edit(value)) + } +} + +pub(super) struct ReadarrEditCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrEditCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrEditCommand> for ReadarrEditCommandHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: ReadarrEditCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrEditCommandHandler { + _app: app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrEditCommand::AllIndexerSettings { + maximum_size, + minimum_age, + retention, + rss_sync_interval, + } => { + if let Serdeable::Readarr(ReadarrSerdeable::IndexerSettings(previous_indexer_settings)) = + self + .network + .handle_network_event(ReadarrEvent::GetAllIndexerSettings.into()) + .await? + { + let params = IndexerSettings { + id: 1, + maximum_size: maximum_size.unwrap_or(previous_indexer_settings.maximum_size), + minimum_age: minimum_age.unwrap_or(previous_indexer_settings.minimum_age), + retention: retention.unwrap_or(previous_indexer_settings.retention), + rss_sync_interval: rss_sync_interval + .unwrap_or(previous_indexer_settings.rss_sync_interval), + }; + self + .network + .handle_network_event(ReadarrEvent::EditAllIndexerSettings(params).into()) + .await?; + "All indexer settings updated".to_owned() + } else { + String::new() + } + } + ReadarrEditCommand::Author { + author_id, + enable_monitoring, + disable_monitoring, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tag, + clear_tags, + } => { + let monitored_value = mutex_flags_or_option(enable_monitoring, disable_monitoring); + let edit_author_params = EditAuthorParams { + author_id, + monitored: monitored_value, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tags: tag, + tag_input_string: None, + clear_tags, + }; + + self + .network + .handle_network_event(ReadarrEvent::EditAuthor(edit_author_params).into()) + .await?; + "Author updated".to_owned() + } + ReadarrEditCommand::Indexer { + indexer_id, + name, + enable_rss, + disable_rss, + enable_automatic_search, + disable_automatic_search, + enable_interactive_search, + disable_interactive_search, + url, + api_key, + seed_ratio, + tag, + priority, + clear_tags, + } => { + let rss_value = mutex_flags_or_option(enable_rss, disable_rss); + let automatic_search_value = + mutex_flags_or_option(enable_automatic_search, disable_automatic_search); + let interactive_search_value = + mutex_flags_or_option(enable_interactive_search, disable_interactive_search); + let edit_indexer_params = EditIndexerParams { + indexer_id, + name, + enable_rss: rss_value, + enable_automatic_search: automatic_search_value, + enable_interactive_search: interactive_search_value, + url, + api_key, + seed_ratio, + tags: tag, + tag_input_string: None, + priority, + clear_tags, + }; + + self + .network + .handle_network_event(ReadarrEvent::EditIndexer(edit_indexer_params).into()) + .await?; + "Indexer updated".to_owned() + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/edit_command_handler_tests.rs b/src/cli/readarr/edit_command_handler_tests.rs new file mode 100644 index 00000000..34da2e65 --- /dev/null +++ b/src/cli/readarr/edit_command_handler_tests.rs @@ -0,0 +1,1055 @@ +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser, error::ErrorKind}; + + use crate::{ + Cli, + cli::{ + Command, + readarr::{ReadarrCommand, edit_command_handler::ReadarrEditCommand}, + }, + }; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_edit_command_from() { + let command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tag: None, + clear_tags: false, + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Edit(command))); + } + + mod cli { + use super::*; + use crate::models::readarr_models::NewItemMonitorType; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[test] + fn test_edit_all_indexer_settings_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "all-indexer-settings", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_edit_all_indexer_settings_assert_argument_flags_require_args( + #[values( + "--maximum-size", + "--minimum-age", + "--retention", + "--rss-sync-interval" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "all-indexer-settings", + flag, + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_all_indexer_settings_only_requires_at_least_one_argument() { + let expected_args = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: None, + retention: None, + rss_sync_interval: None, + }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "all-indexer-settings", + "--maximum-size", + "26500", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_all_indexer_settings_all_arguments_defined() { + let expected_args = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: Some(17), + retention: Some(43), + rss_sync_interval: Some(35), + }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "all-indexer-settings", + "--maximum-size", + "26500", + "--minimum-age", + "17", + "--retention", + "43", + "--rss-sync-interval", + "35", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_author_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "edit", "author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_author_with_author_id_still_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_author_author_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "not_a_number", + "--enable-monitoring", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_edit_author_monitoring_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--enable-monitoring", + "--disable-monitoring", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_author_tag_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--tag", + "1", + "--clear-tags", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[rstest] + fn test_edit_author_assert_argument_flags_require_args( + #[values( + "--monitor-new-items", + "--quality-profile-id", + "--metadata-profile-id", + "--root-folder-path", + "--tag" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + flag, + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_author_monitor_new_items_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--monitor-new-items", + "test", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_author_only_requires_at_least_one_argument_plus_author_id() { + let expected_args = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: Some("/nfs/books".to_owned()), + tag: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--root-folder-path", + "/nfs/books", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_author_tag_argument_is_repeatable() { + let expected_args = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--tag", + "1", + "--tag", + "2", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_author_all_arguments_defined() { + let expected_args = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: true, + disable_monitoring: false, + monitor_new_items: Some(NewItemMonitorType::New), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--enable-monitoring", + "--monitor-new-items", + "new", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + "--root-folder-path", + "/nfs/books", + "--tag", + "1", + "--tag", + "2", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_indexer_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "edit", "indexer"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_indexer_with_indexer_id_still_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_indexer_rss_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--enable-rss", + "--disable-rss", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_automatic_search_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--enable-automatic-search", + "--disable-automatic-search", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_interactive_search_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--enable-interactive-search", + "--disable-interactive-search", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_tag_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--tag", + "1", + "--clear-tags", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[rstest] + fn test_edit_indexer_assert_argument_flags_require_args( + #[values("--name", "--url", "--api-key", "--seed-ratio", "--tag", "--priority")] flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + flag, + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[rstest] + fn test_edit_indexer_every_boolean_flag_satisfies_the_argument_group( + #[values( + "--enable-rss", + "--disable-rss", + "--enable-automatic-search", + "--disable-automatic-search", + "--enable-interactive-search", + "--disable-interactive-search", + "--clear-tags" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + flag, + ]); + + assert_ok!(&result); + } + + #[test] + fn test_edit_indexer_only_requires_at_least_one_argument_plus_indexer_id() { + let expected_args = ReadarrEditCommand::Indexer { + indexer_id: 8, + name: Some("Test".to_owned()), + enable_rss: false, + disable_rss: false, + enable_automatic_search: false, + disable_automatic_search: false, + enable_interactive_search: false, + disable_interactive_search: false, + url: None, + api_key: None, + seed_ratio: None, + tag: None, + priority: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--name", + "Test", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_indexer_tag_argument_is_repeatable() { + let expected_args = ReadarrEditCommand::Indexer { + indexer_id: 8, + name: None, + enable_rss: false, + disable_rss: false, + enable_automatic_search: false, + disable_automatic_search: false, + enable_interactive_search: false, + disable_interactive_search: false, + url: None, + api_key: None, + seed_ratio: None, + tag: Some(vec![2, 3]), + priority: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--tag", + "2", + "--tag", + "3", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_indexer_all_arguments_defined() { + let expected_args = ReadarrEditCommand::Indexer { + indexer_id: 8, + name: Some("Test".to_owned()), + enable_rss: true, + disable_rss: false, + enable_automatic_search: true, + disable_automatic_search: false, + enable_interactive_search: true, + disable_interactive_search: false, + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tag: Some(vec![2, 3]), + priority: Some(25), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "indexer", + "--indexer-id", + "8", + "--name", + "Test", + "--enable-rss", + "--enable-automatic-search", + "--enable-interactive-search", + "--url", + "http://test.com", + "--api-key", + "testKey", + "--seed-ratio", + "1.2", + "--tag", + "2", + "--tag", + "3", + "--priority", + "25", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use pretty_assertions::assert_str_eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::CliCommandHandler; + use crate::cli::readarr::edit_command_handler::{ + ReadarrEditCommand, ReadarrEditCommandHandler, + }; + use crate::models::Serdeable; + use crate::models::readarr_models::{EditAuthorParams, NewItemMonitorType, ReadarrSerdeable}; + use crate::models::servarr_models::{EditIndexerParams, IndexerSettings}; + use crate::network::readarr_network::ReadarrEvent; + use crate::{ + app::App, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_command() { + let expected_edit_all_indexer_settings = IndexerSettings { + id: 1, + maximum_size: 26500, + minimum_age: 17, + retention: 43, + rss_sync_interval: 35, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::IndexerSettings( + IndexerSettings { + id: 9, + maximum_size: 31200, + minimum_age: 22, + retention: 58, + rss_sync_interval: 90, + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_all_indexer_settings_command = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: Some(17), + retention: Some(43), + rss_sync_interval: Some(35), + }; + + let result = ReadarrEditCommandHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_command_defaults_to_previous_values() { + let expected_edit_all_indexer_settings = IndexerSettings { + id: 1, + maximum_size: 26500, + minimum_age: 22, + retention: 58, + rss_sync_interval: 90, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::IndexerSettings( + IndexerSettings { + id: 9, + maximum_size: 31200, + minimum_age: 22, + retention: 58, + rss_sync_interval: 90, + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_all_indexer_settings_command = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: None, + retention: None, + rss_sync_interval: None, + }; + + let result = ReadarrEditCommandHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_command_does_not_edit_on_unexpected_response() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_all_indexer_settings_command = ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: Some(17), + retention: Some(43), + rss_sync_interval: Some(35), + }; + + let result = ReadarrEditCommandHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert_str_eq!(assert_ok!(result), String::new()); + } + + #[tokio::test] + async fn test_handle_edit_author_command() { + let expected_edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(true), + monitor_new_items: Some(NewItemMonitorType::New), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tags: Some(vec![1, 2]), + tag_input_string: None, + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAuthor(expected_edit_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_author_command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: true, + disable_monitoring: false, + monitor_new_items: Some(NewItemMonitorType::New), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_author_command_handles_disable_monitoring_flag_properly() { + let expected_edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tags: Some(vec![1, 2]), + tag_input_string: None, + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAuthor(expected_edit_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_author_command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: true, + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_author_command_no_monitoring_boolean_flags_returns_none_value() { + let expected_edit_author_params = EditAuthorParams { + author_id: 1, + monitored: None, + monitor_new_items: Some(NewItemMonitorType::All), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tags: Some(vec![1, 2]), + tag_input_string: None, + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAuthor(expected_edit_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_author_command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: Some(NewItemMonitorType::All), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_author_command_clear_tags() { + let expected_edit_author_params = EditAuthorParams { + author_id: 1, + monitored: None, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tags: None, + tag_input_string: None, + clear_tags: true, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAuthor(expected_edit_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_author_command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tag: None, + clear_tags: true, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_indexer_command() { + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test".to_owned()), + enable_rss: Some(true), + enable_automatic_search: Some(false), + enable_interactive_search: Some(true), + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tags: Some(vec![2, 3]), + tag_input_string: None, + priority: Some(25), + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditIndexer(expected_edit_indexer_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_indexer_command = ReadarrEditCommand::Indexer { + indexer_id: 8, + name: Some("Test".to_owned()), + enable_rss: true, + disable_rss: false, + enable_automatic_search: false, + disable_automatic_search: true, + enable_interactive_search: true, + disable_interactive_search: false, + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tag: Some(vec![2, 3]), + priority: Some(25), + clear_tags: false, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_indexer_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/get_command_handler.rs b/src/cli/readarr/get_command_handler.rs new file mode 100644 index 00000000..225ce3a3 --- /dev/null +++ b/src/cli/readarr/get_command_handler.rs @@ -0,0 +1,122 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +use super::ReadarrCommand; + +#[cfg(test)] +#[path = "get_command_handler_tests.rs"] +mod get_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrGetCommand { + #[command(about = "Get the shared settings for all indexers")] + AllIndexerSettings, + #[command(about = "Get detailed information for the author with the given ID")] + AuthorDetails { + #[arg( + long, + help = "The Readarr ID of the author whose details you wish to fetch", + required = true + )] + author_id: i64, + }, + #[command(about = "Get detailed information for the book with the given ID")] + BookDetails { + #[arg( + long, + help = "The Readarr ID of the book whose details you wish to fetch", + required = true + )] + book_id: i64, + }, + #[command(about = "Fetch the host config for your Readarr instance")] + HostConfig, + #[command(about = "Fetch the security config for your Readarr instance")] + SecurityConfig, + #[command(about = "Get the system status")] + SystemStatus, +} + +impl From for Command { + fn from(value: ReadarrGetCommand) -> Self { + Command::Readarr(ReadarrCommand::Get(value)) + } +} + +pub(super) struct ReadarrGetCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrGetCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrGetCommand> for ReadarrGetCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: ReadarrGetCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrGetCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrGetCommand::AllIndexerSettings => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetAllIndexerSettings.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrGetCommand::AuthorDetails { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetAuthorDetails(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrGetCommand::BookDetails { book_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBookDetails(book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrGetCommand::HostConfig => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetHostConfig.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrGetCommand::SecurityConfig => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetSecurityConfig.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrGetCommand::SystemStatus => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetStatus.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/get_command_handler_tests.rs b/src/cli/readarr/get_command_handler_tests.rs new file mode 100644 index 00000000..6ec4733b --- /dev/null +++ b/src/cli/readarr/get_command_handler_tests.rs @@ -0,0 +1,263 @@ +#[cfg(test)] +mod tests { + use crate::Cli; + use crate::cli::{ + Command, + readarr::{ReadarrCommand, get_command_handler::ReadarrGetCommand}, + }; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_get_command_from() { + let command = ReadarrGetCommand::SystemStatus; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Get(command))); + } + + mod cli { + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[test] + fn test_all_indexer_settings_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "get", "all-indexer-settings"]); + + assert_ok!(&result); + } + + #[test] + fn test_author_details_requires_author_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "get", "author-details"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_author_details_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "get", + "author-details", + "--author-id", + "1", + ]); + + assert_ok!(&result); + } + + #[test] + fn test_book_details_requires_book_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "get", "book-details"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_book_details_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "get", + "book-details", + "--book-id", + "1", + ]); + + assert_ok!(&result); + } + + #[rstest] + fn test_get_commands_have_no_arg_requirements( + #[values("host-config", "security-config", "system-status")] subcommand: &str, + ) { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "get", subcommand]); + + assert_ok!(&result); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + CliCommandHandler, + readarr::get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}, + }, + models::{Serdeable, readarr_models::ReadarrSerdeable}, + network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, + }; + + #[tokio::test] + async fn test_handle_get_all_indexer_settings_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_all_indexer_settings_command = ReadarrGetCommand::AllIndexerSettings; + + let result = ReadarrGetCommandHandler::with( + &app_arc, + get_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_get_author_details_command() { + let expected_author_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAuthorDetails(expected_author_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_author_details_command = ReadarrGetCommand::AuthorDetails { author_id: 1 }; + + let result = + ReadarrGetCommandHandler::with(&app_arc, get_author_details_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_get_book_details_command() { + let expected_book_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBookDetails(expected_book_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_book_details_command = ReadarrGetCommand::BookDetails { book_id: 1 }; + + let result = + ReadarrGetCommandHandler::with(&app_arc, get_book_details_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_get_host_config_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetHostConfig.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_host_config_command = ReadarrGetCommand::HostConfig; + + let result = + ReadarrGetCommandHandler::with(&app_arc, get_host_config_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_get_security_config_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetSecurityConfig.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_security_config_command = ReadarrGetCommand::SecurityConfig; + + let result = + ReadarrGetCommandHandler::with(&app_arc, get_security_config_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_get_system_status_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetStatus.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_system_status_command = ReadarrGetCommand::SystemStatus; + + let result = + ReadarrGetCommandHandler::with(&app_arc, get_system_status_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs new file mode 100644 index 00000000..265ef2ac --- /dev/null +++ b/src/cli/readarr/list_command_handler.rs @@ -0,0 +1,323 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use super::ReadarrCommand; +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +#[cfg(test)] +#[path = "list_command_handler_tests.rs"] +mod list_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrListCommand { + #[command(about = "Fetch all history events for the author with the given ID")] + AuthorHistory { + #[arg( + long, + help = "The Readarr ID of the author whose history you wish to fetch", + required = true + )] + author_id: i64, + }, + #[command(about = "Trigger a manual search for releases of the author with the given ID")] + AuthorReleases { + #[arg( + long, + help = "The Readarr ID of the author whose releases you wish to search for", + required = true + )] + author_id: i64, + }, + #[command(about = "List all authors in your Readarr library")] + Authors, + #[command(about = "List all items in the Readarr blocklist")] + Blocklist, + #[command(about = "List all editions for the book with the given ID")] + BookEditions { + #[arg( + long, + help = "The Readarr ID of the book whose editions you want to list", + required = true + )] + book_id: i64, + }, + #[command(about = "List all book files for the book with the given ID")] + BookFiles { + #[arg( + long, + help = "The Readarr ID of the book whose book files you want to list", + required = true + )] + book_id: i64, + }, + #[command(about = "Fetch all history events for the book with the given ID")] + BookHistory { + #[arg( + long, + help = "The Readarr ID of the author who wrote the book whose history you wish to fetch", + required = true + )] + author_id: i64, + #[arg( + long, + help = "The Readarr ID of the book whose history you wish to fetch", + required = true + )] + book_id: i64, + }, + #[command(about = "List all books for the author with the given ID")] + Books { + #[arg( + long, + help = "The Readarr ID of the author whose books you want to list", + required = true + )] + author_id: i64, + }, + #[command(about = "List disk space details for all provisioned root folders in Readarr")] + DiskSpace, + #[command(about = "List all active downloads in Readarr")] + Downloads { + #[arg(long, help = "How many downloads to fetch", default_value_t = 500)] + count: u64, + }, + #[command(about = "Fetch all Readarr history events")] + History { + #[arg(long, help = "How many history events to fetch", default_value_t = 500)] + events: u64, + }, + #[command(about = "List all Readarr indexers")] + Indexers, + #[command(about = "Fetch Readarr logs")] + Logs { + #[arg(long, help = "How many log events to fetch", default_value_t = 500)] + events: u64, + #[arg( + long, + help = "Output the logs in the same format as they appear in the log files" + )] + output_in_log_format: bool, + }, + #[command(about = "List all Readarr metadata profiles")] + MetadataProfiles, + #[command(about = "List all Readarr quality profiles")] + QualityProfiles, + #[command(about = "List all queued events")] + QueuedEvents, + #[command(about = "Trigger a manual search for releases of the book with the given ID")] + Releases { + #[arg( + long, + help = "The Readarr ID of the book whose releases you wish to search for", + required = true + )] + book_id: i64, + }, + #[command(about = "List all root folders in Readarr")] + RootFolders, + #[command(about = "List all Readarr tags")] + Tags, + #[command(about = "List all Readarr tasks")] + Tasks, + #[command(about = "List all Readarr updates")] + Updates, +} + +impl From for Command { + fn from(value: ReadarrListCommand) -> Self { + Command::Readarr(ReadarrCommand::List(value)) + } +} + +pub(super) struct ReadarrListCommandHandler<'a, 'b> { + app: &'a Arc>>, + command: ReadarrListCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListCommandHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: ReadarrListCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrListCommandHandler { + app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrListCommand::AuthorHistory { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetAuthorHistory(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::AuthorReleases { author_id } => { + println!("Searching for author releases. This may take a minute..."); + let resp = self + .network + .handle_network_event(ReadarrEvent::GetAuthorReleases(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::Authors => { + let resp = self + .network + .handle_network_event(ReadarrEvent::ListAuthors.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::Blocklist => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBlocklist.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::BookEditions { book_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBookEditions(book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::BookFiles { book_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBookFiles(book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::BookHistory { author_id, book_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBookHistory(author_id, book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::Books { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBooks(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::DiskSpace => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetDiskSpace.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::Downloads { count } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetDownloads(count).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::History { events } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetHistory(events).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::Indexers => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetIndexers.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::Logs { + events, + output_in_log_format, + } => { + let logs = self + .network + .handle_network_event(ReadarrEvent::GetLogs(events).into()) + .await?; + + if output_in_log_format { + let log_lines = &self.app.lock().await.data.readarr_data.logs.items; + + serde_json::to_string_pretty(log_lines)? + } else { + serde_json::to_string_pretty(&logs)? + } + } + ReadarrListCommand::MetadataProfiles => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetMetadataProfiles.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::QualityProfiles => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetQualityProfiles.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::QueuedEvents => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetQueuedEvents.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::Releases { book_id } => { + println!("Searching for book releases. This may take a minute..."); + let resp = self + .network + .handle_network_event(ReadarrEvent::GetBookReleases(book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::RootFolders => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetRootFolders.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::Tags => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetTags.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::Tasks => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetTasks.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrListCommand::Updates => { + let resp = self + .network + .handle_network_event(ReadarrEvent::GetUpdates.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs new file mode 100644 index 00000000..1b11fb5b --- /dev/null +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -0,0 +1,958 @@ +#[cfg(test)] +mod tests { + use crate::Cli; + use crate::cli::{ + Command, + readarr::{ReadarrCommand, list_command_handler::ReadarrListCommand}, + }; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_list_command_from() { + let command = ReadarrListCommand::DiskSpace; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::List(command))); + } + + mod cli { + use super::*; + use clap::{Parser, error::ErrorKind}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_list_commands_have_no_arg_requirements( + #[values( + "authors", + "blocklist", + "disk-space", + "indexers", + "metadata-profiles", + "quality-profiles", + "queued-events", + "root-folders", + "tags", + "tasks", + "updates" + )] + subcommand: &str, + ) { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", subcommand]); + + assert_ok!(&result); + } + + #[test] + fn test_list_logs_events_flag_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "logs", "--events"]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_list_downloads_count_flag_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "list", + "downloads", + "--count", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_list_history_events_flag_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "history", "--events"]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_list_author_history_requires_author_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "author-history"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_author_history_success() { + let expected_args = ReadarrListCommand::AuthorHistory { author_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "list", + "author-history", + "--author-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(author_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(author_command, expected_args); + } + + #[test] + fn test_list_books_requires_author_id() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", "books"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_book_editions_requires_book_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "book-editions"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_book_editions_success() { + let expected_args = ReadarrListCommand::BookEditions { book_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "list", + "book-editions", + "--book-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(book_editions_command))) = + result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(book_editions_command, expected_args); + } + + #[test] + fn test_list_book_files_requires_book_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "book-files"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_book_files_success() { + let expected_args = ReadarrListCommand::BookFiles { book_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "list", + "book-files", + "--book-id", + "1", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(book_files_command))) = + result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(book_files_command, expected_args); + } + + #[test] + fn test_list_book_history_requires_author_id_and_book_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "book-history"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_book_history_success() { + let expected_args = ReadarrListCommand::BookHistory { + author_id: 1, + book_id: 2, + }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "list", + "book-history", + "--author-id", + "1", + "--book-id", + "2", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(book_history_command))) = + result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(book_history_command, expected_args); + } + + #[test] + fn test_list_books_success() { + let expected_args = ReadarrListCommand::Books { author_id: 1 }; + let result = + Cli::try_parse_from(["managarr", "readarr", "list", "books", "--author-id", "1"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(books_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(books_command, expected_args); + } + + #[test] + fn test_list_releases_requires_book_id() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", "releases"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_author_releases_requires_author_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "author-releases"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_author_releases_success() { + let expected_args = ReadarrListCommand::AuthorReleases { author_id: 3 }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "list", + "author-releases", + "--author-id", + "3", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(author_releases_command))) = + result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(author_releases_command, expected_args); + } + + #[test] + fn test_list_releases_success() { + let expected_args = ReadarrListCommand::Releases { book_id: 7 }; + let result = + Cli::try_parse_from(["managarr", "readarr", "list", "releases", "--book-id", "7"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(releases_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(releases_command, expected_args); + } + + #[test] + fn test_list_logs_default_values() { + let expected_args = ReadarrListCommand::Logs { + events: 500, + output_in_log_format: false, + }; + let result = Cli::try_parse_from(["managarr", "readarr", "list", "logs"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(logs_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(logs_command, expected_args); + } + + #[test] + fn test_list_downloads_default_values() { + let expected_args = ReadarrListCommand::Downloads { count: 500 }; + let result = Cli::try_parse_from(["managarr", "readarr", "list", "downloads"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(downloads_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(downloads_command, expected_args); + } + + #[test] + fn test_list_downloads_success() { + let expected_args = ReadarrListCommand::Downloads { count: 1000 }; + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "list", + "downloads", + "--count", + "1000", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(downloads_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(downloads_command, expected_args); + } + + #[test] + fn test_list_history_default_values() { + let expected_args = ReadarrListCommand::History { events: 500 }; + let result = Cli::try_parse_from(["managarr", "readarr", "list", "history"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(history_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(history_command, expected_args); + } + + #[test] + fn test_list_history_success() { + let expected_args = ReadarrListCommand::History { events: 1000 }; + let result = + Cli::try_parse_from(["managarr", "readarr", "list", "history", "--events", "1000"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(history_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(history_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use pretty_assertions::assert_str_eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + CliCommandHandler, + readarr::list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}, + }, + models::{HorizontallyScrollableText, Serdeable, readarr_models::ReadarrSerdeable}, + network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, + }; + + #[tokio::test] + async fn test_handle_list_authors_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::ListAuthors.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_authors_command = ReadarrListCommand::Authors; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_authors_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_blocklist_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_blocklist_command = ReadarrListCommand::Blocklist; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_blocklist_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_author_history_command() { + let expected_author_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAuthorHistory(expected_author_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_author_history_command = ReadarrListCommand::AuthorHistory { author_id: 1 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_author_history_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_books_command() { + let expected_author_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBooks(expected_author_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_books_command = ReadarrListCommand::Books { author_id: 1 }; + + let result = ReadarrListCommandHandler::with(&app_arc, list_books_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_book_editions_command() { + let expected_book_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBookEditions(expected_book_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_book_editions_command = ReadarrListCommand::BookEditions { book_id: 1 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_book_editions_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_book_files_command() { + let expected_book_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBookFiles(expected_book_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_book_files_command = ReadarrListCommand::BookFiles { book_id: 1 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_book_files_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_book_history_command() { + let expected_author_id = 1; + let expected_book_id = 2; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBookHistory(expected_author_id, expected_book_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_book_history_command = ReadarrListCommand::BookHistory { + author_id: 1, + book_id: 2, + }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_book_history_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_disk_space_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetDiskSpace.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_disk_space_command = ReadarrListCommand::DiskSpace; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_disk_space_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_downloads_command() { + let expected_count = 1000; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetDownloads(expected_count).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_downloads_command = ReadarrListCommand::Downloads { count: 1000 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_downloads_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_history_command() { + let expected_events = 1000; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetHistory(expected_events).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_history_command = ReadarrListCommand::History { events: 1000 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_history_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_indexers_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetIndexers.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_indexers_command = ReadarrListCommand::Indexers; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_indexers_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_logs_command() { + let expected_events = 1000; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetLogs(expected_events).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_logs_command = ReadarrListCommand::Logs { + events: 1000, + output_in_log_format: false, + }; + + let result = ReadarrListCommandHandler::with(&app_arc, list_logs_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_logs_command_output_in_log_format() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetLogs(1000).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + app_arc + .lock() + .await + .data + .readarr_data + .logs + .set_items(vec![HorizontallyScrollableText::from("readarr log line")]); + let list_logs_command = ReadarrListCommand::Logs { + events: 1000, + output_in_log_format: true, + }; + + let result = ReadarrListCommandHandler::with(&app_arc, list_logs_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + assert_str_eq!(result.unwrap(), "[\n \"readarr log line\"\n]"); + } + + #[tokio::test] + async fn test_handle_list_metadata_profiles_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetMetadataProfiles.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_metadata_profiles_command = ReadarrListCommand::MetadataProfiles; + + let result = ReadarrListCommandHandler::with( + &app_arc, + list_metadata_profiles_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_quality_profiles_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetQualityProfiles.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_quality_profiles_command = ReadarrListCommand::QualityProfiles; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_quality_profiles_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_queued_events_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetQueuedEvents.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_queued_events_command = ReadarrListCommand::QueuedEvents; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_queued_events_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_releases_command() { + let expected_book_id = 7; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetBookReleases(expected_book_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_releases_command = ReadarrListCommand::Releases { book_id: 7 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_releases_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_author_releases_command() { + let expected_author_id = 3; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAuthorReleases(expected_author_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_author_releases_command = ReadarrListCommand::AuthorReleases { author_id: 3 }; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_author_releases_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_root_folders_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetRootFolders.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_root_folders_command = ReadarrListCommand::RootFolders; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_root_folders_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_tags_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetTags.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_tags_command = ReadarrListCommand::Tags; + + let result = ReadarrListCommandHandler::with(&app_arc, list_tags_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_tasks_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetTasks.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_tasks_command = ReadarrListCommand::Tasks; + + let result = ReadarrListCommandHandler::with(&app_arc, list_tasks_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_updates_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetUpdates.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_updates_command = ReadarrListCommand::Updates; + + let result = + ReadarrListCommandHandler::with(&app_arc, list_updates_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs new file mode 100644 index 00000000..76eb484f --- /dev/null +++ b/src/cli/readarr/mod.rs @@ -0,0 +1,285 @@ +use std::sync::Arc; + +use add_command_handler::{ReadarrAddCommand, ReadarrAddCommandHandler}; +use anyhow::Result; +use clap::Subcommand; +use delete_command_handler::{ReadarrDeleteCommand, ReadarrDeleteCommandHandler}; +use edit_command_handler::{ReadarrEditCommand, ReadarrEditCommandHandler}; +use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; +use list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}; +use refresh_command_handler::{ReadarrRefreshCommand, ReadarrRefreshCommandHandler}; +use serde_json::json; +use tokio::sync::Mutex; +use trigger_automatic_search_command_handler::{ + ReadarrTriggerAutomaticSearchCommand, ReadarrTriggerAutomaticSearchCommandHandler, +}; + +use super::{CliCommandHandler, Command}; +use crate::models::readarr_models::ReadarrTaskName; +use crate::models::servarr_models::ReleaseDownloadBody; +use crate::network::readarr_network::ReadarrEvent; +use crate::{app::App, network::NetworkTrait}; + +mod add_command_handler; +mod delete_command_handler; +mod edit_command_handler; +mod get_command_handler; +mod list_command_handler; +mod refresh_command_handler; +mod trigger_automatic_search_command_handler; + +#[cfg(test)] +#[path = "readarr_command_tests.rs"] +mod readarr_command_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrCommand { + #[command( + subcommand, + about = "Commands to add or create new resources within your Readarr instance" + )] + Add(ReadarrAddCommand), + #[command( + subcommand, + about = "Commands to delete resources from your Readarr instance" + )] + Delete(ReadarrDeleteCommand), + #[command( + subcommand, + about = "Commands to edit resources in your Readarr instance" + )] + Edit(ReadarrEditCommand), + #[command( + subcommand, + about = "Commands to fetch details of the resources in your Readarr instance" + )] + Get(ReadarrGetCommand), + #[command( + subcommand, + about = "Commands to list attributes from your Readarr instance" + )] + List(ReadarrListCommand), + #[command( + subcommand, + about = "Commands to refresh the data in your Readarr instance" + )] + Refresh(ReadarrRefreshCommand), + #[command( + subcommand, + about = "Commands to trigger automatic searches for releases of different resources in your Readarr instance" + )] + TriggerAutomaticSearch(ReadarrTriggerAutomaticSearchCommand), + #[command(about = "Clear the Readarr blocklist")] + ClearBlocklist, + #[command(about = "Manually download the given release")] + DownloadRelease { + #[arg(long, help = "The GUID of the release to download", required = true)] + guid: String, + #[arg( + long, + help = "The indexer ID to download the release from", + required = true + )] + indexer_id: i64, + }, + #[command(about = "Mark the Readarr history item with the given ID as 'failed'")] + MarkHistoryItemAsFailed { + #[arg( + long, + help = "The Readarr ID of the history item you wish to mark as 'failed'", + required = true + )] + history_item_id: i64, + }, + #[command(about = "Search for a new author to add to Readarr")] + SearchNewAuthor { + #[arg( + long, + help = "The name of the author you want to search for", + required = true + )] + query: String, + }, + #[command(about = "Start the specified Readarr task")] + StartTask { + #[arg( + long, + help = "The name of the task to trigger", + value_enum, + required = true + )] + task_name: ReadarrTaskName, + }, + #[command(about = "Test all Readarr indexers")] + TestAllIndexers, + #[command( + about = "Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}'" + )] + TestIndexer { + #[arg(long, help = "The ID of the indexer to test", required = true)] + indexer_id: i64, + }, + #[command( + about = "Toggle monitoring for the specified author corresponding to the given author ID" + )] + ToggleAuthorMonitoring { + #[arg( + long, + help = "The Readarr ID of the author to toggle monitoring on", + required = true + )] + author_id: i64, + }, + #[command(about = "Toggle monitoring for the specified book corresponding to the given book ID")] + ToggleBookMonitoring { + #[arg( + long, + help = "The Readarr ID of the book to toggle monitoring on", + required = true + )] + book_id: i64, + }, +} + +impl From for Command { + fn from(readarr_command: ReadarrCommand) -> Command { + Command::Readarr(readarr_command) + } +} + +pub(super) struct ReadarrCliHandler<'a, 'b> { + app: &'a Arc>>, + command: ReadarrCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: ReadarrCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrCliHandler { + app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrCommand::Add(add_command) => { + ReadarrAddCommandHandler::with(self.app, add_command, self.network) + .handle() + .await? + } + ReadarrCommand::Delete(delete_command) => { + ReadarrDeleteCommandHandler::with(self.app, delete_command, self.network) + .handle() + .await? + } + ReadarrCommand::Edit(edit_command) => { + ReadarrEditCommandHandler::with(self.app, edit_command, self.network) + .handle() + .await? + } + ReadarrCommand::Get(get_command) => { + ReadarrGetCommandHandler::with(self.app, get_command, self.network) + .handle() + .await? + } + ReadarrCommand::List(list_command) => { + ReadarrListCommandHandler::with(self.app, list_command, self.network) + .handle() + .await? + } + ReadarrCommand::Refresh(refresh_command) => { + ReadarrRefreshCommandHandler::with(self.app, refresh_command, self.network) + .handle() + .await? + } + ReadarrCommand::TriggerAutomaticSearch(trigger_automatic_search_command) => { + ReadarrTriggerAutomaticSearchCommandHandler::with( + self.app, + trigger_automatic_search_command, + self.network, + ) + .handle() + .await? + } + ReadarrCommand::ClearBlocklist => { + self + .network + .handle_network_event(ReadarrEvent::GetBlocklist.into()) + .await?; + let resp = self + .network + .handle_network_event(ReadarrEvent::ClearBlocklist.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrCommand::DownloadRelease { guid, indexer_id } => { + let params = ReleaseDownloadBody { guid, indexer_id }; + let resp = self + .network + .handle_network_event(ReadarrEvent::DownloadRelease(params).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrCommand::MarkHistoryItemAsFailed { history_item_id } => { + let _ = self + .network + .handle_network_event(ReadarrEvent::MarkHistoryItemAsFailed(history_item_id).into()) + .await?; + serde_json::to_string_pretty( + &json!({"message": "Readarr history item marked as 'failed'"}), + )? + } + ReadarrCommand::SearchNewAuthor { query } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::SearchNewAuthor(query).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrCommand::StartTask { task_name } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::StartTask(task_name).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrCommand::TestAllIndexers => { + println!("Testing all Readarr indexers. This may take a minute..."); + let resp = self + .network + .handle_network_event(ReadarrEvent::TestAllIndexers.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrCommand::TestIndexer { indexer_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::TestIndexer(indexer_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrCommand::ToggleAuthorMonitoring { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::ToggleAuthorMonitoring(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrCommand::ToggleBookMonitoring { book_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::ToggleBookMonitoring(book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs new file mode 100644 index 00000000..75a1629c --- /dev/null +++ b/src/cli/readarr/readarr_command_tests.rs @@ -0,0 +1,751 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + Command, + readarr::{ReadarrCommand, list_command_handler::ReadarrListCommand}, + }; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_command_from() { + let command = ReadarrCommand::List(ReadarrListCommand::Authors); + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(command)); + } + + mod cli { + use clap::CommandFactory; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::Cli; + + #[test] + fn test_readarr_command_requires_a_subcommand() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr"]); + + assert_err!(&result); + } + + #[test] + fn test_mark_history_item_as_failed_requires_history_item_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "mark-history-item-as-failed"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_release_requires_guid() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "download-release", + "--indexer-id", + "6", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_release_requires_indexer_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "download-release", + "--guid", + "test-release-guid", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_release_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "download-release", + "--guid", + "test-release-guid", + "--indexer-id", + "6", + ]); + + assert_ok!(&result); + } + + #[test] + fn test_mark_history_item_as_failed_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "mark-history-item-as-failed", + "--history-item-id", + "1234", + ]); + + assert_ok!(&result); + } + + #[test] + fn test_search_new_author_requires_query() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "search-new-author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_search_new_author_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "search-new-author", + "--query", + "test query", + ]); + + assert_ok!(&result); + } + + #[test] + fn test_start_task_requires_task_name() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "start-task"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_start_task_task_name_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "start-task", + "--task-name", + "test", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_start_task_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "start-task", + "--task-name", + "check-health", + ]); + + assert_ok!(&result); + } + + #[test] + fn test_test_indexer_requires_indexer_id() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "test-indexer"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_test_indexer_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "test-indexer", + "--indexer-id", + "8", + ]); + + assert_ok!(&result); + } + + #[test] + fn test_toggle_author_monitoring_requires_author_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "toggle-author-monitoring"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_toggle_author_monitoring_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "toggle-author-monitoring", + "--author-id", + "1", + ]); + + assert_ok!(&result); + } + + #[test] + fn test_toggle_book_monitoring_requires_book_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "toggle-book-monitoring"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_commands_that_have_no_arg_requirements( + #[values("clear-blocklist", "test-all-indexers")] subcommand: &str, + ) { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", subcommand]); + + assert_ok!(&result); + } + + #[test] + fn test_toggle_book_monitoring_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "toggle-book-monitoring", + "--book-id", + "1", + ]); + + assert_ok!(&result); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::readarr::add_command_handler::ReadarrAddCommand; + use crate::cli::readarr::edit_command_handler::ReadarrEditCommand; + use crate::cli::readarr::get_command_handler::ReadarrGetCommand; + use crate::cli::readarr::refresh_command_handler::ReadarrRefreshCommand; + use crate::cli::readarr::trigger_automatic_search_command_handler::ReadarrTriggerAutomaticSearchCommand; + use crate::models::readarr_models::{ + Author, BlocklistItem, BlocklistResponse, DeleteParams, ReadarrSerdeable, ReadarrTaskName, + }; + use crate::models::servarr_models::{IndexerSettings, ReleaseDownloadBody}; + use crate::{ + app::App, + cli::{ + CliCommandHandler, + readarr::{ + ReadarrCliHandler, ReadarrCommand, delete_command_handler::ReadarrDeleteCommand, + list_command_handler::ReadarrListCommand, + }, + }, + models::Serdeable, + network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, + }; + + #[tokio::test] + async fn test_readarr_cli_handler_delegates_add_commands_to_the_add_command_handler() { + let expected_tag_name = "test".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::AddTag(expected_tag_name.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let add_tag_command = ReadarrCommand::Add(ReadarrAddCommand::Tag { + name: expected_tag_name, + }); + + let result = ReadarrCliHandler::with(&app_arc, add_tag_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_readarr_cli_handler_delegates_get_commands_to_the_get_command_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetStatus.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let get_system_status_command = ReadarrCommand::Get(ReadarrGetCommand::SystemStatus); + + let result = ReadarrCliHandler::with(&app_arc, get_system_status_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_readarr_cli_handler_delegates_delete_commands_to_the_delete_command_handler() { + let expected_delete_author_params = DeleteParams { + id: 7, + delete_files: true, + add_import_list_exclusion: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::DeleteAuthor(expected_delete_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let delete_author_command = ReadarrCommand::Delete(ReadarrDeleteCommand::Author { + author_id: 7, + delete_files_from_disk: true, + add_list_exclusion: false, + }); + + let result = ReadarrCliHandler::with(&app_arc, delete_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_readarr_cli_handler_delegates_edit_commands_to_the_edit_command_handler() { + let expected_edit_all_indexer_settings = IndexerSettings { + id: 1, + maximum_size: 26500, + minimum_age: 17, + retention: 43, + rss_sync_interval: 35, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::IndexerSettings( + IndexerSettings { + id: 9, + maximum_size: 31200, + minimum_age: 22, + retention: 58, + rss_sync_interval: 90, + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_all_indexer_settings_command = + ReadarrCommand::Edit(ReadarrEditCommand::AllIndexerSettings { + maximum_size: Some(26500), + minimum_age: Some(17), + retention: Some(43), + rss_sync_interval: Some(35), + }); + + let result = ReadarrCliHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_readarr_cli_handler_delegates_list_commands_to_the_list_command_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::ListAuthors.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Authors(vec![ + Author::default(), + ]))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_authors_command = ReadarrCommand::List(ReadarrListCommand::Authors); + + let result = ReadarrCliHandler::with(&app_arc, list_authors_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_readarr_cli_handler_delegates_refresh_commands_to_the_refresh_command_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::UpdateAllAuthors.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let refresh_all_authors_command = ReadarrCommand::Refresh(ReadarrRefreshCommand::AllAuthors); + + let result = + ReadarrCliHandler::with(&app_arc, refresh_all_authors_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_readarr_cli_handler_delegates_trigger_automatic_search_commands_to_the_trigger_automatic_search_command_handler() + { + let expected_author_id = 7; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::TriggerAutomaticAuthorSearch(expected_author_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let trigger_automatic_search_command = + ReadarrCommand::TriggerAutomaticSearch(ReadarrTriggerAutomaticSearchCommand::Author { + author_id: expected_author_id, + }); + + let result = ReadarrCliHandler::with( + &app_arc, + trigger_automatic_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_mark_history_item_as_failed_command() { + let expected_history_item_id = 1234i64; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::MarkHistoryItemAsFailed(expected_history_item_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let mark_history_item_as_failed_command = ReadarrCommand::MarkHistoryItemAsFailed { + history_item_id: expected_history_item_id, + }; + + let result = ReadarrCliHandler::with( + &app_arc, + mark_history_item_as_failed_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_download_release_command() { + let expected_release_download_body = ReleaseDownloadBody { + guid: "test-release-guid".to_owned(), + indexer_id: 6, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::DownloadRelease(expected_release_download_body).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let download_release_command = ReadarrCommand::DownloadRelease { + guid: "test-release-guid".to_owned(), + indexer_id: 6, + }; + + let result = ReadarrCliHandler::with(&app_arc, download_release_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_search_new_author_command() { + let expected_query = "test author".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::SearchNewAuthor(expected_query.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let search_new_author_command = ReadarrCommand::SearchNewAuthor { + query: expected_query, + }; + + let result = ReadarrCliHandler::with(&app_arc, search_new_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_start_task_command() { + let expected_task_name = ReadarrTaskName::CheckHealth; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::StartTask(expected_task_name).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let start_task_command = ReadarrCommand::StartTask { + task_name: ReadarrTaskName::CheckHealth, + }; + + let result = ReadarrCliHandler::with(&app_arc, start_task_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_test_indexer_command() { + let expected_indexer_id = 8; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::TestIndexer(expected_indexer_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let test_indexer_command = ReadarrCommand::TestIndexer { indexer_id: 8 }; + + let result = ReadarrCliHandler::with(&app_arc, test_indexer_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_test_all_indexers_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::TestAllIndexers.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let test_all_indexers_command = ReadarrCommand::TestAllIndexers; + + let result = ReadarrCliHandler::with(&app_arc, test_all_indexers_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_toggle_author_monitoring_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::ToggleAuthorMonitoring(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let toggle_author_monitoring_command = + ReadarrCommand::ToggleAuthorMonitoring { author_id: 1 }; + + let result = ReadarrCliHandler::with( + &app_arc, + toggle_author_monitoring_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_toggle_book_monitoring_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::ToggleBookMonitoring(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let toggle_book_monitoring_command = ReadarrCommand::ToggleBookMonitoring { book_id: 1 }; + + let result = + ReadarrCliHandler::with(&app_arc, toggle_book_monitoring_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_clear_blocklist_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::BlocklistResponse( + BlocklistResponse { + records: vec![BlocklistItem::default()], + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::ClearBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let clear_blocklist_command = ReadarrCommand::ClearBlocklist; + + let result = ReadarrCliHandler::with(&app_arc, clear_blocklist_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/refresh_command_handler.rs b/src/cli/readarr/refresh_command_handler.rs new file mode 100644 index 00000000..5112e7d9 --- /dev/null +++ b/src/cli/readarr/refresh_command_handler.rs @@ -0,0 +1,90 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +use super::ReadarrCommand; + +#[cfg(test)] +#[path = "refresh_command_handler_tests.rs"] +mod refresh_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrRefreshCommand { + #[command(about = "Refresh all author data for all authors in your Readarr library")] + AllAuthors, + #[command(about = "Refresh author data and scan disk for the author with the given ID")] + Author { + #[arg( + long, + help = "The ID of the author to refresh information on and to scan the disk for", + required = true + )] + author_id: i64, + }, + #[command(about = "Refresh all downloads in Readarr")] + Downloads, +} + +impl From for Command { + fn from(value: ReadarrRefreshCommand) -> Self { + Command::Readarr(ReadarrCommand::Refresh(value)) + } +} + +pub(super) struct ReadarrRefreshCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrRefreshCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrRefreshCommand> + for ReadarrRefreshCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: ReadarrRefreshCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrRefreshCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrRefreshCommand::AllAuthors => { + let resp = self + .network + .handle_network_event(ReadarrEvent::UpdateAllAuthors.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrRefreshCommand::Author { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::UpdateAndScanAuthor(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrRefreshCommand::Downloads => { + let resp = self + .network + .handle_network_event(ReadarrEvent::UpdateDownloads.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/refresh_command_handler_tests.rs b/src/cli/readarr/refresh_command_handler_tests.rs new file mode 100644 index 00000000..14309988 --- /dev/null +++ b/src/cli/readarr/refresh_command_handler_tests.rs @@ -0,0 +1,162 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::Cli; + use crate::cli::{ + Command, + readarr::{ReadarrCommand, refresh_command_handler::ReadarrRefreshCommand}, + }; + use clap::CommandFactory; + + #[test] + fn test_readarr_refresh_command_from() { + let command = ReadarrRefreshCommand::Author { author_id: 1 }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Refresh(command))); + } + + mod cli { + use super::*; + use clap::Parser; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + + #[test] + fn test_refresh_all_authors_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "refresh", "all-authors"]); + + assert_ok!(&result); + } + + #[test] + fn test_refresh_downloads_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "refresh", "downloads"]); + + assert_ok!(&result); + } + + #[test] + fn test_refresh_author_requires_author_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "refresh", "author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_refresh_author_with_author_id() { + let expected_args = ReadarrRefreshCommand::Author { author_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "refresh", + "author", + "--author-id", + "1", + ]); + + assert_ok!(&result); + let Some(Command::Readarr(ReadarrCommand::Refresh(refresh_command))) = + result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(refresh_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::readarr::refresh_command_handler::{ + ReadarrRefreshCommand, ReadarrRefreshCommandHandler, + }; + use crate::{app::App, cli::CliCommandHandler}; + use crate::{ + models::{Serdeable, readarr_models::ReadarrSerdeable}, + network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, + }; + + #[tokio::test] + async fn test_handle_refresh_all_authors_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::UpdateAllAuthors.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let refresh_command = ReadarrRefreshCommand::AllAuthors; + + let result = ReadarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_refresh_author_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::UpdateAndScanAuthor(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let refresh_command = ReadarrRefreshCommand::Author { author_id: 1 }; + + let result = ReadarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_refresh_downloads_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::UpdateDownloads.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let refresh_command = ReadarrRefreshCommand::Downloads; + + let result = ReadarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/trigger_automatic_search_command_handler.rs b/src/cli/readarr/trigger_automatic_search_command_handler.rs new file mode 100644 index 00000000..16f2daa0 --- /dev/null +++ b/src/cli/readarr/trigger_automatic_search_command_handler.rs @@ -0,0 +1,88 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +use super::ReadarrCommand; + +#[cfg(test)] +#[path = "trigger_automatic_search_command_handler_tests.rs"] +mod trigger_automatic_search_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrTriggerAutomaticSearchCommand { + #[command(about = "Trigger an automatic search for the author with the specified ID")] + Author { + #[arg( + long, + help = "The ID of the author you want to trigger an automatic search for", + required = true + )] + author_id: i64, + }, + #[command(about = "Trigger an automatic search for the book with the specified ID")] + Book { + #[arg( + long, + help = "The ID of the book you want to trigger an automatic search for", + required = true + )] + book_id: i64, + }, +} + +impl From for Command { + fn from(value: ReadarrTriggerAutomaticSearchCommand) -> Self { + Command::Readarr(ReadarrCommand::TriggerAutomaticSearch(value)) + } +} + +pub(super) struct ReadarrTriggerAutomaticSearchCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrTriggerAutomaticSearchCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrTriggerAutomaticSearchCommand> + for ReadarrTriggerAutomaticSearchCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: ReadarrTriggerAutomaticSearchCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrTriggerAutomaticSearchCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrTriggerAutomaticSearchCommand::Author { author_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::TriggerAutomaticAuthorSearch(author_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + ReadarrTriggerAutomaticSearchCommand::Book { book_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::TriggerAutomaticBookSearch(book_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/trigger_automatic_search_command_handler_tests.rs b/src/cli/readarr/trigger_automatic_search_command_handler_tests.rs new file mode 100644 index 00000000..bf82fe50 --- /dev/null +++ b/src/cli/readarr/trigger_automatic_search_command_handler_tests.rs @@ -0,0 +1,186 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::Cli; + use crate::cli::{ + Command, + readarr::{ + ReadarrCommand, + trigger_automatic_search_command_handler::ReadarrTriggerAutomaticSearchCommand, + }, + }; + use clap::CommandFactory; + + #[test] + fn test_readarr_trigger_automatic_search_command_from() { + let command = ReadarrTriggerAutomaticSearchCommand::Author { author_id: 1 }; + + let result = Command::from(command.clone()); + + assert_eq!( + result, + Command::Readarr(ReadarrCommand::TriggerAutomaticSearch(command)) + ); + } + + mod cli { + use super::*; + use clap::Parser; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + + #[test] + fn test_trigger_automatic_author_search_requires_author_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "trigger-automatic-search", + "author", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_author_search_with_author_id() { + let expected_args = ReadarrTriggerAutomaticSearchCommand::Author { author_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "trigger-automatic-search", + "author", + "--author-id", + "1", + ]); + + assert_ok!(&result); + let Some(Command::Readarr(ReadarrCommand::TriggerAutomaticSearch( + trigger_automatic_search_command, + ))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(trigger_automatic_search_command, expected_args); + } + + #[test] + fn test_trigger_automatic_book_search_requires_book_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "trigger-automatic-search", + "book", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_book_search_with_book_id() { + let expected_args = ReadarrTriggerAutomaticSearchCommand::Book { book_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "trigger-automatic-search", + "book", + "--book-id", + "1", + ]); + + assert_ok!(&result); + let Some(Command::Readarr(ReadarrCommand::TriggerAutomaticSearch( + trigger_automatic_search_command, + ))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(trigger_automatic_search_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::readarr::trigger_automatic_search_command_handler::{ + ReadarrTriggerAutomaticSearchCommand, ReadarrTriggerAutomaticSearchCommandHandler, + }; + use crate::{app::App, cli::CliCommandHandler}; + use crate::{ + models::{Serdeable, readarr_models::ReadarrSerdeable}, + network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, + }; + + #[tokio::test] + async fn test_handle_trigger_automatic_author_search_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::TriggerAutomaticAuthorSearch(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let trigger_automatic_search_command = + ReadarrTriggerAutomaticSearchCommand::Author { author_id: 1 }; + + let result = ReadarrTriggerAutomaticSearchCommandHandler::with( + &app_arc, + trigger_automatic_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_trigger_automatic_book_search_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::TriggerAutomaticBookSearch(1).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let trigger_automatic_search_command = + ReadarrTriggerAutomaticSearchCommand::Book { book_id: 1 }; + + let result = ReadarrTriggerAutomaticSearchCommandHandler::with( + &app_arc, + trigger_automatic_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/sonarr/edit_command_handler_tests.rs b/src/cli/sonarr/edit_command_handler_tests.rs index 69644513..af318771 100644 --- a/src/cli/sonarr/edit_command_handler_tests.rs +++ b/src/cli/sonarr/edit_command_handler_tests.rs @@ -632,10 +632,10 @@ mod tests { async fn test_handle_edit_all_indexer_settings_command() { let expected_edit_all_indexer_settings = IndexerSettings { id: 1, - maximum_size: 1, - minimum_age: 1, - retention: 1, - rss_sync_interval: 1, + maximum_size: 12345, + minimum_age: 13, + retention: 21, + rss_sync_interval: 60, }; let mut mock_network = MockNetworkTrait::new(); mock_network @@ -647,11 +647,11 @@ mod tests { .returning(|_| { Ok(Serdeable::Sonarr(SonarrSerdeable::IndexerSettings( IndexerSettings { - id: 1, - maximum_size: 2, - minimum_age: 2, - retention: 2, - rss_sync_interval: 2, + id: 9, + maximum_size: 22222, + minimum_age: 33, + retention: 47, + rss_sync_interval: 90, }, ))) }); @@ -668,10 +668,10 @@ mod tests { }); let app_arc = Arc::new(Mutex::new(App::test_default())); let edit_all_indexer_settings_command = SonarrEditCommand::AllIndexerSettings { - maximum_size: Some(1), - minimum_age: Some(1), - retention: Some(1), - rss_sync_interval: Some(1), + maximum_size: Some(12345), + minimum_age: Some(13), + retention: Some(21), + rss_sync_interval: Some(60), }; let result = SonarrEditCommandHandler::with( diff --git a/src/event/key_tests.rs b/src/event/key_tests.rs index 8ef6d4c8..986f5c08 100644 --- a/src/event/key_tests.rs +++ b/src/event/key_tests.rs @@ -24,7 +24,7 @@ mod tests { #[case(Key::Char('q'), "q")] #[case(Key::Ctrl('q'), "ctrl-q")] fn test_key_formatter(#[case] key: Key, #[case] expected_str: &str) { - assert_str_eq!(format!("{key}"), format!("{expected_str}")); + assert_str_eq!(format!("{key}"), expected_str.to_string()); } #[test] diff --git a/src/handlers/handler_proptest.rs b/src/handlers/handler_proptest.rs index 1e3df759..1bf29f49 100644 --- a/src/handlers/handler_proptest.rs +++ b/src/handlers/handler_proptest.rs @@ -3,11 +3,16 @@ mod property_tests { use proptest::prelude::*; use crate::app::App; - use crate::handlers::handler_test_utils::test_utils::proptest_helpers::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::handle_events; + use crate::handlers::handler_test_utils::test_utils::proptest_helpers::{ + list_size, text_input_string, + }; use crate::models::radarr_models::Movie; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::stateful_table::StatefulTable; - use crate::models::{Paginated, Scrollable}; + use crate::models::{Paginated, Scrollable, strip_non_search_characters}; proptest! { #[test] @@ -16,11 +21,12 @@ mod property_tests { index in 0usize..1000 ) { let mut table = StatefulTable::::default(); - let movies: Vec = (0..list_size).map(|i| { - let mut movie = Movie::default(); - movie.id = i as i64; - movie - }).collect(); + let movies: Vec = (0..list_size) + .map(|i| Movie { + id: i as i64, + ..Movie::default() + }) + .collect(); table.set_items(movies); @@ -39,11 +45,12 @@ mod property_tests { scroll_amount in 0usize..20 ) { let mut table = StatefulTable::::default(); - let movies: Vec = (0..list_size).map(|i| { - let mut movie = Movie::default(); - movie.id = i as i64; - movie - }).collect(); + let movies: Vec = (0..list_size) + .map(|i| Movie { + id: i as i64, + ..Movie::default() + }) + .collect(); table.set_items(movies); let initial_id = table.current_selection().id; @@ -76,7 +83,7 @@ mod property_tests { let mut app = App::test_default(); let initial_route = app.get_current_route(); - let routes = vec![ + let routes = [ ActiveRadarrBlock::Movies, ActiveRadarrBlock::Collections, ActiveRadarrBlock::Downloads, @@ -100,26 +107,43 @@ mod property_tests { #[test] fn test_string_input_safety(input in text_input_string()) { - let _lowercase = input.to_lowercase(); - let _uppercase = input.to_uppercase(); - let _trimmed = input.trim(); - let _len = input.len(); - let _chars: Vec = input.chars().collect(); + let mut app = App::test_default(); + app.data.radarr_data.movies.set_items(vec![Movie::default()]); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + handle_events(DEFAULT_KEYBINDINGS.filter.key, &mut app); - prop_assert!(true); + for character in input.chars() { + handle_events(Key::Char(character), &mut app); + } + + prop_assert_eq!(app.get_current_route(), ActiveRadarrBlock::FilterMovies.into()); + prop_assert_eq!( + app.data.radarr_data.movies.filter.as_ref().unwrap().text.as_str(), + input.as_str() + ); + + for _ in 0..input.chars().count() { + handle_events(DEFAULT_KEYBINDINGS.backspace.key, &mut app); + } + + prop_assert_eq!( + app.data.radarr_data.movies.filter.as_ref().unwrap().text.as_str(), + "" + ); } #[test] fn test_table_data_integrity( - list_size in 1usize..100 + list_size in list_size() ) { let mut table = StatefulTable::::default(); - let movies: Vec = (0..list_size).map(|i| { - let mut movie = Movie::default(); - movie.id = i as i64; - movie.title = format!("Movie {}", i).into(); - movie - }).collect(); + let movies: Vec = (0..list_size) + .map(|i| Movie { + id: i as i64, + title: format!("Movie {i}").into(), + ..Movie::default() + }) + .collect(); table.set_items(movies.clone()); let original_count = table.items.len(); @@ -137,19 +161,29 @@ mod property_tests { page_ops in 0usize..10 ) { let mut table = StatefulTable::::default(); - let movies: Vec = (0..list_size).map(|i| { - let mut movie = Movie::default(); - movie.id = i as i64; - movie - }).collect(); + let movies: Vec = (0..list_size) + .map(|i| Movie { + id: i as i64, + ..Movie::default() + }) + .collect(); table.set_items(movies); for i in 0..page_ops { + let previous_index = table.state.selected().unwrap(); + if i % 2 == 0 { table.page_down(); + + prop_assert_eq!( + table.state.selected(), + Some((previous_index + 20).min(list_size - 1)) + ); } else { table.page_up(); + + prop_assert_eq!(table.state.selected(), Some(previous_index.saturating_sub(20))); } let current = table.current_selection(); @@ -164,28 +198,43 @@ mod property_tests { filter_term in text_input_string() ) { let mut table = StatefulTable::::default(); - let movies: Vec = (0..list_size).map(|i| { - let mut movie = Movie::default(); - movie.id = i as i64; - movie.title = format!("Test Movie {}", i % 10).into(); - movie - }).collect(); - + let movies: Vec = (0..list_size) + .map(|i| Movie { + id: i as i64, + title: format!("Test Movie {}", i % 10).into(), + ..Movie::default() + }) + .collect(); table.set_items(movies.clone()); - let original_size = table.items.len(); + table.filter = Some(filter_term.clone().into()); + let scrubbed_filter = strip_non_search_characters(&filter_term); + let matches_filter = |movie: &Movie| { + strip_non_search_characters(&movie.title.text).contains(&scrubbed_filter) + }; - if !filter_term.is_empty() { - let filtered: Vec = movies.into_iter() - .filter(|m| m.title.text.to_lowercase().contains(&filter_term.to_lowercase())) - .collect(); - table.set_items(filtered); - } + let has_match = table.apply_filter(|movie| &movie.title.text); - prop_assert!(table.items.len() <= original_size); + prop_assert_eq!(&table.items, &movies); + prop_assert!(table.filter.is_none()); - if !table.items.is_empty() { - let current = table.current_selection(); - prop_assert!(current.id >= 0); + if has_match { + let filtered_items = table.filtered_items.as_ref().unwrap(); + + prop_assert!(!filtered_items.is_empty()); + prop_assert!(filtered_items.len() <= movies.len()); + + for movie in filtered_items { + prop_assert!(matches_filter(movie)); + } + + for movie in movies.iter().filter(|movie| !filtered_items.contains(movie)) { + prop_assert!(!matches_filter(movie)); + } + } else { + prop_assert!(table.filtered_items.is_none()); + prop_assert!( + filter_term.is_empty() || movies.iter().all(|movie| !matches_filter(movie)) + ); } } } diff --git a/src/handlers/handler_test_utils.rs b/src/handlers/handler_test_utils.rs index 592f7c69..7a567b83 100644 --- a/src/handlers/handler_test_utils.rs +++ b/src/handlers/handler_test_utils.rs @@ -1,6 +1,6 @@ #[cfg(test)] #[macro_use] -mod test_utils { +pub mod test_utils { #[macro_export] macro_rules! simple_stateful_iterable_vec { ($name:ident) => { @@ -333,10 +333,16 @@ mod test_utils { let mut app = App::test_default_fully_populated(); app.push_navigation_stack($base.into()); app.push_navigation_stack($active_block.into()); + let navigation_stack_depth_before_delegation = app.get_navigation_stack_depth(); $handler::new(DEFAULT_KEYBINDINGS.esc.key, &mut app, $active_block, None).handle(); pretty_assertions::assert_eq!(app.get_current_route(), $base.into()); + pretty_assertions::assert_eq!( + app.get_navigation_stack_depth(), + navigation_stack_depth_before_delegation - 1, + "Expected the delegated handler to pop exactly one route off the navigation stack" + ); }; } @@ -405,4 +411,16 @@ mod test_utils { ); }; } + + pub mod proptest_helpers { + use proptest::prelude::*; + + pub fn list_size() -> impl Strategy { + 1usize..100 + } + + pub fn text_input_string() -> impl Strategy { + "\\PC{0,50}" + } + } } diff --git a/src/handlers/handlers_tests.rs b/src/handlers/handlers_tests.rs index 9ce97089..72b879d0 100644 --- a/src/handlers/handlers_tests.rs +++ b/src/handlers/handlers_tests.rs @@ -3,6 +3,7 @@ mod tests { use crate::assert_modal_absent; use crate::assert_navigation_pushed; use crate::models::radarr_models::Movie; + use crate::models::readarr_models::Author; use crate::models::sonarr_models::Series; use crate::{assert_modal_present, assert_navigation_popped}; use pretty_assertions::assert_eq; @@ -22,6 +23,7 @@ mod tests { use crate::models::Route; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_data::{ActiveKeybindingBlock, Notification}; use crate::models::servarr_models::KeybindingItem; @@ -40,6 +42,7 @@ mod tests { #[rstest] #[case(ActiveRadarrBlock::Movies.into(), ActiveRadarrBlock::SearchMovie.into())] #[case(ActiveSonarrBlock::Series.into(), ActiveSonarrBlock::SearchSeries.into())] + #[case(ActiveReadarrBlock::Authors.into(), ActiveReadarrBlock::SearchAuthors.into())] fn test_handle_events(#[case] base_block: Route, #[case] top_block: Route) { let mut app = App::test_default(); app.push_navigation_stack(base_block); @@ -54,6 +57,11 @@ mod tests { .radarr_data .movies .set_items(vec![Movie::default()]); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); handle_events(DEFAULT_KEYBINDINGS.esc.key, &mut app); @@ -61,9 +69,10 @@ mod tests { } #[rstest] - #[case(0, ActiveLidarrBlock::Artists, ActiveSonarrBlock::Series)] + #[case(0, ActiveReadarrBlock::Authors, ActiveSonarrBlock::Series)] #[case(1, ActiveRadarrBlock::Movies, ActiveLidarrBlock::Artists)] - #[case(2, ActiveSonarrBlock::Series, ActiveRadarrBlock::Movies)] + #[case(2, ActiveSonarrBlock::Series, ActiveReadarrBlock::Authors)] + #[case(3, ActiveLidarrBlock::Artists, ActiveRadarrBlock::Movies)] fn test_handle_change_tabs( #[case] index: usize, #[case] left_block: T, @@ -226,6 +235,22 @@ mod tests { assert!(!app.data.sonarr_data.prompt_confirm); } + #[rstest] + fn test_handle_prompt_toggle_left_right_readarr(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + assert!(!app.data.readarr_data.prompt_confirm); + + handle_prompt_toggle(&mut app, key); + + assert!(app.data.readarr_data.prompt_confirm); + + handle_prompt_toggle(&mut app, key); + + assert!(!app.data.readarr_data.prompt_confirm); + } + #[test] fn test_populate_keymapping_table_global_options() { let expected_keybinding_items = Vec::from(SERVARR_CONTEXT_CLUES) diff --git a/src/handlers/lidarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/lidarr_handlers/downloads/downloads_handler_tests.rs index 9c65988e..b36ab646 100644 --- a/src/handlers/lidarr_handlers/downloads/downloads_handler_tests.rs +++ b/src/handlers/lidarr_handlers/downloads/downloads_handler_tests.rs @@ -141,7 +141,7 @@ mod tests { #[case( ActiveLidarrBlock::Downloads, ActiveLidarrBlock::DeleteDownloadPrompt, - LidarrEvent::DeleteDownload(1) + LidarrEvent::DeleteDownload(9) )] #[case( ActiveLidarrBlock::Downloads, @@ -338,7 +338,7 @@ mod tests { #[case( ActiveLidarrBlock::Downloads, ActiveLidarrBlock::DeleteDownloadPrompt, - LidarrEvent::DeleteDownload(1) + LidarrEvent::DeleteDownload(9) )] #[case( ActiveLidarrBlock::Downloads, @@ -423,7 +423,7 @@ mod tests { ) .extract_download_id(); - assert_eq!(download_id, 1); + assert_eq!(download_id, 9); } #[test] diff --git a/src/handlers/lidarr_handlers/history/history_handler_tests.rs b/src/handlers/lidarr_handlers/history/history_handler_tests.rs index 75c4e6fb..046b77b5 100644 --- a/src/handlers/lidarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/lidarr_handlers/history/history_handler_tests.rs @@ -196,6 +196,40 @@ mod tests { assert_eq!(app.get_current_route(), ActiveLidarrBlock::History.into()); assert!(!app.should_refresh); } + + #[test] + fn test_search_history_key() { + let mut app = App::test_default(); + app.data.lidarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchHistory.into()); + } + + #[test] + fn test_filter_history_key() { + let mut app = App::test_default(); + app.data.lidarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveLidarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveLidarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::FilterHistory.into()); + } } #[test] diff --git a/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs b/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs index 203089bb..308215b7 100644 --- a/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/add_artist_handler_tests.rs @@ -1076,10 +1076,16 @@ mod tests { .set_index(0, ADD_ARTIST_SELECTION_BLOCKS.len() - 1); app.data.lidarr_data.prompt_confirm = true; let mut add_searched_artists = StatefulTable::default(); - add_searched_artists.set_items(vec![add_artist_search_result()]); + let mut decoy_search_result = add_artist_search_result(); + decoy_search_result.foreign_artist_id = "decoy-foreign-id".to_owned(); + decoy_search_result.artist_name = "Decoy Artist".into(); + add_searched_artists.set_items(vec![decoy_search_result, add_artist_search_result()]); + add_searched_artists.select_index(Some(1)); app.data.lidarr_data.add_searched_artists = Some(add_searched_artists); - app.data.lidarr_data.quality_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); - app.data.lidarr_data.metadata_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); + app.data.lidarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "Lossless".to_owned())]); + app.data.lidarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); app.data.lidarr_data.add_artist_modal = Some(AddArtistModal { tags: "usenet, testing".into(), ..AddArtistModal::default() @@ -1092,6 +1098,15 @@ mod tests { .unwrap() .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + app + .data + .lidarr_data + .add_artist_modal + .as_mut() + .unwrap() + .monitor_list + .state + .select(Some(1)); app .data .lidarr_data @@ -1100,6 +1115,15 @@ mod tests { .unwrap() .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + app + .data + .lidarr_data + .add_artist_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .state + .select(Some(1)); app .data .lidarr_data @@ -1107,7 +1131,16 @@ mod tests { .as_mut() .unwrap() .quality_profile_list - .set_items(vec!["Test".to_owned()]); + .set_items(vec!["Any".to_owned(), "Lossless".to_owned()]); + app + .data + .lidarr_data + .add_artist_modal + .as_mut() + .unwrap() + .quality_profile_list + .state + .select(Some(1)); app .data .lidarr_data @@ -1115,7 +1148,16 @@ mod tests { .as_mut() .unwrap() .metadata_profile_list - .set_items(vec!["Test".to_owned()]); + .set_items(vec!["None".to_owned(), "Standard".to_owned()]); + app + .data + .lidarr_data + .add_artist_modal + .as_mut() + .unwrap() + .metadata_profile_list + .state + .select(Some(1)); app .data .lidarr_data @@ -1123,22 +1165,37 @@ mod tests { .as_mut() .unwrap() .root_folder_list - .set_items(vec![RootFolder { - path: "/music".to_owned(), - ..RootFolder::default() - }]); + .set_items(vec![ + RootFolder { + path: "/decoy".to_owned(), + ..RootFolder::default() + }, + RootFolder { + path: "/music".to_owned(), + ..RootFolder::default() + }, + ]); + app + .data + .lidarr_data + .add_artist_modal + .as_mut() + .unwrap() + .root_folder_list + .state + .select(Some(1)); let expected_add_artist_body = AddArtistBody { foreign_artist_id: "test-foreign-id".to_string(), artist_name: "Test Artist".to_string(), monitored: true, root_folder_path: "/music".to_string(), - quality_profile_id: 1, - metadata_profile_id: 1, + quality_profile_id: 2222, + metadata_profile_id: 4444, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddArtistOptions { - monitor: MonitorType::All, - monitor_new_items: NewItemMonitorType::All, + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::None, search_for_missing_albums: true, }, }; @@ -1468,10 +1525,16 @@ mod tests { .selected_block .set_index(0, ADD_ARTIST_SELECTION_BLOCKS.len() - 1); let mut add_searched_artists = StatefulTable::default(); - add_searched_artists.set_items(vec![add_artist_search_result()]); + let mut decoy_search_result = add_artist_search_result(); + decoy_search_result.foreign_artist_id = "decoy-foreign-id".to_owned(); + decoy_search_result.artist_name = "Decoy Artist".into(); + add_searched_artists.set_items(vec![decoy_search_result, add_artist_search_result()]); + add_searched_artists.select_index(Some(1)); app.data.lidarr_data.add_searched_artists = Some(add_searched_artists); - app.data.lidarr_data.quality_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); - app.data.lidarr_data.metadata_profile_map = BiMap::from_iter([(1, "Test".to_owned())]); + app.data.lidarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "Lossless".to_owned())]); + app.data.lidarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); let mut add_artist_modal = AddArtistModal { tags: "usenet, testing".into(), ..AddArtistModal::default() @@ -1479,15 +1542,22 @@ mod tests { add_artist_modal .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + add_artist_modal.monitor_list.state.select(Some(1)); add_artist_modal .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_artist_modal + .monitor_new_items_list + .state + .select(Some(1)); add_artist_modal .quality_profile_list - .set_items(vec!["Test".to_owned()]); + .set_items(vec!["Any".to_owned(), "Lossless".to_owned()]); + add_artist_modal.quality_profile_list.state.select(Some(1)); add_artist_modal .metadata_profile_list - .set_items(vec!["Test".to_owned()]); + .set_items(vec!["None".to_owned(), "Standard".to_owned()]); + add_artist_modal.metadata_profile_list.state.select(Some(1)); add_artist_modal.root_folder_list.set_items(vec![ RootFolder { id: 1, @@ -1511,13 +1581,13 @@ mod tests { artist_name: "Test Artist".to_string(), monitored: true, root_folder_path: "/nfs2".to_string(), - quality_profile_id: 1, - metadata_profile_id: 1, + quality_profile_id: 2222, + metadata_profile_id: 4444, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddArtistOptions { - monitor: Default::default(), - monitor_new_items: Default::default(), + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::None, search_for_missing_albums: true, }, }; @@ -1641,34 +1711,47 @@ mod tests { add_artist_modal.root_folder_list.state.select(Some(1)); add_artist_modal .quality_profile_list - .set_items(vec!["Lossless".to_owned()]); + .set_items(vec!["Any".to_owned(), "Lossless".to_owned()]); + add_artist_modal.quality_profile_list.state.select(Some(1)); add_artist_modal .metadata_profile_list - .set_items(vec!["Standard".to_owned()]); + .set_items(vec!["None".to_owned(), "Standard".to_owned()]); + add_artist_modal.metadata_profile_list.state.select(Some(1)); add_artist_modal .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + add_artist_modal.monitor_list.state.select(Some(1)); add_artist_modal .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_artist_modal + .monitor_new_items_list + .state + .select(Some(1)); app.data.lidarr_data.add_artist_modal = Some(add_artist_modal); - app.data.lidarr_data.quality_profile_map = BiMap::from_iter([(1, "Lossless".to_owned())]); - app.data.lidarr_data.metadata_profile_map = BiMap::from_iter([(1, "Standard".to_owned())]); + app.data.lidarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "Lossless".to_owned())]); + app.data.lidarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); let mut add_searched_artists = StatefulTable::default(); - add_searched_artists.set_items(vec![add_artist_search_result()]); + let mut decoy_search_result = add_artist_search_result(); + decoy_search_result.foreign_artist_id = "decoy-foreign-id".to_owned(); + decoy_search_result.artist_name = "Decoy Artist".into(); + add_searched_artists.set_items(vec![decoy_search_result, add_artist_search_result()]); + add_searched_artists.select_index(Some(1)); app.data.lidarr_data.add_searched_artists = Some(add_searched_artists); let expected_add_artist_body = AddArtistBody { foreign_artist_id: "test-foreign-id".to_string(), artist_name: "Test Artist".into(), monitored: true, root_folder_path: "/nfs2".to_string(), - quality_profile_id: 1, - metadata_profile_id: 1, + quality_profile_id: 2222, + metadata_profile_id: 4444, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddArtistOptions { - monitor: Default::default(), - monitor_new_items: Default::default(), + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::None, search_for_missing_albums: true, }, }; diff --git a/src/handlers/lidarr_handlers/library/album_details_handler.rs b/src/handlers/lidarr_handlers/library/album_details_handler.rs index 4e69596a..4645df6c 100644 --- a/src/handlers/lidarr_handlers/library/album_details_handler.rs +++ b/src/handlers/lidarr_handlers/library/album_details_handler.rs @@ -5,10 +5,9 @@ use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; use crate::models::Route; -use crate::models::lidarr_models::{ - LidarrHistoryItem, LidarrRelease, LidarrReleaseDownloadBody, Track, -}; +use crate::models::lidarr_models::{LidarrHistoryItem, LidarrRelease, Track}; use crate::models::servarr_data::lidarr::lidarr_data::{ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock}; +use crate::models::servarr_models::ReleaseDownloadBody; use crate::models::stateful_table::SortOption; use crate::network::lidarr_network::LidarrEvent; use serde_json::Number; @@ -281,7 +280,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for AlbumDetailsHandler< .unwrap() .album_releases .current_selection(); - let params = LidarrReleaseDownloadBody { + let params = ReleaseDownloadBody { guid: guid.clone(), indexer_id: *indexer_id, }; @@ -386,7 +385,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for AlbumDetailsHandler< .unwrap() .album_releases .current_selection(); - let params = LidarrReleaseDownloadBody { + let params = ReleaseDownloadBody { guid: guid.clone(), indexer_id: *indexer_id, }; diff --git a/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs b/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs index 843b5227..edfde49a 100644 --- a/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/album_details_handler_tests.rs @@ -9,10 +9,10 @@ mod tests { AlbumDetailsHandler, releases_sorting_options, }; use crate::models::HorizontallyScrollableText; - use crate::models::lidarr_models::{LidarrRelease, LidarrReleaseDownloadBody}; + use crate::models::lidarr_models::LidarrRelease; use crate::models::servarr_data::lidarr::lidarr_data::{ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock}; use crate::models::servarr_data::lidarr::modals::AlbumDetailsModal; - use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::models::servarr_models::{Quality, QualityWrapper, ReleaseDownloadBody}; use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use serde_json::Number; @@ -255,7 +255,7 @@ mod tests { )] #[case( ActiveLidarrBlock::DeleteTrackFilePrompt, - LidarrEvent::DeleteTrackFile(1) + LidarrEvent::DeleteTrackFile(7) )] fn test_album_details_prompt_confirm_submit( #[case] prompt_block: ActiveLidarrBlock, @@ -297,7 +297,7 @@ mod tests { assert_navigation_popped!(app, ActiveLidarrBlock::ManualAlbumSearch.into()); assert_some_eq_x!( &app.data.lidarr_data.prompt_confirm_action, - &LidarrEvent::DownloadRelease(LidarrReleaseDownloadBody { + &LidarrEvent::DownloadRelease(ReleaseDownloadBody { guid: "1234".to_owned(), indexer_id: 2, }) @@ -606,7 +606,7 @@ mod tests { )] #[case( ActiveLidarrBlock::DeleteTrackFilePrompt, - LidarrEvent::DeleteTrackFile(1) + LidarrEvent::DeleteTrackFile(7) )] fn test_album_details_prompt_confirm_confirm_key( #[case] prompt_block: ActiveLidarrBlock, @@ -654,12 +654,60 @@ mod tests { assert_navigation_popped!(app, ActiveLidarrBlock::ManualAlbumSearch.into()); assert_some_eq_x!( &app.data.lidarr_data.prompt_confirm_action, - &LidarrEvent::DownloadRelease(LidarrReleaseDownloadBody { + &LidarrEvent::DownloadRelease(ReleaseDownloadBody { guid: "1234".to_owned(), indexer_id: 2, }) ); } + + #[test] + fn test_search_tracks_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::AlbumDetails.into()); + + AlbumDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::AlbumDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchTracks.into()); + } + + #[test] + fn test_search_album_history_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::AlbumHistory.into()); + + AlbumDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::AlbumHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchAlbumHistory.into()); + } + + #[test] + fn test_filter_album_history_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::AlbumHistory.into()); + + AlbumDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveLidarrBlock::AlbumHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::FilterAlbumHistory.into()); + } } #[test] @@ -704,7 +752,7 @@ mod tests { ) .extract_track_file_id(); - assert_eq!(track_file_id, 1); + assert_eq!(track_file_id, 7); } #[test] diff --git a/src/handlers/lidarr_handlers/library/artist_details_handler.rs b/src/handlers/lidarr_handlers/library/artist_details_handler.rs index fa5fd77f..ed39ed60 100644 --- a/src/handlers/lidarr_handlers/library/artist_details_handler.rs +++ b/src/handlers/lidarr_handlers/library/artist_details_handler.rs @@ -1,18 +1,19 @@ +use super::artist_overview_handler::ArtistOverviewHandler; use crate::app::App; use crate::event::Key; use crate::handlers::lidarr_handlers::history::history_sorting_options; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; -use crate::models::lidarr_models::{ - Album, LidarrHistoryItem, LidarrRelease, LidarrReleaseDownloadBody, -}; +use crate::models::lidarr_models::{Album, LidarrHistoryItem, LidarrRelease}; use crate::models::servarr_data::lidarr::lidarr_data::{ ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, DELETE_ALBUM_SELECTION_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, }; +use crate::models::servarr_data::lidarr::modals::ArtistOverviewModal; +use crate::models::servarr_models::ReleaseDownloadBody; use crate::models::stateful_table::SortOption; -use crate::models::{BlockSelectionState, Route}; +use crate::models::{BlockSelectionState, Route, ScrollableText}; use crate::network::lidarr_network::LidarrEvent; use serde_json::Number; @@ -35,10 +36,36 @@ impl ArtistDetailsHandler<'_, '_> { fn extract_album_id(&self) -> i64 { self.app.data.lidarr_data.albums.current_selection().id } + + fn build_artist_overview_modal(&mut self) { + let overview = self + .app + .data + .lidarr_data + .artists + .current_selection() + .overview + .clone() + .unwrap_or_default(); + + self.app.data.lidarr_data.artist_overview_modal = Some(ArtistOverviewModal { + overview: ScrollableText::with_string(overview), + }); + } } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler<'a, 'b> { fn handle(&mut self) { + if ArtistOverviewHandler::accepts(self.active_lidarr_block) { + return ArtistOverviewHandler::new( + self.key, + self.app, + self.active_lidarr_block, + self._context, + ) + .handle(); + } + let albums_table_handling_config = TableHandlingConfig::new(ActiveLidarrBlock::ArtistDetails.into()) .searching_block(ActiveLidarrBlock::SearchAlbums.into()) @@ -79,7 +106,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler } fn accepts(active_block: ActiveLidarrBlock) -> bool { - ARTIST_DETAILS_BLOCKS.contains(&active_block) + ArtistOverviewHandler::accepts(active_block) || ARTIST_DETAILS_BLOCKS.contains(&active_block) } fn ignore_special_keys(&self) -> bool { @@ -202,7 +229,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler .discography_releases .current_selection() .clone(); - let params = LidarrReleaseDownloadBody { guid, indexer_id }; + let params = ReleaseDownloadBody { guid, indexer_id }; self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::DownloadRelease(params)); } @@ -293,6 +320,12 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler self.app.data.lidarr_data.selected_block = BlockSelectionState::new(EDIT_ARTIST_SELECTION_BLOCKS); } + _ if matches_key!(view, key) => { + self.build_artist_overview_modal(); + self + .app + .push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + } _ if matches_key!(toggle_monitoring, key) && !self.app.data.lidarr_data.albums.is_empty() => { @@ -365,7 +398,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistDetailsHandler .discography_releases .current_selection() .clone(); - let params = LidarrReleaseDownloadBody { guid, indexer_id }; + let params = ReleaseDownloadBody { guid, indexer_id }; self.app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::DownloadRelease(params)); diff --git a/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs b/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs index 2e6e15e5..7670c63c 100644 --- a/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/artist_details_handler_tests.rs @@ -15,9 +15,10 @@ mod tests { use crate::models::HorizontallyScrollableText; use crate::models::lidarr_models::{LidarrHistoryItem, LidarrRelease}; use crate::models::servarr_data::lidarr::lidarr_data::{ - ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, + ARTIST_DETAILS_BLOCKS, ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock, }; use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::test_handler_delegation; mod test_handle_delete { use super::*; @@ -137,8 +138,9 @@ mod tests { use crate::event::Key; use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::library::artist_details_handler::ArtistDetailsHandler; - use crate::models::lidarr_models::{LidarrHistoryItem, LidarrReleaseDownloadBody}; + use crate::models::lidarr_models::LidarrHistoryItem; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; + use crate::models::servarr_models::ReleaseDownloadBody; use crate::network::lidarr_network::LidarrEvent; use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ artist, torrent_release, @@ -307,7 +309,7 @@ mod tests { assert_navigation_popped!(app, ActiveLidarrBlock::ManualArtistSearch.into()); assert_eq!( app.data.lidarr_data.prompt_confirm_action, - Some(LidarrEvent::DownloadRelease(LidarrReleaseDownloadBody { + Some(LidarrEvent::DownloadRelease(ReleaseDownloadBody { guid: release.guid, indexer_id: release.indexer_id, })) @@ -449,14 +451,16 @@ mod tests { use crate::assert_navigation_pushed; use crate::handlers::KeyEventHandler; use crate::handlers::lidarr_handlers::library::artist_details_handler::ArtistDetailsHandler; - use crate::models::lidarr_models::{Artist, LidarrReleaseDownloadBody}; + use crate::handlers::lidarr_handlers::library::artist_overview_handler::ArtistOverviewHandler; + use crate::models::lidarr_models::{Album, Artist, LidarrHistoryItem}; use crate::models::servarr_data::lidarr::lidarr_data::{ ActiveLidarrBlock, EDIT_ARTIST_SELECTION_BLOCKS, }; + use crate::models::servarr_models::ReleaseDownloadBody; use crate::network::lidarr_network::LidarrEvent; use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::torrent_release; use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped}; - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; #[rstest] @@ -522,6 +526,161 @@ mod tests { assert_modal_absent!(app.data.lidarr_data.edit_artist_modal); } + #[test] + fn test_artist_details_view_key_opens_the_artist_overview() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artist_overview_modal = None; + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::ArtistOverview.into()); + assert_modal_present!(app.data.lidarr_data.artist_overview_modal); + } + + #[test] + fn test_artist_details_view_key_builds_the_overview_from_the_selected_artist() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artist_overview_modal = None; + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + + let overview = &app + .data + .lidarr_data + .artist_overview_modal + .as_ref() + .unwrap() + .overview; + assert_eq!(overview.offset, 0); + assert_eq!( + overview.get_text(), + app + .data + .lidarr_data + .artists + .current_selection() + .overview + .clone() + .unwrap() + ); + } + + #[test] + fn test_artist_details_view_key_for_an_artist_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artist_overview_modal = None; + let mut artist = app.data.lidarr_data.artists.current_selection().clone(); + artist.overview = None; + app.data.lidarr_data.artists.set_items(vec![artist]); + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::ArtistOverview.into()); + assert_str_eq!( + app + .data + .lidarr_data + .artist_overview_modal + .as_ref() + .unwrap() + .overview + .get_text(), + "" + ); + } + + #[test] + fn test_artist_details_view_key_reopens_the_artist_overview_at_the_top() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artist_overview_modal = None; + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::ArtistOverview.into()); + assert_eq!( + app + .data + .lidarr_data + .artist_overview_modal + .as_ref() + .unwrap() + .overview + .offset, + 0 + ); + } + + #[test] + fn test_artist_details_view_key_no_op_when_not_ready() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artist_overview_modal = None; + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveLidarrBlock::ArtistDetails.into() + ); + assert_modal_absent!(app.data.lidarr_data.artist_overview_modal); + } + #[test] fn test_artist_details_toggle_monitoring_key() { let mut app = App::test_default_fully_populated(); @@ -801,18 +960,84 @@ mod tests { assert_navigation_popped!(app, ActiveLidarrBlock::ManualArtistSearch.into()); assert_eq!( app.data.lidarr_data.prompt_confirm_action, - Some(LidarrEvent::DownloadRelease(LidarrReleaseDownloadBody { + Some(LidarrEvent::DownloadRelease(ReleaseDownloadBody { guid: release.guid, indexer_id: release.indexer_id, })) ); } + + #[test] + fn test_search_albums_key() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .albums + .set_items(vec![Album::default()]); + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::ArtistDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchAlbums.into()); + } + + #[test] + fn test_search_artist_history_key() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artist_history + .set_items(vec![LidarrHistoryItem::default()]); + app.push_navigation_stack(ActiveLidarrBlock::ArtistHistory.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::ArtistHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchArtistHistory.into()); + } + + #[test] + fn test_filter_artist_history_key() { + let mut app = App::test_default(); + app + .data + .lidarr_data + .artist_history + .set_items(vec![LidarrHistoryItem::default()]); + app.push_navigation_stack(ActiveLidarrBlock::ArtistHistory.into()); + + ArtistDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveLidarrBlock::ArtistHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::FilterArtistHistory.into()); + } } #[test] fn test_artist_details_handler_accepts() { + let mut artist_details_blocks = ARTIST_DETAILS_BLOCKS.to_vec(); + artist_details_blocks.extend(ARTIST_OVERVIEW_BLOCKS); + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { - if ARTIST_DETAILS_BLOCKS.contains(&active_lidarr_block) { + if artist_details_blocks.contains(&active_lidarr_block) { assert!(ArtistDetailsHandler::accepts(active_lidarr_block)); } else { assert!(!ArtistDetailsHandler::accepts(active_lidarr_block)); @@ -820,6 +1045,15 @@ mod tests { }); } + #[test] + fn test_delegates_artist_overview_blocks_to_artist_overview_handler() { + test_handler_delegation!( + ArtistDetailsHandler, + ActiveLidarrBlock::ArtistDetails, + ActiveLidarrBlock::ArtistOverview + ); + } + #[test] fn test_extract_artist_id() { let mut app = App::test_default_fully_populated(); diff --git a/src/handlers/lidarr_handlers/library/artist_overview_handler.rs b/src/handlers/lidarr_handlers/library/artist_overview_handler.rs new file mode 100644 index 00000000..2da3f8ef --- /dev/null +++ b/src/handlers/lidarr_handlers/library/artist_overview_handler.rs @@ -0,0 +1,109 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::matches_key; +use crate::models::servarr_data::lidarr::lidarr_data::{ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock}; +use crate::models::{Route, Scrollable, ScrollableText}; + +#[cfg(test)] +#[path = "artist_overview_handler_tests.rs"] +mod artist_overview_handler_tests; + +pub(super) struct ArtistOverviewHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_lidarr_block: ActiveLidarrBlock, + _context: Option, +} + +impl ArtistOverviewHandler<'_, '_> { + fn overview(&mut self) -> &mut ScrollableText { + &mut self + .app + .data + .lidarr_data + .artist_overview_modal + .as_mut() + .expect("Artist overview modal is undefined") + .overview + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveLidarrBlock> for ArtistOverviewHandler<'a, 'b> { + fn accepts(active_block: ActiveLidarrBlock) -> bool { + ARTIST_OVERVIEW_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveLidarrBlock, + _context: Option, + ) -> ArtistOverviewHandler<'a, 'b> { + ArtistOverviewHandler { + key, + app, + active_lidarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + self.app.data.lidarr_data.artist_overview_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + self.overview().scroll_up(); + } + + fn handle_scroll_down(&mut self) { + self.overview().scroll_down(); + } + + fn handle_home(&mut self) { + self.overview().scroll_to_top(); + } + + fn handle_end(&mut self) { + self.overview().scroll_to_bottom(); + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) {} + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) { + self.app.pop_navigation_stack(); + self.app.data.lidarr_data.artist_overview_modal = None; + } + + fn handle_char_key_event(&mut self) { + if matches_key!(refresh, self.key) { + self + .app + .pop_and_push_navigation_stack(self.active_lidarr_block.into()); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/lidarr_handlers/library/artist_overview_handler_tests.rs b/src/handlers/lidarr_handlers/library/artist_overview_handler_tests.rs new file mode 100644 index 00000000..1f8936fe --- /dev/null +++ b/src/handlers/lidarr_handlers/library/artist_overview_handler_tests.rs @@ -0,0 +1,393 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::lidarr_handlers::library::artist_overview_handler::ArtistOverviewHandler; + use crate::models::ScrollableText; + use crate::models::servarr_data::lidarr::lidarr_data::{ + ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock, + }; + use crate::models::servarr_data::lidarr::modals::ArtistOverviewModal; + + mod test_handle_scroll_up_and_down { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_artist_overview_scroll_up() { + let mut app = app_with_artist_overview(); + set_offset(&mut app, 2); + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_artist_overview_scroll_up_no_op_when_not_ready() { + let mut app = app_with_artist_overview(); + set_offset(&mut app, 2); + app.is_loading = true; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 2); + } + + #[test] + fn test_artist_overview_scroll_down() { + let mut app = app_with_artist_overview(); + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_artist_overview_scroll_down_no_op_when_not_ready() { + let mut app = app_with_artist_overview(); + app.is_loading = true; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_home_end { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_artist_overview_home() { + let mut app = app_with_artist_overview(); + set_offset(&mut app, 3); + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + + #[test] + fn test_artist_overview_end() { + let mut app = app_with_artist_overview(); + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 5); + } + + #[test] + fn test_artist_overview_home_no_op_when_not_ready() { + let mut app = app_with_artist_overview(); + set_offset(&mut app, 3); + app.is_loading = true; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 3); + } + + #[test] + fn test_artist_overview_end_no_op_when_not_ready() { + let mut app = app_with_artist_overview(); + app.is_loading = true; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_esc { + use super::*; + use crate::{assert_modal_absent, assert_navigation_popped}; + + #[test] + fn test_artist_overview_esc_tears_down_the_artist_overview_modal() { + let mut app = app_with_artist_overview(); + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::ArtistDetails.into()); + assert_modal_absent!(app.data.lidarr_data.artist_overview_modal); + } + + #[test] + fn test_artist_overview_esc_tears_down_the_modal_even_when_loading() { + let mut app = app_with_artist_overview(); + app.is_loading = true; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveLidarrBlock::ArtistDetails.into()); + assert_modal_absent!(app.data.lidarr_data.artist_overview_modal); + } + } + + mod test_handle_key_char { + use super::*; + use crate::assert_navigation_pushed; + + #[test] + fn test_artist_overview_refresh_key() { + let mut app = app_with_artist_overview(); + app.is_routing = false; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::ArtistOverview.into()); + assert!(app.is_routing); + } + + #[test] + fn test_artist_overview_refresh_key_no_op_when_not_ready() { + let mut app = app_with_artist_overview(); + app.is_loading = true; + app.is_routing = false; + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert!(!app.is_routing); + } + } + + #[test] + fn test_scrolling_the_overview_does_not_mutate_the_artist_record() { + let mut app = app_with_artist_overview(); + let overview_before = app + .data + .lidarr_data + .artists + .current_selection() + .overview + .clone(); + + ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + assert_eq!( + app.data.lidarr_data.artists.current_selection().overview, + overview_before + ); + } + + #[test] + fn test_artist_overview_handler_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if ARTIST_OVERVIEW_BLOCKS.contains(&active_lidarr_block) { + assert!( + ArtistOverviewHandler::accepts(active_lidarr_block), + "{active_lidarr_block} is not accepted by the ArtistOverviewHandler" + ); + } else { + assert!( + !ArtistOverviewHandler::accepts(active_lidarr_block), + "{active_lidarr_block} is wrongly accepted by the ArtistOverviewHandler" + ); + } + }); + } + + #[test] + fn test_artist_overview_handler_dispatches_no_network_events() { + let mut app = app_with_artist_overview(); + + for key in [ + DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.delete.key, + DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.submit.key, + DEFAULT_KEYBINDINGS.refresh.key, + ] { + ArtistOverviewHandler::new(key, &mut app, ActiveLidarrBlock::ArtistOverview, None).handle(); + + assert_none!(app.data.lidarr_data.prompt_confirm_action); + assert!(!app.data.lidarr_data.prompt_confirm); + } + } + + #[test] + fn test_artist_overview_handler_is_not_ready_when_loading() { + let mut app = app_with_artist_overview(); + app.is_loading = true; + + let handler = ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_artist_overview_handler_is_not_ready_when_the_modal_is_absent() { + let mut app = app_with_artist_overview(); + app.data.lidarr_data.artist_overview_modal = None; + + let handler = ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_artist_overview_handler_is_ready() { + let mut app = app_with_artist_overview(); + + let handler = ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_artist_overview_handler_ignores_special_keys_for_textbox_input() { + let mut app = app_with_artist_overview(); + app.ignore_special_keys_for_textbox_input = true; + + let handler = ArtistOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveLidarrBlock::ArtistOverview, + None, + ); + + assert!(handler.ignore_special_keys()); + } + + fn app_with_artist_overview() -> App<'static> { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artist_overview_modal = Some(ArtistOverviewModal { + overview: ScrollableText::with_string( + "some interesting description of the artist\r\n\ + \r\n\ + She was born in Madison, Wisconsin: a city she has never really left.\r\n\ + \r\n\ + Her first album took her seven years to finish.\r\n" + .to_owned(), + ), + }); + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + + app + } + + fn overview_offset(app: &App<'_>) -> u16 { + app + .data + .lidarr_data + .artist_overview_modal + .as_ref() + .unwrap() + .overview + .offset + } + + fn set_offset(app: &mut App<'_>, offset: u16) { + app + .data + .lidarr_data + .artist_overview_modal + .as_mut() + .unwrap() + .overview + .offset = offset; + } +} diff --git a/src/handlers/lidarr_handlers/library/delete_album_handler_tests.rs b/src/handlers/lidarr_handlers/library/delete_album_handler_tests.rs index 65ddd494..18068f5a 100644 --- a/src/handlers/lidarr_handlers/library/delete_album_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/delete_album_handler_tests.rs @@ -127,7 +127,7 @@ mod tests { app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); app.push_navigation_stack(ActiveLidarrBlock::DeleteAlbumPrompt.into()); app.data.lidarr_data.prompt_confirm = true; - app.data.lidarr_data.delete_files = true; + app.data.lidarr_data.delete_files = false; app.data.lidarr_data.add_import_list_exclusion = true; app .data @@ -136,7 +136,7 @@ mod tests { .set_items(vec![Album::default()]); let expected_delete_album_params = DeleteParams { id: 0, - delete_files: true, + delete_files: false, add_import_list_exclusion: true, }; app.data.lidarr_data.selected_block = BlockSelectionState::new(DELETE_ALBUM_SELECTION_BLOCKS); @@ -275,7 +275,7 @@ mod tests { app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); app.push_navigation_stack(ActiveLidarrBlock::DeleteAlbumPrompt.into()); app.data.lidarr_data.delete_files = true; - app.data.lidarr_data.add_import_list_exclusion = true; + app.data.lidarr_data.add_import_list_exclusion = false; app .data .lidarr_data @@ -284,7 +284,7 @@ mod tests { let expected_delete_album_params = DeleteParams { id: 0, delete_files: true, - add_import_list_exclusion: true, + add_import_list_exclusion: false, }; app.data.lidarr_data.selected_block = BlockSelectionState::new(DELETE_ALBUM_SELECTION_BLOCKS); app @@ -352,11 +352,11 @@ mod tests { .albums .set_items(vec![Album::default()]); app.data.lidarr_data.delete_files = true; - app.data.lidarr_data.add_import_list_exclusion = true; + app.data.lidarr_data.add_import_list_exclusion = false; let expected_delete_album_params = DeleteParams { id: 0, delete_files: true, - add_import_list_exclusion: true, + add_import_list_exclusion: false, }; let delete_album_params = DeleteAlbumHandler::new( diff --git a/src/handlers/lidarr_handlers/library/delete_artist_handler_tests.rs b/src/handlers/lidarr_handlers/library/delete_artist_handler_tests.rs index 6c068407..70574dea 100644 --- a/src/handlers/lidarr_handlers/library/delete_artist_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/delete_artist_handler_tests.rs @@ -130,7 +130,7 @@ mod tests { app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); app.data.lidarr_data.prompt_confirm = true; - app.data.lidarr_data.delete_files = true; + app.data.lidarr_data.delete_files = false; app.data.lidarr_data.add_import_list_exclusion = true; app .data @@ -139,7 +139,7 @@ mod tests { .set_items(vec![Artist::default()]); let expected_delete_artist_params = DeleteParams { id: 0, - delete_files: true, + delete_files: false, add_import_list_exclusion: true, }; app.data.lidarr_data.selected_block = @@ -280,7 +280,7 @@ mod tests { app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); app.push_navigation_stack(ActiveLidarrBlock::DeleteArtistPrompt.into()); app.data.lidarr_data.delete_files = true; - app.data.lidarr_data.add_import_list_exclusion = true; + app.data.lidarr_data.add_import_list_exclusion = false; app .data .lidarr_data @@ -289,7 +289,7 @@ mod tests { let expected_delete_artist_params = DeleteParams { id: 0, delete_files: true, - add_import_list_exclusion: true, + add_import_list_exclusion: false, }; app.data.lidarr_data.selected_block = BlockSelectionState::new(DELETE_ARTIST_SELECTION_BLOCKS); @@ -358,11 +358,11 @@ mod tests { .artists .set_items(vec![Artist::default()]); app.data.lidarr_data.delete_files = true; - app.data.lidarr_data.add_import_list_exclusion = true; + app.data.lidarr_data.add_import_list_exclusion = false; let expected_delete_artist_params = DeleteParams { id: 0, delete_files: true, - add_import_list_exclusion: true, + add_import_list_exclusion: false, }; let delete_artist_params = DeleteArtistHandler::new( diff --git a/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs b/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs index fee625cd..2075364d 100644 --- a/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/edit_artist_handler_tests.rs @@ -782,29 +782,41 @@ mod tests { edit_artist .quality_profile_list .set_items(vec!["Lossless".to_owned(), "HD - 1080p".to_owned()]); + edit_artist.quality_profile_list.state.select(Some(1)); edit_artist .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + edit_artist.metadata_profile_list.state.select(Some(1)); edit_artist .monitor_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + edit_artist.monitor_list.state.select(Some(1)); app.data.lidarr_data.edit_artist_modal = Some(edit_artist); - app.data.lidarr_data.artists.set_items(vec![Artist { - monitored: false, - ..Artist::default() - }]); + app.data.lidarr_data.artists.set_items(vec![ + Artist { + id: 999, + monitored: false, + ..Artist::default() + }, + Artist { + id: 2, + monitored: false, + ..Artist::default() + }, + ]); + app.data.lidarr_data.artists.select_index(Some(1)); app.data.lidarr_data.quality_profile_map = BiMap::from_iter([ (1111, "Lossless".to_owned()), (2222, "HD - 1080p".to_owned()), ]); app.data.lidarr_data.metadata_profile_map = - BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); let expected_edit_artist_params = EditArtistParams { - artist_id: 0, + artist_id: 2, monitored: Some(false), - monitor_new_items: Some(NewItemMonitorType::All), - quality_profile_id: Some(1111), - metadata_profile_id: Some(1111), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(2222), + metadata_profile_id: Some(4444), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".to_owned()), ..EditArtistParams::default() @@ -1236,29 +1248,41 @@ mod tests { edit_artist .quality_profile_list .set_items(vec!["Lossless".to_owned(), "HD - 1080p".to_owned()]); + edit_artist.quality_profile_list.state.select(Some(1)); edit_artist .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + edit_artist.metadata_profile_list.state.select(Some(1)); edit_artist .monitor_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + edit_artist.monitor_list.state.select(Some(1)); app.data.lidarr_data.edit_artist_modal = Some(edit_artist); - app.data.lidarr_data.artists.set_items(vec![Artist { - monitored: false, - ..Artist::default() - }]); + app.data.lidarr_data.artists.set_items(vec![ + Artist { + id: 999, + monitored: false, + ..Artist::default() + }, + Artist { + id: 2, + monitored: false, + ..Artist::default() + }, + ]); + app.data.lidarr_data.artists.select_index(Some(1)); app.data.lidarr_data.quality_profile_map = BiMap::from_iter([ (1111, "Lossless".to_owned()), (2222, "HD - 1080p".to_owned()), ]); app.data.lidarr_data.metadata_profile_map = - BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); let expected_edit_artist_params = EditArtistParams { - artist_id: 0, + artist_id: 2, monitored: Some(false), - monitor_new_items: Some(NewItemMonitorType::All), - quality_profile_id: Some(1111), - metadata_profile_id: Some(1111), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(2222), + metadata_profile_id: Some(4444), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".to_owned()), ..EditArtistParams::default() @@ -1332,29 +1356,41 @@ mod tests { edit_artist .quality_profile_list .set_items(vec!["Lossless".to_owned(), "HD - 1080p".to_owned()]); + edit_artist.quality_profile_list.state.select(Some(1)); edit_artist .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + edit_artist.metadata_profile_list.state.select(Some(1)); edit_artist .monitor_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + edit_artist.monitor_list.state.select(Some(1)); app.data.lidarr_data.edit_artist_modal = Some(edit_artist); - app.data.lidarr_data.artists.set_items(vec![Artist { - monitored: false, - ..Artist::default() - }]); + app.data.lidarr_data.artists.set_items(vec![ + Artist { + id: 999, + monitored: false, + ..Artist::default() + }, + Artist { + id: 2, + monitored: false, + ..Artist::default() + }, + ]); + app.data.lidarr_data.artists.select_index(Some(1)); app.data.lidarr_data.quality_profile_map = BiMap::from_iter([ (1111, "Lossless".to_owned()), (2222, "HD - 1080p".to_owned()), ]); app.data.lidarr_data.metadata_profile_map = - BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); let expected_edit_artist_params = EditArtistParams { - artist_id: 0, + artist_id: 2, monitored: Some(false), - monitor_new_items: Some(NewItemMonitorType::All), - quality_profile_id: Some(1111), - metadata_profile_id: Some(1111), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(2222), + metadata_profile_id: Some(4444), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".to_owned()), ..EditArtistParams::default() diff --git a/src/handlers/lidarr_handlers/library/library_handler_tests.rs b/src/handlers/lidarr_handlers/library/library_handler_tests.rs index b28cb156..0e4f93f6 100644 --- a/src/handlers/lidarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/library_handler_tests.rs @@ -13,9 +13,9 @@ mod tests { use crate::handlers::lidarr_handlers::library::{LibraryHandler, artists_sorting_options}; use crate::models::lidarr_models::{Album, Artist, ArtistStatistics, ArtistStatus}; use crate::models::servarr_data::lidarr::lidarr_data::{ - ADD_ARTIST_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, - DELETE_ALBUM_BLOCKS, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, - LIBRARY_BLOCKS, TRACK_DETAILS_BLOCKS, + ADD_ARTIST_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, ARTIST_OVERVIEW_BLOCKS, + ActiveLidarrBlock, DELETE_ALBUM_BLOCKS, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, + EDIT_ARTIST_SELECTION_BLOCKS, LIBRARY_BLOCKS, TRACK_DETAILS_BLOCKS, }; use crate::models::servarr_data::lidarr::modals::EditArtistModal; use crate::network::lidarr_network::LidarrEvent; @@ -29,6 +29,7 @@ mod tests { let mut library_handler_blocks = Vec::new(); library_handler_blocks.extend(LIBRARY_BLOCKS); library_handler_blocks.extend(ARTIST_DETAILS_BLOCKS); + library_handler_blocks.extend(ARTIST_OVERVIEW_BLOCKS); library_handler_blocks.extend(DELETE_ARTIST_BLOCKS); library_handler_blocks.extend(DELETE_ALBUM_BLOCKS); library_handler_blocks.extend(EDIT_ARTIST_BLOCKS); @@ -243,11 +244,8 @@ mod tests { #[test] fn test_toggle_monitoring_key() { let mut app = App::test_default(); - app - .data - .lidarr_data - .artists - .set_items(vec![Artist::default()]); + app.data.lidarr_data.artists.set_items(artists_vec()); + app.data.lidarr_data.artists.select_index(Some(1)); app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); app.is_routing = false; @@ -264,7 +262,7 @@ mod tests { assert!(app.is_routing); assert_some_eq_x!( &app.data.lidarr_data.prompt_confirm_action, - &LidarrEvent::ToggleArtistMonitoring(0) + &LidarrEvent::ToggleArtistMonitoring(2) ); } @@ -769,4 +767,38 @@ mod tests { assert_eq!(app.get_current_route(), ActiveLidarrBlock::Artists.into()); assert!(app.should_refresh); } + + #[test] + fn test_search_artists_key() { + let mut app = App::test_default(); + app.data.lidarr_data.artists.set_items(artists_vec()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::Artists, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchArtists.into()); + } + + #[test] + fn test_filter_artists_key() { + let mut app = App::test_default(); + app.data.lidarr_data.artists.set_items(artists_vec()); + app.push_navigation_stack(ActiveLidarrBlock::Artists.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveLidarrBlock::Artists, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::FilterArtists.into()); + } } diff --git a/src/handlers/lidarr_handlers/library/mod.rs b/src/handlers/lidarr_handlers/library/mod.rs index 82db4b2d..5334dd27 100644 --- a/src/handlers/lidarr_handlers/library/mod.rs +++ b/src/handlers/lidarr_handlers/library/mod.rs @@ -21,6 +21,7 @@ use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; mod add_artist_handler; mod album_details_handler; mod artist_details_handler; +mod artist_overview_handler; mod delete_album_handler; mod delete_artist_handler; mod edit_artist_handler; diff --git a/src/handlers/lidarr_handlers/library/track_details_handler_tests.rs b/src/handlers/lidarr_handlers/library/track_details_handler_tests.rs index 9529db72..55101bdf 100644 --- a/src/handlers/lidarr_handlers/library/track_details_handler_tests.rs +++ b/src/handlers/lidarr_handlers/library/track_details_handler_tests.rs @@ -244,6 +244,38 @@ mod tests { assert_eq!(app.get_current_route(), active_lidarr_block.into()); assert!(!app.is_routing); } + + #[test] + fn test_search_track_history_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::TrackHistory.into()); + + TrackDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveLidarrBlock::TrackHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::SearchTrackHistory.into()); + } + + #[test] + fn test_filter_track_history_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::TrackHistory.into()); + + TrackDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveLidarrBlock::TrackHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveLidarrBlock::FilterTrackHistory.into()); + } } #[test] diff --git a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs index 1a507092..b11d2131 100644 --- a/src/handlers/lidarr_handlers/lidarr_handler_tests.rs +++ b/src/handlers/lidarr_handlers/lidarr_handler_tests.rs @@ -160,7 +160,6 @@ mod tests { #[rstest] fn test_delegates_library_blocks_to_library_handler( #[values( - ActiveLidarrBlock::Artists, ActiveLidarrBlock::ArtistsSortPrompt, ActiveLidarrBlock::FilterArtists, ActiveLidarrBlock::FilterArtistsError, @@ -187,7 +186,6 @@ mod tests { #[rstest] fn test_delegates_downloads_blocks_to_downloads_handler( #[values( - ActiveLidarrBlock::Downloads, ActiveLidarrBlock::DeleteDownloadPrompt, ActiveLidarrBlock::UpdateDownloadsPrompt )] @@ -203,7 +201,6 @@ mod tests { #[rstest] fn test_delegates_blocklist_blocks_to_blocklist_handler( #[values( - ActiveLidarrBlock::Blocklist, ActiveLidarrBlock::BlocklistItemDetails, ActiveLidarrBlock::DeleteBlocklistItemPrompt, ActiveLidarrBlock::BlocklistClearAllItemsPrompt, @@ -221,7 +218,6 @@ mod tests { #[rstest] fn test_delegates_history_blocks_to_history_handler( #[values( - ActiveLidarrBlock::History, ActiveLidarrBlock::HistoryItemDetails, ActiveLidarrBlock::HistorySortPrompt, ActiveLidarrBlock::FilterHistory, @@ -241,7 +237,6 @@ mod tests { #[rstest] fn test_delegates_root_folders_blocks_to_root_folders_handler( #[values( - ActiveLidarrBlock::RootFolders, ActiveLidarrBlock::AddRootFolderPrompt, ActiveLidarrBlock::DeleteRootFolderPrompt )] @@ -258,7 +253,6 @@ mod tests { fn test_delegates_indexers_blocks_to_indexers_handler( #[values( ActiveLidarrBlock::DeleteIndexerPrompt, - ActiveLidarrBlock::Indexers, ActiveLidarrBlock::AllIndexerSettingsPrompt, ActiveLidarrBlock::IndexerSettingsConfirmPrompt, ActiveLidarrBlock::IndexerSettingsMaximumSizeInput, @@ -278,7 +272,6 @@ mod tests { #[rstest] fn test_delegates_system_blocks_to_system_handler( #[values( - ActiveLidarrBlock::System, ActiveLidarrBlock::SystemLogs, ActiveLidarrBlock::SystemQueuedEvents, ActiveLidarrBlock::SystemTasks, diff --git a/src/handlers/lidarr_handlers/root_folders/add_root_folder_handler_tests.rs b/src/handlers/lidarr_handlers/root_folders/add_root_folder_handler_tests.rs index 004706d1..71376f98 100644 --- a/src/handlers/lidarr_handlers/root_folders/add_root_folder_handler_tests.rs +++ b/src/handlers/lidarr_handlers/root_folders/add_root_folder_handler_tests.rs @@ -1052,27 +1052,31 @@ mod tests { add_root_folder .quality_profile_list .set_items(vec!["Lossless".to_owned(), "FLAC".to_owned()]); + add_root_folder.quality_profile_list.state.select(Some(1)); add_root_folder .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + add_root_folder.metadata_profile_list.state.select(Some(1)); add_root_folder .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + add_root_folder.monitor_list.state.select(Some(1)); add_root_folder .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_root_folder.monitor_new_items_list.state.select(Some(1)); app.data.lidarr_data.add_root_folder_modal = Some(add_root_folder); app.data.lidarr_data.quality_profile_map = BiMap::from_iter([(1111, "Lossless".to_owned()), (2222, "FLAC".to_owned())]); app.data.lidarr_data.metadata_profile_map = - BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); let expected_add_root_folder_body = AddLidarrRootFolderBody { name: "Test Name".to_owned(), path: "/nfs/Test Path".to_owned(), - default_quality_profile_id: 1111, - default_metadata_profile_id: 1111, - default_monitor_option: MonitorType::All, - default_new_item_monitor_option: NewItemMonitorType::All, + default_quality_profile_id: 2222, + default_metadata_profile_id: 4444, + default_monitor_option: MonitorType::Future, + default_new_item_monitor_option: NewItemMonitorType::None, default_tags: Vec::new(), tag_input_string: Some("usenet, testing".to_owned()), }; @@ -1525,27 +1529,31 @@ mod tests { add_root_folder .quality_profile_list .set_items(vec!["Lossless".to_owned(), "FLAC".to_owned()]); + add_root_folder.quality_profile_list.state.select(Some(1)); add_root_folder .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + add_root_folder.metadata_profile_list.state.select(Some(1)); add_root_folder .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + add_root_folder.monitor_list.state.select(Some(1)); add_root_folder .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_root_folder.monitor_new_items_list.state.select(Some(1)); app.data.lidarr_data.add_root_folder_modal = Some(add_root_folder); app.data.lidarr_data.quality_profile_map = BiMap::from_iter([(1111, "Lossless".to_owned()), (2222, "FLAC".to_owned())]); app.data.lidarr_data.metadata_profile_map = - BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); let expected_add_root_folder_body = AddLidarrRootFolderBody { name: "Test Name".to_owned(), path: "/nfs/Test Path".to_owned(), - default_quality_profile_id: 1111, - default_metadata_profile_id: 1111, - default_monitor_option: MonitorType::All, - default_new_item_monitor_option: NewItemMonitorType::All, + default_quality_profile_id: 2222, + default_metadata_profile_id: 4444, + default_monitor_option: MonitorType::Future, + default_new_item_monitor_option: NewItemMonitorType::None, default_tags: Vec::new(), tag_input_string: Some("usenet, testing".to_owned()), }; @@ -1619,27 +1627,31 @@ mod tests { add_root_folder .quality_profile_list .set_items(vec!["Lossless".to_owned(), "FLAC".to_owned()]); + add_root_folder.quality_profile_list.state.select(Some(1)); add_root_folder .metadata_profile_list .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + add_root_folder.metadata_profile_list.state.select(Some(1)); add_root_folder .monitor_list .set_items(Vec::from_iter(MonitorType::iter())); + add_root_folder.monitor_list.state.select(Some(1)); add_root_folder .monitor_new_items_list .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_root_folder.monitor_new_items_list.state.select(Some(1)); app.data.lidarr_data.add_root_folder_modal = Some(add_root_folder); app.data.lidarr_data.quality_profile_map = BiMap::from_iter([(1111, "Lossless".to_owned()), (2222, "FLAC".to_owned())]); app.data.lidarr_data.metadata_profile_map = - BiMap::from_iter([(1111, "Standard".to_owned()), (2222, "Full".to_owned())]); + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); let expected_add_root_folder_body = AddLidarrRootFolderBody { name: "Test Name".to_owned(), path: "/nfs/Test Path".to_owned(), - default_quality_profile_id: 1111, - default_metadata_profile_id: 1111, - default_monitor_option: MonitorType::All, - default_new_item_monitor_option: NewItemMonitorType::All, + default_quality_profile_id: 2222, + default_metadata_profile_id: 4444, + default_monitor_option: MonitorType::Future, + default_new_item_monitor_option: NewItemMonitorType::None, default_tags: Vec::new(), tag_input_string: Some("usenet, testing".to_owned()), }; diff --git a/src/handlers/lidarr_handlers/system/system_details_handler_tests.rs b/src/handlers/lidarr_handlers/system/system_details_handler_tests.rs index 0487ef52..03749144 100644 --- a/src/handlers/lidarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/lidarr_handlers/system/system_details_handler_tests.rs @@ -970,11 +970,17 @@ mod tests { #[test] fn test_extract_task_name() { let mut app = App::test_default(); - app - .data - .lidarr_data - .tasks - .set_items(vec![LidarrTask::default()]); + app.data.lidarr_data.tasks.set_items(vec![ + LidarrTask { + task_name: LidarrTaskName::Backup, + ..LidarrTask::default() + }, + LidarrTask { + task_name: LidarrTaskName::RefreshArtist, + ..LidarrTask::default() + }, + ]); + app.data.lidarr_data.tasks.select_index(Some(1)); let task_name = SystemDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -984,7 +990,7 @@ mod tests { ) .extract_task_name(); - assert_eq!(task_name, LidarrTaskName::default()); + assert_eq!(task_name, LidarrTaskName::RefreshArtist); } #[test] diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index bf72eb0d..533e6890 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,5 +1,6 @@ use lidarr_handlers::LidarrHandler; use radarr_handlers::RadarrHandler; +use readarr_handlers::ReadarrHandler; use sonarr_handlers::SonarrHandler; use crate::app::App; @@ -18,12 +19,16 @@ use crate::models::{HorizontallyScrollableText, Route}; mod keybinding_handler; mod lidarr_handlers; mod radarr_handlers; +mod readarr_handlers; mod sonarr_handlers; #[cfg(test)] #[path = "handlers_tests.rs"] mod handlers_tests; +#[cfg(test)] +mod handler_proptest; + #[cfg(test)] #[path = "handler_test_utils.rs"] pub mod handler_test_utils; @@ -132,6 +137,9 @@ pub fn handle_events(key: Key, app: &mut App<'_>) { Route::Lidarr(active_lidarr_block, context) => { LidarrHandler::new(key, app, active_lidarr_block, context).handle() } + Route::Readarr(active_readarr_block, context) => { + ReadarrHandler::new(key, app, active_readarr_block, context).handle() + } _ => (), } } @@ -197,6 +205,9 @@ fn handle_prompt_toggle(app: &mut App<'_>, key: Key) { Route::Lidarr(_, _) => { app.data.lidarr_data.prompt_confirm = !app.data.lidarr_data.prompt_confirm } + Route::Readarr(_, _) => { + app.data.readarr_data.prompt_confirm = !app.data.readarr_data.prompt_confirm + } _ => (), }, _ => (), diff --git a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs index bc53e171..316dfdba 100644 --- a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs @@ -154,7 +154,7 @@ mod tests { #[case( ActiveRadarrBlock::Blocklist, ActiveRadarrBlock::DeleteBlocklistItemPrompt, - RadarrEvent::DeleteBlocklistItem(3) + RadarrEvent::DeleteBlocklistItem(2) )] #[case( ActiveRadarrBlock::Blocklist, @@ -168,6 +168,7 @@ mod tests { ) { let mut app = App::test_default(); app.data.radarr_data.blocklist.set_items(blocklist_vec()); + app.data.radarr_data.blocklist.select_index(Some(1)); app.data.radarr_data.prompt_confirm = true; app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -353,7 +354,7 @@ mod tests { #[case( ActiveRadarrBlock::Blocklist, ActiveRadarrBlock::DeleteBlocklistItemPrompt, - RadarrEvent::DeleteBlocklistItem(3) + RadarrEvent::DeleteBlocklistItem(2) )] #[case( ActiveRadarrBlock::Blocklist, @@ -367,6 +368,7 @@ mod tests { ) { let mut app = App::test_default(); app.data.radarr_data.blocklist.set_items(blocklist_vec()); + app.data.radarr_data.blocklist.select_index(Some(1)); app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -556,6 +558,7 @@ mod tests { fn test_extract_blocklist_item_id() { let mut app = App::test_default(); app.data.radarr_data.blocklist.set_items(blocklist_vec()); + app.data.radarr_data.blocklist.select_index(Some(1)); let blocklist_item_id = BlocklistHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -565,7 +568,7 @@ mod tests { ) .extract_blocklist_item_id(); - assert_eq!(blocklist_item_id, 3); + assert_eq!(blocklist_item_id, 2); } #[test] diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler.rs b/src/handlers/radarr_handlers/collections/collection_details_handler.rs index 92ca215f..a91faef0 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler.rs @@ -1,14 +1,16 @@ +use super::movie_overview_handler::MovieOverviewHandler; use crate::app::App; use crate::event::Key; use crate::handlers::KeyEventHandler; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::matches_key; +use crate::models::servarr_data::radarr::modals::MovieOverviewModal; use crate::models::servarr_data::radarr::radarr_data::{ ADD_MOVIE_SELECTION_BLOCKS, ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, EDIT_COLLECTION_SELECTION_BLOCKS, }; use crate::models::stateful_table::StatefulTable; -use crate::models::{BlockSelectionState, Route}; +use crate::models::{BlockSelectionState, Route, ScrollableText}; #[cfg(test)] #[path = "collection_details_handler_tests.rs"] @@ -21,10 +23,35 @@ pub(super) struct CollectionDetailsHandler<'a, 'b> { _context: Option, } -impl CollectionDetailsHandler<'_, '_> {} +impl CollectionDetailsHandler<'_, '_> { + fn build_movie_overview_modal(&mut self) { + let overview = self + .app + .data + .radarr_data + .collection_movies + .current_selection() + .overview + .clone(); + + self.app.data.radarr_data.movie_overview_modal = Some(MovieOverviewModal { + overview: ScrollableText::with_string(overview), + }); + } +} impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHandler<'a, 'b> { fn handle(&mut self) { + if MovieOverviewHandler::accepts(self.active_radarr_block) { + return MovieOverviewHandler::new( + self.key, + self.app, + self.active_radarr_block, + self._context, + ) + .handle(); + } + let collection_movies_table_handling_config = TableHandlingConfig::new(ActiveRadarrBlock::CollectionDetails.into()); @@ -98,6 +125,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan .iter() .any(|movie| movie.tmdb_id == tmdb_id) { + self.build_movie_overview_modal(); self .app .push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()); @@ -117,13 +145,9 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for CollectionDetailsHan } fn handle_esc(&mut self) { - match self.active_radarr_block { - ActiveRadarrBlock::CollectionDetails => { - self.app.data.radarr_data.collection_movies = StatefulTable::default(); - self.app.pop_navigation_stack(); - } - ActiveRadarrBlock::ViewMovieOverview => self.app.pop_navigation_stack(), - _ => (), + if self.active_radarr_block == ActiveRadarrBlock::CollectionDetails { + self.app.data.radarr_data.collection_movies = StatefulTable::default(); + self.app.pop_navigation_stack(); } } diff --git a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs index 2f5bdb87..35f980f8 100644 --- a/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collection_details_handler_tests.rs @@ -11,15 +11,18 @@ mod tests { use crate::handlers::KeyEventHandler; use crate::handlers::radarr_handlers::collections::collection_details_handler::CollectionDetailsHandler; use crate::models::radarr_models::CollectionMovie; + use crate::models::servarr_data::radarr::modals::MovieOverviewModal; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, }; + use crate::test_handler_delegation; mod test_handle_submit { - use crate::assert_navigation_pushed; + use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::collection_movie; use crate::models::BlockSelectionState; use crate::models::radarr_models::Movie; use crate::models::servarr_data::radarr::radarr_data::ADD_MOVIE_SELECTION_BLOCKS; + use crate::{assert_modal_present, assert_navigation_pushed}; use bimap::BiMap; use pretty_assertions::assert_eq; @@ -139,16 +142,19 @@ mod tests { #[test] fn test_collection_details_submit_movie_already_in_library() { let mut app = App::test_default(); - app - .data - .radarr_data - .collection_movies - .set_items(vec![CollectionMovie::default()]); - app - .data - .radarr_data - .movies - .set_items(vec![Movie::default()]); + app.data.radarr_data.collection_movies.set_items(vec![ + CollectionMovie { + tmdb_id: 5678, + overview: "Some other overview".to_owned(), + ..CollectionMovie::default() + }, + collection_movie(), + ]); + app.data.radarr_data.collection_movies.select_index(Some(1)); + app.data.radarr_data.movies.set_items(vec![Movie { + tmdb_id: collection_movie().tmdb_id, + ..Movie::default() + }]); CollectionDetailsHandler::new( SUBMIT_KEY, @@ -159,6 +165,63 @@ mod tests { .handle(); assert_navigation_pushed!(app, ActiveRadarrBlock::ViewMovieOverview.into()); + assert_modal_present!(app.data.radarr_data.movie_overview_modal); + let movie_overview_modal = app.data.radarr_data.movie_overview_modal.as_ref().unwrap(); + assert_eq!(movie_overview_modal.overview.offset, 0); + assert_str_eq!( + movie_overview_modal.overview.get_text(), + collection_movie().overview + ); + } + + #[test] + fn test_collection_details_submit_reopens_the_movie_overview_at_the_top() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into()); + app + .data + .radarr_data + .collection_movies + .set_items(vec![collection_movie()]); + app.data.radarr_data.movies.set_items(vec![Movie { + tmdb_id: collection_movie().tmdb_id, + ..Movie::default() + }]); + + CollectionDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveRadarrBlock::CollectionDetails, + None, + ) + .handle(); + CollectionDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 5); + + CollectionDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + CollectionDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveRadarrBlock::CollectionDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::ViewMovieOverview.into()); + assert_eq!(overview_offset(&app), 0); } } @@ -198,6 +261,7 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into()); app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()); + app.data.radarr_data.movie_overview_modal = Some(MovieOverviewModal::default()); CollectionDetailsHandler::new( ESC_KEY, @@ -208,6 +272,7 @@ mod tests { .handle(); assert_navigation_popped!(app, ActiveRadarrBlock::CollectionDetails.into()); + assert_modal_absent!(app.data.radarr_data.movie_overview_modal); } } @@ -277,6 +342,15 @@ mod tests { }); } + #[test] + fn test_delegates_view_movie_overview_to_movie_overview_handler() { + test_handler_delegation!( + CollectionDetailsHandler, + ActiveRadarrBlock::CollectionDetails, + ActiveRadarrBlock::ViewMovieOverview + ); + } + #[rstest] fn test_collection_details_handler_ignore_special_keys( #[values(true, false)] ignore_special_keys_for_textbox_input: bool, @@ -345,4 +419,15 @@ mod tests { assert!(handler.is_ready()); } + + fn overview_offset(app: &App<'_>) -> u16 { + app + .data + .radarr_data + .movie_overview_modal + .as_ref() + .unwrap() + .overview + .offset + } } diff --git a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs index 2b15773c..17873078 100644 --- a/src/handlers/radarr_handlers/collections/collections_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/collections_handler_tests.rs @@ -381,6 +381,48 @@ mod tests { assert!(!app.should_refresh); } + #[test] + fn test_search_collections_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); + app + .data + .radarr_data + .collections + .set_items(vec![Collection::default()]); + + CollectionsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveRadarrBlock::Collections, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::SearchCollection.into()); + } + + #[test] + fn test_filter_collections_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::Collections.into()); + app + .data + .radarr_data + .collections + .set_items(vec![Collection::default()]); + + CollectionsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveRadarrBlock::Collections, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::FilterCollections.into()); + } + #[test] fn test_update_all_collections_prompt_confirm_confirm() { let mut app = App::test_default(); diff --git a/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs b/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs index ac355afe..f4000935 100644 --- a/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs +++ b/src/handlers/radarr_handlers/collections/edit_collection_handler_tests.rs @@ -532,19 +532,34 @@ mod tests { edit_collection_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + edit_collection_modal + .quality_profile_list + .state + .select(Some(1)); + edit_collection_modal + .minimum_availability_list + .state + .select(Some(1)); app.data.radarr_data.edit_collection_modal = Some(edit_collection_modal); - app.data.radarr_data.collections.set_items(vec![Collection { - monitored: false, - search_on_add: false, - ..collection() - }]); + app.data.radarr_data.collections.set_items(vec![ + Collection { + id: 999, + ..collection() + }, + Collection { + monitored: false, + search_on_add: false, + ..collection() + }, + ]); + app.data.radarr_data.collections.select_index(Some(1)); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let expected_edit_collection_params = EditCollectionParams { collection_id: 123, monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), + minimum_availability: Some(MinimumAvailability::InCinemas), + quality_profile_id: Some(2222), root_folder_path: Some("/nfs/Test Path".to_owned()), search_on_add: Some(false), }; @@ -945,19 +960,34 @@ mod tests { edit_collection_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + edit_collection_modal + .quality_profile_list + .state + .select(Some(1)); + edit_collection_modal + .minimum_availability_list + .state + .select(Some(1)); app.data.radarr_data.edit_collection_modal = Some(edit_collection_modal); - app.data.radarr_data.collections.set_items(vec![Collection { - monitored: false, - search_on_add: false, - ..collection() - }]); + app.data.radarr_data.collections.set_items(vec![ + Collection { + id: 999, + ..collection() + }, + Collection { + monitored: false, + search_on_add: false, + ..collection() + }, + ]); + app.data.radarr_data.collections.select_index(Some(1)); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let expected_edit_collection_params = EditCollectionParams { collection_id: 123, monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), + minimum_availability: Some(MinimumAvailability::InCinemas), + quality_profile_id: Some(2222), root_folder_path: Some("/nfs/Test Path".to_owned()), search_on_add: Some(false), }; @@ -1034,19 +1064,34 @@ mod tests { edit_collection_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + edit_collection_modal + .quality_profile_list + .state + .select(Some(1)); + edit_collection_modal + .minimum_availability_list + .state + .select(Some(1)); app.data.radarr_data.edit_collection_modal = Some(edit_collection_modal); - app.data.radarr_data.collections.set_items(vec![Collection { - monitored: false, - search_on_add: false, - ..collection() - }]); + app.data.radarr_data.collections.set_items(vec![ + Collection { + id: 999, + ..collection() + }, + Collection { + monitored: false, + search_on_add: false, + ..collection() + }, + ]); + app.data.radarr_data.collections.select_index(Some(1)); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let expected_edit_collection_params = EditCollectionParams { collection_id: 123, monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), + minimum_availability: Some(MinimumAvailability::InCinemas), + quality_profile_id: Some(2222), root_folder_path: Some("/nfs/Test Path".to_owned()), search_on_add: Some(false), }; diff --git a/src/handlers/radarr_handlers/collections/mod.rs b/src/handlers/radarr_handlers/collections/mod.rs index 4e2e32a5..37bbdccd 100644 --- a/src/handlers/radarr_handlers/collections/mod.rs +++ b/src/handlers/radarr_handlers/collections/mod.rs @@ -16,6 +16,7 @@ use crate::network::radarr_network::RadarrEvent; mod collection_details_handler; mod edit_collection_handler; +mod movie_overview_handler; #[cfg(test)] #[path = "collections_handler_tests.rs"] diff --git a/src/handlers/radarr_handlers/collections/movie_overview_handler.rs b/src/handlers/radarr_handlers/collections/movie_overview_handler.rs new file mode 100644 index 00000000..57ca1375 --- /dev/null +++ b/src/handlers/radarr_handlers/collections/movie_overview_handler.rs @@ -0,0 +1,102 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::{Route, Scrollable, ScrollableText}; + +#[cfg(test)] +#[path = "movie_overview_handler_tests.rs"] +mod movie_overview_handler_tests; + +pub(super) struct MovieOverviewHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + _active_radarr_block: ActiveRadarrBlock, + _context: Option, +} + +impl MovieOverviewHandler<'_, '_> { + fn overview(&mut self) -> &mut ScrollableText { + &mut self + .app + .data + .radarr_data + .movie_overview_modal + .as_mut() + .expect("Movie overview modal is undefined") + .overview + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieOverviewHandler<'a, 'b> { + fn accepts(active_block: ActiveRadarrBlock) -> bool { + active_block == ActiveRadarrBlock::ViewMovieOverview + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveRadarrBlock, + _context: Option, + ) -> MovieOverviewHandler<'a, 'b> { + MovieOverviewHandler { + key, + app, + _active_radarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + self.app.data.radarr_data.movie_overview_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + self.overview().scroll_up(); + } + + fn handle_scroll_down(&mut self) { + self.overview().scroll_down(); + } + + fn handle_home(&mut self) { + self.overview().scroll_to_top(); + } + + fn handle_end(&mut self) { + self.overview().scroll_to_bottom(); + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) {} + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) { + self.app.pop_navigation_stack(); + self.app.data.radarr_data.movie_overview_modal = None; + } + + fn handle_char_key_event(&mut self) {} + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/radarr_handlers/collections/movie_overview_handler_tests.rs b/src/handlers/radarr_handlers/collections/movie_overview_handler_tests.rs new file mode 100644 index 00000000..ce345fc3 --- /dev/null +++ b/src/handlers/radarr_handlers/collections/movie_overview_handler_tests.rs @@ -0,0 +1,345 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::radarr_handlers::collections::movie_overview_handler::MovieOverviewHandler; + use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + + mod test_handle_scroll_up_and_down { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_movie_overview_scroll_up() { + let mut app = app_with_movie_overview(); + set_offset(&mut app, 2); + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_movie_overview_scroll_up_no_op_when_not_ready() { + let mut app = app_with_movie_overview(); + set_offset(&mut app, 2); + app.is_loading = true; + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 2); + } + + #[test] + fn test_movie_overview_scroll_down() { + let mut app = app_with_movie_overview(); + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_movie_overview_scroll_down_no_op_when_not_ready() { + let mut app = app_with_movie_overview(); + app.is_loading = true; + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_home_end { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_movie_overview_home() { + let mut app = app_with_movie_overview(); + set_offset(&mut app, 3); + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + + #[test] + fn test_movie_overview_end() { + let mut app = app_with_movie_overview(); + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 5); + } + + #[test] + fn test_movie_overview_home_no_op_when_not_ready() { + let mut app = app_with_movie_overview(); + set_offset(&mut app, 3); + app.is_loading = true; + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 3); + } + + #[test] + fn test_movie_overview_end_no_op_when_not_ready() { + let mut app = app_with_movie_overview(); + app.is_loading = true; + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_esc { + use super::*; + use crate::{assert_modal_absent, assert_navigation_popped}; + + #[test] + fn test_movie_overview_esc_tears_down_the_movie_overview_modal() { + let mut app = app_with_movie_overview(); + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveRadarrBlock::CollectionDetails.into()); + assert_modal_absent!(app.data.radarr_data.movie_overview_modal); + } + + #[test] + fn test_movie_overview_esc_tears_down_the_modal_even_when_loading() { + let mut app = app_with_movie_overview(); + app.is_loading = true; + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveRadarrBlock::CollectionDetails.into()); + assert_modal_absent!(app.data.radarr_data.movie_overview_modal); + } + } + + #[test] + fn test_scrolling_the_overview_does_not_mutate_the_collection_movie_record() { + let mut app = app_with_movie_overview(); + let overview_before = app + .data + .radarr_data + .collection_movies + .current_selection() + .overview + .clone(); + + MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + assert_eq!( + app + .data + .radarr_data + .collection_movies + .current_selection() + .overview, + overview_before + ); + } + + #[test] + fn test_movie_overview_handler_accepts() { + ActiveRadarrBlock::iter().for_each(|active_radarr_block| { + if active_radarr_block == ActiveRadarrBlock::ViewMovieOverview { + assert!( + MovieOverviewHandler::accepts(active_radarr_block), + "{active_radarr_block} is not accepted by the MovieOverviewHandler" + ); + } else { + assert!( + !MovieOverviewHandler::accepts(active_radarr_block), + "{active_radarr_block} is wrongly accepted by the MovieOverviewHandler" + ); + } + }); + } + + #[test] + fn test_movie_overview_handler_dispatches_no_network_events() { + let mut app = app_with_movie_overview(); + + for key in [ + DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.delete.key, + DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.submit.key, + DEFAULT_KEYBINDINGS.refresh.key, + ] { + MovieOverviewHandler::new(key, &mut app, ActiveRadarrBlock::ViewMovieOverview, None).handle(); + + assert_none!(app.data.radarr_data.prompt_confirm_action); + assert!(!app.data.radarr_data.prompt_confirm); + } + } + + #[test] + fn test_movie_overview_handler_is_not_ready_when_loading() { + let mut app = app_with_movie_overview(); + app.is_loading = true; + + let handler = MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_movie_overview_handler_is_not_ready_when_the_modal_is_absent() { + let mut app = app_with_movie_overview(); + app.data.radarr_data.movie_overview_modal = None; + + let handler = MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_movie_overview_handler_is_ready() { + let mut app = app_with_movie_overview(); + + let handler = MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_movie_overview_handler_ignores_special_keys_for_textbox_input() { + let mut app = app_with_movie_overview(); + app.ignore_special_keys_for_textbox_input = true; + + let handler = MovieOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveRadarrBlock::ViewMovieOverview, + None, + ); + + assert!(handler.ignore_special_keys()); + } + + fn app_with_movie_overview() -> App<'static> { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into()); + app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()); + + app + } + + fn overview_offset(app: &App<'_>) -> u16 { + app + .data + .radarr_data + .movie_overview_modal + .as_ref() + .unwrap() + .overview + .offset + } + + fn set_offset(app: &mut App<'_>, offset: u16) { + app + .data + .radarr_data + .movie_overview_modal + .as_mut() + .unwrap() + .overview + .offset = offset; + } +} diff --git a/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs index afc98052..45ed2bd7 100644 --- a/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs +++ b/src/handlers/radarr_handlers/downloads/downloads_handler_tests.rs @@ -136,7 +136,7 @@ mod tests { #[case( ActiveRadarrBlock::Downloads, ActiveRadarrBlock::DeleteDownloadPrompt, - RadarrEvent::DeleteDownload(1) + RadarrEvent::DeleteDownload(2) )] #[case( ActiveRadarrBlock::Downloads, @@ -149,11 +149,15 @@ mod tests { #[case] expected_action: RadarrEvent, ) { let mut app = App::test_default(); - app - .data - .radarr_data - .downloads - .set_items(vec![download_record()]); + app.data.radarr_data.downloads.set_items(vec![ + download_record(), + DownloadRecord { + id: 2, + title: "Test Download Title 2".to_owned(), + ..download_record() + }, + ]); + app.data.radarr_data.downloads.select_index(Some(1)); app.data.radarr_data.prompt_confirm = true; app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -332,7 +336,7 @@ mod tests { #[case( ActiveRadarrBlock::Downloads, ActiveRadarrBlock::DeleteDownloadPrompt, - RadarrEvent::DeleteDownload(1) + RadarrEvent::DeleteDownload(2) )] #[case( ActiveRadarrBlock::Downloads, @@ -345,11 +349,15 @@ mod tests { #[case] expected_action: RadarrEvent, ) { let mut app = App::test_default(); - app - .data - .radarr_data - .downloads - .set_items(vec![download_record()]); + app.data.radarr_data.downloads.set_items(vec![ + download_record(), + DownloadRecord { + id: 2, + title: "Test Download Title 2".to_owned(), + ..download_record() + }, + ]); + app.data.radarr_data.downloads.select_index(Some(1)); app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -403,11 +411,15 @@ mod tests { #[test] fn test_extract_download_id() { let mut app = App::test_default(); - app - .data - .radarr_data - .downloads - .set_items(vec![download_record()]); + app.data.radarr_data.downloads.set_items(vec![ + download_record(), + DownloadRecord { + id: 2, + title: "Test Download Title 2".to_owned(), + ..download_record() + }, + ]); + app.data.radarr_data.downloads.select_index(Some(1)); let download_id = DownloadsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -417,7 +429,7 @@ mod tests { ) .extract_download_id(); - assert_eq!(download_id, 1); + assert_eq!(download_id, 2); } #[test] diff --git a/src/handlers/radarr_handlers/history/history_handler_tests.rs b/src/handlers/radarr_handlers/history/history_handler_tests.rs index 4bd6e51c..f3eb025a 100644 --- a/src/handlers/radarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/radarr_handlers/history/history_handler_tests.rs @@ -199,6 +199,40 @@ mod tests { assert_eq!(app.get_current_route(), ActiveRadarrBlock::History.into()); assert!(!app.should_refresh); } + + #[test] + fn test_search_history_key() { + let mut app = App::test_default(); + app.data.radarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveRadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveRadarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::SearchHistory.into()); + } + + #[test] + fn test_filter_history_key() { + let mut app = App::test_default(); + app.data.radarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveRadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveRadarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::FilterHistory.into()); + } } #[test] diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs index adc84549..9a773066 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs @@ -11,7 +11,7 @@ mod tests { use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::indexer; use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS}; - use crate::models::servarr_models::EditIndexerParams; + use crate::models::servarr_models::{EditIndexerParams, Indexer}; use pretty_assertions::assert_eq; use rstest::rstest; use strum::IntoEnumIterator; @@ -916,9 +916,14 @@ mod tests { priority: 25, }; app.data.radarr_data.edit_indexer_modal = Some(edit_indexer_modal); - app.data.radarr_data.indexers.set_items(vec![indexer()]); + app + .data + .radarr_data + .indexers + .set_items(vec![indexer(), Indexer { id: 2, ..indexer() }]); + app.data.radarr_data.indexers.select_index(Some(1)); let expected_edit_indexer_params = EditIndexerParams { - indexer_id: 1, + indexer_id: 2, name: Some("Test Update".to_owned()), enable_rss: Some(false), enable_automatic_search: Some(false), @@ -1759,9 +1764,14 @@ mod tests { priority: 25, }; app.data.radarr_data.edit_indexer_modal = Some(edit_indexer_modal); - app.data.radarr_data.indexers.set_items(vec![indexer()]); + app + .data + .radarr_data + .indexers + .set_items(vec![indexer(), Indexer { id: 2, ..indexer() }]); + app.data.radarr_data.indexers.select_index(Some(1)); let expected_edit_indexer_params = EditIndexerParams { - indexer_id: 1, + indexer_id: 2, name: Some("Test Update".to_owned()), enable_rss: Some(false), enable_automatic_search: Some(false), @@ -1837,9 +1847,14 @@ mod tests { priority: 25, }; app.data.radarr_data.edit_indexer_modal = Some(edit_indexer_modal); - app.data.radarr_data.indexers.set_items(vec![indexer()]); + app + .data + .radarr_data + .indexers + .set_items(vec![indexer(), Indexer { id: 2, ..indexer() }]); + app.data.radarr_data.indexers.select_index(Some(1)); let expected_edit_indexer_params = EditIndexerParams { - indexer_id: 1, + indexer_id: 2, name: Some("Test Update".to_owned()), enable_rss: Some(false), enable_automatic_search: Some(false), diff --git a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs index cc1885d2..88808b3c 100644 --- a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs @@ -143,6 +143,11 @@ mod tests { } else { "usenet".to_owned() }; + let other_protocol = if torrent_protocol { + "usenet".to_owned() + } else { + "torrent".to_owned() + }; let mut expected_edit_indexer_modal = EditIndexerModal { name: "Test".into(), enable_rss: Some(true), @@ -186,7 +191,14 @@ mod tests { fields: Some(fields), ..Indexer::default() }; - radarr_data.indexers.set_items(vec![indexer]); + radarr_data.indexers.set_items(vec![ + Indexer { + protocol: other_protocol, + ..Indexer::default() + }, + indexer, + ]); + radarr_data.indexers.select_index(Some(1)); app.data.radarr_data = radarr_data; IndexersHandler::new(SUBMIT_KEY, &mut app, ActiveRadarrBlock::Indexers, None).handle(); @@ -233,7 +245,12 @@ mod tests { #[test] fn test_delete_indexer_prompt_confirm_submit() { let mut app = App::test_default(); - app.data.radarr_data.indexers.set_items(vec![indexer()]); + app + .data + .radarr_data + .indexers + .set_items(vec![indexer(), Indexer { id: 2, ..indexer() }]); + app.data.radarr_data.indexers.select_index(Some(1)); app.data.radarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteIndexerPrompt.into()); @@ -249,7 +266,7 @@ mod tests { assert!(app.data.radarr_data.prompt_confirm); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::DeleteIndexer(1) + &RadarrEvent::DeleteIndexer(2) ); assert_navigation_popped!(app, ActiveRadarrBlock::Indexers.into()); } @@ -523,7 +540,12 @@ mod tests { #[test] fn test_delete_indexer_prompt_confirm() { let mut app = App::test_default(); - app.data.radarr_data.indexers.set_items(vec![indexer()]); + app + .data + .radarr_data + .indexers + .set_items(vec![indexer(), Indexer { id: 2, ..indexer() }]); + app.data.radarr_data.indexers.select_index(Some(1)); app.push_navigation_stack(ActiveRadarrBlock::Indexers.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteIndexerPrompt.into()); @@ -538,7 +560,7 @@ mod tests { assert!(app.data.radarr_data.prompt_confirm); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::DeleteIndexer(1) + &RadarrEvent::DeleteIndexer(2) ); assert_navigation_popped!(app, ActiveRadarrBlock::Indexers.into()); } diff --git a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs index b61d7ec8..a078831b 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs @@ -17,7 +17,9 @@ mod tests { use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::add_movie_body; use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::collection_movie; use crate::models::HorizontallyScrollableText; - use crate::models::radarr_models::{AddMovieSearchResult, MinimumAvailability, MovieMonitor}; + use crate::models::radarr_models::{ + AddMovieBody, AddMovieSearchResult, CollectionMovie, MinimumAvailability, MovieMonitor, + }; use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ADD_MOVIE_BLOCKS, ActiveRadarrBlock}; use crate::models::servarr_models::RootFolder; @@ -766,7 +768,7 @@ mod tests { mod test_handle_submit { use crate::assert_navigation_popped; use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::{ - add_movie_body, add_movie_search_result, collection_movie, + add_movie_search_result, collection_movie, }; use crate::models::BlockSelectionState; use crate::models::radarr_models::Movie; @@ -942,7 +944,14 @@ mod tests { fn test_add_movie_search_results_submit_movie_already_in_library() { let mut app = App::test_default(); let mut add_searched_movies = StatefulTable::default(); - add_searched_movies.set_items(vec![AddMovieSearchResult::default()]); + add_searched_movies.set_items(vec![ + AddMovieSearchResult { + tmdb_id: 5678, + ..AddMovieSearchResult::default() + }, + AddMovieSearchResult::default(), + ]); + add_searched_movies.select_index(Some(1)); app.data.radarr_data.add_searched_movies = Some(add_searched_movies); app .data @@ -1026,16 +1035,22 @@ mod tests { add_movie_modal.root_folder_list.state.select(Some(1)); add_movie_modal .quality_profile_list - .set_items(vec!["HD - 1080p".to_owned()]); + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); add_movie_modal .monitor_list .set_items(Vec::from_iter(MovieMonitor::iter())); add_movie_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + add_movie_modal.quality_profile_list.state.select(Some(1)); + add_movie_modal.monitor_list.state.select(Some(1)); + add_movie_modal + .minimum_availability_list + .state + .select(Some(1)); app.data.radarr_data.add_movie_modal = Some(add_movie_modal); app.data.radarr_data.quality_profile_map = - BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let context = if movie_details_context { app .data @@ -1061,7 +1076,7 @@ mod tests { assert_navigation_popped!(app, ActiveRadarrBlock::Movies.into()); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::AddMovie(add_movie_body()) + &RadarrEvent::AddMovie(expected_add_movie_body(movie_details_context)) ); assert_modal_absent!(app.data.radarr_data.add_movie_modal); } @@ -1321,7 +1336,7 @@ mod tests { use crate::{ assert_navigation_popped, handlers::radarr_handlers::radarr_handler_test_utils::utils::{ - add_movie_body, add_movie_search_result, collection_movie, + add_movie_search_result, collection_movie, }, models::{ BlockSelectionState, @@ -1461,16 +1476,22 @@ mod tests { add_movie_modal.root_folder_list.state.select(Some(1)); add_movie_modal .quality_profile_list - .set_items(vec!["HD - 1080p".to_owned()]); + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); add_movie_modal .monitor_list .set_items(Vec::from_iter(MovieMonitor::iter())); add_movie_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + add_movie_modal.quality_profile_list.state.select(Some(1)); + add_movie_modal.monitor_list.state.select(Some(1)); + add_movie_modal + .minimum_availability_list + .state + .select(Some(1)); app.data.radarr_data.add_movie_modal = Some(add_movie_modal); app.data.radarr_data.quality_profile_map = - BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let context = if movie_details_context { app .data @@ -1496,7 +1517,7 @@ mod tests { assert_navigation_popped!(app, ActiveRadarrBlock::Movies.into()); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::AddMovie(add_movie_body()) + &RadarrEvent::AddMovie(expected_add_movie_body(movie_details_context)) ); assert_modal_absent!(app.data.radarr_data.add_movie_modal); } @@ -1572,26 +1593,45 @@ mod tests { add_movie_modal.root_folder_list.state.select(Some(1)); add_movie_modal .quality_profile_list - .set_items(vec!["HD - 1080p".to_owned()]); + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); add_movie_modal .monitor_list .set_items(Vec::from_iter(MovieMonitor::iter())); add_movie_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + add_movie_modal.quality_profile_list.state.select(Some(1)); + add_movie_modal.monitor_list.state.select(Some(1)); + add_movie_modal + .minimum_availability_list + .state + .select(Some(1)); app.data.radarr_data.add_movie_modal = Some(add_movie_modal); - app.data.radarr_data.quality_profile_map = BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + app.data.radarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); + app.data.radarr_data.collection_movies.set_items(vec![ + CollectionMovie { + tmdb_id: 5678, + title: HorizontallyScrollableText::from("Some Other Movie"), + ..CollectionMovie::default() + }, + collection_movie(), + ]); + app.data.radarr_data.collection_movies.select_index(Some(1)); + let mut add_searched_movies = StatefulTable::default(); + add_searched_movies.set_items(vec![ + AddMovieSearchResult { + tmdb_id: 5678, + title: HorizontallyScrollableText::from("Some Other Movie"), + ..AddMovieSearchResult::default() + }, + add_movie_search_result(), + ]); + add_searched_movies.select_index(Some(1)); + app.data.radarr_data.add_searched_movies = Some(add_searched_movies); let context = if movie_details_context { - app - .data - .radarr_data - .collection_movies - .set_items(vec![collection_movie()]); Some(ActiveRadarrBlock::CollectionDetails) } else { - let mut add_searched_movies = StatefulTable::default(); - add_searched_movies.set_items(vec![add_movie_search_result()]); - app.data.radarr_data.add_searched_movies = Some(add_searched_movies); None }; @@ -1603,7 +1643,10 @@ mod tests { ) .build_add_movie_body(); - assert_eq!(actual_add_movie_body, add_movie_body()); + assert_eq!( + actual_add_movie_body, + expected_add_movie_body(movie_details_context) + ); } #[test] @@ -1635,4 +1678,16 @@ mod tests { assert!(handler.is_ready()); } + + fn expected_add_movie_body(movie_details_context: bool) -> AddMovieBody { + if movie_details_context { + add_movie_body() + } else { + AddMovieBody { + tmdb_id: 4321, + title: "Test Search Result".to_owned(), + ..add_movie_body() + } + } + } } diff --git a/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs index ca73c5a8..01bdd1c4 100644 --- a/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/delete_movie_handler_tests.rs @@ -10,7 +10,7 @@ mod tests { use crate::handlers::KeyEventHandler; use crate::handlers::radarr_handlers::library::delete_movie_handler::DeleteMovieHandler; use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::movie; - use crate::models::radarr_models::DeleteMovieParams; + use crate::models::radarr_models::{DeleteMovieParams, Movie}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, DELETE_MOVIE_BLOCKS}; mod test_handle_scroll_up_and_down { @@ -126,14 +126,19 @@ mod tests { let mut app = App::test_default(); let expected_delete_movie_params = DeleteMovieParams { id: 1, - delete_movie_files: true, + delete_movie_files: false, add_list_exclusion: true, }; app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into()); - app.data.radarr_data.movies.set_items(vec![movie()]); + app + .data + .radarr_data + .movies + .set_items(vec![Movie { id: 999, ..movie() }, movie()]); + app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.prompt_confirm = true; - app.data.radarr_data.delete_movie_files = true; + app.data.radarr_data.delete_movie_files = false; app.data.radarr_data.add_list_exclusion = true; app.data.radarr_data.selected_block = BlockSelectionState::new(DELETE_MOVIE_SELECTION_BLOCKS); app @@ -271,13 +276,18 @@ mod tests { let expected_delete_movie_params = DeleteMovieParams { id: 1, delete_movie_files: true, - add_list_exclusion: true, + add_list_exclusion: false, }; app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteMoviePrompt.into()); - app.data.radarr_data.movies.set_items(vec![movie()]); + app + .data + .radarr_data + .movies + .set_items(vec![Movie { id: 999, ..movie() }, movie()]); + app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.delete_movie_files = true; - app.data.radarr_data.add_list_exclusion = true; + app.data.radarr_data.add_list_exclusion = false; app.data.radarr_data.selected_block = BlockSelectionState::new(DELETE_MOVIE_SELECTION_BLOCKS); app .data @@ -338,13 +348,18 @@ mod tests { #[test] fn test_build_delete_movie_params() { let mut app = App::test_default(); - app.data.radarr_data.movies.set_items(vec![movie()]); + app + .data + .radarr_data + .movies + .set_items(vec![Movie { id: 999, ..movie() }, movie()]); + app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.delete_movie_files = true; - app.data.radarr_data.add_list_exclusion = true; + app.data.radarr_data.add_list_exclusion = false; let expected_delete_movie_params = DeleteMovieParams { id: 1, delete_movie_files: true, - add_list_exclusion: true, + add_list_exclusion: false, }; let delete_movie_params = DeleteMovieHandler::new( diff --git a/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs index 03bfc52e..ad878f9a 100644 --- a/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/edit_movie_handler_tests.rs @@ -656,18 +656,24 @@ mod tests { edit_movie .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + edit_movie.quality_profile_list.state.select(Some(1)); + edit_movie.minimum_availability_list.state.select(Some(1)); app.data.radarr_data.edit_movie_modal = Some(edit_movie); - app.data.radarr_data.movies.set_items(vec![Movie { - monitored: false, - ..movie() - }]); + app.data.radarr_data.movies.set_items(vec![ + Movie { id: 999, ..movie() }, + Movie { + monitored: false, + ..movie() + }, + ]); + app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let expected_edit_movie_params = EditMovieParams { movie_id: 1, monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), + minimum_availability: Some(MinimumAvailability::InCinemas), + quality_profile_id: Some(2222), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".into()), ..EditMovieParams::default() @@ -1089,18 +1095,24 @@ mod tests { edit_movie .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + edit_movie.quality_profile_list.state.select(Some(1)); + edit_movie.minimum_availability_list.state.select(Some(1)); app.data.radarr_data.edit_movie_modal = Some(edit_movie); - app.data.radarr_data.movies.set_items(vec![Movie { - monitored: false, - ..movie() - }]); + app.data.radarr_data.movies.set_items(vec![ + Movie { id: 999, ..movie() }, + Movie { + monitored: false, + ..movie() + }, + ]); + app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let expected_edit_movie_params = EditMovieParams { movie_id: 1, monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), + minimum_availability: Some(MinimumAvailability::InCinemas), + quality_profile_id: Some(2222), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".into()), ..EditMovieParams::default() @@ -1177,18 +1189,24 @@ mod tests { edit_movie .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); + edit_movie.quality_profile_list.state.select(Some(1)); + edit_movie.minimum_availability_list.state.select(Some(1)); app.data.radarr_data.edit_movie_modal = Some(edit_movie); - app.data.radarr_data.movies.set_items(vec![Movie { - monitored: false, - ..movie() - }]); + app.data.radarr_data.movies.set_items(vec![ + Movie { id: 999, ..movie() }, + Movie { + monitored: false, + ..movie() + }, + ]); + app.data.radarr_data.movies.select_index(Some(1)); app.data.radarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); let expected_edit_movie_params = EditMovieParams { movie_id: 1, monitored: Some(false), - minimum_availability: Some(MinimumAvailability::Announced), - quality_profile_id: Some(1111), + minimum_availability: Some(MinimumAvailability::InCinemas), + quality_profile_id: Some(2222), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".into()), ..EditMovieParams::default() diff --git a/src/handlers/radarr_handlers/library/library_handler_tests.rs b/src/handlers/radarr_handlers/library/library_handler_tests.rs index 5ac7b9a1..be1174b1 100644 --- a/src/handlers/radarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/library_handler_tests.rs @@ -391,6 +391,12 @@ mod tests { fn test_toggle_monitoring_key() { let mut app = App::test_default(); app.data.radarr_data = create_test_radarr_data(); + app + .data + .radarr_data + .movies + .set_items(vec![movie(), Movie { id: 2, ..movie() }]); + app.data.radarr_data.movies.select_index(Some(1)); app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); app.is_routing = false; @@ -407,7 +413,7 @@ mod tests { assert!(app.is_routing); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::ToggleMovieMonitoring(0) + &RadarrEvent::ToggleMovieMonitoring(2) ); } @@ -519,6 +525,48 @@ mod tests { assert!(!app.should_refresh); } + #[test] + fn test_search_movies_key() { + let mut app = App::test_default(); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveRadarrBlock::Movies, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::SearchMovie.into()); + } + + #[test] + fn test_filter_movies_key() { + let mut app = App::test_default(); + app + .data + .radarr_data + .movies + .set_items(vec![Movie::default()]); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveRadarrBlock::Movies, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveRadarrBlock::FilterMovies.into()); + } + #[test] fn test_update_all_movies_prompt_confirm() { let mut app = App::test_default(); @@ -621,7 +669,12 @@ mod tests { #[test] fn test_extract_movie_id() { let mut app = App::test_default(); - app.data.radarr_data.movies.set_items(vec![movie()]); + app + .data + .radarr_data + .movies + .set_items(vec![movie(), Movie { id: 2, ..movie() }]); + app.data.radarr_data.movies.select_index(Some(1)); let movie_id = LibraryHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -631,7 +684,7 @@ mod tests { ) .extract_movie_id(); - assert_eq!(movie_id, 1); + assert_eq!(movie_id, 2); } #[test] diff --git a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs index a5d8077a..250301fe 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs @@ -17,7 +17,7 @@ mod tests { MovieDetailsHandler, releases_sorting_options, }; use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::{movie, release}; - use crate::models::radarr_models::{Credit, MovieHistoryItem}; + use crate::models::radarr_models::{Credit, Movie, MovieHistoryItem}; use crate::models::radarr_models::{RadarrRelease, RadarrReleaseDownloadBody}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; @@ -819,11 +819,22 @@ mod tests { fn test_build_radarr_release_download_body() { let mut app = App::test_default(); let mut movie_details_modal = MovieDetailsModal::default(); - movie_details_modal - .movie_releases - .set_items(vec![release()]); + movie_details_modal.movie_releases.set_items(vec![ + RadarrRelease { + guid: "9999".to_owned(), + indexer_id: 99, + ..release() + }, + release(), + ]); + movie_details_modal.movie_releases.select_index(Some(1)); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); - app.data.radarr_data.movies.set_items(vec![movie()]); + app + .data + .radarr_data + .movies + .set_items(vec![Movie { id: 999, ..movie() }, movie()]); + app.data.radarr_data.movies.select_index(Some(1)); let expected_body = RadarrReleaseDownloadBody { guid: "1234".to_owned(), indexer_id: 2, @@ -844,7 +855,12 @@ mod tests { #[test] fn test_extract_movie_id() { let mut app = App::test_default(); - app.data.radarr_data.movies.set_items(vec![movie()]); + app + .data + .radarr_data + .movies + .set_items(vec![Movie { id: 999, ..movie() }, movie()]); + app.data.radarr_data.movies.select_index(Some(1)); let movie_id = MovieDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, diff --git a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs index cc54e07c..d277e7f2 100644 --- a/src/handlers/radarr_handlers/radarr_handler_test_utils.rs +++ b/src/handlers/radarr_handlers/radarr_handler_test_utils.rs @@ -5,10 +5,10 @@ pub(in crate::handlers::radarr_handlers) mod utils { use crate::models::radarr_models::{ AddMovieBody, AddMovieOptions, AddMovieSearchResult, Collection, CollectionMovie, DownloadRecord, IndexerSettings, MediaInfo, MinimumAvailability, Movie, MovieCollection, - MovieFile, MovieMonitor, RadarrRelease, Rating, RatingsList, + MovieFile, MovieMonitor, MovieStatus, RadarrRelease, Rating, RatingsList, }; use crate::models::servarr_models::{ - Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, + DownloadStatus, Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, }; use chrono::DateTime; use serde_json::{Number, json}; @@ -295,7 +295,12 @@ pub(in crate::handlers::radarr_handlers) mod utils { pub fn collection_movie() -> CollectionMovie { CollectionMovie { title: "Test".to_owned().into(), - overview: "Collection blah blah blah".to_owned(), + overview: "Collection movie blah blah blah\r\n\ + \r\n\ + It was shot in Madison, Wisconsin: a city the cast never really left.\r\n\ + \r\n\ + The sequel took seven years to finish.\r\n" + .to_owned(), year: 2023, runtime: 120, tmdb_id: 1234, @@ -324,7 +329,7 @@ pub(in crate::handlers::radarr_handlers) mod utils { title: "Test".to_owned().into(), original_language: language(), size_on_disk: 3543348019, - status: "Downloaded".to_owned(), + status: MovieStatus::Released, overview: "Blah blah blah".to_owned(), path: "/nfs/movies".to_owned(), studio: Some("21st Century Alex".to_owned()), @@ -387,10 +392,10 @@ pub(in crate::handlers::radarr_handlers) mod utils { pub fn add_movie_search_result() -> AddMovieSearchResult { AddMovieSearchResult { - tmdb_id: 1234, - title: HorizontallyScrollableText::from("Test"), + tmdb_id: 4321, + title: HorizontallyScrollableText::from("Test Search Result"), original_language: language(), - status: "released".to_owned(), + status: MovieStatus::Released, overview: "New movie blah blah blah".to_owned(), genres: genres(), year: 2023, @@ -402,7 +407,7 @@ pub(in crate::handlers::radarr_handlers) mod utils { pub fn download_record() -> DownloadRecord { DownloadRecord { title: "Test Download Title".to_owned(), - status: "downloading".to_owned(), + status: DownloadStatus::Downloading, id: 1, movie_id: 1, size: 3543348019, @@ -470,13 +475,13 @@ pub(in crate::handlers::radarr_handlers) mod utils { tmdb_id: 1234, title: "Test".to_owned(), root_folder_path: "/nfs2".to_owned(), - minimum_availability: MinimumAvailability::Announced, + minimum_availability: MinimumAvailability::InCinemas, monitored: true, quality_profile_id: 2222, tags: Vec::new(), tag_input_string: Some("usenet, testing".into()), add_options: AddMovieOptions { - monitor: MovieMonitor::MovieOnly, + monitor: MovieMonitor::MovieAndCollection, search_for_movie: true, }, } diff --git a/src/handlers/radarr_handlers/radarr_handler_tests.rs b/src/handlers/radarr_handlers/radarr_handler_tests.rs index 5333f699..58c2ec15 100644 --- a/src/handlers/radarr_handlers/radarr_handler_tests.rs +++ b/src/handlers/radarr_handlers/radarr_handler_tests.rs @@ -123,7 +123,6 @@ mod tests { #[rstest] fn test_delegates_system_blocks_to_system_handler( #[values( - ActiveRadarrBlock::System, ActiveRadarrBlock::SystemLogs, ActiveRadarrBlock::SystemQueuedEvents, ActiveRadarrBlock::SystemTasks, @@ -142,7 +141,6 @@ mod tests { #[rstest] fn test_delegates_library_blocks_to_library_handler( #[values( - ActiveRadarrBlock::Movies, ActiveRadarrBlock::MoviesSortPrompt, ActiveRadarrBlock::SearchMovie, ActiveRadarrBlock::SearchMovieError, @@ -186,7 +184,6 @@ mod tests { #[rstest] fn test_delegates_collections_blocks_to_collections_handler( #[values( - ActiveRadarrBlock::Collections, ActiveRadarrBlock::SearchCollection, ActiveRadarrBlock::CollectionsSortPrompt, ActiveRadarrBlock::SearchCollectionError, @@ -213,7 +210,6 @@ mod tests { fn test_delegates_indexers_blocks_to_indexers_handler( #[values( ActiveRadarrBlock::DeleteIndexerPrompt, - ActiveRadarrBlock::Indexers, ActiveRadarrBlock::AllIndexerSettingsPrompt, ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput, ActiveRadarrBlock::IndexerSettingsConfirmPrompt, @@ -237,7 +233,6 @@ mod tests { #[rstest] fn test_delegates_downloads_blocks_to_downloads_handler( #[values( - ActiveRadarrBlock::Downloads, ActiveRadarrBlock::DeleteDownloadPrompt, ActiveRadarrBlock::UpdateDownloadsPrompt )] @@ -253,7 +248,6 @@ mod tests { #[rstest] fn test_delegates_root_folders_blocks_to_root_folders_handler( #[values( - ActiveRadarrBlock::RootFolders, ActiveRadarrBlock::AddRootFolderPrompt, ActiveRadarrBlock::DeleteRootFolderPrompt )] @@ -269,7 +263,6 @@ mod tests { #[rstest] fn test_delegates_blocklist_blocks_to_blocklist_handler( #[values( - ActiveRadarrBlock::Blocklist, ActiveRadarrBlock::BlocklistItemDetails, ActiveRadarrBlock::DeleteBlocklistItemPrompt, ActiveRadarrBlock::BlocklistClearAllItemsPrompt, @@ -287,7 +280,6 @@ mod tests { #[rstest] fn test_delegates_history_blocks_to_history_handler( #[values( - ActiveRadarrBlock::History, ActiveRadarrBlock::HistoryItemDetails, ActiveRadarrBlock::HistorySortPrompt, ActiveRadarrBlock::FilterHistory, diff --git a/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs index 9c42cb27..a6157b88 100644 --- a/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs +++ b/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs @@ -314,11 +314,15 @@ mod tests { #[test] fn test_delete_root_folder_prompt_confirm_submit() { let mut app = App::test_default(); - app - .data - .radarr_data - .root_folders - .set_items(vec![root_folder()]); + app.data.radarr_data.root_folders.set_items(vec![ + root_folder(), + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + ..root_folder() + }, + ]); + app.data.radarr_data.root_folders.select_index(Some(1)); app.data.radarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveRadarrBlock::RootFolders.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteRootFolderPrompt.into()); @@ -334,7 +338,7 @@ mod tests { assert!(app.data.radarr_data.prompt_confirm); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::DeleteRootFolder(1) + &RadarrEvent::DeleteRootFolder(2) ); assert_navigation_popped!(app, ActiveRadarrBlock::RootFolders.into()); } @@ -583,11 +587,15 @@ mod tests { #[test] fn test_delete_root_folder_prompt_confirm() { let mut app = App::test_default(); - app - .data - .radarr_data - .root_folders - .set_items(vec![root_folder()]); + app.data.radarr_data.root_folders.set_items(vec![ + root_folder(), + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + ..root_folder() + }, + ]); + app.data.radarr_data.root_folders.select_index(Some(1)); app.push_navigation_stack(ActiveRadarrBlock::RootFolders.into()); app.push_navigation_stack(ActiveRadarrBlock::DeleteRootFolderPrompt.into()); @@ -602,7 +610,7 @@ mod tests { assert!(app.data.radarr_data.prompt_confirm); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::DeleteRootFolder(1) + &RadarrEvent::DeleteRootFolder(2) ); assert_navigation_popped!(app, ActiveRadarrBlock::RootFolders.into()); } diff --git a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs index a2eed580..d058ada4 100644 --- a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs @@ -677,10 +677,17 @@ mod tests { let mut app = App::test_default(); app.data.radarr_data.updates = ScrollableText::with_string("Test".to_owned()); app.data.radarr_data.prompt_confirm = true; - app.data.radarr_data.tasks.set_items(vec![RadarrTask { - task_name: RadarrTaskName::default(), - ..RadarrTask::default() - }]); + app.data.radarr_data.tasks.set_items(vec![ + RadarrTask { + task_name: RadarrTaskName::Backup, + ..RadarrTask::default() + }, + RadarrTask { + task_name: RadarrTaskName::RefreshMovie, + ..RadarrTask::default() + }, + ]); + app.data.radarr_data.tasks.select_index(Some(1)); app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into()); app.push_navigation_stack(ActiveRadarrBlock::SystemTaskStartConfirmPrompt.into()); @@ -695,7 +702,7 @@ mod tests { assert!(app.data.radarr_data.prompt_confirm); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::StartTask(RadarrTaskName::default()) + &RadarrEvent::StartTask(RadarrTaskName::RefreshMovie) ); assert_navigation_popped!(app, ActiveRadarrBlock::SystemTasks.into()); } @@ -891,10 +898,17 @@ mod tests { fn test_system_tasks_start_task_prompt_confirm() { let mut app = App::test_default(); app.data.radarr_data.updates = ScrollableText::with_string("Test".to_owned()); - app.data.radarr_data.tasks.set_items(vec![RadarrTask { - task_name: RadarrTaskName::default(), - ..RadarrTask::default() - }]); + app.data.radarr_data.tasks.set_items(vec![ + RadarrTask { + task_name: RadarrTaskName::Backup, + ..RadarrTask::default() + }, + RadarrTask { + task_name: RadarrTaskName::RefreshMovie, + ..RadarrTask::default() + }, + ]); + app.data.radarr_data.tasks.select_index(Some(1)); app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into()); app.push_navigation_stack(ActiveRadarrBlock::SystemTaskStartConfirmPrompt.into()); @@ -909,7 +923,7 @@ mod tests { assert!(app.data.radarr_data.prompt_confirm); assert_some_eq_x!( &app.data.radarr_data.prompt_confirm_action, - &RadarrEvent::StartTask(RadarrTaskName::default()) + &RadarrEvent::StartTask(RadarrTaskName::RefreshMovie) ); assert_navigation_popped!(app, ActiveRadarrBlock::SystemTasks.into()); } @@ -948,10 +962,17 @@ mod tests { #[test] fn test_extract_task_name() { let mut app = App::test_default(); - app.data.radarr_data.tasks.set_items(vec![RadarrTask { - task_name: RadarrTaskName::default(), - ..RadarrTask::default() - }]); + app.data.radarr_data.tasks.set_items(vec![ + RadarrTask { + task_name: RadarrTaskName::Backup, + ..RadarrTask::default() + }, + RadarrTask { + task_name: RadarrTaskName::RefreshMovie, + ..RadarrTask::default() + }, + ]); + app.data.radarr_data.tasks.select_index(Some(1)); let task_name = SystemDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -961,7 +982,7 @@ mod tests { ) .extract_task_name(); - assert_eq!(task_name, RadarrTaskName::default()); + assert_eq!(task_name, RadarrTaskName::RefreshMovie); } #[test] diff --git a/src/handlers/readarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/readarr_handlers/blocklist/blocklist_handler_tests.rs new file mode 100644 index 00000000..e3964cc7 --- /dev/null +++ b/src/handlers/readarr_handlers/blocklist/blocklist_handler_tests.rs @@ -0,0 +1,627 @@ +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use chrono::DateTime; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::blocklist::{BlocklistHandler, blocklist_sorting_options}; + use crate::models::readarr_models::{Author, BlocklistItem}; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BLOCKLIST_BLOCKS}; + use crate::models::servarr_models::{Quality, QualityWrapper}; + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use super::*; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_blocklist_item_prompt() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Blocklist, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::DeleteBlocklistItemPrompt.into()); + } + + #[test] + fn test_delete_blocklist_item_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Blocklist, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Blocklist.into() + ); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_blocklist_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(2); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Downloads.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Downloads.into()); + } + + #[rstest] + fn test_blocklist_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(2); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::History.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::History.into()); + } + + #[rstest] + fn test_blocklist_left_right_prompt_toggle( + #[values( + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt + )] + active_readarr_block: ActiveReadarrBlock, + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + BlocklistHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + BlocklistHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use crate::assert_navigation_popped; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_blocklist_submit() { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + BlocklistHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Blocklist, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BlocklistItemDetails.into()); + } + + #[test] + fn test_blocklist_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + BlocklistHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Blocklist, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Blocklist.into() + ); + } + + #[rstest] + #[case( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ReadarrEvent::DeleteBlocklistItem(3) + )] + #[case( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt, + ReadarrEvent::ClearBlocklist + )] + fn test_blocklist_prompt_confirm_submit( + #[case] base_route: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + ) { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + assert_navigation_popped!(app, base_route.into()); + } + + #[rstest] + fn test_blocklist_prompt_decline_submit( + #[values( + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt + )] + prompt_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveReadarrBlock::Blocklist.into()); + } + } + + mod test_handle_esc { + use rstest::rstest; + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + #[case( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::DeleteBlocklistItemPrompt + )] + #[case( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt + )] + fn test_blocklist_prompt_blocks_esc( + #[case] base_block: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(base_block.into()); + app.push_navigation_stack(prompt_block.into()); + app.data.readarr_data.prompt_confirm = true; + + BlocklistHandler::new(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert_navigation_popped!(app, base_block.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[test] + fn test_esc_blocklist_item_details() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.push_navigation_stack(ActiveReadarrBlock::BlocklistItemDetails.into()); + + BlocklistHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::BlocklistItemDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Blocklist.into()); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + BlocklistHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::Blocklist, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Blocklist.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::network::readarr_network::ReadarrEvent; + + use super::*; + use crate::{assert_navigation_popped, assert_navigation_pushed}; + + #[test] + fn test_refresh_blocklist_key() { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::Blocklist.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_blocklist_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Blocklist.into() + ); + assert!(!app.should_refresh); + } + + #[test] + fn test_clear_blocklist_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.clear.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BlocklistClearAllItemsPrompt.into()); + } + + #[test] + fn test_clear_blocklist_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.clear.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Blocklist.into() + ); + } + + #[rstest] + #[case( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ReadarrEvent::DeleteBlocklistItem(3) + )] + #[case( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt, + ReadarrEvent::ClearBlocklist + )] + fn test_blocklist_prompt_confirm( + #[case] base_route: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + ) { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + BlocklistHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + assert_navigation_popped!(app, base_route.into()); + } + } + + #[test] + fn test_blocklist_sorting_options_author_name() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.author + .author_name + .text + .to_lowercase() + .cmp(&b.author.author_name.text.to_lowercase()) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[0].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Author Name"); + } + + #[test] + fn test_blocklist_sorting_options_source_title() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[1].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Source Title"); + } + + #[test] + fn test_blocklist_sorting_options_quality() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = |a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }; + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[2].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Quality"); + } + + #[test] + fn test_blocklist_sorting_options_date() { + let expected_cmp_fn: fn(&BlocklistItem, &BlocklistItem) -> Ordering = + |a, b| a.date.cmp(&b.date); + let mut expected_blocklist_vec = blocklist_vec(); + expected_blocklist_vec.sort_by(expected_cmp_fn); + + let sort_option = blocklist_sorting_options()[3].clone(); + let mut sorted_blocklist_vec = blocklist_vec(); + sorted_blocklist_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_blocklist_vec, expected_blocklist_vec); + assert_str_eq!(sort_option.name, "Date"); + } + + #[test] + fn test_blocklist_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if BLOCKLIST_BLOCKS.contains(&active_readarr_block) { + assert!(BlocklistHandler::accepts(active_readarr_block)); + } else { + assert!(!BlocklistHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_blocklist_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_extract_blocklist_item_id() { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.data.readarr_data.blocklist.select_index(Some(1)); + + let blocklist_item_id = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ) + .extract_blocklist_item_id(); + + assert_eq!(blocklist_item_id, 2); + } + + #[test] + fn test_blocklist_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.is_loading = true; + + let handler = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_blocklist_handler_not_ready_when_blocklist_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.is_loading = false; + + let handler = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_blocklist_handler_ready_when_not_loading_and_blocklist_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + app.is_loading = false; + app + .data + .readarr_data + .blocklist + .set_items(vec![BlocklistItem::default()]); + + let handler = BlocklistHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Blocklist, + None, + ); + + assert!(handler.is_ready()); + } + + fn blocklist_vec() -> Vec { + vec![ + BlocklistItem { + id: 3, + source_title: "test 2".to_owned(), + quality: QualityWrapper { + quality: Quality { + name: "AZW3".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()), + author: Author { + author_name: "Zora Neale".into(), + ..Author::default() + }, + ..BlocklistItem::default() + }, + BlocklistItem { + id: 2, + source_title: "test 3".to_owned(), + quality: QualityWrapper { + quality: Quality { + name: "MOBI".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()), + author: Author { + author_name: "Mary Shelley".into(), + ..Author::default() + }, + ..BlocklistItem::default() + }, + BlocklistItem { + id: 1, + source_title: "test 1".to_owned(), + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + author: Author { + author_name: "".into(), + ..Author::default() + }, + ..BlocklistItem::default() + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/blocklist/mod.rs b/src/handlers/readarr_handlers/blocklist/mod.rs new file mode 100644 index 00000000..c24243e2 --- /dev/null +++ b/src/handlers/readarr_handlers/blocklist/mod.rs @@ -0,0 +1,224 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::Route; +use crate::models::readarr_models::BlocklistItem; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BLOCKLIST_BLOCKS}; +use crate::models::stateful_table::SortOption; +use crate::network::readarr_network::ReadarrEvent; + +#[cfg(test)] +#[path = "blocklist_handler_tests.rs"] +mod blocklist_handler_tests; + +pub(super) struct BlocklistHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl BlocklistHandler<'_, '_> { + fn extract_blocklist_item_id(&self) -> i64 { + self.app.data.readarr_data.blocklist.current_selection().id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for BlocklistHandler<'a, 'b> { + fn handle(&mut self) { + let blocklist_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::Blocklist.into()) + .sorting_block(ActiveReadarrBlock::BlocklistSortPrompt.into()) + .sort_options(blocklist_sorting_options()); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.blocklist, + blocklist_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + BLOCKLIST_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> Self { + BlocklistHandler { + key, + app, + active_readarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.blocklist.is_empty() + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::Blocklist { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteBlocklistItemPrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::Blocklist => handle_change_tab_left_right_keys(self.app, self.key), + ActiveReadarrBlock::DeleteBlocklistItemPrompt + | ActiveReadarrBlock::BlocklistClearAllItemsPrompt => { + handle_prompt_toggle(self.app, self.key) + } + _ => {} + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteBlocklistItemPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::DeleteBlocklistItem(self.extract_blocklist_item_id()), + ); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::BlocklistClearAllItemsPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::ClearBlocklist); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::Blocklist => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::BlocklistItemDetails.into()); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteBlocklistItemPrompt + | ActiveReadarrBlock::BlocklistClearAllItemsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + ActiveReadarrBlock::BlocklistItemDetails | ActiveReadarrBlock::BlocklistSortPrompt => { + self.app.pop_navigation_stack(); + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::Blocklist => match self.key { + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ if matches_key!(clear, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::BlocklistClearAllItemsPrompt.into()); + } + _ => (), + }, + ActiveReadarrBlock::DeleteBlocklistItemPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::DeleteBlocklistItem(self.extract_blocklist_item_id()), + ); + + self.app.pop_navigation_stack(); + } + } + ActiveReadarrBlock::BlocklistClearAllItemsPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::ClearBlocklist); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} + +fn blocklist_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Author Name", + cmp_fn: Some(|a, b| { + a.author + .author_name + .text + .to_lowercase() + .cmp(&b.author.author_name.text.to_lowercase()) + }), + }, + SortOption { + name: "Source Title", + cmp_fn: Some(|a, b| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }), + }, + SortOption { + name: "Date", + cmp_fn: Some(|a, b| a.date.cmp(&b.date)), + }, + ] +} diff --git a/src/handlers/readarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/readarr_handlers/downloads/downloads_handler_tests.rs new file mode 100644 index 00000000..c803be20 --- /dev/null +++ b/src/handlers/readarr_handlers/downloads/downloads_handler_tests.rs @@ -0,0 +1,503 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use serde_json::Number; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::downloads::DownloadsHandler; + use crate::models::readarr_models::DownloadRecord; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DOWNLOADS_BLOCKS}; + use crate::models::servarr_models::DownloadStatus; + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use super::*; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_download_prompt() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.data.readarr_data.downloads.set_items(downloads_vec()); + + DownloadsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Downloads, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::DeleteDownloadPrompt.into()); + } + + #[test] + fn test_delete_download_prompt_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.data.readarr_data.downloads.set_items(downloads_vec()); + + DownloadsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Downloads, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Downloads.into() + ); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_downloads_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(1); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Authors.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Authors.into()); + } + + #[rstest] + fn test_downloads_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(1); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Blocklist.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Blocklist.into()); + } + + #[rstest] + fn test_downloads_left_right_prompt_toggle( + #[values( + ActiveReadarrBlock::DeleteDownloadPrompt, + ActiveReadarrBlock::UpdateDownloadsPrompt + )] + active_readarr_block: ActiveReadarrBlock, + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + + DownloadsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + DownloadsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use rstest::rstest; + + use super::*; + use crate::assert_navigation_popped; + use crate::network::readarr_network::ReadarrEvent; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[rstest] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt, + ReadarrEvent::DeleteDownload(3) + )] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::UpdateDownloadsPrompt, + ReadarrEvent::UpdateDownloads + )] + fn test_downloads_prompt_confirm_submit( + #[case] base_route: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + ) { + let mut app = App::test_default(); + app.data.readarr_data.downloads.set_items(downloads_vec()); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + DownloadsHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + assert_navigation_popped!(app, base_route.into()); + } + + #[rstest] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt + )] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::UpdateDownloadsPrompt + )] + fn test_downloads_prompt_decline_submit( + #[case] base_route: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.downloads.set_items(downloads_vec()); + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + DownloadsHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_popped!(app, base_route.into()); + } + } + + mod test_handle_esc { + use rstest::rstest; + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt + )] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::UpdateDownloadsPrompt + )] + fn test_downloads_prompt_blocks_esc( + #[case] base_block: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(base_block.into()); + app.push_navigation_stack(prompt_block.into()); + app.data.readarr_data.prompt_confirm = true; + + DownloadsHandler::new(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert_navigation_popped!(app, base_block.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + + DownloadsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::Downloads, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Downloads.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::network::readarr_network::ReadarrEvent; + use crate::{assert_navigation_popped, assert_navigation_pushed}; + + #[test] + fn test_update_downloads_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.data.readarr_data.downloads.set_items(downloads_vec()); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::UpdateDownloadsPrompt.into()); + } + + #[test] + fn test_update_downloads_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.data.readarr_data.downloads.set_items(downloads_vec()); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Downloads.into() + ); + } + + #[test] + fn test_refresh_downloads_key() { + let mut app = App::test_default(); + app.data.readarr_data.downloads.set_items(downloads_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::Downloads.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_downloads_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.data.readarr_data.downloads.set_items(downloads_vec()); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::Downloads.into() + ); + assert!(!app.should_refresh); + } + + #[rstest] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt, + ReadarrEvent::DeleteDownload(3) + )] + #[case( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::UpdateDownloadsPrompt, + ReadarrEvent::UpdateDownloads + )] + fn test_downloads_prompt_confirm( + #[case] base_route: ActiveReadarrBlock, + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + ) { + let mut app = App::test_default(); + app.data.readarr_data.downloads.set_items(downloads_vec()); + app.push_navigation_stack(base_route.into()); + app.push_navigation_stack(prompt_block.into()); + + DownloadsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + assert_navigation_popped!(app, base_route.into()); + } + } + + #[test] + fn test_downloads_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if DOWNLOADS_BLOCKS.contains(&active_readarr_block) { + assert!(DownloadsHandler::accepts(active_readarr_block)); + } else { + assert!(!DownloadsHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_downloads_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = DownloadsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_extract_download_id() { + let mut app = App::test_default(); + app.data.readarr_data.downloads.set_items(downloads_vec()); + app.data.readarr_data.downloads.select_index(Some(1)); + + let download_id = DownloadsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ) + .extract_download_id(); + + assert_eq!(download_id, 2); + } + + #[test] + fn test_downloads_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.is_loading = true; + + let handler = DownloadsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_downloads_handler_not_ready_when_downloads_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.is_loading = false; + + let handler = DownloadsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_downloads_handler_ready_when_not_loading_and_downloads_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + app.is_loading = false; + app + .data + .readarr_data + .downloads + .set_items(vec![DownloadRecord::default()]); + + let handler = DownloadsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Downloads, + None, + ); + + assert!(handler.is_ready()); + } + + fn downloads_vec() -> Vec { + vec![ + DownloadRecord { + title: "Test Book Download Three".to_owned(), + status: DownloadStatus::Downloading, + id: 3, + book_id: Some(Number::from(13)), + author_id: Some(Number::from(23)), + size: 3000.0, + sizeleft: 750.0, + output_path: Some("/nfs/nzbget/completed/books/Zora Neale - Test Book Three".into()), + indexer: "test-indexer-three".to_owned(), + download_client: Some("NZBGet".to_owned()), + }, + DownloadRecord { + title: "Test Book Download Two".to_owned(), + status: DownloadStatus::Queued, + id: 2, + book_id: Some(Number::from(12)), + author_id: Some(Number::from(22)), + size: 2000.0, + sizeleft: 500.0, + output_path: Some("/nfs/nzbget/completed/books/Mary Shelley - Test Book Two".into()), + indexer: "test-indexer-two".to_owned(), + download_client: Some("Transmission".to_owned()), + }, + DownloadRecord { + title: "Test Book Download One".to_owned(), + status: DownloadStatus::Paused, + id: 1, + book_id: Some(Number::from(11)), + author_id: Some(Number::from(21)), + size: 1000.0, + sizeleft: 250.0, + output_path: Some("/nfs/nzbget/completed/books/Ursula Le Guin - Test Book One".into()), + indexer: "test-indexer-one".to_owned(), + download_client: Some("SABnzbd".to_owned()), + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/downloads/mod.rs b/src/handlers/readarr_handlers/downloads/mod.rs new file mode 100644 index 00000000..3e0bf18d --- /dev/null +++ b/src/handlers/readarr_handlers/downloads/mod.rs @@ -0,0 +1,171 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DOWNLOADS_BLOCKS}; +use crate::network::readarr_network::ReadarrEvent; + +#[cfg(test)] +#[path = "downloads_handler_tests.rs"] +mod downloads_handler_tests; + +pub(super) struct DownloadsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl DownloadsHandler<'_, '_> { + fn extract_download_id(&self) -> i64 { + self.app.data.readarr_data.downloads.current_selection().id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for DownloadsHandler<'a, 'b> { + fn handle(&mut self) { + let downloads_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::Downloads.into()); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.downloads, + downloads_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + DOWNLOADS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> DownloadsHandler<'a, 'b> { + DownloadsHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.downloads.is_empty() + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::Downloads { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteDownloadPrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::Downloads => handle_change_tab_left_right_keys(self.app, self.key), + ActiveReadarrBlock::DeleteDownloadPrompt | ActiveReadarrBlock::UpdateDownloadsPrompt => { + handle_prompt_toggle(self.app, self.key) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteDownloadPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteDownload(self.extract_download_id())); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::UpdateDownloadsPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::UpdateDownloads); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteDownloadPrompt | ActiveReadarrBlock::UpdateDownloadsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::Downloads => match self.key { + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::UpdateDownloadsPrompt.into()); + } + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ => (), + }, + ActiveReadarrBlock::DeleteDownloadPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteDownload(self.extract_download_id())); + + self.app.pop_navigation_stack(); + } + } + ActiveReadarrBlock::UpdateDownloadsPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::UpdateDownloads); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/history/history_handler_tests.rs b/src/handlers/readarr_handlers/history/history_handler_tests.rs new file mode 100644 index 00000000..d29b66cb --- /dev/null +++ b/src/handlers/readarr_handlers/history/history_handler_tests.rs @@ -0,0 +1,428 @@ +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use chrono::DateTime; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::history::{HistoryHandler, history_sorting_options}; + use crate::models::readarr_models::{ReadarrHistoryEventType, ReadarrHistoryItem}; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, HISTORY_BLOCKS}; + use crate::models::servarr_models::{Quality, QualityWrapper}; + + #[test] + fn test_history_sorting_options_source_title() { + let expected_cmp_fn: fn(&ReadarrHistoryItem, &ReadarrHistoryItem) -> Ordering = |a, b| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[0].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Source Title"); + } + + #[test] + fn test_history_sorting_options_event_type() { + let expected_cmp_fn: fn(&ReadarrHistoryItem, &ReadarrHistoryItem) -> Ordering = |a, b| { + a.event_type + .to_string() + .to_lowercase() + .cmp(&b.event_type.to_string().to_lowercase()) + }; + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[1].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Event Type"); + } + + #[test] + fn test_history_sorting_options_quality() { + let expected_cmp_fn: fn(&ReadarrHistoryItem, &ReadarrHistoryItem) -> Ordering = |a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }; + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[2].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Quality"); + } + + #[test] + fn test_history_sorting_options_date() { + let expected_cmp_fn: fn(&ReadarrHistoryItem, &ReadarrHistoryItem) -> Ordering = + |a, b| a.date.cmp(&b.date); + let mut expected_history_vec = history_vec(); + expected_history_vec.sort_by(expected_cmp_fn); + + let sort_option = history_sorting_options()[3].clone(); + let mut sorted_history_vec = history_vec(); + sorted_history_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_history_vec, expected_history_vec); + assert_str_eq!(sort_option.name, "Date"); + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_history_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(3); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Blocklist.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Blocklist.into()); + } + + #[rstest] + fn test_history_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(3); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::RootFolders.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::RootFolders.into()); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_history_submit() { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::History, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::HistoryItemDetails.into()); + } + + #[test] + fn test_history_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::History, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::History.into()); + } + } + + mod test_handle_esc { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_esc_history_item_details() { + let mut app = App::test_default(); + app + .data + .readarr_data + .history + .set_items(vec![ReadarrHistoryItem::default()]); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.push_navigation_stack(ActiveReadarrBlock::HistoryItemDetails.into()); + + HistoryHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::HistoryItemDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::History.into()); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app + .data + .readarr_data + .history + .set_items(vec![ReadarrHistoryItem::default()]); + + HistoryHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::History, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::History.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_navigation_pushed; + + #[test] + fn test_refresh_history_key() { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::History.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_history_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::History.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_search_history_key() { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SearchHistory.into()); + } + + #[test] + fn test_filter_history_key() { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveReadarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::FilterHistory.into()); + } + } + + #[test] + fn test_history_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if HISTORY_BLOCKS.contains(&active_readarr_block) { + assert!(HistoryHandler::accepts(active_readarr_block)); + } else { + assert!(!HistoryHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_history_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_history_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = true; + + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::History, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_history_handler_not_ready_when_history_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = false; + + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::History, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_history_handler_ready_when_not_loading_and_history_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + app.is_loading = false; + app + .data + .readarr_data + .history + .set_items(vec![ReadarrHistoryItem::default()]); + + let handler = HistoryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::History, + None, + ); + + assert!(handler.is_ready()); + } + + fn history_vec() -> Vec { + vec![ + ReadarrHistoryItem { + id: 3, + source_title: "test 1".into(), + event_type: ReadarrHistoryEventType::Grabbed, + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()), + ..ReadarrHistoryItem::default() + }, + ReadarrHistoryItem { + id: 2, + source_title: "test 2".into(), + event_type: ReadarrHistoryEventType::DownloadImported, + quality: QualityWrapper { + quality: Quality { + name: "AZW3".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + ..ReadarrHistoryItem::default() + }, + ReadarrHistoryItem { + id: 1, + source_title: "test 3".into(), + event_type: ReadarrHistoryEventType::BookFileDeleted, + quality: QualityWrapper { + quality: Quality { + name: "MOBI".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()), + ..ReadarrHistoryItem::default() + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/history/mod.rs b/src/handlers/readarr_handlers/history/mod.rs new file mode 100644 index 00000000..128349de --- /dev/null +++ b/src/handlers/readarr_handlers/history/mod.rs @@ -0,0 +1,164 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_clear_errors}; +use crate::matches_key; +use crate::models::Route; +use crate::models::readarr_models::ReadarrHistoryItem; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, HISTORY_BLOCKS}; +use crate::models::stateful_table::SortOption; + +#[cfg(test)] +#[path = "history_handler_tests.rs"] +mod history_handler_tests; + +pub(super) struct HistoryHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for HistoryHandler<'a, 'b> { + fn handle(&mut self) { + let history_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::History.into()) + .sorting_block(ActiveReadarrBlock::HistorySortPrompt.into()) + .sort_options(history_sorting_options()) + .searching_block(ActiveReadarrBlock::SearchHistory.into()) + .search_error_block(ActiveReadarrBlock::SearchHistoryError.into()) + .search_field_fn(|history| &history.source_title.text) + .filtering_block(ActiveReadarrBlock::FilterHistory.into()) + .filter_error_block(ActiveReadarrBlock::FilterHistoryError.into()) + .filter_field_fn(|history| &history.source_title.text); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.history, + history_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + HISTORY_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> Self { + HistoryHandler { + key, + app, + active_readarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.history.is_empty() + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::History { + handle_change_tab_left_right_keys(self.app, self.key) + } + } + + fn handle_submit(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::History { + self + .app + .push_navigation_stack(ActiveReadarrBlock::HistoryItemDetails.into()); + } + } + + fn handle_esc(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::HistoryItemDetails { + self.app.pop_navigation_stack(); + } else { + handle_clear_errors(self.app); + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + if self.active_readarr_block == ActiveReadarrBlock::History { + match self.key { + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ => (), + } + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} + +pub(in crate::handlers::readarr_handlers) fn history_sorting_options() +-> Vec> { + vec![ + SortOption { + name: "Source Title", + cmp_fn: Some(|a, b| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }), + }, + SortOption { + name: "Event Type", + cmp_fn: Some(|a, b| { + a.event_type + .to_string() + .to_lowercase() + .cmp(&b.event_type.to_string().to_lowercase()) + }), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| { + a.quality + .quality + .name + .to_lowercase() + .cmp(&b.quality.quality.name.to_lowercase()) + }), + }, + SortOption { + name: "Date", + cmp_fn: Some(|a, b| a.date.cmp(&b.date)), + }, + ] +} diff --git a/src/handlers/readarr_handlers/indexers/edit_indexer_handler.rs b/src/handlers/readarr_handlers/indexers/edit_indexer_handler.rs new file mode 100644 index 00000000..fef24458 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/edit_indexer_handler.rs @@ -0,0 +1,532 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::Route; +use crate::models::servarr_data::modals::EditIndexerModal; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, EDIT_INDEXER_BLOCKS}; +use crate::models::servarr_models::EditIndexerParams; +use crate::network::readarr_network::ReadarrEvent; +use crate::{ + handle_prompt_left_right_keys, handle_text_box_keys, handle_text_box_left_right_keys, matches_key, +}; + +#[cfg(test)] +#[path = "edit_indexer_handler_tests.rs"] +mod edit_indexer_handler_tests; + +pub(super) struct EditIndexerHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl EditIndexerHandler<'_, '_> { + fn build_edit_indexer_params(&mut self) -> EditIndexerParams { + let edit_indexer_modal = self + .app + .data + .readarr_data + .edit_indexer_modal + .take() + .expect("EditIndexerModal is None"); + let indexer_id = self.app.data.readarr_data.indexers.current_selection().id; + let tags = edit_indexer_modal.tags.text; + let EditIndexerModal { + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + priority, + .. + } = edit_indexer_modal; + + EditIndexerParams { + indexer_id, + name: Some(name.text), + enable_rss, + enable_automatic_search, + enable_interactive_search, + url: Some(url.text), + api_key: Some(api_key.text), + seed_ratio: Some(seed_ratio.text), + tags: None, + tag_input_string: Some(tags), + priority: Some(priority), + clear_tags: false, + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for EditIndexerHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + EDIT_INDEXER_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> EditIndexerHandler<'a, 'b> { + EditIndexerHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && self.app.data.readarr_data.edit_indexer_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerPrompt => { + self.app.data.readarr_data.selected_block.up(); + } + ActiveReadarrBlock::EditIndexerPriorityInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .priority += 1; + } + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerPrompt => { + self.app.data.readarr_data.selected_block.down(); + } + ActiveReadarrBlock::EditIndexerPriorityInput => { + let edit_indexer_modal = self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap(); + if edit_indexer_modal.priority > 1 { + edit_indexer_modal.priority -= 1; + } + } + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerNameInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .name + .scroll_home(); + } + ActiveReadarrBlock::EditIndexerUrlInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .url + .scroll_home(); + } + ActiveReadarrBlock::EditIndexerApiKeyInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .api_key + .scroll_home(); + } + ActiveReadarrBlock::EditIndexerSeedRatioInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .seed_ratio + .scroll_home(); + } + ActiveReadarrBlock::EditIndexerTagsInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .tags + .scroll_home(); + } + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerNameInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .name + .reset_offset(); + } + ActiveReadarrBlock::EditIndexerUrlInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .url + .reset_offset(); + } + ActiveReadarrBlock::EditIndexerApiKeyInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .api_key + .reset_offset(); + } + ActiveReadarrBlock::EditIndexerSeedRatioInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .seed_ratio + .reset_offset(); + } + ActiveReadarrBlock::EditIndexerTagsInput => { + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .tags + .reset_offset(); + } + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerPrompt => { + handle_prompt_left_right_keys!( + self, + ActiveReadarrBlock::EditIndexerConfirmPrompt, + readarr_data + ); + } + ActiveReadarrBlock::EditIndexerNameInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .name + ); + } + ActiveReadarrBlock::EditIndexerUrlInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .url + ); + } + ActiveReadarrBlock::EditIndexerApiKeyInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .api_key + ); + } + ActiveReadarrBlock::EditIndexerSeedRatioInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .seed_ratio + ); + } + ActiveReadarrBlock::EditIndexerTagsInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .tags + ); + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerPrompt => { + let selected_block = self.app.data.readarr_data.selected_block.get_active_block(); + match selected_block { + ActiveReadarrBlock::EditIndexerConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::EditIndexer(self.build_edit_indexer_params())); + self.app.should_refresh = true; + } else { + self.app.data.readarr_data.edit_indexer_modal = None; + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::EditIndexerNameInput + | ActiveReadarrBlock::EditIndexerUrlInput + | ActiveReadarrBlock::EditIndexerApiKeyInput + | ActiveReadarrBlock::EditIndexerSeedRatioInput + | ActiveReadarrBlock::EditIndexerTagsInput => { + self.app.push_navigation_stack(selected_block.into()); + self.app.ignore_special_keys_for_textbox_input = true; + } + ActiveReadarrBlock::EditIndexerPriorityInput => self + .app + .push_navigation_stack(ActiveReadarrBlock::EditIndexerPriorityInput.into()), + ActiveReadarrBlock::EditIndexerToggleEnableRss => { + let indexer = self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap(); + indexer.enable_rss = Some(!indexer.enable_rss.unwrap_or_default()); + } + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch => { + let indexer = self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap(); + indexer.enable_automatic_search = + Some(!indexer.enable_automatic_search.unwrap_or_default()); + } + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch => { + let indexer = self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap(); + indexer.enable_interactive_search = + Some(!indexer.enable_interactive_search.unwrap_or_default()); + } + _ => (), + } + } + ActiveReadarrBlock::EditIndexerNameInput + | ActiveReadarrBlock::EditIndexerUrlInput + | ActiveReadarrBlock::EditIndexerApiKeyInput + | ActiveReadarrBlock::EditIndexerSeedRatioInput + | ActiveReadarrBlock::EditIndexerTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + ActiveReadarrBlock::EditIndexerPriorityInput => self.app.pop_navigation_stack(), + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + self.app.data.readarr_data.edit_indexer_modal = None; + } + ActiveReadarrBlock::EditIndexerNameInput + | ActiveReadarrBlock::EditIndexerUrlInput + | ActiveReadarrBlock::EditIndexerApiKeyInput + | ActiveReadarrBlock::EditIndexerSeedRatioInput + | ActiveReadarrBlock::EditIndexerPriorityInput + | ActiveReadarrBlock::EditIndexerTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + _ => self.app.pop_navigation_stack(), + } + } + + fn handle_char_key_event(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditIndexerNameInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .name + ); + } + ActiveReadarrBlock::EditIndexerUrlInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .url + ); + } + ActiveReadarrBlock::EditIndexerApiKeyInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .api_key + ); + } + ActiveReadarrBlock::EditIndexerSeedRatioInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .seed_ratio + ); + } + ActiveReadarrBlock::EditIndexerTagsInput => { + handle_text_box_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .unwrap() + .tags + ); + } + ActiveReadarrBlock::EditIndexerPrompt + if self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::EditIndexerConfirmPrompt + && matches_key!(confirm, self.key) => + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::EditIndexer(self.build_edit_indexer_params())); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/indexers/edit_indexer_handler_tests.rs b/src/handlers/readarr_handlers/indexers/edit_indexer_handler_tests.rs new file mode 100644 index 00000000..2ba69b47 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/edit_indexer_handler_tests.rs @@ -0,0 +1,1925 @@ +#[cfg(test)] +mod tests { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_absent; + use crate::assert_modal_present; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_INDEXER_BLOCKS, + }; + use crate::models::servarr_models::{EditIndexerParams, Indexer}; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + mod test_handle_scroll_up_and_down { + use crate::app::App; + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::readarr::readarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_edit_indexer_priority_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditIndexerPriorityInput, + None, + ) + .handle(); + + if key == Key::Up { + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 2 + ); + } else { + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 1 + ); + + EditIndexerHandler::new( + Key::Up, + &mut app, + ActiveReadarrBlock::EditIndexerPriorityInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 2 + ); + + EditIndexerHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditIndexerPriorityInput, + None, + ) + .handle(); + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .priority, + 1 + ); + } + } + + #[rstest] + fn test_edit_indexer_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditIndexerNameInput + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch + ); + } + } + + #[rstest] + fn test_edit_indexer_prompt_scroll_no_op_when_not_ready( + #[values(Key::Up, Key::Down)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditIndexerToggleEnableRss + ); + } + } + + mod test_handle_home_end { + use std::sync::atomic::Ordering; + + use crate::app::App; + use crate::models::servarr_data::modals::EditIndexerModal; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_edit_indexer_name_input_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + name: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_url_input_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + url: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_api_key_input_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + api_key: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_seed_ratio_input_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + seed_ratio: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_tags_input_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + tags: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_left_right_action { + use std::sync::atomic::Ordering; + + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::readarr::readarr_data::{ + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + }; + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.y = EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1; + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + #[case( + 0, + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput + )] + #[case( + 1, + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerApiKeyInput + )] + #[case( + 2, + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerSeedRatioInput + )] + #[case( + 3, + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerTagsInput + )] + fn test_left_right_block_toggle_torrents( + #[values(Key::Left, Key::Right)] key: Key, + #[case] starting_y_index: usize, + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.y = starting_y_index; + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + left_block + ); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + right_block + ); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + left_block + ); + } + + #[rstest] + #[case( + 0, + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput + )] + #[case( + 1, + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerApiKeyInput + )] + #[case( + 2, + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerTagsInput + )] + #[case( + 3, + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerPriorityInput + )] + fn test_left_right_block_toggle_nzb( + #[values(Key::Left, Key::Right)] key: Key, + #[case] starting_y_index: usize, + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.y = starting_y_index; + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + left_block + ); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + right_block + ); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + left_block + ); + } + + #[rstest] + fn test_left_right_block_toggle_torren_empty_row_to_prompt_confirm( + #[values(Key::Left, Key::Right)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.y = 4; + app.data.readarr_data.prompt_confirm = false; + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditIndexerPriorityInput + ); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditIndexerConfirmPrompt + ); + + EditIndexerHandler::new(key, &mut app, ActiveReadarrBlock::EditIndexerPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditIndexerConfirmPrompt + ); + assert!(app.data.readarr_data.prompt_confirm); + } + + #[test] + fn test_edit_indexer_name_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + name: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_url_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + url: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_api_key_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + api_key: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_seed_ratio_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + seed_ratio: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_indexer_tags_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + tags: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::app::App; + use crate::assert_navigation_popped; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::{ + BlockSelectionState, + servarr_data::readarr::readarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + }; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_edit_indexer_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + assert_none!(app.data.readarr_data.edit_indexer_modal); + } + + #[test] + fn test_edit_indexer_prompt_prompt_confirmation_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); + let edit_indexer_modal = EditIndexerModal { + name: "Test Update".into(), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: "https://localhost:9696/1/".into(), + api_key: "test1234".into(), + seed_ratio: "1.3".into(), + tags: "usenet, testing".into(), + priority: 0, + }; + app.data.readarr_data.edit_indexer_modal = Some(edit_indexer_modal); + app.data.readarr_data.indexers.set_items(vec![indexer()]); + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(0), + ..EditIndexerParams::default() + }; + app.data.readarr_data.prompt_confirm = true; + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_modal_absent!(app.data.readarr_data.edit_indexer_modal); + assert!(app.should_refresh); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::EditIndexer(expected_edit_indexer_params)) + ); + } + + #[test] + fn test_edit_indexer_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.readarr_data.prompt_confirm = true; + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert_modal_present!(app.data.readarr_data.edit_indexer_modal); + assert!(!app.should_refresh); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[rstest] + #[case(0, 0, ActiveReadarrBlock::EditIndexerNameInput)] + #[case(0, 1, ActiveReadarrBlock::EditIndexerUrlInput)] + #[case(1, 1, ActiveReadarrBlock::EditIndexerApiKeyInput)] + #[case(2, 1, ActiveReadarrBlock::EditIndexerSeedRatioInput)] + #[case(3, 1, ActiveReadarrBlock::EditIndexerTagsInput)] + fn test_edit_indexer_prompt_submit_input_fields( + #[case] starting_y: usize, + #[case] starting_x: usize, + #[case] block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(starting_x, starting_y); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_pushed!(app, block.into()); + assert!(app.ignore_special_keys_for_textbox_input); + } + + #[test] + fn test_edit_indexer_priority_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, 4); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::EditIndexerPriorityInput.into()); + assert!(!app.ignore_special_keys_for_textbox_input); + } + + #[test] + fn test_edit_indexer_toggle_enable_rss_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, 1); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_rss + .unwrap() + ); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_rss + .unwrap() + ); + } + + #[test] + fn test_edit_indexer_toggle_enable_automatic_search_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, 2); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_automatic_search + .unwrap() + ); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_automatic_search + .unwrap() + ); + } + + #[test] + fn test_edit_indexer_toggle_enable_interactive_search_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, 3); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_interactive_search + .unwrap() + ); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditIndexerPrompt.into() + ); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .enable_interactive_search + .unwrap() + ); + } + + #[test] + fn test_edit_indexer_name_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + name: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerNameInput.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditIndexerPrompt.into()); + } + + #[test] + fn test_edit_indexer_url_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + url: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerUrlInput.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditIndexerPrompt.into()); + } + + #[test] + fn test_edit_indexer_api_key_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + api_key: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerApiKeyInput.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditIndexerPrompt.into()); + } + + #[test] + fn test_edit_indexer_seed_ratio_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + seed_ratio: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerSeedRatioInput.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditIndexerPrompt.into()); + } + + #[test] + fn test_edit_indexer_tags_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + tags: "Test".into(), + ..EditIndexerModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerTagsInput.into()); + + EditIndexerHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditIndexerPrompt.into()); + } + } + + mod test_handle_esc { + use super::*; + use crate::app::App; + use crate::assert_navigation_popped; + use crate::event::Key; + use crate::models::servarr_data::modals::EditIndexerModal; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_edit_indexer_prompt_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.edit_indexer_modal); + } + + #[rstest] + fn test_edit_indexer_input_fields_esc( + #[values( + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput, + ActiveReadarrBlock::EditIndexerApiKeyInput, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + ActiveReadarrBlock::EditIndexerTagsInput, + ActiveReadarrBlock::EditIndexerPriorityInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + app.ignore_special_keys_for_textbox_input = true; + + EditIndexerHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert!(!app.ignore_special_keys_for_textbox_input); + assert_some_eq_x!( + &app.data.readarr_data.edit_indexer_modal, + &EditIndexerModal::default() + ); + } + } + + mod test_handle_key_char { + use super::*; + use crate::app::App; + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::readarr::readarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + use pretty_assertions::{assert_eq, assert_str_eq}; + + #[test] + fn test_edit_indexer_name_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + name: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_url_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + url: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_api_key_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + api_key: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_seed_ratio_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + seed_ratio: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_tags_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal { + tags: "Test".into(), + ..EditIndexerModal::default() + }); + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .text, + "Tes" + ); + } + + #[test] + fn test_edit_indexer_name_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditIndexerNameInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .name + .text, + "a" + ); + } + + #[test] + fn test_edit_indexer_url_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditIndexerUrlInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .url + .text, + "a" + ); + } + + #[test] + fn test_edit_indexer_api_key_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditIndexerApiKeyInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .api_key + .text, + "a" + ); + } + + #[test] + fn test_edit_indexer_seed_ratio_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .seed_ratio + .text, + "a" + ); + } + + #[test] + fn test_edit_indexer_tags_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + EditIndexerHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditIndexerTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .text, + "a" + ); + } + + #[test] + fn test_edit_indexer_prompt_prompt_confirmation_confirm() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.len() - 1); + let edit_indexer_modal = EditIndexerModal { + name: "Test Update".into(), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: "https://localhost:9696/1/".into(), + api_key: "test1234".into(), + seed_ratio: "1.3".into(), + tags: "usenet, testing".into(), + priority: 0, + }; + app.data.readarr_data.edit_indexer_modal = Some(edit_indexer_modal); + app.data.readarr_data.indexers.set_items(vec![indexer()]); + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(0), + ..EditIndexerParams::default() + }; + + EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_modal_absent!(app.data.readarr_data.edit_indexer_modal); + assert!(app.should_refresh); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::EditIndexer(expected_edit_indexer_params)) + ); + } + } + + #[test] + fn test_edit_indexer_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if EDIT_INDEXER_BLOCKS.contains(&active_readarr_block) { + assert!(EditIndexerHandler::accepts(active_readarr_block)); + } else { + assert!(!EditIndexerHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_edit_indexer_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_edit_indexer_params() { + let mut app = App::test_default(); + let edit_indexer_modal = EditIndexerModal { + name: "Test Update".into(), + enable_rss: Some(false), + enable_automatic_search: Some(true), + enable_interactive_search: Some(false), + url: "https://localhost:9696/1/".into(), + api_key: "test1234".into(), + seed_ratio: "1.3".into(), + tags: "usenet, testing".into(), + priority: 0, + }; + app.data.readarr_data.edit_indexer_modal = Some(edit_indexer_modal); + let decoy_indexer = Indexer { id: 3, ..indexer() }; + app + .data + .readarr_data + .indexers + .set_items(vec![decoy_indexer, indexer()]); + app.data.readarr_data.indexers.select_index(Some(1)); + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(true), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(0), + ..EditIndexerParams::default() + }; + + let params = EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ) + .build_edit_indexer_params(); + + assert_eq!(params, expected_edit_indexer_params); + assert_modal_absent!(app.data.readarr_data.edit_indexer_modal); + } + + #[test] + fn test_edit_indexer_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + + let handler = EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_indexer_handler_is_not_ready_when_edit_indexer_modal_is_none() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = false; + + let handler = EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_indexer_handler_is_ready_when_edit_indexer_modal_is_some() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = false; + app.data.readarr_data.edit_indexer_modal = Some(EditIndexerModal::default()); + + let handler = EditIndexerHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::EditIndexerPrompt, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler.rs b/src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler.rs new file mode 100644 index 00000000..524daa11 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler.rs @@ -0,0 +1,221 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, INDEXER_SETTINGS_BLOCKS, +}; +use crate::models::servarr_models::IndexerSettings; +use crate::network::readarr_network::ReadarrEvent; +use crate::{handle_prompt_left_right_keys, matches_key}; + +#[cfg(test)] +#[path = "edit_indexer_settings_handler_tests.rs"] +mod edit_indexer_settings_handler_tests; + +pub(super) struct IndexerSettingsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl IndexerSettingsHandler<'_, '_> { + fn build_edit_indexer_settings_params(&mut self) -> IndexerSettings { + self + .app + .data + .readarr_data + .indexer_settings + .take() + .expect("IndexerSettings is None") + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for IndexerSettingsHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + INDEXER_SETTINGS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> IndexerSettingsHandler<'a, 'b> { + IndexerSettingsHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && self.app.data.readarr_data.indexer_settings.is_some() + } + + fn handle_scroll_up(&mut self) { + let indexer_settings = self + .app + .data + .readarr_data + .indexer_settings + .as_mut() + .unwrap(); + match self.active_readarr_block { + ActiveReadarrBlock::AllIndexerSettingsPrompt => { + self.app.data.readarr_data.selected_block.up(); + } + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput => { + indexer_settings.minimum_age += 1; + } + ActiveReadarrBlock::IndexerSettingsRetentionInput => { + indexer_settings.retention += 1; + } + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput => { + indexer_settings.maximum_size += 1; + } + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput => { + indexer_settings.rss_sync_interval += 1; + } + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + let indexer_settings = self + .app + .data + .readarr_data + .indexer_settings + .as_mut() + .unwrap(); + match self.active_readarr_block { + ActiveReadarrBlock::AllIndexerSettingsPrompt => { + self.app.data.readarr_data.selected_block.down() + } + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput => { + if indexer_settings.minimum_age > 0 { + indexer_settings.minimum_age -= 1; + } + } + ActiveReadarrBlock::IndexerSettingsRetentionInput => { + if indexer_settings.retention > 0 { + indexer_settings.retention -= 1; + } + } + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput => { + if indexer_settings.maximum_size > 0 { + indexer_settings.maximum_size -= 1; + } + } + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput + if indexer_settings.rss_sync_interval > 0 => + { + indexer_settings.rss_sync_interval -= 1; + } + _ => (), + } + } + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::AllIndexerSettingsPrompt { + handle_prompt_left_right_keys!( + self, + ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + readarr_data + ); + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AllIndexerSettingsPrompt => { + match self.app.data.readarr_data.selected_block.get_active_block() { + ActiveReadarrBlock::IndexerSettingsConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::EditAllIndexerSettings(self.build_edit_indexer_settings_params()), + ); + self.app.should_refresh = true; + } else { + self.app.data.readarr_data.indexer_settings = None; + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput + | ActiveReadarrBlock::IndexerSettingsRetentionInput + | ActiveReadarrBlock::IndexerSettingsMaximumSizeInput + | ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput => { + self.app.push_navigation_stack( + ( + self.app.data.readarr_data.selected_block.get_active_block(), + None, + ) + .into(), + ) + } + + _ => (), + } + } + + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput + | ActiveReadarrBlock::IndexerSettingsRetentionInput + | ActiveReadarrBlock::IndexerSettingsMaximumSizeInput + | ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput => self.app.pop_navigation_stack(), + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AllIndexerSettingsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + self.app.data.readarr_data.indexer_settings = None; + } + _ => self.app.pop_navigation_stack(), + } + } + + fn handle_char_key_event(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::AllIndexerSettingsPrompt + && self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::IndexerSettingsConfirmPrompt + && matches_key!(confirm, self.key) + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::EditAllIndexerSettings(self.build_edit_indexer_settings_params()), + ); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler_tests.rs b/src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler_tests.rs new file mode 100644 index 00000000..2e6b3fb3 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/edit_indexer_settings_handler_tests.rs @@ -0,0 +1,610 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_absent; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::indexers::edit_indexer_settings_handler::IndexerSettingsHandler; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, INDEXER_SETTINGS_BLOCKS, + }; + use crate::models::servarr_models::IndexerSettings; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer_settings; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS; + use crate::models::servarr_models::IndexerSettings; + + use super::*; + + macro_rules! test_i64_counter_scroll_value { + ($block:expr, $key:expr, $data_ref:ident, $negatives:literal) => { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + + IndexerSettingsHandler::new($key, &mut app, $block, None).handle(); + + if $key == Key::Up { + assert_eq!( + app + .data + .readarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + 1 + ); + } else { + if $negatives { + assert_eq!( + app + .data + .readarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + -1 + ); + } else { + assert_eq!( + app + .data + .readarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + 0 + ); + + IndexerSettingsHandler::new(Key::Up, &mut app, $block, None).handle(); + + assert_eq!( + app + .data + .readarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + 1 + ); + + IndexerSettingsHandler::new($key, &mut app, $block, None).handle(); + assert_eq!( + app + .data + .readarr_data + .indexer_settings + .as_ref() + .unwrap() + .$data_ref, + 0 + ); + } + } + }; + } + + #[rstest] + fn test_edit_indexer_settings_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + IndexerSettingsHandler::new( + key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput + ); + } + } + + #[rstest] + fn test_edit_indexer_settings_prompt_scroll_no_op_when_not_ready( + #[values(Key::Up, Key::Down)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + IndexerSettingsHandler::new( + key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::IndexerSettingsRetentionInput + ); + } + + #[rstest] + fn test_edit_indexer_settings_minimum_age_scroll(#[values(Key::Up, Key::Down)] key: Key) { + test_i64_counter_scroll_value!( + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + key, + minimum_age, + false + ); + } + + #[rstest] + fn test_edit_indexer_settings_retention_scroll(#[values(Key::Up, Key::Down)] key: Key) { + test_i64_counter_scroll_value!( + ActiveReadarrBlock::IndexerSettingsRetentionInput, + key, + retention, + false + ); + } + + #[rstest] + fn test_edit_indexer_settings_maximum_size_scroll(#[values(Key::Up, Key::Down)] key: Key) { + test_i64_counter_scroll_value!( + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + key, + maximum_size, + false + ); + } + + #[rstest] + fn test_edit_indexer_settings_rss_sync_interval_scroll(#[values(Key::Up, Key::Down)] key: Key) { + test_i64_counter_scroll_value!( + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput, + key, + rss_sync_interval, + false + ); + } + } + + mod test_handle_left_right_action { + use crate::models::servarr_data::readarr::readarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS; + + use crate::models::BlockSelectionState; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.y = INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1; + + IndexerSettingsHandler::new( + key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + IndexerSettingsHandler::new( + key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::{ + assert_navigation_popped, + models::{ + BlockSelectionState, + servarr_data::readarr::readarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS, + servarr_models::IndexerSettings, + }, + network::readarr_network::ReadarrEvent, + }; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_edit_indexer_settings_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + + IndexerSettingsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + assert_none!(app.data.readarr_data.indexer_settings); + } + + #[test] + fn test_edit_indexer_settings_prompt_prompt_confirmation_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.indexer_settings = Some(indexer_settings()); + app.data.readarr_data.prompt_confirm = true; + + IndexerSettingsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::EditAllIndexerSettings(indexer_settings()) + ); + assert_modal_absent!(app.data.readarr_data.indexer_settings); + assert!(app.should_refresh); + } + + #[test] + fn test_edit_indexer_settings_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + app.data.readarr_data.prompt_confirm = true; + + IndexerSettingsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AllIndexerSettingsPrompt.into() + ); + assert!(!app.should_refresh); + } + + #[rstest] + #[case(ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, 0)] + #[case(ActiveReadarrBlock::IndexerSettingsRetentionInput, 1)] + #[case(ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, 2)] + #[case(ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput, 3)] + fn test_edit_indexer_settings_prompt_submit_selected_block( + #[case] selected_block: ActiveReadarrBlock, + #[case] y_index: usize, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + IndexerSettingsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_navigation_pushed!(app, selected_block.into()); + } + + #[rstest] + fn test_edit_indexer_settings_prompt_submit_selected_block_no_op_when_not_ready( + #[values(0, 1, 2, 3, 4)] y_index: usize, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + IndexerSettingsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AllIndexerSettingsPrompt.into() + ); + } + + #[rstest] + fn test_edit_indexer_settings_selected_block_submit( + #[values( + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveReadarrBlock::IndexerSettingsRetentionInput, + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + IndexerSettingsHandler::new(SUBMIT_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + } + } + + mod test_handle_esc { + use rstest::rstest; + + use crate::models::servarr_models::IndexerSettings; + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_edit_indexer_settings_prompt_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + + IndexerSettingsHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.indexer_settings); + } + + #[rstest] + fn test_edit_indexer_settings_selected_blocks_esc( + #[values( + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveReadarrBlock::IndexerSettingsRetentionInput, + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + + IndexerSettingsHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_some_eq_x!( + &app.data.readarr_data.indexer_settings, + &IndexerSettings::default() + ); + } + } + + mod test_handle_key_char { + use crate::{ + assert_navigation_popped, + models::{ + BlockSelectionState, servarr_data::readarr::readarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS, + }, + network::readarr_network::ReadarrEvent, + }; + + use super::*; + + #[test] + fn test_edit_indexer_settings_prompt_prompt_confirmation_confirm() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, INDEXER_SETTINGS_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.indexer_settings = Some(indexer_settings()); + + IndexerSettingsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::EditAllIndexerSettings(indexer_settings()) + ); + assert_modal_absent!(app.data.readarr_data.indexer_settings); + assert!(app.should_refresh); + } + } + + #[test] + fn test_indexer_settings_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if INDEXER_SETTINGS_BLOCKS.contains(&active_readarr_block) { + assert!(IndexerSettingsHandler::accepts(active_readarr_block)); + } else { + assert!(!IndexerSettingsHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_indexer_settings_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = IndexerSettingsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_edit_indexer_settings_params() { + let mut app = App::test_default(); + app.data.readarr_data.indexer_settings = Some(indexer_settings()); + + let actual_indexer_settings = IndexerSettingsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ) + .build_edit_indexer_settings_params(); + + assert_eq!(actual_indexer_settings, indexer_settings()); + assert_modal_absent!(app.data.readarr_data.indexer_settings); + } + + #[test] + fn test_edit_indexer_settings_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + + let handler = IndexerSettingsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_indexer_settings_handler_not_ready_when_indexer_settings_is_none() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = false; + + let handler = IndexerSettingsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_indexer_settings_handler_ready_when_not_loading_and_indexer_settings_is_some() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = false; + app.data.readarr_data.indexer_settings = Some(IndexerSettings::default()); + + let handler = IndexerSettingsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/readarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/readarr_handlers/indexers/indexers_handler_tests.rs new file mode 100644 index 00000000..9ad8265b --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/indexers_handler_tests.rs @@ -0,0 +1,750 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::indexers::IndexersHandler; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, INDEXERS_BLOCKS, + }; + use crate::models::servarr_models::Indexer; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + use crate::test_handler_delegation; + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use super::*; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_indexer_prompt() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Indexers, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::DeleteIndexerPrompt.into()); + } + + #[test] + fn test_delete_indexer_prompt_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Indexers, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Indexers.into()); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_indexers_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(5); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::RootFolders.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::RootFolders.into()); + } + + #[rstest] + fn test_indexers_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(5); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::System.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::System.into()); + } + + #[rstest] + fn test_left_right_delete_indexer_prompt_toggle( + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + + IndexersHandler::new(key, &mut app, ActiveReadarrBlock::DeleteIndexerPrompt, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + IndexersHandler::new(key, &mut app, ActiveReadarrBlock::DeleteIndexerPrompt, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use super::*; + use crate::assert_navigation_popped; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::readarr::readarr_data::{ + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, ReadarrData, + }; + use crate::models::servarr_models::{Indexer, IndexerField}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + use bimap::BiMap; + use pretty_assertions::assert_eq; + use serde_json::{Number, Value}; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[rstest] + fn test_edit_indexer_submit(#[values(true, false)] torrent_protocol: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + let protocol = if torrent_protocol { + "torrent".to_owned() + } else { + "usenet".to_owned() + }; + let decoy_protocol = if torrent_protocol { + "usenet".to_owned() + } else { + "torrent".to_owned() + }; + let mut expected_edit_indexer_modal = EditIndexerModal { + name: "Test".into(), + enable_rss: Some(true), + enable_automatic_search: Some(false), + enable_interactive_search: Some(true), + url: "https://test.com".into(), + api_key: "1234".into(), + tags: "usenet, test".into(), + ..EditIndexerModal::default() + }; + let mut readarr_data = ReadarrData { + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), + ..ReadarrData::default() + }; + let mut fields = vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(Value::String("https://test.com".to_owned())), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(Value::String("1234".to_owned())), + }, + ]; + + if torrent_protocol { + fields.push(IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(Value::from(1.2f64)), + }); + expected_edit_indexer_modal.seed_ratio = "1.2".into(); + } + + let indexer = Indexer { + name: Some("Test".to_owned()), + enable_rss: true, + enable_automatic_search: false, + enable_interactive_search: true, + protocol, + tags: vec![Number::from(1), Number::from(2)], + fields: Some(fields), + ..Indexer::default() + }; + let decoy_indexer = Indexer { + name: Some("Decoy".to_owned()), + enable_rss: false, + enable_automatic_search: true, + enable_interactive_search: false, + protocol: decoy_protocol, + tags: vec![Number::from(2)], + fields: Some(vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(Value::String("https://decoy.com".to_owned())), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(Value::String("9999".to_owned())), + }, + ]), + ..Indexer::default() + }; + readarr_data + .indexers + .set_items(vec![decoy_indexer, indexer]); + readarr_data.indexers.select_index(Some(1)); + app.data.readarr_data = readarr_data; + + IndexersHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Indexers, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::EditIndexerPrompt.into()); + assert_some_eq_x!( + &app.data.readarr_data.edit_indexer_modal, + &EditIndexerModal::from(&app.data.readarr_data) + ); + assert_some_eq_x!( + &app.data.readarr_data.edit_indexer_modal, + &expected_edit_indexer_modal + ); + if torrent_protocol { + assert_eq!( + app.data.readarr_data.selected_block.blocks, + EDIT_INDEXER_TORRENT_SELECTION_BLOCKS + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.blocks, + EDIT_INDEXER_NZB_SELECTION_BLOCKS + ); + } + } + + #[test] + fn test_edit_indexer_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Indexers, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Indexers.into()); + assert_none!(app.data.readarr_data.edit_indexer_modal); + } + + #[test] + fn test_delete_indexer_prompt_confirm_submit() { + let mut app = App::test_default(); + app.data.readarr_data.indexers.set_items(vec![indexer()]); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteIndexerPrompt.into()); + + IndexersHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteIndexerPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::DeleteIndexer(8) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + } + + #[test] + fn test_prompt_decline_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteIndexerPrompt.into()); + + IndexersHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteIndexerPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + } + } + + mod test_handle_esc { + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_delete_indexer_prompt_block_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteIndexerPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + + IndexersHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::DeleteIndexerPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + fn test_test_indexer_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.readarr_data.indexer_test_errors = Some("test result".to_owned()); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::TestIndexer.into()); + + IndexersHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::TestIndexer, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_none!(app.data.readarr_data.indexer_test_errors); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + + IndexersHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::Indexers, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use super::*; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + use crate::{ + assert_navigation_popped, + models::servarr_data::readarr::readarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS, + network::readarr_network::ReadarrEvent, + }; + + #[test] + fn test_refresh_indexers_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::Indexers.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_indexers_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Indexers.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_indexer_settings_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.settings.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + INDEXER_SETTINGS_SELECTION_BLOCKS + ); + } + + #[test] + fn test_indexer_settings_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.settings.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Indexers.into()); + } + + #[test] + fn test_test_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.test.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::TestIndexer.into()); + } + + #[test] + fn test_test_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.test.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Indexers.into()); + } + + #[test] + fn test_test_all_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.test_all.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::TestAllIndexers.into()); + } + + #[test] + fn test_test_all_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.test_all.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Indexers.into()); + } + + #[test] + fn test_delete_indexer_prompt_confirm() { + let mut app = App::test_default(); + app.data.readarr_data.indexers.set_items(vec![indexer()]); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteIndexerPrompt.into()); + + IndexersHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::DeleteIndexerPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::DeleteIndexer(8) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + } + } + + #[rstest] + fn test_delegates_edit_indexer_blocks_to_edit_indexer_handler( + #[values( + ActiveReadarrBlock::EditIndexerPrompt, + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ActiveReadarrBlock::EditIndexerApiKeyInput, + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerUrlInput, + ActiveReadarrBlock::EditIndexerTagsInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + IndexersHandler, + ActiveReadarrBlock::Indexers, + active_readarr_block + ); + } + + #[rstest] + fn test_delegates_indexer_settings_blocks_to_indexer_settings_handler( + #[values( + ActiveReadarrBlock::AllIndexerSettingsPrompt, + ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveReadarrBlock::IndexerSettingsRetentionInput, + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + IndexersHandler, + ActiveReadarrBlock::Indexers, + active_readarr_block + ); + } + + #[test] + fn test_delegates_test_all_indexers_block_to_test_all_indexers_handler() { + test_handler_delegation!( + IndexersHandler, + ActiveReadarrBlock::Indexers, + ActiveReadarrBlock::TestAllIndexers + ); + } + + #[test] + fn test_indexers_handler_accepts() { + let mut indexers_blocks = Vec::new(); + indexers_blocks.extend(INDEXERS_BLOCKS); + indexers_blocks.extend(INDEXER_SETTINGS_BLOCKS); + indexers_blocks.extend(EDIT_INDEXER_BLOCKS); + indexers_blocks.push(ActiveReadarrBlock::TestAllIndexers); + + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if indexers_blocks.contains(&active_readarr_block) { + assert!(IndexersHandler::accepts(active_readarr_block)); + } else { + assert!(!IndexersHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_indexers_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = IndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_extract_indexer_id() { + let mut app = App::test_default(); + let decoy_indexer = Indexer { id: 3, ..indexer() }; + app + .data + .readarr_data + .indexers + .set_items(vec![decoy_indexer, indexer()]); + app.data.readarr_data.indexers.select_index(Some(1)); + + let indexer_id = IndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ) + .extract_indexer_id(); + + assert_eq!(indexer_id, 8); + } + + #[test] + fn test_indexers_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = true; + + let handler = IndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_indexers_handler_not_ready_when_indexers_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = false; + + let handler = IndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_indexers_handler_ready_when_not_loading_and_indexers_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.is_loading = false; + app + .data + .readarr_data + .indexers + .set_items(vec![Indexer::default()]); + + let handler = IndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Indexers, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/readarr_handlers/indexers/mod.rs b/src/handlers/readarr_handlers/indexers/mod.rs new file mode 100644 index 00000000..52b8c431 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/mod.rs @@ -0,0 +1,217 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::readarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler; +use crate::handlers::readarr_handlers::indexers::edit_indexer_settings_handler::IndexerSettingsHandler; +use crate::handlers::readarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, +}; +use crate::models::{BlockSelectionState, Route}; +use crate::network::readarr_network::ReadarrEvent; + +mod edit_indexer_handler; +mod edit_indexer_settings_handler; +mod test_all_indexers_handler; + +#[cfg(test)] +#[path = "indexers_handler_tests.rs"] +mod indexers_handler_tests; + +pub(super) struct IndexersHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl IndexersHandler<'_, '_> { + fn extract_indexer_id(&self) -> i64 { + self.app.data.readarr_data.indexers.current_selection().id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for IndexersHandler<'a, 'b> { + fn handle(&mut self) { + let indexers_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::Indexers.into()); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.indexers, + indexers_table_handling_config, + ) { + match self.active_readarr_block { + _ if EditIndexerHandler::accepts(self.active_readarr_block) => { + EditIndexerHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle() + } + _ if IndexerSettingsHandler::accepts(self.active_readarr_block) => { + IndexerSettingsHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle() + } + _ if TestAllIndexersHandler::accepts(self.active_readarr_block) => { + TestAllIndexersHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle() + } + _ => self.handle_key_event(), + } + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + EditIndexerHandler::accepts(active_block) + || IndexerSettingsHandler::accepts(active_block) + || TestAllIndexersHandler::accepts(active_block) + || INDEXERS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> IndexersHandler<'a, 'b> { + IndexersHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.indexers.is_empty() + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::Indexers { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteIndexerPrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::Indexers => handle_change_tab_left_right_keys(self.app, self.key), + ActiveReadarrBlock::DeleteIndexerPrompt => handle_prompt_toggle(self.app, self.key), + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteIndexerPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteIndexer(self.extract_indexer_id())); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::Indexers => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + self.app.data.readarr_data.edit_indexer_modal = Some((&self.app.data.readarr_data).into()); + let protocol = &self + .app + .data + .readarr_data + .indexers + .current_selection() + .protocol; + if protocol == "torrent" { + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + } else { + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); + } + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteIndexerPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + ActiveReadarrBlock::TestIndexer => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.indexer_test_errors = None; + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::Indexers => match self.key { + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ if matches_key!(test, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::TestIndexer.into()); + } + _ if matches_key!(test_all, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + } + _ if matches_key!(settings, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + } + _ => (), + }, + ActiveReadarrBlock::DeleteIndexerPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteIndexer(self.extract_indexer_id())); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/indexers/test_all_indexers_handler.rs b/src/handlers/readarr_handlers/indexers/test_all_indexers_handler.rs new file mode 100644 index 00000000..178620c4 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/test_all_indexers_handler.rs @@ -0,0 +1,108 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + +#[cfg(test)] +#[path = "test_all_indexers_handler_tests.rs"] +mod test_all_indexers_handler_tests; + +pub(super) struct TestAllIndexersHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl TestAllIndexersHandler<'_, '_> {} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for TestAllIndexersHandler<'a, 'b> { + fn handle(&mut self) { + let indexer_test_all_results_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::TestAllIndexers.into()); + + if !handle_table( + self, + |app| { + app + .data + .readarr_data + .indexer_test_all_results + .as_mut() + .unwrap() + }, + indexer_test_all_results_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + active_block == ActiveReadarrBlock::TestAllIndexers + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> TestAllIndexersHandler<'a, 'b> { + TestAllIndexersHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + let table_is_ready = if let Some(table) = &self.app.data.readarr_data.indexer_test_all_results { + !table.is_empty() + } else { + false + }; + + !self.app.is_loading && table_is_ready + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) {} + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::TestAllIndexers { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.indexer_test_all_results = None; + } + } + + fn handle_char_key_event(&mut self) {} + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/indexers/test_all_indexers_handler_tests.rs b/src/handlers/readarr_handlers/indexers/test_all_indexers_handler_tests.rs new file mode 100644 index 00000000..ba912014 --- /dev/null +++ b/src/handlers/readarr_handlers/indexers/test_all_indexers_handler_tests.rs @@ -0,0 +1,133 @@ +#[cfg(test)] +mod tests { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_popped; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::StatefulTable; + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + mod test_handle_esc { + use super::*; + + const ESC_KEY: crate::event::Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_test_all_indexers_prompt_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + app.data.readarr_data.indexer_test_all_results = Some(StatefulTable::default()); + + TestAllIndexersHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::TestAllIndexers, None) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Indexers.into()); + assert_none!(app.data.readarr_data.indexer_test_all_results); + } + } + + #[test] + fn test_test_all_indexers_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if active_readarr_block == ActiveReadarrBlock::TestAllIndexers { + assert!(TestAllIndexersHandler::accepts(active_readarr_block)); + } else { + assert!(!TestAllIndexersHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_test_all_indexers_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = TestAllIndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_test_all_indexers_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + app.is_loading = true; + + let handler = TestAllIndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::TestAllIndexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_test_all_indexers_handler_not_ready_when_results_is_none() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + app.is_loading = false; + + let handler = TestAllIndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::TestAllIndexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_test_all_indexers_handler_not_ready_when_results_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + app.is_loading = false; + app.data.readarr_data.indexer_test_all_results = Some(StatefulTable::default()); + + let handler = TestAllIndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::TestAllIndexers, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_test_all_indexers_handler_ready_when_not_loading_and_results_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + app.is_loading = false; + let mut results = StatefulTable::default(); + results.set_items(vec![IndexerTestResultModalItem::default()]); + app.data.readarr_data.indexer_test_all_results = Some(results); + + let handler = TestAllIndexersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::TestAllIndexers, + None, + ); + + assert!(handler.is_ready()); + } +} diff --git a/src/handlers/readarr_handlers/library/add_author_handler.rs b/src/handlers/readarr_handlers/library/add_author_handler.rs new file mode 100644 index 00000000..fec7dd40 --- /dev/null +++ b/src/handlers/readarr_handlers/library/add_author_handler.rs @@ -0,0 +1,616 @@ +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::readarr_models::{AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult}; +use crate::models::servarr_data::readarr::modals::AddAuthorModal; +use crate::models::servarr_data::readarr::readarr_data::{ + ADD_AUTHOR_BLOCKS, ADD_AUTHOR_SELECTION_BLOCKS, ActiveReadarrBlock, +}; +use crate::models::{BlockSelectionState, Route, Scrollable}; +use crate::network::readarr_network::ReadarrEvent; +use crate::{App, Key, handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; + +#[cfg(test)] +#[path = "add_author_handler_tests.rs"] +mod add_author_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct AddAuthorHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl AddAuthorHandler<'_, '_> { + fn build_add_author_body(&mut self) -> AddAuthorBody { + let add_author_modal = self + .app + .data + .readarr_data + .add_author_modal + .take() + .expect("AddAuthorModal is None"); + let tags = add_author_modal.tags.text; + let AddAuthorModal { + root_folder_list, + monitor_list, + monitor_new_items_list, + quality_profile_list, + metadata_profile_list, + .. + } = add_author_modal; + let (foreign_author_id, author_name) = { + let AddAuthorSearchResult { + foreign_author_id, + author_name, + .. + } = self + .app + .data + .readarr_data + .add_searched_authors + .as_ref() + .unwrap() + .current_selection(); + (foreign_author_id.clone(), author_name.text.clone()) + }; + let quality_profile = quality_profile_list.current_selection(); + let quality_profile_id = *self + .app + .data + .readarr_data + .quality_profile_map + .iter() + .filter(|(_, value)| *value == quality_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + let metadata_profile = metadata_profile_list.current_selection(); + let metadata_profile_id = *self + .app + .data + .readarr_data + .metadata_profile_map + .iter() + .filter(|(_, value)| *value == metadata_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + let path = root_folder_list.current_selection().path.clone(); + let monitor = *monitor_list.current_selection(); + let monitor_new_items = *monitor_new_items_list.current_selection(); + + AddAuthorBody { + foreign_author_id, + author_name, + monitored: true, + root_folder_path: path, + quality_profile_id, + metadata_profile_id, + tags: Vec::new(), + images: Vec::new(), + tag_input_string: Some(tags), + add_options: AddAuthorOptions { + monitor, + monitor_new_items, + search_for_missing_books: true, + }, + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AddAuthorHandler<'a, 'b> { + fn handle(&mut self) { + let add_author_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::AddAuthorSearchResults.into()); + + if !handle_table( + self, + |app| { + app + .data + .readarr_data + .add_searched_authors + .as_mut() + .expect("add_searched_authors should be initialized") + }, + add_author_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + ADD_AUTHOR_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> AddAuthorHandler<'a, 'b> { + AddAuthorHandler { + key, + app, + active_readarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + } + + fn handle_scroll_up(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSelectMonitor => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_up(), + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_up(), + ActiveReadarrBlock::AddAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_up(), + ActiveReadarrBlock::AddAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_up(), + ActiveReadarrBlock::AddAuthorSelectRootFolder => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .scroll_up(), + ActiveReadarrBlock::AddAuthorPrompt => self.app.data.readarr_data.selected_block.up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSelectMonitor => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_down(), + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_down(), + ActiveReadarrBlock::AddAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_down(), + ActiveReadarrBlock::AddAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_down(), + ActiveReadarrBlock::AddAuthorSelectRootFolder => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .scroll_down(), + ActiveReadarrBlock::AddAuthorPrompt => self.app.data.readarr_data.selected_block.down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSelectMonitor => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_top(), + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_to_top(), + ActiveReadarrBlock::AddAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_top(), + ActiveReadarrBlock::AddAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_top(), + ActiveReadarrBlock::AddAuthorSelectRootFolder => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .scroll_to_top(), + ActiveReadarrBlock::AddAuthorSearchInput => self + .app + .data + .readarr_data + .add_author_search + .as_mut() + .unwrap() + .scroll_home(), + ActiveReadarrBlock::AddAuthorTagsInput => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .tags + .scroll_home(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSelectMonitor => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddAuthorSelectRootFolder => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddAuthorSearchInput => self + .app + .data + .readarr_data + .add_author_search + .as_mut() + .unwrap() + .reset_offset(), + ActiveReadarrBlock::AddAuthorTagsInput => self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .tags + .reset_offset(), + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorPrompt => handle_prompt_toggle(self.app, self.key), + ActiveReadarrBlock::AddAuthorSearchInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .add_author_search + .as_mut() + .unwrap() + ) + } + ActiveReadarrBlock::AddAuthorTagsInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .tags + ) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSearchInput + if !self + .app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .text + .is_empty() => + { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + self.app.ignore_special_keys_for_textbox_input = false; + } + ActiveReadarrBlock::AddAuthorSearchResults + if self.app.data.readarr_data.add_searched_authors.is_some() => + { + let foreign_author_id = self + .app + .data + .readarr_data + .add_searched_authors + .as_ref() + .unwrap() + .current_selection() + .foreign_author_id + .clone(); + + if self + .app + .data + .readarr_data + .authors + .items + .iter() + .any(|author| author.foreign_author_id == foreign_author_id) + { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AddAuthorAlreadyInLibrary.into()); + } else { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + self.app.data.readarr_data.add_author_modal = Some((&self.app.data.readarr_data).into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + } + } + ActiveReadarrBlock::AddAuthorPrompt => { + match self.app.data.readarr_data.selected_block.get_active_block() { + ActiveReadarrBlock::AddAuthorConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::AddAuthor(self.build_add_author_body())); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::AddAuthorSelectMonitor + | ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + | ActiveReadarrBlock::AddAuthorSelectQualityProfile + | ActiveReadarrBlock::AddAuthorSelectMetadataProfile + | ActiveReadarrBlock::AddAuthorSelectRootFolder => self.app.push_navigation_stack( + self + .app + .data + .readarr_data + .selected_block + .get_active_block() + .into(), + ), + ActiveReadarrBlock::AddAuthorTagsInput => { + self.app.push_navigation_stack( + self + .app + .data + .readarr_data + .selected_block + .get_active_block() + .into(), + ); + self.app.ignore_special_keys_for_textbox_input = true; + } + _ => (), + } + } + ActiveReadarrBlock::AddAuthorSelectMonitor + | ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + | ActiveReadarrBlock::AddAuthorSelectQualityProfile + | ActiveReadarrBlock::AddAuthorSelectMetadataProfile + | ActiveReadarrBlock::AddAuthorSelectRootFolder => self.app.pop_navigation_stack(), + ActiveReadarrBlock::AddAuthorTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSearchInput => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.add_author_search = None; + self.app.ignore_special_keys_for_textbox_input = false; + } + ActiveReadarrBlock::AddAuthorSearchResults + | ActiveReadarrBlock::AddAuthorEmptySearchResults => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.add_searched_authors = None; + self.app.ignore_special_keys_for_textbox_input = true; + } + ActiveReadarrBlock::AddAuthorPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.add_author_modal = None; + self.app.data.readarr_data.prompt_confirm = false; + } + ActiveReadarrBlock::AddAuthorSelectMonitor + | ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + | ActiveReadarrBlock::AddAuthorSelectQualityProfile + | ActiveReadarrBlock::AddAuthorSelectMetadataProfile + | ActiveReadarrBlock::AddAuthorAlreadyInLibrary + | ActiveReadarrBlock::AddAuthorSelectRootFolder => self.app.pop_navigation_stack(), + ActiveReadarrBlock::AddAuthorTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::AddAuthorSearchInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .add_author_search + .as_mut() + .unwrap() + ) + } + ActiveReadarrBlock::AddAuthorTagsInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .tags + ) + } + ActiveReadarrBlock::AddAuthorPrompt + if self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::AddAuthorConfirmPrompt + && matches_key!(confirm, key) => + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::AddAuthor(self.build_add_author_body())); + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/add_author_handler_tests.rs b/src/handlers/readarr_handlers/library/add_author_handler_tests.rs new file mode 100644 index 00000000..928536b5 --- /dev/null +++ b/src/handlers/readarr_handlers/library/add_author_handler_tests.rs @@ -0,0 +1,1842 @@ +#[cfg(test)] +mod tests { + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use std::sync::atomic::Ordering; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_absent; + use crate::assert_modal_present; + use crate::assert_navigation_popped; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::add_author_handler::AddAuthorHandler; + use crate::models::readarr_models::{ + AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, MonitorType, + NewItemMonitorType, + }; + use crate::models::servarr_data::readarr::modals::AddAuthorModal; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_AUTHOR_BLOCKS, ADD_AUTHOR_SELECTION_BLOCKS, ActiveReadarrBlock, + }; + use crate::models::servarr_models::RootFolder; + use crate::models::stateful_table::StatefulTable; + use crate::models::{BlockSelectionState, HorizontallyScrollableText}; + use crate::simple_stateful_iterable_vec; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::ADD_AUTHOR_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_add_author_select_monitor_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let monitor_vec = Vec::from_iter(MonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_vec.clone()); + + if key == Key::Up { + for i in (0..monitor_vec.len()).rev() { + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_vec[i] + ); + } + } else { + for i in 0..monitor_vec.len() { + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_vec[(i + 1) % monitor_vec.len()] + ); + } + } + } + + #[rstest] + fn test_add_author_select_monitor_new_items_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let monitor_new_items_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .set_items(monitor_new_items_vec.clone()); + + if key == Key::Up { + for i in (0..monitor_new_items_vec.len()).rev() { + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_new_items_vec[i] + ); + } + } else { + for i in 0..monitor_new_items_vec.len() { + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_new_items_vec[(i + 1) % monitor_new_items_vec.len()] + ); + } + } + } + + #[rstest] + fn test_add_author_select_quality_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 2" + ); + + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[rstest] + fn test_add_author_select_metadata_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 2" + ); + + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 1" + ); + } + + #[rstest] + fn test_add_author_select_root_folder_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .set_items(simple_stateful_iterable_vec!(RootFolder, String, path)); + + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectRootFolder, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .root_folder_list + .current_selection() + .path, + "Test 2" + ); + + AddAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectRootFolder, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .root_folder_list + .current_selection() + .path, + "Test 1" + ); + } + + #[rstest] + fn test_add_author_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + AddAuthorHandler::new(key, &mut app, ActiveReadarrBlock::AddAuthorPrompt, None).handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddAuthorSelectRootFolder + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + ); + } + } + + #[rstest] + fn test_add_author_prompt_scroll_no_op_when_not_ready(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_loading = true; + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + AddAuthorHandler::new(key, &mut app, ActiveReadarrBlock::AddAuthorPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddAuthorSelectMonitor + ); + } + } + + mod test_handle_home_end { + use pretty_assertions::assert_eq; + + use crate::extended_stateful_iterable_vec; + + use super::*; + + #[test] + fn test_add_author_select_monitor_home_end() { + let monitor_vec = Vec::from_iter(MonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_vec.clone()); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_vec[monitor_vec.len() - 1] + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_vec[0] + ); + } + + #[test] + fn test_add_author_select_monitor_new_items_home_end() { + let monitor_new_items_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .set_items(monitor_new_items_vec.clone()); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_new_items_vec[monitor_new_items_vec.len() - 1] + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_new_items_vec[0] + ); + } + + #[test] + fn test_add_author_select_quality_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 3" + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_add_author_select_metadata_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 3" + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_add_author_select_root_folder_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .set_items(extended_stateful_iterable_vec!(RootFolder, String, path)); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectRootFolder, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .root_folder_list + .current_selection() + .path, + "Test 3" + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorSelectRootFolder, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .root_folder_list + .current_selection() + .path, + "Test 1" + ); + } + + #[test] + fn test_add_author_search_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + app.data.readarr_data.add_author_search = Some("Test".into()); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 4 + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_author_tags_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal { + tags: "Test".into(), + ..AddAuthorModal::default() + }); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 4 + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + AddAuthorHandler::new(key, &mut app, ActiveReadarrBlock::AddAuthorPrompt, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + AddAuthorHandler::new(key, &mut app, ActiveReadarrBlock::AddAuthorPrompt, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[test] + fn test_add_author_search_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + app.data.readarr_data.add_author_search = Some("Test".into()); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_author_tags_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal { + tags: "Test".into(), + ..AddAuthorModal::default() + }); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use super::*; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::assert_eq; + use rstest::rstest; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_add_author_search_input_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.add_author_search = Some("test".into()); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_navigation_pushed!(app, ActiveReadarrBlock::AddAuthorSearchResults.into()); + } + + #[test] + fn test_add_author_search_input_submit_noop_on_empty_search() { + let mut app = App::test_default(); + app.data.readarr_data.add_author_search = Some(HorizontallyScrollableText::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + app.ignore_special_keys_for_textbox_input = true; + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert!(app.ignore_special_keys_for_textbox_input); + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AddAuthorSearchInput.into() + ); + } + + #[test] + fn test_add_author_search_results_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + let mut add_searched_authors = StatefulTable::default(); + let mut decoy_search_result = add_author_search_result(); + decoy_search_result.foreign_author_id = "decoy-foreign-id".to_owned(); + decoy_search_result.author_name = "Decoy Author".into(); + add_searched_authors.set_items(vec![decoy_search_result, add_author_search_result()]); + add_searched_authors.select_index(Some(1)); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + app.data.readarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "eBook".to_owned())]); + app.data.readarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchResults, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AddAuthorPrompt.into() + ); + assert_modal_present!(app.data.readarr_data.add_author_modal); + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddAuthorSelectRootFolder + ); + } + + #[test] + fn test_add_author_search_results_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(vec![add_author_search_result()]); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchResults, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AddAuthorSearchResults.into() + ); + assert_modal_absent!(app.data.readarr_data.add_author_modal); + } + + #[test] + fn test_add_author_search_results_submit_does_nothing_on_empty_table() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchResults, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AddAuthorSearchResults.into() + ); + } + + #[test] + fn test_add_author_search_results_submit_author_already_in_library() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + let mut add_searched_authors = StatefulTable::default(); + let mut decoy_search_result = add_author_search_result(); + decoy_search_result.foreign_author_id = "decoy-foreign-id".to_owned(); + decoy_search_result.author_name = "Decoy Author".into(); + add_searched_authors.set_items(vec![decoy_search_result, add_author_search_result()]); + add_searched_authors.select_index(Some(1)); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + app + .data + .readarr_data + .authors + .set_items(vec![author(SEARCHED_FOREIGN_AUTHOR_ID)]); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchResults, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AddAuthorAlreadyInLibrary.into()); + assert_modal_absent!(app.data.readarr_data.add_author_modal); + } + + #[test] + fn test_add_author_search_results_submit_author_not_in_library() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + let mut add_searched_authors = StatefulTable::default(); + let mut decoy_search_result = add_author_search_result(); + decoy_search_result.foreign_author_id = OTHER_FOREIGN_AUTHOR_ID.to_owned(); + decoy_search_result.author_name = "Decoy Author".into(); + add_searched_authors.set_items(vec![decoy_search_result, add_author_search_result()]); + add_searched_authors.select_index(Some(1)); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + app + .data + .readarr_data + .authors + .set_items(vec![author(OTHER_FOREIGN_AUTHOR_ID)]); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchResults, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AddAuthorPrompt.into()); + assert_modal_present!(app.data.readarr_data.add_author_modal); + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddAuthorSelectRootFolder + ); + } + + #[test] + fn test_add_author_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, ADD_AUTHOR_SELECTION_BLOCKS.len() - 1); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_add_author_confirm_prompt_prompt_confirm_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, ADD_AUTHOR_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.prompt_confirm = true; + let mut add_searched_authors = StatefulTable::default(); + let mut decoy_search_result = add_author_search_result(); + decoy_search_result.foreign_author_id = "decoy-foreign-id".to_owned(); + decoy_search_result.author_name = "Decoy Author".into(); + add_searched_authors.set_items(vec![decoy_search_result, add_author_search_result()]); + add_searched_authors.select_index(Some(1)); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + app.data.readarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "eBook".to_owned())]); + app.data.readarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal { + tags: "usenet, testing".into(), + ..AddAuthorModal::default() + }); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_list + .state + .select(Some(1)); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .state + .select(Some(1)); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec!["Any".to_owned(), "eBook".to_owned()]); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .state + .select(Some(1)); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec!["None".to_owned(), "Standard".to_owned()]); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .state + .select(Some(1)); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .set_items(vec![ + RootFolder { + path: "/decoy".to_owned(), + ..RootFolder::default() + }, + RootFolder { + path: "/books".to_owned(), + ..RootFolder::default() + }, + ]); + app + .data + .readarr_data + .add_author_modal + .as_mut() + .unwrap() + .root_folder_list + .state + .select(Some(1)); + let expected_add_author_body = AddAuthorBody { + foreign_author_id: SEARCHED_FOREIGN_AUTHOR_ID.to_string(), + author_name: "Test Author".to_string(), + monitored: true, + root_folder_path: "/books".to_string(), + quality_profile_id: 2222, + metadata_profile_id: 4444, + tags: Vec::default(), + images: Vec::new(), + tag_input_string: Some("usenet, testing".to_owned()), + add_options: AddAuthorOptions { + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::None, + search_for_missing_books: true, + }, + }; + let expected_readarr_event = ReadarrEvent::AddAuthor(expected_add_author_body); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorSearchResults.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_readarr_event + ); + assert_modal_absent!(app.data.readarr_data.add_author_modal); + } + + #[rstest] + #[case(ActiveReadarrBlock::AddAuthorSelectRootFolder, 0)] + #[case(ActiveReadarrBlock::AddAuthorSelectMonitor, 1)] + #[case(ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, 2)] + #[case(ActiveReadarrBlock::AddAuthorSelectQualityProfile, 3)] + #[case(ActiveReadarrBlock::AddAuthorSelectMetadataProfile, 4)] + #[case(ActiveReadarrBlock::AddAuthorTagsInput, 5)] + fn test_add_author_prompt_selected_block_submit( + #[case] block: ActiveReadarrBlock, + #[case] y_index: usize, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + AddAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_pushed!(app, block.into()); + } + + #[rstest] + fn test_add_author_prompt_selecting_preferences_blocks_submit( + #[values( + ActiveReadarrBlock::AddAuthorSelectRootFolder, + ActiveReadarrBlock::AddAuthorSelectMonitor, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + ActiveReadarrBlock::AddAuthorTagsInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AddAuthorHandler::new(SUBMIT_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorPrompt.into()); + + if active_readarr_block == ActiveReadarrBlock::AddAuthorTagsInput { + assert!(!app.ignore_special_keys_for_textbox_input); + } + } + } + + mod test_handle_esc { + use rstest::rstest; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_add_author_search_input_esc(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.data.readarr_data.add_author_search = Some("test".into()); + app.ignore_special_keys_for_textbox_input = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + + AddAuthorHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_modal_absent!(app.data.readarr_data.add_author_search); + } + + #[rstest] + fn test_add_author_search_results_esc( + #[values( + ActiveReadarrBlock::AddAuthorSearchResults, + ActiveReadarrBlock::AddAuthorEmptySearchResults + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + app.push_navigation_stack(active_readarr_block.into()); + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(vec![add_author_search_result()]); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + + AddAuthorHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorSearchInput.into()); + assert_modal_absent!(app.data.readarr_data.add_searched_authors); + assert!(app.ignore_special_keys_for_textbox_input); + } + + #[test] + fn test_add_author_already_in_library_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorAlreadyInLibrary.into()); + + AddAuthorHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorAlreadyInLibrary, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorSearchResults.into()); + } + + #[test] + fn test_add_author_prompt_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + app.data.readarr_data.prompt_confirm = true; + + AddAuthorHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::AddAuthorPrompt, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorSearchResults.into()); + assert_modal_absent!(app.data.readarr_data.add_author_modal); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[test] + fn test_add_author_tags_input_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorTagsInput.into()); + app.ignore_special_keys_for_textbox_input = true; + + AddAuthorHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorPrompt.into()); + assert!(!app.ignore_special_keys_for_textbox_input); + } + + #[rstest] + fn test_add_author_selecting_preferences_blocks_esc( + #[values( + ActiveReadarrBlock::AddAuthorSelectMonitor, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + ActiveReadarrBlock::AddAuthorSelectRootFolder + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + + AddAuthorHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorPrompt.into()); + } + } + + mod test_handle_key_char { + use super::*; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::assert_str_eq; + + #[test] + fn test_add_author_search_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_search = Some("Test".into()); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .text, + "Tes" + ); + } + + #[test] + fn test_add_author_search_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_search = Some(HorizontallyScrollableText::default()); + + AddAuthorHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_search + .as_ref() + .unwrap() + .text, + "a" + ); + } + + #[test] + fn test_add_author_tags_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal { + tags: "Test".into(), + ..AddAuthorModal::default() + }); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .tags + .text, + "Tes" + ); + } + + #[test] + fn test_add_author_tags_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.add_author_modal = Some(AddAuthorModal::default()); + + AddAuthorHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::AddAuthorTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_author_modal + .as_ref() + .unwrap() + .tags + .text, + "a" + ); + } + + #[test] + fn test_add_author_confirm_prompt_confirm_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, ADD_AUTHOR_SELECTION_BLOCKS.len() - 1); + let mut add_searched_authors = StatefulTable::default(); + let mut decoy_search_result = add_author_search_result(); + decoy_search_result.foreign_author_id = "decoy-foreign-id".to_owned(); + decoy_search_result.author_name = "Decoy Author".into(); + add_searched_authors.set_items(vec![decoy_search_result, add_author_search_result()]); + add_searched_authors.select_index(Some(1)); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + app.data.readarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "eBook".to_owned())]); + app.data.readarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); + let mut add_author_modal = AddAuthorModal { + tags: "usenet, testing".into(), + ..AddAuthorModal::default() + }; + add_author_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_author_modal.monitor_list.state.select(Some(1)); + add_author_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_author_modal + .monitor_new_items_list + .state + .select(Some(1)); + add_author_modal + .quality_profile_list + .set_items(vec!["Any".to_owned(), "eBook".to_owned()]); + add_author_modal.quality_profile_list.state.select(Some(1)); + add_author_modal + .metadata_profile_list + .set_items(vec!["None".to_owned(), "Standard".to_owned()]); + add_author_modal.metadata_profile_list.state.select(Some(1)); + add_author_modal.root_folder_list.set_items(vec![ + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + accessible: true, + free_space: 21990232555520, + unmapped_folders: None, + }, + ]); + add_author_modal.root_folder_list.state.select(Some(1)); + app.data.readarr_data.add_author_modal = Some(add_author_modal); + let expected_add_author_body = AddAuthorBody { + foreign_author_id: SEARCHED_FOREIGN_AUTHOR_ID.to_string(), + author_name: "Test Author".to_string(), + monitored: true, + root_folder_path: "/nfs2".to_string(), + quality_profile_id: 2222, + metadata_profile_id: 4444, + tags: Vec::default(), + images: Vec::new(), + tag_input_string: Some("usenet, testing".to_owned()), + add_options: AddAuthorOptions { + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::None, + search_for_missing_books: true, + }, + }; + let expected_readarr_event = ReadarrEvent::AddAuthor(expected_add_author_body); + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::AddAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddAuthorSearchResults.into()); + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_readarr_event + ); + assert_modal_absent!(app.data.readarr_data.add_author_modal); + } + } + + #[test] + fn test_add_author_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|readarr_block| { + if ADD_AUTHOR_BLOCKS.contains(&readarr_block) { + assert!( + AddAuthorHandler::accepts(readarr_block), + "{readarr_block} is not accepted by the AddAuthorHandler" + ); + } else { + assert!(!AddAuthorHandler::accepts(readarr_block)); + } + }); + } + + #[rstest] + fn test_add_author_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_add_author_search_no_panic_on_none_search_result() { + let mut app = App::test_default(); + app.data.readarr_data.add_searched_authors = None; + + AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchResults, + None, + ) + .handle(); + } + + #[test] + fn test_add_author_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + + let handler = AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_add_author_handler_is_ready_when_not_loading() { + let mut app = App::test_default(); + app.is_loading = false; + + let handler = AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddAuthorSearchInput, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_build_add_author_body() { + let mut app = App::test_default(); + let mut add_author_modal = AddAuthorModal { + tags: "usenet, testing".into(), + ..AddAuthorModal::default() + }; + add_author_modal.root_folder_list.set_items(vec![ + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + accessible: true, + free_space: 21990232555520, + unmapped_folders: None, + }, + ]); + add_author_modal.root_folder_list.state.select(Some(1)); + add_author_modal + .quality_profile_list + .set_items(vec!["Any".to_owned(), "eBook".to_owned()]); + add_author_modal.quality_profile_list.state.select(Some(1)); + add_author_modal + .metadata_profile_list + .set_items(vec!["None".to_owned(), "Standard".to_owned()]); + add_author_modal.metadata_profile_list.state.select(Some(1)); + add_author_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_author_modal.monitor_list.state.select(Some(1)); + add_author_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_author_modal + .monitor_new_items_list + .state + .select(Some(1)); + app.data.readarr_data.add_author_modal = Some(add_author_modal); + app.data.readarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "eBook".to_owned())]); + app.data.readarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "None".to_owned()), (4444, "Standard".to_owned())]); + let mut add_searched_authors = StatefulTable::default(); + let mut decoy_search_result = add_author_search_result(); + decoy_search_result.foreign_author_id = "decoy-foreign-id".to_owned(); + decoy_search_result.author_name = "Decoy Author".into(); + add_searched_authors.set_items(vec![decoy_search_result, add_author_search_result()]); + add_searched_authors.select_index(Some(1)); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + let expected_add_author_body = AddAuthorBody { + foreign_author_id: SEARCHED_FOREIGN_AUTHOR_ID.to_string(), + author_name: "Test Author".into(), + monitored: true, + root_folder_path: "/nfs2".to_string(), + quality_profile_id: 2222, + metadata_profile_id: 4444, + tags: Vec::default(), + images: Vec::new(), + tag_input_string: Some("usenet, testing".to_owned()), + add_options: AddAuthorOptions { + monitor: MonitorType::Future, + monitor_new_items: NewItemMonitorType::None, + search_for_missing_books: true, + }, + }; + + let add_author_body = AddAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddAuthorPrompt, + None, + ) + .build_add_author_body(); + + assert_eq!(add_author_body, expected_add_author_body); + assert_modal_absent!(app.data.readarr_data.add_author_modal); + } + + const SEARCHED_FOREIGN_AUTHOR_ID: &str = "aaaa-1111"; + const OTHER_FOREIGN_AUTHOR_ID: &str = "bbbb-2222"; + + fn add_author_search_result() -> AddAuthorSearchResult { + AddAuthorSearchResult { + foreign_author_id: SEARCHED_FOREIGN_AUTHOR_ID.to_owned(), + author_name: "Test Author".into(), + status: AuthorStatus::Continuing, + ..AddAuthorSearchResult::default() + } + } + + fn author(foreign_author_id: &str) -> Author { + Author { + id: 1, + author_name: "Test Author".into(), + foreign_author_id: foreign_author_id.to_owned(), + status: AuthorStatus::Continuing, + ..Author::default() + } + } +} diff --git a/src/handlers/readarr_handlers/library/author_details_handler.rs b/src/handlers/readarr_handlers/library/author_details_handler.rs new file mode 100644 index 00000000..38a4db60 --- /dev/null +++ b/src/handlers/readarr_handlers/library/author_details_handler.rs @@ -0,0 +1,483 @@ +use super::author_overview_handler::AuthorOverviewHandler; +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::history::history_sorting_options; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::readarr_models::{Book, ReadarrHistoryItem, ReadarrRelease}; +use crate::models::servarr_data::readarr::modals::AuthorOverviewModal; +use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, DELETE_BOOK_SELECTION_BLOCKS, + EDIT_AUTHOR_SELECTION_BLOCKS, +}; +use crate::models::servarr_models::ReleaseDownloadBody; +use crate::models::stateful_table::SortOption; +use crate::models::{BlockSelectionState, Route, ScrollableText}; +use crate::network::readarr_network::ReadarrEvent; +use serde_json::Number; + +#[cfg(test)] +#[path = "author_details_handler_tests.rs"] +mod author_details_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct AuthorDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl AuthorDetailsHandler<'_, '_> { + fn extract_author_id(&self) -> i64 { + self.app.data.readarr_data.authors.current_selection().id + } + + fn extract_book_id(&self) -> i64 { + self.app.data.readarr_data.books.current_selection().id + } + + fn build_author_overview_modal(&mut self) { + let overview = self + .app + .data + .readarr_data + .authors + .current_selection() + .overview + .clone() + .unwrap_or_default(); + + self.app.data.readarr_data.author_overview_modal = Some(AuthorOverviewModal { + overview: ScrollableText::with_string(overview), + }); + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AuthorDetailsHandler<'a, 'b> { + fn handle(&mut self) { + if AuthorOverviewHandler::accepts(self.active_readarr_block) { + return AuthorOverviewHandler::new( + self.key, + self.app, + self.active_readarr_block, + self._context, + ) + .handle(); + } + + let books_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::AuthorDetails.into()) + .searching_block(ActiveReadarrBlock::SearchBooks.into()) + .search_error_block(ActiveReadarrBlock::SearchBooksError.into()) + .search_field_fn(|book: &Book| &book.title.text); + + let author_history_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::AuthorHistory.into()) + .sorting_block(ActiveReadarrBlock::AuthorHistorySortPrompt.into()) + .sort_options(history_sorting_options()) + .searching_block(ActiveReadarrBlock::SearchAuthorHistory.into()) + .search_error_block(ActiveReadarrBlock::SearchAuthorHistoryError.into()) + .search_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text) + .filtering_block(ActiveReadarrBlock::FilterAuthorHistory.into()) + .filter_error_block(ActiveReadarrBlock::FilterAuthorHistoryError.into()) + .filter_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text); + + let author_releases_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::ManualAuthorSearch.into()) + .sorting_block(ActiveReadarrBlock::ManualAuthorSearchSortPrompt.into()) + .sort_options(releases_sorting_options()); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.books, + books_table_handling_config, + ) && !handle_table( + self, + |app| &mut app.data.readarr_data.author_history, + author_history_table_handling_config, + ) && !handle_table( + self, + |app| &mut app.data.readarr_data.author_releases, + author_releases_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + AuthorOverviewHandler::accepts(active_block) || AUTHOR_DETAILS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> AuthorDetailsHandler<'a, 'b> { + AuthorDetailsHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + match self.active_readarr_block { + ActiveReadarrBlock::AuthorHistory => !self.app.data.readarr_data.author_history.is_empty(), + ActiveReadarrBlock::ManualAuthorSearch => { + !self.app.data.readarr_data.author_releases.is_empty() + } + _ => true, + } + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::AuthorDetails { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AuthorDetails + | ActiveReadarrBlock::AuthorHistory + | ActiveReadarrBlock::ManualAuthorSearch => match self.key { + _ if matches_key!(left, self.key) => { + self.app.data.readarr_data.author_info_tabs.previous(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .readarr_data + .author_info_tabs + .get_active_route(), + ); + } + _ if matches_key!(right, self.key) => { + self.app.data.readarr_data.author_info_tabs.next(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .readarr_data + .author_info_tabs + .get_active_route(), + ); + } + _ => (), + }, + ActiveReadarrBlock::UpdateAndScanAuthorPrompt + | ActiveReadarrBlock::AutomaticallySearchAuthorPrompt + | ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + handle_prompt_toggle(self.app, self.key); + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AuthorDetails if !self.app.data.readarr_data.books.is_empty() => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + } + ActiveReadarrBlock::AuthorHistory + if !self.app.data.readarr_data.author_history.is_empty() => + { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AuthorHistoryDetails.into()); + } + ActiveReadarrBlock::ManualAuthorSearch => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into()); + } + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + let ReadarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .readarr_data + .author_releases + .current_selection() + .clone(); + let params = ReleaseDownloadBody { guid, indexer_id }; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DownloadRelease(params)); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::TriggerAutomaticAuthorSearch(self.extract_author_id()), + ); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::UpdateAndScanAuthorPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::UpdateAndScanAuthor(self.extract_author_id())); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::UpdateAndScanAuthorPrompt + | ActiveReadarrBlock::AutomaticallySearchAuthorPrompt + | ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + ActiveReadarrBlock::AuthorHistoryDetails => { + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::AuthorHistory => { + if self + .app + .data + .readarr_data + .author_history + .filtered_items + .is_some() + { + self.app.data.readarr_data.author_history.reset_filter(); + } else { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.reset_author_info_tabs(); + } + } + ActiveReadarrBlock::AuthorDetails | ActiveReadarrBlock::ManualAuthorSearch => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.reset_author_info_tabs(); + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::AuthorDetails => match self.key { + _ if matches_key!(refresh, key) => self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()), + _ if matches_key!(auto_search, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt.into()); + } + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::UpdateAndScanAuthorPrompt.into()); + } + _ if matches_key!(edit, key) => { + self.app.push_navigation_stack( + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(self.active_readarr_block), + ) + .into(), + ); + self.app.data.readarr_data.edit_author_modal = Some((&self.app.data.readarr_data).into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + } + _ if matches_key!(view, key) => { + self.build_author_overview_modal(); + self + .app + .push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + } + _ if matches_key!(toggle_monitoring, key) + && !self.app.data.readarr_data.books.is_empty() => + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::ToggleBookMonitoring(self.extract_book_id())); + + self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()); + } + _ => (), + }, + ActiveReadarrBlock::AuthorHistory | ActiveReadarrBlock::ManualAuthorSearch => { + match self.key { + _ if matches_key!(refresh, key) => self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()), + _ if matches_key!(auto_search, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt.into()); + } + _ if matches_key!(edit, key) => { + self.app.push_navigation_stack( + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(self.active_readarr_block), + ) + .into(), + ); + self.app.data.readarr_data.edit_author_modal = + Some((&self.app.data.readarr_data).into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + } + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::UpdateAndScanAuthorPrompt.into()); + } + _ => (), + } + } + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::TriggerAutomaticAuthorSearch(self.extract_author_id()), + ); + + self.app.pop_navigation_stack(); + } + } + ActiveReadarrBlock::UpdateAndScanAuthorPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::UpdateAndScanAuthor(self.extract_author_id())); + + self.app.pop_navigation_stack(); + } + } + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + let ReadarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .readarr_data + .author_releases + .current_selection() + .clone(); + let params = ReleaseDownloadBody { guid, indexer_id }; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DownloadRelease(params)); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} + +pub(in crate::handlers::readarr_handlers::library) fn releases_sorting_options() +-> Vec> { + vec![ + SortOption { + name: "Source", + cmp_fn: Some(|a, b| a.protocol.cmp(&b.protocol)), + }, + SortOption { + name: "Age", + cmp_fn: Some(|a, b| a.age.cmp(&b.age)), + }, + SortOption { + name: "Rejected", + cmp_fn: Some(|a, b| a.rejected.cmp(&b.rejected)), + }, + SortOption { + name: "Title", + cmp_fn: Some(|a, b| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }), + }, + SortOption { + name: "Indexer", + cmp_fn: Some(|a, b| a.indexer.to_lowercase().cmp(&b.indexer.to_lowercase())), + }, + SortOption { + name: "Size", + cmp_fn: Some(|a, b| a.size.cmp(&b.size)), + }, + SortOption { + name: "Peers", + cmp_fn: Some(|a, b| { + let default_number = Number::from(i64::MAX); + let seeder_a = a + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + let seeder_b = b + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + + seeder_a.cmp(&seeder_b) + }), + }, + SortOption { + name: "Quality", + cmp_fn: Some(|a, b| a.quality.cmp(&b.quality)), + }, + ] +} diff --git a/src/handlers/readarr_handlers/library/author_details_handler_tests.rs b/src/handlers/readarr_handlers/library/author_details_handler_tests.rs new file mode 100644 index 00000000..034da500 --- /dev/null +++ b/src/handlers/readarr_handlers/library/author_details_handler_tests.rs @@ -0,0 +1,1445 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use serde_json::Number; + use std::cmp::Ordering; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::{ + AuthorDetailsHandler, releases_sorting_options, + }; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{ReadarrHistoryItem, ReadarrRelease}; + use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, + }; + use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::test_handler_delegation; + + mod test_handle_delete { + use super::*; + use crate::assert_delete_prompt; + use crate::event::Key; + use crate::models::readarr_models::Book; + use crate::models::servarr_data::readarr::readarr_data::DELETE_BOOK_SELECTION_BLOCKS; + use pretty_assertions::assert_eq; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_book_delete() { + let mut app = App::test_default(); + app.data.readarr_data.books.set_items(vec![Book::default()]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + assert_delete_prompt!( + AuthorDetailsHandler, + app, + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::DeleteBookPrompt + ); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + DELETE_BOOK_SELECTION_BLOCKS + ); + } + } + + mod test_handle_left_right_action { + use rstest::rstest; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::AuthorDetailsHandler; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use pretty_assertions::assert_eq; + + #[rstest] + fn test_left_right_prompt_toggle( + #[values( + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt + )] + active_readarr_block: ActiveReadarrBlock, + #[values(Key::Left, Key::Right)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + AuthorDetailsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + #[case(ActiveReadarrBlock::AuthorDetails, ActiveReadarrBlock::AuthorHistory)] + #[case( + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + #[case( + ActiveReadarrBlock::ManualAuthorSearch, + ActiveReadarrBlock::AuthorDetails + )] + fn test_author_details_tabs_left_right_action( + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + #[values(true, false)] is_loading: bool, + ) { + let mut app = App::test_default_fully_populated(); + app.is_loading = is_loading; + app.push_navigation_stack(right_block.into()); + app.data.readarr_data.author_info_tabs.index = app + .data + .readarr_data + .author_info_tabs + .tabs + .iter() + .position(|tab_route| tab_route.route == right_block.into()) + .unwrap_or_default(); + + AuthorDetailsHandler::new(DEFAULT_KEYBINDINGS.left.key, &mut app, right_block, None).handle(); + + assert_eq!( + app.get_current_route(), + app.data.readarr_data.author_info_tabs.get_active_route() + ); + assert_navigation_pushed!(app, left_block.into()); + + AuthorDetailsHandler::new(DEFAULT_KEYBINDINGS.right.key, &mut app, left_block, None).handle(); + + assert_eq!( + app.get_current_route(), + app.data.readarr_data.author_info_tabs.get_active_route() + ); + assert_navigation_pushed!(app, right_block.into()); + } + } + + mod test_handle_submit { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::AuthorDetailsHandler; + use crate::models::readarr_models::{Author, ReadarrHistoryItem}; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::torrent_release; + use crate::{assert_navigation_popped, assert_navigation_pushed}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[rstest] + #[case( + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ReadarrEvent::TriggerAutomaticAuthorSearch(1) + )] + #[case( + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, + ReadarrEvent::UpdateAndScanAuthor(1) + )] + fn test_author_details_prompt_confirm_submit( + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + ) { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + AuthorDetailsHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + } + + #[rstest] + fn test_author_details_prompt_decline_submit( + #[values( + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ActiveReadarrBlock::UpdateAndScanAuthorPrompt + )] + prompt_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + AuthorDetailsHandler::new(SUBMIT_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_author_history_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .author_history + .set_items(vec![ReadarrHistoryItem::default()]); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorHistoryDetails.into()); + } + + #[test] + fn test_author_history_submit_no_op_when_author_history_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistory.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorHistory.into() + ); + } + + #[test] + fn test_author_history_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + } + + #[test] + fn test_manual_author_search_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .author_releases + .set_items(vec![torrent_release()]); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualAuthorSearch, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into() + ); + } + + #[test] + fn test_manual_author_search_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualAuthorSearch, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::ManualAuthorSearch.into() + ); + } + + #[test] + fn test_manual_author_search_confirm_prompt_confirm_submit() { + let mut app = App::test_default(); + let release = torrent_release(); + let mut decoy_release = torrent_release(); + decoy_release.guid = "decoy-guid".to_owned(); + decoy_release.indexer_id = 999; + app + .data + .readarr_data + .author_releases + .set_items(vec![decoy_release, release.clone()]); + app.data.readarr_data.author_releases.select_index(Some(1)); + app.data.readarr_data.prompt_confirm = true; + let expected_action = ReadarrEvent::DownloadRelease(ReleaseDownloadBody { + guid: release.guid, + indexer_id: release.indexer_id, + }); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::ManualAuthorSearch.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + } + + #[test] + fn test_manual_author_search_confirm_prompt_decline_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .author_releases + .set_items(vec![torrent_release()]); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into()); + + AuthorDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::ManualAuthorSearch.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + } + + mod test_handle_esc { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_popped; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::AuthorDetailsHandler; + use crate::models::readarr_models::ReadarrHistoryItem; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::StatefulTable; + use pretty_assertions::assert_eq; + use ratatui::widgets::TableState; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_author_history_details_block_esc() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistory.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistoryDetails.into()); + + AuthorDetailsHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::AuthorHistoryDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorHistory.into()); + } + + #[test] + fn test_author_history_esc_resets_filter_if_one_is_set_instead_of_closing_the_window() { + let mut app = App::test_default(); + app.data.readarr_data.author_history = StatefulTable { + filter: Some("Test".into()), + filtered_items: Some(vec![ReadarrHistoryItem::default()]), + filtered_state: Some(TableState::default()), + ..StatefulTable::default() + }; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistory.into()); + + AuthorDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::AuthorHistory, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorHistory.into() + ); + assert_none!(app.data.readarr_data.author_history.filter); + assert_none!(app.data.readarr_data.author_history.filtered_items); + assert_none!(app.data.readarr_data.author_history.filtered_state); + } + + #[rstest] + fn test_author_details_esc( + #[values( + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt + )] + prompt_block: ActiveReadarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(prompt_block.into()); + + AuthorDetailsHandler::new(ESC_KEY, &mut app, prompt_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + } + + #[rstest] + fn test_author_details_blocks_esc( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.author_history.filter = None; + app.data.readarr_data.author_history.filtered_items = None; + app.data.readarr_data.author_history.filtered_state = None; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_is_empty!(app.data.readarr_data.books); + assert_is_empty!(app.data.readarr_data.author_releases); + assert_is_empty!(app.data.readarr_data.author_history); + assert_eq!(app.data.readarr_data.author_info_tabs.index, 0); + } + } + + mod test_handle_char_key_event { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_details_handler::AuthorDetailsHandler; + use crate::handlers::readarr_handlers::library::author_overview_handler::AuthorOverviewHandler; + use crate::models::readarr_models::Author; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_AUTHOR_SELECTION_BLOCKS, + }; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::torrent_release; + use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped}; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + + #[rstest] + fn test_author_details_edit_key( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(active_readarr_block) + ) + .into() + ); + assert_modal_present!(app.data.readarr_data.edit_author_modal); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + EDIT_AUTHOR_SELECTION_BLOCKS + ); + } + + #[rstest] + fn test_author_details_edit_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + } + + #[test] + fn test_author_details_view_key_opens_the_author_overview() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.author_overview_modal = None; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorOverview.into()); + assert_modal_present!(app.data.readarr_data.author_overview_modal); + } + + #[test] + fn test_author_details_view_key_builds_the_overview_from_the_selected_author() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.author_overview_modal = None; + let author = app.data.readarr_data.authors.current_selection().clone(); + let mut decoy_author = author.clone(); + decoy_author.id = 999; + decoy_author.overview = Some("decoy author overview".to_owned()); + app + .data + .readarr_data + .authors + .set_items(vec![decoy_author, author]); + app.data.readarr_data.authors.select_index(Some(1)); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + let overview = &app + .data + .readarr_data + .author_overview_modal + .as_ref() + .unwrap() + .overview; + assert_eq!(overview.offset, 0); + assert_eq!( + overview.get_text(), + app + .data + .readarr_data + .authors + .current_selection() + .overview + .clone() + .unwrap() + ); + } + + #[test] + fn test_author_details_view_key_for_an_author_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.author_overview_modal = None; + let mut author = app.data.readarr_data.authors.current_selection().clone(); + author.overview = None; + app.data.readarr_data.authors.set_items(vec![author]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorOverview.into()); + assert_str_eq!( + app + .data + .readarr_data + .author_overview_modal + .as_ref() + .unwrap() + .overview + .get_text(), + "" + ); + } + + #[test] + fn test_author_details_view_key_reopens_the_author_overview_at_the_top() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.author_overview_modal = None; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorOverview.into()); + assert_eq!( + app + .data + .readarr_data + .author_overview_modal + .as_ref() + .unwrap() + .overview + .offset, + 0 + ); + } + + #[test] + fn test_author_details_view_key_no_op_when_not_ready() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.author_overview_modal = None; + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorDetails.into() + ); + assert_modal_absent!(app.data.readarr_data.author_overview_modal); + } + + #[test] + fn test_author_details_toggle_monitoring_key() { + let mut app = App::test_default_fully_populated(); + let book = app.data.readarr_data.books.current_selection().clone(); + let mut decoy_book = book.clone(); + decoy_book.id = 999; + app + .data + .readarr_data + .books + .set_items(vec![decoy_book, book]); + app.data.readarr_data.books.select_index(Some(1)); + app.is_routing = false; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorDetails.into() + ); + assert!(app.data.readarr_data.prompt_confirm); + assert!(app.is_routing); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::ToggleBookMonitoring(1) + ); + } + + #[test] + fn test_author_details_toggle_monitoring_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.prompt_confirm = false; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AuthorDetails.into() + ); + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_author_details_toggle_monitoring_key_no_op_when_books_empty() { + let mut app = App::test_default(); + app.data.readarr_data.authors.set_items(vec![Author { + id: 1, + ..Author::default() + }]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[rstest] + fn test_author_details_auto_search_key( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt.into() + ); + } + + #[rstest] + fn test_author_details_auto_search_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + } + + #[rstest] + fn test_author_details_update_key( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::UpdateAndScanAuthorPrompt.into()); + } + + #[rstest] + fn test_author_details_update_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + } + + #[rstest] + fn test_author_details_refresh_key( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.is_routing = false; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!(app, active_readarr_block.into()); + assert!(app.is_routing); + } + + #[rstest] + fn test_author_details_refresh_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::ManualAuthorSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(active_readarr_block.into()); + app.is_routing = false; + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + assert!(!app.is_routing); + } + + #[rstest] + #[case( + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ReadarrEvent::TriggerAutomaticAuthorSearch(1) + )] + #[case( + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, + ReadarrEvent::UpdateAndScanAuthor(1) + )] + fn test_author_details_prompt_confirm_key( + #[case] prompt_block: ActiveReadarrBlock, + #[case] expected_action: ReadarrEvent, + #[values(ActiveReadarrBlock::AuthorDetails, ActiveReadarrBlock::AuthorHistory)] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + app.push_navigation_stack(prompt_block.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + prompt_block, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, active_readarr_block.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + } + + #[test] + fn test_manual_author_search_confirm_prompt_confirm_key() { + let mut app = App::test_default(); + let release = torrent_release(); + let mut decoy_release = torrent_release(); + decoy_release.guid = "decoy-guid".to_owned(); + decoy_release.indexer_id = 999; + app + .data + .readarr_data + .author_releases + .set_items(vec![decoy_release, release.clone()]); + app.data.readarr_data.author_releases.select_index(Some(1)); + let expected_action = ReadarrEvent::DownloadRelease(ReleaseDownloadBody { + guid: release.guid, + indexer_id: release.indexer_id, + }); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_navigation_popped!(app, ActiveReadarrBlock::ManualAuthorSearch.into()); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &expected_action + ); + } + + #[test] + fn test_search_books_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SearchBooks.into()); + } + + #[test] + fn test_search_author_history_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistory.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SearchAuthorHistory.into()); + } + + #[test] + fn test_filter_author_history_key() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistory.into()); + + AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::FilterAuthorHistory.into()); + } + } + + #[test] + fn test_author_details_handler_accepts() { + let mut author_details_blocks = AUTHOR_DETAILS_BLOCKS.to_vec(); + author_details_blocks.extend(AUTHOR_OVERVIEW_BLOCKS); + + ActiveReadarrBlock::iter().for_each(|readarr_block| { + if author_details_blocks.contains(&readarr_block) { + assert!( + AuthorDetailsHandler::accepts(readarr_block), + "{readarr_block} is not accepted by the AuthorDetailsHandler" + ); + } else { + assert!(!AuthorDetailsHandler::accepts(readarr_block)); + } + }); + } + + #[test] + fn test_delegates_author_overview_blocks_to_author_overview_handler() { + test_handler_delegation!( + AuthorDetailsHandler, + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorOverview + ); + } + + #[test] + fn test_extract_author_id() { + let mut app = App::test_default_fully_populated(); + let author = app.data.readarr_data.authors.current_selection().clone(); + let mut decoy_author = author.clone(); + decoy_author.id = 999; + app + .data + .readarr_data + .authors + .set_items(vec![decoy_author, author]); + app.data.readarr_data.authors.select_index(Some(1)); + + let author_id = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .extract_author_id(); + + assert_eq!(author_id, 1); + } + + #[test] + fn test_extract_book_id() { + let mut app = App::test_default_fully_populated(); + let book = app.data.readarr_data.books.current_selection().clone(); + let mut decoy_book = book.clone(); + decoy_book.id = 999; + app + .data + .readarr_data + .books + .set_items(vec![decoy_book, book]); + app.data.readarr_data.books.select_index(Some(1)); + + let book_id = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ) + .extract_book_id(); + + assert_eq!(book_id, 1); + } + + #[rstest] + fn test_author_details_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_author_details_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.is_loading = true; + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_author_details_handler_is_ready_when_not_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.is_loading = false; + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorDetails, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_author_details_handler_is_not_ready_when_not_loading_and_author_history_is_none() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_author_details_handler_ready_when_not_loading_and_author_history_is_non_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .author_history + .set_items(vec![ReadarrHistoryItem::default()]); + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorHistory, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_author_details_handler_is_not_ready_when_not_loading_and_author_releases_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::ManualAuthorSearch, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_author_details_handler_ready_when_not_loading_and_author_releases_is_non_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .author_releases + .set_items(vec![ReadarrRelease::default()]); + + let handler = AuthorDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::ManualAuthorSearch, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_releases_sorting_options_source() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.protocol.cmp(&b.protocol); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[0].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Source"); + } + + #[test] + fn test_releases_sorting_options_age() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.age.cmp(&b.age); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[1].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Age"); + } + + #[test] + fn test_releases_sorting_options_rejected() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.rejected.cmp(&b.rejected); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[2].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Rejected"); + } + + #[test] + fn test_releases_sorting_options_title() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = |a, b| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[3].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Title"); + } + + #[test] + fn test_releases_sorting_options_indexer() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.indexer.to_lowercase().cmp(&b.indexer.to_lowercase()); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[4].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Indexer"); + } + + #[test] + fn test_releases_sorting_options_size() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.size.cmp(&b.size); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[5].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Size"); + } + + #[test] + fn test_releases_sorting_options_peers() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = |a, b| { + let default_number = Number::from(i64::MAX); + let seeder_a = a + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + let seeder_b = b + .seeders + .as_ref() + .unwrap_or(&default_number) + .as_u64() + .unwrap(); + + seeder_a.cmp(&seeder_b) + }; + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[6].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Peers"); + } + + #[test] + fn test_releases_sorting_options_quality() { + let expected_cmp_fn: fn(&ReadarrRelease, &ReadarrRelease) -> Ordering = + |a, b| a.quality.cmp(&b.quality); + let mut expected_releases_vec = release_vec(); + expected_releases_vec.sort_by(expected_cmp_fn); + + let sort_option = releases_sorting_options()[7].clone(); + let mut sorted_releases_vec = release_vec(); + sorted_releases_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_releases_vec, expected_releases_vec); + assert_str_eq!(sort_option.name, "Quality"); + } + + fn release_vec() -> Vec { + let release_a = ReadarrRelease { + protocol: "Protocol A".to_owned(), + age: 1, + title: HorizontallyScrollableText::from("Title A"), + indexer: "Indexer A".to_owned(), + size: 1, + rejected: true, + seeders: Some(Number::from(1)), + quality: QualityWrapper { + quality: Quality { + name: "Quality A".to_owned(), + }, + }, + ..ReadarrRelease::default() + }; + let release_b = ReadarrRelease { + protocol: "Protocol B".to_owned(), + age: 2, + title: HorizontallyScrollableText::from("title B"), + indexer: "indexer B".to_owned(), + size: 2, + rejected: false, + seeders: Some(Number::from(2)), + quality: QualityWrapper { + quality: Quality { + name: "Quality B".to_owned(), + }, + }, + ..ReadarrRelease::default() + }; + let release_c = ReadarrRelease { + protocol: "Protocol C".to_owned(), + age: 3, + title: HorizontallyScrollableText::from("Title C"), + indexer: "Indexer C".to_owned(), + size: 3, + rejected: false, + seeders: None, + quality: QualityWrapper { + quality: Quality { + name: "Quality C".to_owned(), + }, + }, + ..ReadarrRelease::default() + }; + + vec![release_a, release_b, release_c] + } +} diff --git a/src/handlers/readarr_handlers/library/author_overview_handler.rs b/src/handlers/readarr_handlers/library/author_overview_handler.rs new file mode 100644 index 00000000..5f404972 --- /dev/null +++ b/src/handlers/readarr_handlers/library/author_overview_handler.rs @@ -0,0 +1,111 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::matches_key; +use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, +}; +use crate::models::{Route, Scrollable, ScrollableText}; + +#[cfg(test)] +#[path = "author_overview_handler_tests.rs"] +mod author_overview_handler_tests; + +pub(super) struct AuthorOverviewHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl AuthorOverviewHandler<'_, '_> { + fn overview(&mut self) -> &mut ScrollableText { + &mut self + .app + .data + .readarr_data + .author_overview_modal + .as_mut() + .expect("Author overview modal is undefined") + .overview + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AuthorOverviewHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + AUTHOR_OVERVIEW_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> AuthorOverviewHandler<'a, 'b> { + AuthorOverviewHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + self.app.data.readarr_data.author_overview_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + self.overview().scroll_up(); + } + + fn handle_scroll_down(&mut self) { + self.overview().scroll_down(); + } + + fn handle_home(&mut self) { + self.overview().scroll_to_top(); + } + + fn handle_end(&mut self) { + self.overview().scroll_to_bottom(); + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) {} + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.author_overview_modal = None; + } + + fn handle_char_key_event(&mut self) { + if matches_key!(refresh, self.key) { + self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/author_overview_handler_tests.rs b/src/handlers/readarr_handlers/library/author_overview_handler_tests.rs new file mode 100644 index 00000000..107598f8 --- /dev/null +++ b/src/handlers/readarr_handlers/library/author_overview_handler_tests.rs @@ -0,0 +1,393 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::author_overview_handler::AuthorOverviewHandler; + use crate::models::ScrollableText; + use crate::models::servarr_data::readarr::modals::AuthorOverviewModal; + use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, + }; + + mod test_handle_scroll_up_and_down { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_author_overview_scroll_up() { + let mut app = app_with_author_overview(); + set_offset(&mut app, 2); + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_author_overview_scroll_up_no_op_when_not_ready() { + let mut app = app_with_author_overview(); + set_offset(&mut app, 2); + app.is_loading = true; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 2); + } + + #[test] + fn test_author_overview_scroll_down() { + let mut app = app_with_author_overview(); + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_author_overview_scroll_down_no_op_when_not_ready() { + let mut app = app_with_author_overview(); + app.is_loading = true; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_home_end { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_author_overview_home() { + let mut app = app_with_author_overview(); + set_offset(&mut app, 3); + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + + #[test] + fn test_author_overview_end() { + let mut app = app_with_author_overview(); + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 5); + } + + #[test] + fn test_author_overview_home_no_op_when_not_ready() { + let mut app = app_with_author_overview(); + set_offset(&mut app, 3); + app.is_loading = true; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 3); + } + + #[test] + fn test_author_overview_end_no_op_when_not_ready() { + let mut app = app_with_author_overview(); + app.is_loading = true; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_esc { + use super::*; + use crate::{assert_modal_absent, assert_navigation_popped}; + + #[test] + fn test_author_overview_esc_tears_down_the_author_overview_modal() { + let mut app = app_with_author_overview(); + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_modal_absent!(app.data.readarr_data.author_overview_modal); + } + + #[test] + fn test_author_overview_esc_tears_down_the_modal_even_when_loading() { + let mut app = app_with_author_overview(); + app.is_loading = true; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_modal_absent!(app.data.readarr_data.author_overview_modal); + } + } + + mod test_handle_key_char { + use super::*; + use crate::assert_navigation_pushed; + + #[test] + fn test_author_overview_refresh_key() { + let mut app = app_with_author_overview(); + app.is_routing = false; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorOverview.into()); + assert!(app.is_routing); + } + + #[test] + fn test_author_overview_refresh_key_no_op_when_not_ready() { + let mut app = app_with_author_overview(); + app.is_loading = true; + app.is_routing = false; + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert!(!app.is_routing); + } + } + + #[test] + fn test_scrolling_the_overview_does_not_mutate_the_author_record() { + let mut app = app_with_author_overview(); + let overview_before = app + .data + .readarr_data + .authors + .current_selection() + .overview + .clone(); + + AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + assert_eq!( + app.data.readarr_data.authors.current_selection().overview, + overview_before + ); + } + + #[test] + fn test_author_overview_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if AUTHOR_OVERVIEW_BLOCKS.contains(&active_readarr_block) { + assert!( + AuthorOverviewHandler::accepts(active_readarr_block), + "{active_readarr_block} is not accepted by the AuthorOverviewHandler" + ); + } else { + assert!( + !AuthorOverviewHandler::accepts(active_readarr_block), + "{active_readarr_block} is wrongly accepted by the AuthorOverviewHandler" + ); + } + }); + } + + #[test] + fn test_author_overview_handler_dispatches_no_network_events() { + let mut app = app_with_author_overview(); + + for key in [ + DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.delete.key, + DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.submit.key, + DEFAULT_KEYBINDINGS.refresh.key, + ] { + AuthorOverviewHandler::new(key, &mut app, ActiveReadarrBlock::AuthorOverview, None).handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + #[test] + fn test_author_overview_handler_is_not_ready_when_loading() { + let mut app = app_with_author_overview(); + app.is_loading = true; + + let handler = AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_author_overview_handler_is_not_ready_when_the_modal_is_absent() { + let mut app = app_with_author_overview(); + app.data.readarr_data.author_overview_modal = None; + + let handler = AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_author_overview_handler_is_ready() { + let mut app = app_with_author_overview(); + + let handler = AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_author_overview_handler_ignores_special_keys_for_textbox_input() { + let mut app = app_with_author_overview(); + app.ignore_special_keys_for_textbox_input = true; + + let handler = AuthorOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::AuthorOverview, + None, + ); + + assert!(handler.ignore_special_keys()); + } + + fn app_with_author_overview() -> App<'static> { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.author_overview_modal = Some(AuthorOverviewModal { + overview: ScrollableText::with_string( + "some interesting description of the author\r\n\ + \r\n\ + He was born in Madison, Wisconsin: a city he has never really left.\r\n\ + \r\n\ + His first novel took him seven years to finish.\r\n" + .to_owned(), + ), + }); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + + app + } + + fn overview_offset(app: &App<'_>) -> u16 { + app + .data + .readarr_data + .author_overview_modal + .as_ref() + .unwrap() + .overview + .offset + } + + fn set_offset(app: &mut App<'_>, offset: u16) { + app + .data + .readarr_data + .author_overview_modal + .as_mut() + .unwrap() + .overview + .offset = offset; + } +} diff --git a/src/handlers/readarr_handlers/library/book_details_handler.rs b/src/handlers/readarr_handlers/library/book_details_handler.rs new file mode 100644 index 00000000..092b46b5 --- /dev/null +++ b/src/handlers/readarr_handlers/library/book_details_handler.rs @@ -0,0 +1,503 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::history::history_sorting_options; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::readarr_models::{Edition, Ratings, ReadarrHistoryItem, ReadarrRelease}; +use crate::models::servarr_data::readarr::modals::EditionDetailsModal; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BOOK_DETAILS_BLOCKS}; +use crate::models::servarr_models::ReleaseDownloadBody; +use crate::models::{Route, ScrollableText}; +use crate::network::readarr_network::ReadarrEvent; +use indoc::formatdoc; + +use super::author_details_handler::releases_sorting_options; +use super::edition_details_handler::EditionDetailsHandler; + +#[cfg(test)] +#[path = "book_details_handler_tests.rs"] +mod book_details_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct BookDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl BookDetailsHandler<'_, '_> { + fn extract_book_id(&self) -> i64 { + self.app.data.readarr_data.books.current_selection().id + } + + fn extract_book_file_id(&self) -> i64 { + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_files + .current_selection() + .id + } + + fn book_files_are_populated(&self) -> bool { + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .is_some_and(|modal| !modal.book_files.is_empty()) + } + + fn editions_are_populated(&self) -> bool { + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .is_some_and(|modal| !modal.editions.is_empty()) + } + + fn build_edition_details_modal(&mut self) { + let Edition { + title, + overview, + format, + publisher, + page_count, + release_date, + isbn13, + asin, + ratings, + language, + monitored, + .. + } = self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .editions + .current_selection() + .clone(); + let format = format.unwrap_or_default(); + let language = language.unwrap_or_default(); + let publisher = publisher.unwrap_or_default(); + let page_count = page_count + .map(|count| count.to_string()) + .unwrap_or_default(); + let release_date = release_date + .map(|date| date.to_string()) + .unwrap_or_default(); + let isbn13 = isbn13.unwrap_or_default(); + let asin = asin.unwrap_or_default(); + let ratings = ratings + .map(|Ratings { votes, value, .. }| format!("{value} ({votes} votes)")) + .unwrap_or_default(); + let overview = overview.unwrap_or_default(); + let details = formatdoc!( + " + Title: {title} + Format: {format} + Language: {language} + Publisher: {publisher} + Page Count: {page_count} + Release Date: {release_date} + ISBN13: {isbn13} + ASIN: {asin} + Ratings: {ratings} + Monitored: {monitored} + Overview: {overview} + " + ); + + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .edition_details_modal = Some(EditionDetailsModal { + edition_details: ScrollableText::with_string(details), + }); + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for BookDetailsHandler<'a, 'b> { + fn handle(&mut self) { + if EditionDetailsHandler::accepts(self.active_readarr_block) { + return EditionDetailsHandler::new( + self.key, + self.app, + self.active_readarr_block, + self._context, + ) + .handle(); + } + + let editions_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::BookDetails.into()) + .searching_block(ActiveReadarrBlock::SearchEditions.into()) + .search_error_block(ActiveReadarrBlock::SearchEditionsError.into()) + .search_field_fn(|edition: &Edition| &edition.title); + + let book_history_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::BookHistory.into()) + .sorting_block(ActiveReadarrBlock::BookHistorySortPrompt.into()) + .sort_options(history_sorting_options()) + .searching_block(ActiveReadarrBlock::SearchBookHistory.into()) + .search_error_block(ActiveReadarrBlock::SearchBookHistoryError.into()) + .search_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text) + .filtering_block(ActiveReadarrBlock::FilterBookHistory.into()) + .filter_error_block(ActiveReadarrBlock::FilterBookHistoryError.into()) + .filter_field_fn(|history_item: &ReadarrHistoryItem| &history_item.source_title.text); + + let book_releases_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::ManualBookSearch.into()) + .sorting_block(ActiveReadarrBlock::ManualBookSearchSortPrompt.into()) + .sort_options(releases_sorting_options()); + + if !handle_table( + self, + |app| { + &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .editions + }, + editions_table_handling_config, + ) && !handle_table( + self, + |app| { + &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_history + }, + book_history_table_handling_config, + ) && !handle_table( + self, + |app| { + &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_releases + }, + book_releases_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + EditionDetailsHandler::accepts(active_block) || BOOK_DETAILS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> BookDetailsHandler<'a, 'b> { + BookDetailsHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + let Some(book_details_modal) = &self.app.data.readarr_data.book_details_modal else { + return false; + }; + + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails => !book_details_modal.editions.is_empty(), + ActiveReadarrBlock::BookHistory => !book_details_modal.book_history.is_empty(), + ActiveReadarrBlock::BookFileInfo => !book_details_modal.book_files.is_empty(), + ActiveReadarrBlock::ManualBookSearch => !book_details_modal.book_releases.is_empty(), + _ => true, + } + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::BookFileInfo + && self.book_files_are_populated() + { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails + | ActiveReadarrBlock::BookHistory + | ActiveReadarrBlock::BookFileInfo + | ActiveReadarrBlock::ManualBookSearch => match self.key { + _ if matches_key!(left, self.key) => { + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_details_tabs + .previous(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_details_tabs + .get_active_route(), + ); + } + _ if matches_key!(right, self.key) => { + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_details_tabs + .next(); + self.app.pop_and_push_navigation_stack( + self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_details_tabs + .get_active_route(), + ); + } + _ => (), + }, + ActiveReadarrBlock::AutomaticallySearchBookPrompt + | ActiveReadarrBlock::ManualBookSearchConfirmPrompt + | ActiveReadarrBlock::DeleteBookFilePrompt => { + handle_prompt_toggle(self.app, self.key); + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails if self.editions_are_populated() => { + self.build_edition_details_modal(); + self + .app + .push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + } + ActiveReadarrBlock::BookHistory => self + .app + .push_navigation_stack(ActiveReadarrBlock::BookHistoryDetails.into()), + ActiveReadarrBlock::ManualBookSearch => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); + } + ActiveReadarrBlock::ManualBookSearchConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + let ReadarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_releases + .current_selection() + .clone(); + let params = ReleaseDownloadBody { guid, indexer_id }; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DownloadRelease(params)); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::AutomaticallySearchBookPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::TriggerAutomaticBookSearch(self.extract_book_id()), + ); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::DeleteBookFilePrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteBookFile(self.extract_book_file_id())); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails + | ActiveReadarrBlock::BookFileInfo + | ActiveReadarrBlock::ManualBookSearch => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.book_details_modal = None; + } + ActiveReadarrBlock::BookHistoryDetails => { + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::BookHistory => { + if self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_history + .filtered_items + .is_some() + { + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_history + .reset_filter(); + } else { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.book_details_modal = None; + } + } + ActiveReadarrBlock::AutomaticallySearchBookPrompt + | ActiveReadarrBlock::ManualBookSearchConfirmPrompt + | ActiveReadarrBlock::DeleteBookFilePrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::BookDetails + | ActiveReadarrBlock::BookHistory + | ActiveReadarrBlock::BookFileInfo + | ActiveReadarrBlock::ManualBookSearch => match key { + _ if matches_key!(refresh, key) => { + self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()); + } + _ if matches_key!(auto_search, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into()); + } + _ => (), + }, + ActiveReadarrBlock::AutomaticallySearchBookPrompt if matches_key!(confirm, key) => { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::TriggerAutomaticBookSearch(self.extract_book_id()), + ); + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::DeleteBookFilePrompt if matches_key!(confirm, key) => { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteBookFile(self.extract_book_file_id())); + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::ManualBookSearchConfirmPrompt if matches_key!(confirm, key) => { + self.app.data.readarr_data.prompt_confirm = true; + let ReadarrRelease { + guid, indexer_id, .. + } = self + .app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_releases + .current_selection() + .clone(); + let params = ReleaseDownloadBody { guid, indexer_id }; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DownloadRelease(params)); + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/book_details_handler_tests.rs b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs new file mode 100644 index 00000000..cfdf6808 --- /dev/null +++ b/src/handlers/readarr_handlers/library/book_details_handler_tests.rs @@ -0,0 +1,1195 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::book_details_handler::BookDetailsHandler; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{ + Book, BookFile, Edition, Ratings, ReadarrHistoryItem, ReadarrRelease, + }; + use crate::models::servarr_data::readarr::modals::BookDetailsModal; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::test_handler_delegation; + + mod test_handle_scroll_up_and_down { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use pretty_assertions::assert_str_eq; + use rstest::rstest; + + #[rstest] + fn test_editions_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(key, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 2"); + } + + #[rstest] + fn test_editions_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(key, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 1"); + } + + #[rstest] + fn test_book_history_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(key, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_history + .current_selection() + .source_title + .text, + "Test History 2" + ); + } + + #[rstest] + fn test_book_releases_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + + BookDetailsHandler::new(key, &mut app, ActiveReadarrBlock::ManualBookSearch, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_releases + .current_selection() + .title + .text, + "Test Release 2" + ); + } + } + + mod test_handle_home_end { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use pretty_assertions::assert_str_eq; + + #[test] + fn test_editions_home_end() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 2"); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 1"); + } + + #[test] + fn test_editions_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_str_eq!(selected_edition(&app).title, "Test Edition 1"); + } + } + + mod test_handle_delete { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_book_file_prompt() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookFileInfo.into()); + + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookFileInfo, None) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::DeleteBookFilePrompt.into()); + } + + #[test] + fn test_delete_book_file_prompt_is_a_no_op_on_the_editions_tab() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_delete_book_file_prompt_is_a_no_op_when_book_files_are_empty() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.book_files.set_items(Vec::new()); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookFileInfo.into()); + + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookFileInfo, None) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookFileInfo.into()); + } + + #[test] + fn test_delete_book_file_prompt_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookFileInfo.into()); + + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookFileInfo, None) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookFileInfo.into()); + } + } + + mod test_handle_left_right_action { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use rstest::rstest; + + #[rstest] + #[case(ActiveReadarrBlock::BookDetails, ActiveReadarrBlock::BookHistory)] + #[case(ActiveReadarrBlock::BookHistory, ActiveReadarrBlock::BookFileInfo)] + #[case(ActiveReadarrBlock::BookFileInfo, ActiveReadarrBlock::ManualBookSearch)] + #[case(ActiveReadarrBlock::ManualBookSearch, ActiveReadarrBlock::BookDetails)] + fn test_book_details_tabs_left_right_action( + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + #[values(true, false)] is_loading: bool, + ) { + let mut app = App::test_default_fully_populated(); + app.is_loading = is_loading; + app.push_navigation_stack(right_block.into()); + let tab_index = app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_details_tabs + .tabs + .iter() + .position(|tab_route| tab_route.route == right_block.into()) + .unwrap_or_default(); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .book_details_tabs + .index = tab_index; + + BookDetailsHandler::new(DEFAULT_KEYBINDINGS.left.key, &mut app, right_block, None).handle(); + + assert_navigation_pushed!(app, left_block.into()); + + BookDetailsHandler::new(DEFAULT_KEYBINDINGS.right.key, &mut app, left_block, None).handle(); + + assert_navigation_pushed!(app, right_block.into()); + } + + #[rstest] + fn test_left_right_prompt_toggle( + #[values( + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + ActiveReadarrBlock::DeleteBookFilePrompt + )] + active_readarr_block: ActiveReadarrBlock, + #[values(Key::Left, Key::Right)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + BookDetailsHandler::new(key, &mut app, active_readarr_block, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::readarr_network::ReadarrEvent; + use crate::{assert_modal_absent, assert_modal_present, assert_navigation_pushed}; + use pretty_assertions::assert_eq; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_book_details_submit_builds_the_edition_details_modal_and_pushes_the_popup() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookEditionDetails.into()); + assert_modal_present!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + ); + assert!(edition_details_text(&app).contains("Title: Test Edition 1")); + assert!(edition_details_text(&app).contains("Format: Paperback")); + assert!(edition_details_text(&app).contains("ISBN13: isbn-1")); + assert!(edition_details_text(&app).contains("ASIN: asin-1")); + assert!(edition_details_text(&app).contains("Overview: Overview 1")); + } + + #[test] + fn test_book_details_submit_dispatches_no_network_event() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_book_details_submit_rebuilds_the_modal_when_a_different_edition_is_selected() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert!(edition_details_text(&app).contains("Title: Test Edition 1")); + assert!(edition_details_text(&app).contains("Format: Paperback")); + + app.pop_navigation_stack(); + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert!(edition_details_text(&app).contains("Title: Test Edition 2")); + assert!(edition_details_text(&app).contains("Format: Hardcover")); + assert!(edition_details_text(&app).contains("ISBN13: isbn-2")); + assert!(!edition_details_text(&app).contains("Test Edition 1")); + } + + #[test] + fn test_book_details_submit_is_a_no_op_when_the_editions_table_is_empty() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.editions.set_items(Vec::new()); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + assert_modal_absent!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + ); + } + + #[test] + fn test_book_details_submit_is_a_no_op_when_the_book_details_modal_is_absent() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + assert_modal_absent!(app.data.readarr_data.book_details_modal); + } + + #[test] + fn test_book_details_submit_with_a_single_edition() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.editions.set_items(vec![editions_vec()[0].clone()]); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookEditionDetails.into()); + assert!(edition_details_text(&app).contains("Title: Test Edition 1")); + } + + #[test] + fn test_book_details_submit_renders_every_optional_field_when_absent() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.editions.set_items(vec![Edition { + title: "Bare Edition".to_owned(), + ..Edition::default() + }]); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookEditionDetails.into()); + assert!(edition_details_text(&app).contains("Title: Bare Edition")); + assert!(edition_details_text(&app).contains("Format: \n")); + assert!(edition_details_text(&app).contains("Ratings: \n")); + } + + #[test] + fn test_book_details_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookDetails, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + assert_modal_absent!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + ); + } + + #[test] + fn test_book_history_submit() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookHistoryDetails.into()); + } + + #[test] + fn test_book_history_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookHistory.into()); + } + + #[test] + fn test_manual_book_search_submit() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualBookSearch, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into() + ); + } + + #[test] + fn test_manual_book_search_confirm_prompt_submit() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + let mut modal = book_details_modal(); + modal.book_releases.select_index(Some(1)); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DownloadRelease(ReleaseDownloadBody { + guid: "guid-2".to_owned(), + indexer_id: 2, + })) + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::ManualBookSearch.into()); + } + + #[test] + fn test_manual_book_search_confirm_prompt_submit_no_confirmation() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + None, + ) + .handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_pushed!(app, ActiveReadarrBlock::ManualBookSearch.into()); + } + + #[test] + fn test_automatically_search_book_prompt_submit() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.books.select_index(Some(1)); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::TriggerAutomaticBookSearch(7)) + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_automatically_search_book_prompt_submit_no_confirmation() { + let mut app = App::test_default(); + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + None, + ) + .handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_delete_book_file_prompt_submit() { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + let mut modal = book_details_modal(); + modal.book_files.select_index(Some(1)); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookFilePrompt, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteBookFile(11)) + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_delete_book_file_prompt_submit_no_confirmation() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); + + BookDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookFilePrompt, + None, + ) + .handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + } + + mod test_handle_esc { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped}; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_book_details_tabs_esc_tears_down_the_book_details_modal( + #[values( + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookFileInfo, + ActiveReadarrBlock::ManualBookSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_modal_absent!(app.data.readarr_data.book_details_modal); + } + + #[test] + fn test_book_history_esc_tears_down_the_book_details_modal() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_modal_absent!(app.data.readarr_data.book_details_modal); + } + + #[test] + fn test_book_history_esc_resets_the_filter_first() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.book_history.filtered_items = Some(history_vec()); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::BookHistory, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::BookHistory.into()); + assert_none!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_history + .filtered_items + ); + } + + #[test] + fn test_book_history_details_esc() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistoryDetails.into()); + + BookDetailsHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::BookHistoryDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::BookHistory.into()); + assert_modal_present!(app.data.readarr_data.book_details_modal); + } + + #[rstest] + fn test_prompt_esc( + #[values( + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + ActiveReadarrBlock::DeleteBookFilePrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_key_char { + use super::*; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::readarr_network::ReadarrEvent; + use crate::{assert_navigation_popped, assert_navigation_pushed}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_refresh_key( + #[values( + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::ManualBookSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!(app, active_readarr_block.into()); + } + + #[rstest] + fn test_auto_search_key( + #[values( + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::ManualBookSearch + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(active_readarr_block.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!( + app, + ActiveReadarrBlock::AutomaticallySearchBookPrompt.into() + ); + } + + #[test] + fn test_auto_search_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.auto_search.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_automatically_search_book_prompt_confirm() { + let mut app = App::test_default(); + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.books.select_index(Some(1)); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchBookPrompt.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::TriggerAutomaticBookSearch(7)) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_delete_book_file_prompt_confirm() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.book_files.select_index(Some(1)); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookFilePrompt.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::DeleteBookFilePrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteBookFile(11)) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into()); + } + + #[test] + fn test_manual_book_search_confirm_prompt_confirm() { + let mut app = App::test_default(); + let mut modal = book_details_modal(); + modal.book_releases.select_index(Some(1)); + app.data.readarr_data.book_details_modal = Some(modal); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearch.into()); + app.push_navigation_stack(ActiveReadarrBlock::ManualBookSearchConfirmPrompt.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DownloadRelease(ReleaseDownloadBody { + guid: "guid-2".to_owned(), + indexer_id: 2, + })) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::ManualBookSearch.into()); + } + + #[test] + fn test_search_editions_key() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveReadarrBlock::BookDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SearchEditions.into()); + } + + #[test] + fn test_search_book_history_key() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveReadarrBlock::BookHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SearchBookHistory.into()); + } + + #[test] + fn test_filter_book_history_key() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + BookDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveReadarrBlock::BookHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::FilterBookHistory.into()); + } + } + + #[test] + fn test_book_details_handler_accepts() { + let mut book_details_handler_blocks = Vec::new(); + book_details_handler_blocks.extend(BOOK_DETAILS_BLOCKS); + book_details_handler_blocks.extend(EDITION_DETAILS_BLOCKS); + + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if book_details_handler_blocks.contains(&active_readarr_block) { + assert!( + BookDetailsHandler::accepts(active_readarr_block), + "{active_readarr_block} is not accepted by the BookDetailsHandler" + ); + } else { + assert!( + !BookDetailsHandler::accepts(active_readarr_block), + "{active_readarr_block} is wrongly accepted by the BookDetailsHandler" + ); + } + }); + } + + #[test] + fn test_delegates_edition_details_blocks_to_edition_details_handler() { + test_handler_delegation!( + BookDetailsHandler, + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookEditionDetails + ); + } + + #[test] + fn test_book_details_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + + let handler = + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_book_details_handler_is_not_ready_when_the_modal_is_absent() { + let mut app = App::test_default(); + + let handler = + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_book_details_handler_is_ready_per_tab() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(book_details_modal()); + + for active_readarr_block in [ + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::BookFileInfo, + ActiveReadarrBlock::ManualBookSearch, + ] { + let handler = BookDetailsHandler::new(DELETE_KEY, &mut app, active_readarr_block, None); + + assert!(handler.is_ready(), "{active_readarr_block} should be ready"); + } + } + + #[test] + fn test_book_details_handler_is_not_ready_when_its_tab_table_is_empty() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(BookDetailsModal::default()); + + for active_readarr_block in [ + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::BookFileInfo, + ActiveReadarrBlock::ManualBookSearch, + ] { + let handler = BookDetailsHandler::new(DELETE_KEY, &mut app, active_readarr_block, None); + + assert!( + !handler.is_ready(), + "{active_readarr_block} should not be ready" + ); + } + } + + #[test] + fn test_book_details_handler_ignores_special_keys_for_textbox_input() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + + let handler = + BookDetailsHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::BookDetails, None); + + assert!(handler.ignore_special_keys()); + } + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + fn selected_edition(app: &App<'_>) -> Edition { + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .editions + .current_selection() + .clone() + } + + fn edition_details_text(app: &App<'_>) -> String { + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + .as_ref() + .unwrap() + .edition_details + .get_text() + } + + fn book_details_modal() -> BookDetailsModal { + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal.editions.set_items(editions_vec()); + book_details_modal.book_files.set_items(book_files_vec()); + book_details_modal.book_history.set_items(history_vec()); + book_details_modal.book_releases.set_items(release_vec()); + + book_details_modal + } + + fn editions_vec() -> Vec { + vec![ + Edition { + id: 1, + book_id: 1, + title: "Test Edition 1".to_owned(), + format: Some("Paperback".to_owned()), + isbn13: Some("isbn-1".to_owned()), + asin: Some("asin-1".to_owned()), + publisher: Some("Publisher 1".to_owned()), + language: Some("eng".to_owned()), + overview: Some("Overview 1".to_owned()), + page_count: Some(662), + monitored: true, + ratings: Some(Ratings { + votes: 10, + value: 4.5, + popularity: None, + }), + ..Edition::default() + }, + Edition { + id: 2, + book_id: 1, + title: "Test Edition 2".to_owned(), + format: Some("Hardcover".to_owned()), + isbn13: Some("isbn-2".to_owned()), + asin: Some("asin-2".to_owned()), + publisher: Some("Publisher 2".to_owned()), + language: Some("eng".to_owned()), + overview: Some("Overview 2".to_owned()), + page_count: Some(722), + monitored: false, + ..Edition::default() + }, + ] + } + + fn book_files_vec() -> Vec { + vec![ + BookFile { + id: 999, + author_id: 1, + book_id: 1, + path: "/nfs/books/Test Author/Decoy Book.epub".to_owned(), + size: 2048, + quality: QualityWrapper { + quality: Quality { + name: "MOBI".to_owned(), + }, + }, + ..BookFile::default() + }, + BookFile { + id: 11, + author_id: 1, + book_id: 1, + path: "/nfs/books/Test Author/Test Book.epub".to_owned(), + size: 1024, + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + ..BookFile::default() + }, + ] + } + + fn books_vec() -> Vec { + vec![ + Book { + id: 999, + ..Book::default() + }, + Book { + id: 7, + ..Book::default() + }, + ] + } + + fn history_vec() -> Vec { + vec![ + ReadarrHistoryItem { + id: 1, + source_title: HorizontallyScrollableText::from("Test History 1"), + ..ReadarrHistoryItem::default() + }, + ReadarrHistoryItem { + id: 2, + source_title: HorizontallyScrollableText::from("Test History 2"), + ..ReadarrHistoryItem::default() + }, + ] + } + + fn release_vec() -> Vec { + vec![ + ReadarrRelease { + guid: "guid-1".to_owned(), + indexer_id: 1, + title: HorizontallyScrollableText::from("Test Release 1"), + ..ReadarrRelease::default() + }, + ReadarrRelease { + guid: "guid-2".to_owned(), + indexer_id: 2, + title: HorizontallyScrollableText::from("Test Release 2"), + ..ReadarrRelease::default() + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/library/delete_author_handler.rs b/src/handlers/readarr_handlers/library/delete_author_handler.rs new file mode 100644 index 00000000..8f541a4b --- /dev/null +++ b/src/handlers/readarr_handlers/library/delete_author_handler.rs @@ -0,0 +1,151 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::Route; +use crate::models::readarr_models::DeleteParams; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_AUTHOR_BLOCKS, +}; +use crate::network::readarr_network::ReadarrEvent; + +#[cfg(test)] +#[path = "delete_author_handler_tests.rs"] +mod delete_author_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct DeleteAuthorHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl DeleteAuthorHandler<'_, '_> { + fn build_delete_author_params(&mut self) -> DeleteParams { + let id = self.app.data.readarr_data.authors.current_selection().id; + let delete_files = self.app.data.readarr_data.delete_files; + let add_import_list_exclusion = self.app.data.readarr_data.add_import_list_exclusion; + self.app.data.readarr_data.reset_delete_preferences(); + + DeleteParams { + id, + delete_files, + add_import_list_exclusion, + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for DeleteAuthorHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + DELETE_AUTHOR_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> DeleteAuthorHandler<'a, 'b> { + DeleteAuthorHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + } + + fn handle_scroll_up(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteAuthorPrompt { + self.app.data.readarr_data.selected_block.up(); + } + } + + fn handle_scroll_down(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteAuthorPrompt { + self.app.data.readarr_data.selected_block.down(); + } + } + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteAuthorPrompt { + handle_prompt_toggle(self.app, self.key); + } + } + + fn handle_submit(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteAuthorPrompt { + match self.app.data.readarr_data.selected_block.get_active_block() { + ActiveReadarrBlock::DeleteAuthorConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::DeleteAuthor( + self.build_delete_author_params(), + )); + self.app.should_refresh = true; + } else { + self.app.data.readarr_data.reset_delete_preferences(); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::DeleteAuthorToggleDeleteFile => { + self.app.data.readarr_data.delete_files = !self.app.data.readarr_data.delete_files; + } + ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion => { + self.app.data.readarr_data.add_import_list_exclusion = + !self.app.data.readarr_data.add_import_list_exclusion; + } + _ => (), + } + } + } + + fn handle_esc(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteAuthorPrompt { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.reset_delete_preferences(); + self.app.data.readarr_data.prompt_confirm = false; + } + } + + fn handle_char_key_event(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteAuthorPrompt + && self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::DeleteAuthorConfirmPrompt + && matches_key!(confirm, self.key) + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::DeleteAuthor( + self.build_delete_author_params(), + )); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/delete_author_handler_tests.rs b/src/handlers/readarr_handlers/library/delete_author_handler_tests.rs new file mode 100644 index 00000000..fcd4eef0 --- /dev/null +++ b/src/handlers/readarr_handlers/library/delete_author_handler_tests.rs @@ -0,0 +1,498 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::delete_author_handler::DeleteAuthorHandler; + use crate::models::readarr_models::{Author, DeleteParams}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_AUTHOR_BLOCKS, + }; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::DELETE_AUTHOR_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_delete_author_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + DeleteAuthorHandler::new(key, &mut app, ActiveReadarrBlock::DeleteAuthorPrompt, None) + .handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::DeleteAuthorToggleDeleteFile + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::DeleteAuthorConfirmPrompt + ); + } + } + + #[rstest] + fn test_delete_author_prompt_scroll_no_op_when_not_ready( + #[values(Key::Up, Key::Down)] key: Key, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + DeleteAuthorHandler::new(key, &mut app, ActiveReadarrBlock::DeleteAuthorPrompt, None) + .handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion + ); + } + } + + mod test_handle_left_right_action { + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + + DeleteAuthorHandler::new(key, &mut app, ActiveReadarrBlock::DeleteAuthorPrompt, None) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + DeleteAuthorHandler::new(key, &mut app, ActiveReadarrBlock::DeleteAuthorPrompt, None) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::DELETE_AUTHOR_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_delete_author_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_AUTHOR_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = true; + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_author_confirm_prompt_prompt_confirmation_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.delete_files = false; + app.data.readarr_data.add_import_list_exclusion = true; + app.data.readarr_data.authors.set_items(authors_vec()); + app.data.readarr_data.authors.select_index(Some(1)); + let expected_delete_author_params = DeleteParams { + id: 42, + delete_files: false, + add_import_list_exclusion: true, + }; + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_AUTHOR_SELECTION_BLOCKS.len() - 1); + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteAuthor(expected_delete_author_params)) + ); + assert!(app.should_refresh); + assert!(app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_author_confirm_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::DeleteAuthorPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + assert!(app.data.readarr_data.prompt_confirm); + assert!(app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_author_toggle_delete_files_submit() { + let current_route = ActiveReadarrBlock::DeleteAuthorPrompt.into(); + let mut app = App::test_default(); + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_author_toggle_add_list_exclusion_submit() { + let current_route = ActiveReadarrBlock::DeleteAuthorPrompt.into(); + let mut app = App::test_default(); + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(app.data.readarr_data.add_import_list_exclusion); + assert!(!app.data.readarr_data.delete_files); + + DeleteAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(!app.data.readarr_data.add_import_list_exclusion); + assert!(!app.data.readarr_data.delete_files); + } + } + + mod test_handle_esc { + use rstest::rstest; + + use crate::assert_navigation_popped; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_delete_author_prompt_esc(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = true; + + DeleteAuthorHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::DELETE_AUTHOR_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + + use super::*; + + const CONFIRM_KEY: Key = DEFAULT_KEYBINDINGS.confirm.key; + + #[test] + fn test_delete_author_confirm_prompt_prompt_confirm() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + app.data.readarr_data.authors.set_items(authors_vec()); + app.data.readarr_data.authors.select_index(Some(1)); + let expected_delete_author_params = DeleteParams { + id: 42, + delete_files: true, + add_import_list_exclusion: false, + }; + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_AUTHOR_SELECTION_BLOCKS.len() - 1); + + DeleteAuthorHandler::new( + CONFIRM_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteAuthor(expected_delete_author_params)) + ); + assert!(app.should_refresh); + assert!(app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_author_confirm_prompt_prompt_confirm_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + app.data.readarr_data.authors.set_items(authors_vec()); + app.data.readarr_data.authors.select_index(Some(1)); + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_AUTHOR_SELECTION_BLOCKS.len() - 1); + + DeleteAuthorHandler::new( + CONFIRM_KEY, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::DeleteAuthorPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(app.data.readarr_data.delete_files); + } + } + + #[test] + fn test_delete_author_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if DELETE_AUTHOR_BLOCKS.contains(&active_readarr_block) { + assert!(DeleteAuthorHandler::accepts(active_readarr_block)); + } else { + assert!(!DeleteAuthorHandler::accepts(active_readarr_block)); + } + }); + } + + #[rstest] + fn test_delete_author_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = DeleteAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_delete_author_params() { + let mut app = App::test_default(); + app.data.readarr_data.authors.set_items(authors_vec()); + app.data.readarr_data.authors.select_index(Some(1)); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + let expected_delete_author_params = DeleteParams { + id: 42, + delete_files: true, + add_import_list_exclusion: false, + }; + + let delete_author_params = DeleteAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ) + .build_delete_author_params(); + + assert_eq!(delete_author_params, expected_delete_author_params); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_author_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + + let handler = DeleteAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_delete_author_handler_ready_when_not_loading() { + let mut app = App::test_default(); + app.is_loading = false; + + let handler = DeleteAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteAuthorPrompt, + None, + ); + + assert!(handler.is_ready()); + } + + fn author() -> Author { + Author { + id: 42, + ..Author::default() + } + } + + fn authors_vec() -> Vec { + vec![ + Author { + id: 999, + ..Author::default() + }, + author(), + ] + } +} diff --git a/src/handlers/readarr_handlers/library/delete_book_handler.rs b/src/handlers/readarr_handlers/library/delete_book_handler.rs new file mode 100644 index 00000000..3ef9b49b --- /dev/null +++ b/src/handlers/readarr_handlers/library/delete_book_handler.rs @@ -0,0 +1,147 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::Route; +use crate::models::readarr_models::DeleteParams; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DELETE_BOOK_BLOCKS}; +use crate::network::readarr_network::ReadarrEvent; + +#[cfg(test)] +#[path = "delete_book_handler_tests.rs"] +mod delete_book_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct DeleteBookHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl DeleteBookHandler<'_, '_> { + fn build_delete_book_params(&mut self) -> DeleteParams { + let id = self.app.data.readarr_data.books.current_selection().id; + let delete_files = self.app.data.readarr_data.delete_files; + let add_import_list_exclusion = self.app.data.readarr_data.add_import_list_exclusion; + self.app.data.readarr_data.reset_delete_preferences(); + + DeleteParams { + id, + delete_files, + add_import_list_exclusion, + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for DeleteBookHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + DELETE_BOOK_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> DeleteBookHandler<'a, 'b> { + DeleteBookHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + } + + fn handle_scroll_up(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteBookPrompt { + self.app.data.readarr_data.selected_block.up(); + } + } + + fn handle_scroll_down(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteBookPrompt { + self.app.data.readarr_data.selected_block.down(); + } + } + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteBookPrompt { + handle_prompt_toggle(self.app, self.key); + } + } + + fn handle_submit(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteBookPrompt { + match self.app.data.readarr_data.selected_block.get_active_block() { + ActiveReadarrBlock::DeleteBookConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteBook(self.build_delete_book_params())); + self.app.should_refresh = true; + } else { + self.app.data.readarr_data.reset_delete_preferences(); + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::DeleteBookToggleDeleteFile => { + self.app.data.readarr_data.delete_files = !self.app.data.readarr_data.delete_files; + } + ActiveReadarrBlock::DeleteBookToggleAddListExclusion => { + self.app.data.readarr_data.add_import_list_exclusion = + !self.app.data.readarr_data.add_import_list_exclusion; + } + _ => (), + } + } + } + + fn handle_esc(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteBookPrompt { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.reset_delete_preferences(); + self.app.data.readarr_data.prompt_confirm = false; + } + } + + fn handle_char_key_event(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteBookPrompt + && self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::DeleteBookConfirmPrompt + && matches_key!(confirm, self.key) + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::DeleteBook(self.build_delete_book_params())); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/delete_book_handler_tests.rs b/src/handlers/readarr_handlers/library/delete_book_handler_tests.rs new file mode 100644 index 00000000..a6b8113b --- /dev/null +++ b/src/handlers/readarr_handlers/library/delete_book_handler_tests.rs @@ -0,0 +1,484 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::delete_book_handler::DeleteBookHandler; + use crate::models::readarr_models::{Book, DeleteParams}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_BOOK_BLOCKS, + }; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::DELETE_BOOK_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_delete_book_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + DeleteBookHandler::new(key, &mut app, ActiveReadarrBlock::DeleteBookPrompt, None).handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::DeleteBookToggleDeleteFile + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::DeleteBookConfirmPrompt + ); + } + } + + #[rstest] + fn test_delete_book_prompt_scroll_no_op_when_not_ready(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + DeleteBookHandler::new(key, &mut app, ActiveReadarrBlock::DeleteBookPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::DeleteBookToggleAddListExclusion + ); + } + } + + mod test_handle_left_right_action { + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + + DeleteBookHandler::new(key, &mut app, ActiveReadarrBlock::DeleteBookPrompt, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + DeleteBookHandler::new(key, &mut app, ActiveReadarrBlock::DeleteBookPrompt, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::DELETE_BOOK_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_delete_book_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_BOOK_SELECTION_BLOCKS.len() - 1); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = true; + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_book_confirm_prompt_prompt_confirmation_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.delete_files = false; + app.data.readarr_data.add_import_list_exclusion = true; + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.books.select_index(Some(1)); + let expected_delete_book_params = DeleteParams { + id: 42, + delete_files: false, + add_import_list_exclusion: true, + }; + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_BOOK_SELECTION_BLOCKS.len() - 1); + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteBook(expected_delete_book_params)) + ); + assert!(app.should_refresh); + assert!(app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_book_confirm_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::DeleteBookPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + assert!(app.data.readarr_data.prompt_confirm); + assert!(app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_book_toggle_delete_files_submit() { + let current_route = ActiveReadarrBlock::DeleteBookPrompt.into(); + let mut app = App::test_default(); + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_book_toggle_add_list_exclusion_submit() { + let current_route = ActiveReadarrBlock::DeleteBookPrompt.into(); + let mut app = App::test_default(); + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(app.data.readarr_data.add_import_list_exclusion); + assert!(!app.data.readarr_data.delete_files); + + DeleteBookHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert!(!app.data.readarr_data.add_import_list_exclusion); + assert!(!app.data.readarr_data.delete_files); + } + } + + mod test_handle_esc { + use rstest::rstest; + + use crate::assert_navigation_popped; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_delete_book_prompt_esc(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = true; + + DeleteBookHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::DELETE_BOOK_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + + use super::*; + + const CONFIRM_KEY: Key = DEFAULT_KEYBINDINGS.confirm.key; + + #[test] + fn test_delete_book_confirm_prompt_prompt_confirm() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.books.select_index(Some(1)); + let expected_delete_book_params = DeleteParams { + id: 42, + delete_files: true, + add_import_list_exclusion: false, + }; + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_BOOK_SELECTION_BLOCKS.len() - 1); + + DeleteBookHandler::new( + CONFIRM_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AuthorDetails.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::DeleteBook(expected_delete_book_params)) + ); + assert!(app.should_refresh); + assert!(app.data.readarr_data.prompt_confirm); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_book_confirm_prompt_prompt_confirm_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.books.select_index(Some(1)); + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, DELETE_BOOK_SELECTION_BLOCKS.len() - 1); + + DeleteBookHandler::new( + CONFIRM_KEY, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::DeleteBookPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + assert!(!app.data.readarr_data.prompt_confirm); + assert!(app.data.readarr_data.delete_files); + } + } + + #[test] + fn test_delete_book_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if DELETE_BOOK_BLOCKS.contains(&active_readarr_block) { + assert!(DeleteBookHandler::accepts(active_readarr_block)); + } else { + assert!(!DeleteBookHandler::accepts(active_readarr_block)); + } + }); + } + + #[rstest] + fn test_delete_book_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = DeleteBookHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_delete_book_params() { + let mut app = App::test_default(); + app.data.readarr_data.books.set_items(books_vec()); + app.data.readarr_data.books.select_index(Some(1)); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + let expected_delete_book_params = DeleteParams { + id: 42, + delete_files: true, + add_import_list_exclusion: false, + }; + + let delete_book_params = DeleteBookHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ) + .build_delete_book_params(); + + assert_eq!(delete_book_params, expected_delete_book_params); + assert!(!app.data.readarr_data.delete_files); + assert!(!app.data.readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_delete_book_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + + let handler = DeleteBookHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_delete_book_handler_ready_when_not_loading() { + let mut app = App::test_default(); + app.is_loading = false; + + let handler = DeleteBookHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteBookPrompt, + None, + ); + + assert!(handler.is_ready()); + } + + fn book() -> Book { + Book { + id: 42, + ..Book::default() + } + } + + fn books_vec() -> Vec { + vec![ + Book { + id: 999, + ..Book::default() + }, + book(), + ] + } +} diff --git a/src/handlers/readarr_handlers/library/edit_author_handler.rs b/src/handlers/readarr_handlers/library/edit_author_handler.rs new file mode 100644 index 00000000..7d1eb05b --- /dev/null +++ b/src/handlers/readarr_handlers/library/edit_author_handler.rs @@ -0,0 +1,454 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::readarr_models::EditAuthorParams; +use crate::models::servarr_data::readarr::modals::EditAuthorModal; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, EDIT_AUTHOR_BLOCKS}; +use crate::models::{Route, Scrollable}; +use crate::network::readarr_network::ReadarrEvent; +use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; + +#[cfg(test)] +#[path = "edit_author_handler_tests.rs"] +mod edit_author_handler_tests; + +pub(in crate::handlers::readarr_handlers) struct EditAuthorHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl EditAuthorHandler<'_, '_> { + fn build_edit_author_params(&mut self) -> EditAuthorParams { + let edit_author_modal = self + .app + .data + .readarr_data + .edit_author_modal + .take() + .expect("EditAuthorModal is None"); + let author_id = self.app.data.readarr_data.authors.current_selection().id; + let tags = edit_author_modal.tags.text; + + let EditAuthorModal { + monitored, + path, + monitor_list, + quality_profile_list, + metadata_profile_list, + .. + } = edit_author_modal; + let quality_profile = quality_profile_list.current_selection(); + let quality_profile_id = *self + .app + .data + .readarr_data + .quality_profile_map + .iter() + .filter(|(_, value)| *value == quality_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + let metadata_profile = metadata_profile_list.current_selection(); + let metadata_profile_id = *self + .app + .data + .readarr_data + .metadata_profile_map + .iter() + .filter(|(_, value)| *value == metadata_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + EditAuthorParams { + author_id, + monitored, + monitor_new_items: Some(*monitor_list.current_selection()), + quality_profile_id: Some(quality_profile_id), + metadata_profile_id: Some(metadata_profile_id), + root_folder_path: Some(path.text), + tag_input_string: Some(tags), + ..EditAuthorParams::default() + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for EditAuthorHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + EDIT_AUTHOR_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> EditAuthorHandler<'a, 'b> { + EditAuthorHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && self.app.data.readarr_data.edit_author_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_up(), + ActiveReadarrBlock::EditAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_up(), + ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_up(), + ActiveReadarrBlock::EditAuthorPrompt => self.app.data.readarr_data.selected_block.up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_down(), + ActiveReadarrBlock::EditAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_down(), + ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_down(), + ActiveReadarrBlock::EditAuthorPrompt => self.app.data.readarr_data.selected_block.down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_top(), + ActiveReadarrBlock::EditAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_top(), + ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_top(), + ActiveReadarrBlock::EditAuthorPathInput => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .path + .scroll_home(), + ActiveReadarrBlock::EditAuthorTagsInput => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .tags + .scroll_home(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_bottom(), + ActiveReadarrBlock::EditAuthorSelectQualityProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_bottom(), + ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_bottom(), + ActiveReadarrBlock::EditAuthorPathInput => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .path + .reset_offset(), + ActiveReadarrBlock::EditAuthorTagsInput => self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .tags + .reset_offset(), + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorPrompt => handle_prompt_toggle(self.app, self.key), + ActiveReadarrBlock::EditAuthorPathInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .path + ) + } + ActiveReadarrBlock::EditAuthorTagsInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .tags + ) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorPrompt => { + match self.app.data.readarr_data.selected_block.get_active_block() { + ActiveReadarrBlock::EditAuthorConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::EditAuthor(self.build_edit_author_params())); + self.app.should_refresh = true; + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems + | ActiveReadarrBlock::EditAuthorSelectQualityProfile + | ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self.app.push_navigation_stack( + ( + self.app.data.readarr_data.selected_block.get_active_block(), + self.context, + ) + .into(), + ), + ActiveReadarrBlock::EditAuthorPathInput | ActiveReadarrBlock::EditAuthorTagsInput => { + self.app.push_navigation_stack( + ( + self.app.data.readarr_data.selected_block.get_active_block(), + self.context, + ) + .into(), + ); + self.app.ignore_special_keys_for_textbox_input = true; + } + ActiveReadarrBlock::EditAuthorToggleMonitored => { + self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitored = Some( + !self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitored + .unwrap_or_default(), + ) + } + _ => (), + } + } + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems + | ActiveReadarrBlock::EditAuthorSelectQualityProfile + | ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self.app.pop_navigation_stack(), + ActiveReadarrBlock::EditAuthorPathInput | ActiveReadarrBlock::EditAuthorTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorTagsInput | ActiveReadarrBlock::EditAuthorPathInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + ActiveReadarrBlock::EditAuthorPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.edit_author_modal = None; + self.app.data.readarr_data.prompt_confirm = false; + } + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems + | ActiveReadarrBlock::EditAuthorSelectQualityProfile + | ActiveReadarrBlock::EditAuthorSelectMetadataProfile => self.app.pop_navigation_stack(), + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::EditAuthorPathInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .path + ) + } + ActiveReadarrBlock::EditAuthorTagsInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .tags + ) + } + ActiveReadarrBlock::EditAuthorPrompt + if self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::EditAuthorConfirmPrompt + && matches_key!(confirm, key) => + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::EditAuthor(self.build_edit_author_params())); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/edit_author_handler_tests.rs b/src/handlers/readarr_handlers/library/edit_author_handler_tests.rs new file mode 100644 index 00000000..6078c466 --- /dev/null +++ b/src/handlers/readarr_handlers/library/edit_author_handler_tests.rs @@ -0,0 +1,1374 @@ +#[cfg(test)] +mod tests { + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_absent; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::edit_author_handler::EditAuthorHandler; + use crate::models::readarr_models::{Author, EditAuthorParams, NewItemMonitorType}; + use crate::models::servarr_data::readarr::modals::EditAuthorModal; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_AUTHOR_BLOCKS, + }; + use crate::network::readarr_network::ReadarrEvent; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::EDIT_AUTHOR_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_edit_author_select_monitor_new_items_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let monitor_type_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_type_vec.clone()); + + if key == Key::Up { + for i in (0..monitor_type_vec.len()).rev() { + EditAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[i] + ); + } + } else { + for i in 0..monitor_type_vec.len() { + EditAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[(i + 1) % monitor_type_vec.len()] + ); + } + } + } + + #[rstest] + fn test_edit_author_select_quality_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + EditAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 2" + ); + + EditAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[rstest] + fn test_edit_author_select_metadata_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec!["Test 1".to_owned(), "Test 2".to_owned()]); + + EditAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 2" + ); + + EditAuthorHandler::new( + key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 1" + ); + } + + #[rstest] + fn test_edit_author_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + EditAuthorHandler::new(key, &mut app, ActiveReadarrBlock::EditAuthorPrompt, None).handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditAuthorToggleMonitored + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditAuthorSelectQualityProfile + ); + } + } + + #[rstest] + fn test_edit_author_prompt_scroll_no_op_when_not_ready(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_loading = true; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + EditAuthorHandler::new(key, &mut app, ActiveReadarrBlock::EditAuthorPrompt, None).handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems + ); + } + } + + mod test_handle_home_end { + use pretty_assertions::assert_eq; + use std::sync::atomic::Ordering; + + use strum::IntoEnumIterator; + + use super::*; + + #[test] + fn test_edit_author_select_monitor_new_items_home_end() { + let monitor_type_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_type_vec.clone()); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[monitor_type_vec.len() - 1] + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[0] + ); + } + + #[test] + fn test_edit_author_select_quality_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 3" + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_edit_author_select_metadata_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec![ + "Test 1".to_owned(), + "Test 2".to_owned(), + "Test 3".to_owned(), + ]); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 3" + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Test 1" + ); + } + + #[test] + fn test_edit_author_path_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + path: "Test".into(), + ..EditAuthorModal::default() + }); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_author_tags_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + tags: "Test".into(), + ..EditAuthorModal::default() + }); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 4 + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use std::sync::atomic::Ordering; + + use rstest::rstest; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + + EditAuthorHandler::new(key, &mut app, ActiveReadarrBlock::EditAuthorPrompt, None).handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + EditAuthorHandler::new(key, &mut app, ActiveReadarrBlock::EditAuthorPrompt, None).handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[test] + fn test_edit_author_path_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + path: "Test".into(), + ..EditAuthorModal::default() + }); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_edit_author_tags_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + tags: "Test".into(), + ..EditAuthorModal::default() + }); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 1 + ); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use crate::assert_navigation_popped; + use crate::models::servarr_data::readarr::readarr_data::EDIT_AUTHOR_SELECTION_BLOCKS; + use crate::models::{BlockSelectionState, Route}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_edit_author_path_input_submit() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + path: "Test Path".into(), + ..EditAuthorModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPathInput.into()); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditAuthorPrompt.into()); + } + + #[test] + fn test_edit_author_tags_input_submit() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + tags: "Test Tags".into(), + ..EditAuthorModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorTagsInput.into()); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert!( + !app + .data + .readarr_data + .edit_author_modal + .as_mut() + .unwrap() + .tags + .text + .is_empty() + ); + assert_navigation_popped!(app, ActiveReadarrBlock::EditAuthorPrompt.into()); + } + + #[test] + fn test_edit_author_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, EDIT_AUTHOR_SELECTION_BLOCKS.len() - 1); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_edit_author_confirm_prompt_prompt_confirmation_submit() { + let mut app = app_with_populated_edit_author_modal(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, EDIT_AUTHOR_SELECTION_BLOCKS.len() - 1); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::EditAuthor(expected_edit_author_params())) + ); + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + assert!(app.should_refresh); + } + + #[test] + fn test_edit_author_confirm_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::EditAuthorPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + } + + #[test] + fn test_edit_author_toggle_monitored_submit() { + let current_route = Route::from(( + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + )); + let mut app = App::test_default(); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(current_route); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert_some_eq_x!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .monitored, + true + ); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .handle(); + + assert_eq!(app.get_current_route(), current_route); + assert_some_eq_x!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .monitored, + false + ); + } + + #[rstest] + #[case(ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, 1)] + #[case(ActiveReadarrBlock::EditAuthorSelectQualityProfile, 2)] + #[case(ActiveReadarrBlock::EditAuthorSelectMetadataProfile, 3)] + #[case(ActiveReadarrBlock::EditAuthorPathInput, 4)] + #[case(ActiveReadarrBlock::EditAuthorTagsInput, 5)] + fn test_edit_author_prompt_selected_block_submit( + #[case] selected_block: ActiveReadarrBlock, + #[case] y_index: usize, + ) { + let mut app = App::test_default(); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack( + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .into(), + ); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .handle(); + + assert_navigation_pushed!( + app, + (selected_block, Some(ActiveReadarrBlock::AuthorDetails)).into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + + if selected_block == ActiveReadarrBlock::EditAuthorPathInput + || selected_block == ActiveReadarrBlock::EditAuthorTagsInput + { + assert!(app.ignore_special_keys_for_textbox_input); + } + } + + #[rstest] + fn test_edit_author_prompt_selected_block_submit_no_op_when_not_ready( + #[values(1, 2, 3, 4)] y_index: usize, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack( + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .into(), + ); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.ignore_special_keys_for_textbox_input); + } + + #[rstest] + fn test_edit_author_prompt_selecting_preferences_blocks_submit( + #[values( + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + ActiveReadarrBlock::EditAuthorPathInput, + ActiveReadarrBlock::EditAuthorTagsInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + EditAuthorHandler::new( + SUBMIT_KEY, + &mut app, + active_readarr_block, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::EditAuthorPrompt.into()); + + if active_readarr_block == ActiveReadarrBlock::EditAuthorPathInput + || active_readarr_block == ActiveReadarrBlock::EditAuthorTagsInput + { + assert!(!app.ignore_special_keys_for_textbox_input); + } + } + } + + mod test_handle_esc { + use crate::assert_navigation_popped; + use rstest::rstest; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_edit_author_input_esc( + #[values( + ActiveReadarrBlock::EditAuthorTagsInput, + ActiveReadarrBlock::EditAuthorPathInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.ignore_special_keys_for_textbox_input = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + EditAuthorHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_navigation_popped!(app, ActiveReadarrBlock::EditAuthorPrompt.into()); + } + + #[test] + fn test_edit_author_prompt_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + + EditAuthorHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + fn test_edit_author_esc( + #[values( + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile + )] + active_readarr_block: ActiveReadarrBlock, + #[values(true, false)] is_loading: bool, + ) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(active_readarr_block.into()); + + EditAuthorHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + } + } + + mod test_handle_key_char { + use super::*; + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::EDIT_AUTHOR_SELECTION_BLOCKS; + use pretty_assertions::assert_eq; + + #[test] + fn test_edit_author_path_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + path: "Test".into(), + ..EditAuthorModal::default() + }); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .text, + "Tes" + ); + } + + #[test] + fn test_edit_author_tags_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal { + tags: "Test".into(), + ..EditAuthorModal::default() + }); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .tags + .text, + "Tes" + ); + } + + #[test] + fn test_edit_author_path_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + + EditAuthorHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditAuthorPathInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .path + .text, + "a" + ); + } + + #[test] + fn test_edit_author_tags_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + + EditAuthorHandler::new( + Key::Char('a'), + &mut app, + ActiveReadarrBlock::EditAuthorTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .edit_author_modal + .as_ref() + .unwrap() + .tags + .text, + "a" + ); + } + + #[test] + fn test_edit_author_confirm_prompt_prompt_confirm() { + let mut app = app_with_populated_edit_author_modal(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, EDIT_AUTHOR_SELECTION_BLOCKS.len() - 1); + + EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::EditAuthor(expected_edit_author_params())) + ); + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + assert!(app.should_refresh); + } + } + + #[test] + fn test_edit_author_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if EDIT_AUTHOR_BLOCKS.contains(&active_readarr_block) { + assert!(EditAuthorHandler::accepts(active_readarr_block)); + } else { + assert!(!EditAuthorHandler::accepts(active_readarr_block)); + } + }); + } + + #[rstest] + fn test_edit_author_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_edit_author_params() { + let mut app = app_with_populated_edit_author_modal(); + + let edit_author_params = EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ) + .build_edit_author_params(); + + assert_eq!(edit_author_params, expected_edit_author_params()); + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + } + + #[test] + fn test_edit_author_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_loading = true; + + let handler = EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_author_handler_is_not_ready_when_edit_author_modal_is_none() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_loading = false; + + let handler = EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edit_author_handler_is_ready_when_edit_author_modal_is_some() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_loading = false; + app.data.readarr_data.edit_author_modal = Some(EditAuthorModal::default()); + + let handler = EditAuthorHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::EditAuthorPrompt, + None, + ); + + assert!(handler.is_ready()); + } + + fn app_with_populated_edit_author_modal() -> App<'static> { + let mut app = App::test_default(); + let mut edit_author_modal = EditAuthorModal { + tags: "usenet, testing".to_owned().into(), + path: "/nfs/Test Path".to_owned().into(), + monitored: Some(false), + ..EditAuthorModal::default() + }; + edit_author_modal + .quality_profile_list + .set_items(vec!["Lossless".to_owned(), "HD - 1080p".to_owned()]); + edit_author_modal.quality_profile_list.state.select(Some(1)); + edit_author_modal + .metadata_profile_list + .set_items(vec!["Standard".to_owned(), "Full".to_owned()]); + edit_author_modal + .metadata_profile_list + .state + .select(Some(1)); + edit_author_modal + .monitor_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + edit_author_modal.monitor_list.state.select(Some(1)); + app.data.readarr_data.edit_author_modal = Some(edit_author_modal); + app.data.readarr_data.authors.set_items(vec![ + Author { + id: 999, + monitored: false, + ..Author::default() + }, + Author { + id: 42, + monitored: false, + ..Author::default() + }, + ]); + app.data.readarr_data.authors.select_index(Some(1)); + app.data.readarr_data.quality_profile_map = BiMap::from_iter([ + (1111, "Lossless".to_owned()), + (2222, "HD - 1080p".to_owned()), + ]); + app.data.readarr_data.metadata_profile_map = + BiMap::from_iter([(3333, "Standard".to_owned()), (4444, "Full".to_owned())]); + + app + } + + fn expected_edit_author_params() -> EditAuthorParams { + EditAuthorParams { + author_id: 42, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(2222), + metadata_profile_id: Some(4444), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + ..EditAuthorParams::default() + } + } +} diff --git a/src/handlers/readarr_handlers/library/edition_details_handler.rs b/src/handlers/readarr_handlers/library/edition_details_handler.rs new file mode 100644 index 00000000..9235b866 --- /dev/null +++ b/src/handlers/readarr_handlers/library/edition_details_handler.rs @@ -0,0 +1,125 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::matches_key; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDITION_DETAILS_BLOCKS, +}; +use crate::models::{Route, Scrollable, ScrollableText}; + +#[cfg(test)] +#[path = "edition_details_handler_tests.rs"] +mod edition_details_handler_tests; + +pub(super) struct EditionDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl EditionDetailsHandler<'_, '_> { + fn edition_details(&mut self) -> &mut ScrollableText { + &mut self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .edition_details_modal + .as_mut() + .expect("Edition details modal is undefined") + .edition_details + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for EditionDetailsHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + EDITION_DETAILS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + _context: Option, + ) -> EditionDetailsHandler<'a, 'b> { + EditionDetailsHandler { + key, + app, + active_readarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + let Some(book_details_modal) = &self.app.data.readarr_data.book_details_modal else { + return false; + }; + + book_details_modal.edition_details_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + self.edition_details().scroll_up(); + } + + fn handle_scroll_down(&mut self) { + self.edition_details().scroll_down(); + } + + fn handle_home(&mut self) { + self.edition_details().scroll_to_top(); + } + + fn handle_end(&mut self) { + self.edition_details().scroll_to_bottom(); + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) {} + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) { + self.app.pop_navigation_stack(); + self + .app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .edition_details_modal = None; + } + + fn handle_char_key_event(&mut self) { + if matches_key!(refresh, self.key) { + self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/library/edition_details_handler_tests.rs b/src/handlers/readarr_handlers/library/edition_details_handler_tests.rs new file mode 100644 index 00000000..f41215f3 --- /dev/null +++ b/src/handlers/readarr_handlers/library/edition_details_handler_tests.rs @@ -0,0 +1,420 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::edition_details_handler::EditionDetailsHandler; + use crate::models::ScrollableText; + use crate::models::servarr_data::readarr::modals::{BookDetailsModal, EditionDetailsModal}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDITION_DETAILS_BLOCKS, + }; + + mod test_handle_scroll_up_and_down { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_edition_details_scroll_down() { + let mut app = app_with_edition_details(); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 1); + } + + #[test] + fn test_edition_details_scroll_up() { + let mut app = app_with_edition_details(); + set_offset(&mut app, 2); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 1); + } + + #[test] + fn test_edition_details_scroll_down_no_op_when_not_ready() { + let mut app = app_with_edition_details(); + app.is_loading = true; + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 0); + } + + #[test] + fn test_edition_details_scroll_up_no_op_when_not_ready() { + let mut app = app_with_edition_details(); + set_offset(&mut app, 2); + app.is_loading = true; + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 2); + } + + #[test] + fn test_edition_details_scrolls_past_the_first_screen() { + let mut app = app_with_edition_details(); + + for _ in 0..4 { + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + } + + assert_eq!(edition_details_offset(&app), 4); + } + } + + mod test_handle_home_end { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_edition_details_end() { + let mut app = app_with_edition_details(); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 5); + } + + #[test] + fn test_edition_details_home() { + let mut app = app_with_edition_details(); + set_offset(&mut app, 4); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 0); + } + + #[test] + fn test_edition_details_end_no_op_when_not_ready() { + let mut app = app_with_edition_details(); + app.is_loading = true; + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 0); + } + + #[test] + fn test_edition_details_home_no_op_when_not_ready() { + let mut app = app_with_edition_details(); + set_offset(&mut app, 4); + app.is_loading = true; + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_eq!(edition_details_offset(&app), 4); + } + } + + mod test_handle_esc { + use super::*; + use crate::{assert_modal_absent, assert_modal_present, assert_navigation_popped}; + + #[test] + fn test_edition_details_esc_tears_down_the_edition_details_modal() { + let mut app = app_with_edition_details(); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into()); + assert_modal_absent!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + ); + assert_modal_present!(app.data.readarr_data.book_details_modal); + } + + #[test] + fn test_edition_details_esc_tears_down_the_modal_even_when_loading() { + let mut app = app_with_edition_details(); + app.is_loading = true; + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::BookDetails.into()); + assert_modal_absent!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + ); + } + } + + mod test_handle_key_char { + use super::*; + use crate::assert_navigation_pushed; + + #[test] + fn test_refresh_key() { + let mut app = app_with_edition_details(); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookEditionDetails.into()); + } + + #[test] + fn test_refresh_key_no_op_when_not_ready() { + let mut app = app_with_edition_details(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::BookHistory.into()); + + EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::BookHistory.into()); + } + } + + #[test] + fn test_edition_details_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if EDITION_DETAILS_BLOCKS.contains(&active_readarr_block) { + assert!( + EditionDetailsHandler::accepts(active_readarr_block), + "{active_readarr_block} is not accepted by the EditionDetailsHandler" + ); + } else { + assert!( + !EditionDetailsHandler::accepts(active_readarr_block), + "{active_readarr_block} is wrongly accepted by the EditionDetailsHandler" + ); + } + }); + } + + #[test] + fn test_edition_details_handler_dispatches_no_network_events() { + let mut app = app_with_edition_details(); + + for key in [ + DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.delete.key, + DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.submit.key, + DEFAULT_KEYBINDINGS.refresh.key, + ] { + EditionDetailsHandler::new(key, &mut app, ActiveReadarrBlock::BookEditionDetails, None) + .handle(); + + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + #[test] + fn test_edition_details_handler_is_not_ready_when_loading() { + let mut app = app_with_edition_details(); + app.is_loading = true; + + let handler = EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edition_details_handler_is_not_ready_when_the_book_details_modal_is_absent() { + let mut app = App::test_default(); + + let handler = EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edition_details_handler_is_not_ready_when_the_edition_details_modal_is_absent() { + let mut app = App::test_default(); + app.data.readarr_data.book_details_modal = Some(BookDetailsModal::default()); + + let handler = EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_edition_details_handler_is_ready() { + let mut app = app_with_edition_details(); + + let handler = EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_edition_details_handler_ignores_special_keys_for_textbox_input() { + let mut app = app_with_edition_details(); + app.ignore_special_keys_for_textbox_input = true; + + let handler = EditionDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::BookEditionDetails, + None, + ); + + assert!(handler.ignore_special_keys()); + } + + fn app_with_edition_details() -> App<'static> { + let mut app = App::test_default(); + let book_details_modal = BookDetailsModal { + edition_details_modal: Some(EditionDetailsModal { + edition_details: ScrollableText::with_string( + "Title: Test Edition\nFormat: Paperback\nLanguage: eng\nPublisher: Test\nPage Count: 662\nOverview: A long overview" + .to_owned(), + ), + }), + ..BookDetailsModal::default() + }; + app.data.readarr_data.book_details_modal = Some(book_details_modal); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + + app + } + + fn edition_details_offset(app: &App<'_>) -> u16 { + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .edition_details_modal + .as_ref() + .unwrap() + .edition_details + .offset + } + + fn set_offset(app: &mut App<'_>, offset: u16) { + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .edition_details_modal + .as_mut() + .unwrap() + .edition_details + .offset = offset; + } +} diff --git a/src/handlers/readarr_handlers/library/library_handler_tests.rs b/src/handlers/readarr_handlers/library/library_handler_tests.rs new file mode 100644 index 00000000..515805c8 --- /dev/null +++ b/src/handlers/readarr_handlers/library/library_handler_tests.rs @@ -0,0 +1,1108 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use serde_json::Number; + use std::cmp::Ordering; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::library::{LibraryHandler, authors_sorting_options}; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, + BOOK_DETAILS_BLOCKS, DELETE_AUTHOR_BLOCKS, DELETE_BOOK_BLOCKS, EDIT_AUTHOR_BLOCKS, + EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, + }; + use crate::test_handler_delegation; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_str_eq; + + use super::*; + use crate::simple_stateful_iterable_vec; + + #[rstest] + fn test_authors_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(simple_stateful_iterable_vec!( + Author, + HorizontallyScrollableText, + author_name + )); + + LibraryHandler::new(key, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 2" + ); + + LibraryHandler::new(key, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 1" + ); + } + + #[rstest] + fn test_authors_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(simple_stateful_iterable_vec!( + Author, + HorizontallyScrollableText, + author_name + )); + + LibraryHandler::new(key, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 1" + ); + } + } + + mod test_handle_home_end { + use pretty_assertions::assert_str_eq; + + use super::*; + use crate::extended_stateful_iterable_vec; + + #[test] + fn test_authors_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(extended_stateful_iterable_vec!( + Author, + HorizontallyScrollableText, + author_name + )); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 3" + ); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 1" + ); + } + + #[test] + fn test_authors_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(extended_stateful_iterable_vec!( + Author, + HorizontallyScrollableText, + author_name + )); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + "Test 1" + ); + } + } + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_delete_prompt; + use crate::models::servarr_data::readarr::readarr_data::DELETE_AUTHOR_SELECTION_BLOCKS; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_author_delete() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + assert_delete_prompt!( + LibraryHandler, + app, + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::DeleteAuthorPrompt + ); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + DELETE_AUTHOR_SELECTION_BLOCKS + ); + } + + #[test] + fn test_author_delete_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_authors_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(0); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::System.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::System.into()); + } + + #[rstest] + fn test_authors_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(0); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Downloads.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Downloads.into()); + } + + #[rstest] + fn test_left_right_update_all_authors_prompt_toggle( + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + key, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + LibraryHandler::new( + key, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_navigation_popped; + use crate::assert_navigation_pushed; + use crate::network::readarr_network::ReadarrEvent; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_author_details_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AuthorDetails.into()); + } + + #[test] + fn test_author_details_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + } + + #[test] + fn test_update_all_authors_prompt_confirm_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + + LibraryHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::UpdateAllAuthors + ); + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + } + + #[test] + fn test_update_all_authors_prompt_decline_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + + LibraryHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + } + } + + mod test_handle_esc { + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_update_all_authors_prompt_blocks_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + + LibraryHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::Authors, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use super::*; + use crate::models::servarr_data::readarr::readarr_data::EDIT_AUTHOR_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + use crate::{ + assert_modal_absent, assert_modal_present, assert_navigation_popped, assert_navigation_pushed, + }; + + #[test] + fn test_author_add_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AddAuthorSearchInput.into()); + assert!(app.ignore_special_keys_for_textbox_input); + assert_modal_present!(app.data.readarr_data.add_author_search); + } + + #[test] + fn test_author_add_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(!app.ignore_special_keys_for_textbox_input); + assert_modal_absent!(app.data.readarr_data.add_author_search); + } + + #[test] + fn test_author_edit_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.data.readarr_data.quality_profile_map = + bimap::BiMap::from_iter([(0i64, "Default Quality".to_owned())]); + app.data.readarr_data.metadata_profile_map = + bimap::BiMap::from_iter([(0i64, "Default Metadata".to_owned())]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::EditAuthorPrompt.into()); + assert_modal_present!(app.data.readarr_data.edit_author_modal); + assert_eq!( + app.data.readarr_data.selected_block.blocks, + EDIT_AUTHOR_SELECTION_BLOCKS + ); + } + + #[test] + fn test_author_edit_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.edit.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert_modal_absent!(app.data.readarr_data.edit_author_modal); + } + + #[test] + fn test_toggle_monitoring_key() { + let mut app = App::test_default(); + app.data.readarr_data.authors.set_items(authors_vec()); + app.data.readarr_data.authors.select_index(Some(1)); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_routing = false; + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(app.data.readarr_data.prompt_confirm); + assert!(app.is_routing); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::ToggleAuthorMonitoring(2) + ); + } + + #[test] + fn test_toggle_monitoring_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.is_routing = false; + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.toggle_monitoring.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.is_routing); + } + + #[test] + fn test_update_all_authors_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + } + + #[test] + fn test_update_all_authors_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + } + + #[test] + fn test_refresh_authors_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_authors_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::Authors.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_search_authors_key() { + let mut app = App::test_default(); + app.data.readarr_data.authors.set_items(authors_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SearchAuthors.into()); + } + + #[test] + fn test_filter_authors_key() { + let mut app = App::test_default(); + app.data.readarr_data.authors.set_items(authors_vec()); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::FilterAuthors.into()); + } + + #[test] + fn test_update_all_authors_prompt_confirm() { + let mut app = App::test_default(); + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::UpdateAllAuthors + ); + assert_navigation_popped!(app, ActiveReadarrBlock::Authors.into()); + } + } + + #[test] + fn test_authors_sorting_options_name() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + a.author_name + .text + .to_lowercase() + .cmp(&b.author_name.text.to_lowercase()) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[0].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Name"); + } + + #[test] + fn test_authors_sorting_options_status() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + a.status + .to_string() + .to_lowercase() + .cmp(&b.status.to_string().to_lowercase()) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[1].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Status"); + } + + #[test] + fn test_authors_sorting_options_quality_profile() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = + |a, b| a.quality_profile_id.cmp(&b.quality_profile_id); + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[2].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Quality Profile"); + } + + #[test] + fn test_authors_sorting_options_metadata_profile() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = + |a, b| a.metadata_profile_id.cmp(&b.metadata_profile_id); + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[3].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Metadata Profile"); + } + + #[test] + fn test_authors_sorting_options_books() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.book_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.book_count)) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[4].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Books"); + } + + #[test] + fn test_authors_sorting_options_size() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.size_on_disk) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.size_on_disk)) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[5].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Size"); + } + + #[test] + fn test_authors_sorting_options_monitored() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| a.monitored.cmp(&b.monitored); + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[6].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Monitored"); + } + + #[test] + fn test_authors_sorting_options_tags() { + let expected_cmp_fn: fn(&Author, &Author) -> Ordering = |a, b| { + let a_str = a + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + let b_str = b + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + + a_str.cmp(&b_str) + }; + let mut expected_authors_vec = authors_vec(); + expected_authors_vec.sort_by(expected_cmp_fn); + + let sort_option = authors_sorting_options()[7].clone(); + let mut sorted_authors_vec = authors_vec(); + sorted_authors_vec.sort_by(sort_option.cmp_fn.unwrap()); + + assert_eq!(sorted_authors_vec, expected_authors_vec); + assert_str_eq!(sort_option.name, "Tags"); + } + + #[test] + fn test_delegates_book_details_blocks_to_book_details_handler() { + test_handler_delegation!( + LibraryHandler, + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::BookDetails + ); + } + + #[test] + fn test_delegates_add_author_blocks_to_add_author_handler() { + test_handler_delegation!( + LibraryHandler, + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::AddAuthorSearchInput + ); + } + + #[test] + fn test_delegates_delete_author_blocks_to_delete_author_handler() { + test_handler_delegation!( + LibraryHandler, + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::DeleteAuthorPrompt + ); + } + + #[test] + fn test_delegates_delete_book_blocks_to_delete_book_handler() { + test_handler_delegation!( + LibraryHandler, + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::DeleteBookPrompt + ); + } + + #[test] + fn test_delegates_edit_author_blocks_to_edit_author_handler() { + test_handler_delegation!( + LibraryHandler, + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::EditAuthorPrompt + ); + } + + #[test] + fn test_library_handler_accepts() { + let mut library_handler_blocks = Vec::new(); + library_handler_blocks.extend(LIBRARY_BLOCKS); + library_handler_blocks.extend(AUTHOR_DETAILS_BLOCKS); + library_handler_blocks.extend(AUTHOR_OVERVIEW_BLOCKS); + library_handler_blocks.extend(BOOK_DETAILS_BLOCKS); + library_handler_blocks.extend(EDITION_DETAILS_BLOCKS); + library_handler_blocks.extend(ADD_AUTHOR_BLOCKS); + library_handler_blocks.extend(DELETE_AUTHOR_BLOCKS); + library_handler_blocks.extend(DELETE_BOOK_BLOCKS); + library_handler_blocks.extend(EDIT_AUTHOR_BLOCKS); + + ActiveReadarrBlock::iter().for_each(|readarr_block| { + if library_handler_blocks.contains(&readarr_block) { + assert!( + LibraryHandler::accepts(readarr_block), + "{readarr_block} is not accepted by the LibraryHandler" + ); + } else { + assert!(!LibraryHandler::accepts(readarr_block)); + } + }); + } + + #[rstest] + fn test_library_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_library_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + let handler = LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_library_handler_not_ready_when_authors_is_empty() { + let mut app = App::test_default(); + app.is_loading = false; + + let handler = LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_library_handler_ready_when_not_loading_and_authors_is_not_empty() { + let mut app = App::test_default(); + app.is_loading = false; + app + .data + .readarr_data + .authors + .set_items(vec![Author::default()]); + + let handler = LibraryHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::Authors, + None, + ); + + assert!(handler.is_ready()); + } + + fn authors_vec() -> Vec { + vec![ + Author { + id: 3, + author_name: "Test Author 1".into(), + status: AuthorStatus::Ended, + quality_profile_id: 1, + metadata_profile_id: 1, + monitored: false, + tags: vec![Number::from(1), Number::from(2)], + statistics: Some(AuthorStatistics { + book_count: 5, + size_on_disk: 789, + ..AuthorStatistics::default() + }), + ..Author::default() + }, + Author { + id: 2, + author_name: "Test Author 2".into(), + status: AuthorStatus::Continuing, + quality_profile_id: 2, + metadata_profile_id: 2, + monitored: false, + tags: vec![Number::from(1), Number::from(3)], + statistics: Some(AuthorStatistics { + book_count: 10, + size_on_disk: 456, + ..AuthorStatistics::default() + }), + ..Author::default() + }, + Author { + id: 1, + author_name: "Test Author 3".into(), + status: AuthorStatus::Deleted, + quality_profile_id: 3, + metadata_profile_id: 3, + monitored: true, + tags: vec![Number::from(2), Number::from(3)], + statistics: Some(AuthorStatistics { + book_count: 3, + size_on_disk: 123, + ..AuthorStatistics::default() + }), + ..Author::default() + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/library/mod.rs b/src/handlers/readarr_handlers/library/mod.rs new file mode 100644 index 00000000..8df9d253 --- /dev/null +++ b/src/handlers/readarr_handlers/library/mod.rs @@ -0,0 +1,325 @@ +use crate::{ + app::App, + event::Key, + handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}, + matches_key, + models::{ + BlockSelectionState, HorizontallyScrollableText, + readarr_models::Author, + servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_AUTHOR_SELECTION_BLOCKS, EDIT_AUTHOR_SELECTION_BLOCKS, + LIBRARY_BLOCKS, + }, + stateful_table::SortOption, + }, + network::readarr_network::ReadarrEvent, +}; + +use super::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::models::Route; + +mod add_author_handler; +mod author_details_handler; +mod author_overview_handler; +mod book_details_handler; +mod delete_author_handler; +mod delete_book_handler; +mod edit_author_handler; +mod edition_details_handler; + +pub(in crate::handlers::readarr_handlers) use { + add_author_handler::AddAuthorHandler, author_details_handler::AuthorDetailsHandler, + book_details_handler::BookDetailsHandler, delete_author_handler::DeleteAuthorHandler, + delete_book_handler::DeleteBookHandler, edit_author_handler::EditAuthorHandler, +}; + +#[cfg(test)] +#[path = "library_handler_tests.rs"] +mod library_handler_tests; + +pub(super) struct LibraryHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl LibraryHandler<'_, '_> { + fn extract_author_id(&self) -> i64 { + self.app.data.readarr_data.authors.current_selection().id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for LibraryHandler<'a, 'b> { + fn handle(&mut self) { + let authors_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::Authors.into()) + .sorting_block(ActiveReadarrBlock::AuthorsSortPrompt.into()) + .sort_options(authors_sorting_options()) + .searching_block(ActiveReadarrBlock::SearchAuthors.into()) + .search_error_block(ActiveReadarrBlock::SearchAuthorsError.into()) + .search_field_fn(|author| &author.author_name.text) + .filtering_block(ActiveReadarrBlock::FilterAuthors.into()) + .filter_error_block(ActiveReadarrBlock::FilterAuthorsError.into()) + .filter_field_fn(|author| &author.author_name.text); + + if !handle_table( + self, + |app| &mut app.data.readarr_data.authors, + authors_table_handling_config, + ) { + match self.active_readarr_block { + _ if AddAuthorHandler::accepts(self.active_readarr_block) => { + AddAuthorHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } + _ if DeleteAuthorHandler::accepts(self.active_readarr_block) => { + DeleteAuthorHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } + _ if EditAuthorHandler::accepts(self.active_readarr_block) => { + EditAuthorHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } + _ if AuthorDetailsHandler::accepts(self.active_readarr_block) => { + AuthorDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } + _ if DeleteBookHandler::accepts(self.active_readarr_block) => { + DeleteBookHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } + _ if BookDetailsHandler::accepts(self.active_readarr_block) => { + BookDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } + _ => self.handle_key_event(), + } + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + AddAuthorHandler::accepts(active_block) + || DeleteAuthorHandler::accepts(active_block) + || DeleteBookHandler::accepts(active_block) + || EditAuthorHandler::accepts(active_block) + || AuthorDetailsHandler::accepts(active_block) + || BookDetailsHandler::accepts(active_block) + || LIBRARY_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> LibraryHandler<'a, 'b> { + LibraryHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.authors.is_empty() + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::Authors { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::Authors => handle_change_tab_left_right_keys(self.app, self.key), + ActiveReadarrBlock::UpdateAllAuthorsPrompt => handle_prompt_toggle(self.app, self.key), + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::Authors => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + } + ActiveReadarrBlock::UpdateAllAuthorsPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::UpdateAllAuthors); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::UpdateAllAuthorsPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + _ => { + handle_clear_errors(self.app); + } + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::Authors => match key { + _ if matches_key!(add, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchInput.into()); + self.app.data.readarr_data.add_author_search = + Some(HorizontallyScrollableText::default()); + self.app.ignore_special_keys_for_textbox_input = true; + } + _ if matches_key!(toggle_monitoring, key) => { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some( + ReadarrEvent::ToggleAuthorMonitoring(self.extract_author_id()), + ); + + self + .app + .pop_and_push_navigation_stack(self.active_readarr_block.into()); + } + _ if matches_key!(edit, key) => { + self.app.data.readarr_data.edit_author_modal = Some((&self.app.data.readarr_data).into()); + self + .app + .push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + } + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + } + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ => (), + }, + ActiveReadarrBlock::UpdateAllAuthorsPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::UpdateAllAuthors); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} + +fn authors_sorting_options() -> Vec> { + vec![ + SortOption { + name: "Name", + cmp_fn: Some(|a, b| { + a.author_name + .text + .to_lowercase() + .cmp(&b.author_name.text.to_lowercase()) + }), + }, + SortOption { + name: "Status", + cmp_fn: Some(|a, b| { + a.status + .to_string() + .to_lowercase() + .cmp(&b.status.to_string().to_lowercase()) + }), + }, + SortOption { + name: "Quality Profile", + cmp_fn: Some(|a, b| a.quality_profile_id.cmp(&b.quality_profile_id)), + }, + SortOption { + name: "Metadata Profile", + cmp_fn: Some(|a, b| a.metadata_profile_id.cmp(&b.metadata_profile_id)), + }, + SortOption { + name: "Books", + cmp_fn: Some(|a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.book_count) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.book_count)) + }), + }, + SortOption { + name: "Size", + cmp_fn: Some(|a, b| { + a.statistics + .as_ref() + .map_or(0, |stats| stats.size_on_disk) + .cmp(&b.statistics.as_ref().map_or(0, |stats| stats.size_on_disk)) + }), + }, + SortOption { + name: "Monitored", + cmp_fn: Some(|a, b| a.monitored.cmp(&b.monitored)), + }, + SortOption { + name: "Tags", + cmp_fn: Some(|a, b| { + let a_str = a + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + let b_str = b + .tags + .iter() + .map(|tag| tag.as_i64().unwrap().to_string()) + .collect::>() + .join(","); + + a_str.cmp(&b_str) + }), + }, + ] +} diff --git a/src/handlers/readarr_handlers/mod.rs b/src/handlers/readarr_handlers/mod.rs new file mode 100644 index 00000000..c2961253 --- /dev/null +++ b/src/handlers/readarr_handlers/mod.rs @@ -0,0 +1,133 @@ +use super::KeyEventHandler; +use crate::handlers::readarr_handlers::blocklist::BlocklistHandler; +use crate::handlers::readarr_handlers::downloads::DownloadsHandler; +use crate::handlers::readarr_handlers::history::HistoryHandler; +use crate::handlers::readarr_handlers::indexers::IndexersHandler; +use crate::handlers::readarr_handlers::library::LibraryHandler; +use crate::handlers::readarr_handlers::root_folders::RootFoldersHandler; +use crate::handlers::readarr_handlers::system::SystemHandler; +use crate::models::Route; +use crate::{ + app::App, event::Key, matches_key, + models::servarr_data::readarr::readarr_data::ActiveReadarrBlock, +}; + +mod blocklist; +mod downloads; +mod history; +mod indexers; +mod library; +mod root_folders; +mod system; + +#[cfg(test)] +#[path = "readarr_handler_tests.rs"] +mod readarr_handler_tests; + +pub(super) struct ReadarrHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for ReadarrHandler<'a, 'b> { + fn handle(&mut self) { + match self.active_readarr_block { + _ if LibraryHandler::accepts(self.active_readarr_block) => { + LibraryHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } + _ if DownloadsHandler::accepts(self.active_readarr_block) => { + DownloadsHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } + _ if BlocklistHandler::accepts(self.active_readarr_block) => { + BlocklistHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } + _ if HistoryHandler::accepts(self.active_readarr_block) => { + HistoryHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } + _ if RootFoldersHandler::accepts(self.active_readarr_block) => { + RootFoldersHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle(); + } + _ if IndexersHandler::accepts(self.active_readarr_block) => { + IndexersHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } + _ if SystemHandler::accepts(self.active_readarr_block) => { + SystemHandler::new(self.key, self.app, self.active_readarr_block, self.context).handle(); + } + _ => self.handle_key_event(), + } + } + + fn accepts(_active_block: ActiveReadarrBlock) -> bool { + true + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> ReadarrHandler<'a, 'b> { + ReadarrHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn is_ready(&self) -> bool { + true + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) {} + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) {} + + fn handle_char_key_event(&mut self) {} + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} + +pub fn handle_change_tab_left_right_keys(app: &mut App<'_>, key: Key) { + let key_ref = key; + match key_ref { + _ if matches_key!(left, key, app.ignore_special_keys_for_textbox_input) => { + app.data.readarr_data.main_tabs.previous(); + app.pop_and_push_navigation_stack(app.data.readarr_data.main_tabs.get_active_route()); + } + _ if matches_key!(right, key, app.ignore_special_keys_for_textbox_input) => { + app.data.readarr_data.main_tabs.next(); + app.pop_and_push_navigation_stack(app.data.readarr_data.main_tabs.get_active_route()); + } + _ => (), + } +} diff --git a/src/handlers/readarr_handlers/readarr_handler_tests.rs b/src/handlers/readarr_handlers/readarr_handler_tests.rs new file mode 100644 index 00000000..dd8e7aa4 --- /dev/null +++ b/src/handlers/readarr_handlers/readarr_handler_tests.rs @@ -0,0 +1,285 @@ +#[cfg(test)] +mod tests { + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::{ReadarrHandler, handle_change_tab_left_right_keys}; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::{assert_navigation_pushed, test_handler_delegation}; + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + #[rstest] + fn test_readarr_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = ReadarrHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_readarr_handler_is_ready() { + let mut app = App::test_default(); + app.is_loading = true; + + let handler = ReadarrHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_readarr_handler_accepts() { + for readarr_block in ActiveReadarrBlock::iter() { + assert!(ReadarrHandler::accepts(readarr_block)); + } + } + + #[rstest] + #[case(0, ActiveReadarrBlock::System, ActiveReadarrBlock::Downloads)] + #[case(1, ActiveReadarrBlock::Authors, ActiveReadarrBlock::Blocklist)] + #[case(2, ActiveReadarrBlock::Downloads, ActiveReadarrBlock::History)] + #[case(3, ActiveReadarrBlock::Blocklist, ActiveReadarrBlock::RootFolders)] + #[case(4, ActiveReadarrBlock::History, ActiveReadarrBlock::Indexers)] + #[case(5, ActiveReadarrBlock::RootFolders, ActiveReadarrBlock::System)] + #[case(6, ActiveReadarrBlock::Indexers, ActiveReadarrBlock::Authors)] + fn test_readarr_handler_change_tab_left_right_keys( + #[case] index: usize, + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.key); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + left_block.into() + ); + assert_navigation_pushed!(app, left_block.into()); + + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.key); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + right_block.into() + ); + assert_navigation_pushed!(app, right_block.into()); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::System, ActiveReadarrBlock::Downloads)] + #[case(1, ActiveReadarrBlock::Authors, ActiveReadarrBlock::Blocklist)] + #[case(2, ActiveReadarrBlock::Downloads, ActiveReadarrBlock::History)] + #[case(3, ActiveReadarrBlock::Blocklist, ActiveReadarrBlock::RootFolders)] + #[case(4, ActiveReadarrBlock::History, ActiveReadarrBlock::Indexers)] + #[case(5, ActiveReadarrBlock::RootFolders, ActiveReadarrBlock::System)] + #[case(6, ActiveReadarrBlock::Indexers, ActiveReadarrBlock::Authors)] + fn test_readarr_handler_change_tab_left_right_keys_alt_navigation( + #[case] index: usize, + #[case] left_block: ActiveReadarrBlock, + #[case] right_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.alt.unwrap()); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + left_block.into() + ); + assert_navigation_pushed!(app, left_block.into()); + + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.alt.unwrap()); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + right_block.into() + ); + assert_navigation_pushed!(app, right_block.into()); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::Authors)] + #[case(1, ActiveReadarrBlock::Downloads)] + #[case(2, ActiveReadarrBlock::Blocklist)] + #[case(3, ActiveReadarrBlock::History)] + #[case(4, ActiveReadarrBlock::RootFolders)] + #[case(5, ActiveReadarrBlock::Indexers)] + #[case(6, ActiveReadarrBlock::System)] + fn test_readarr_handler_change_tab_left_right_keys_alt_navigation_no_op_when_ignoring_quit_key( + #[case] index: usize, + #[case] block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(block.into()); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.left.alt.unwrap()); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + block.into() + ); + assert_eq!(app.get_current_route(), block.into()); + + app.data.readarr_data.main_tabs.set_index(index); + + handle_change_tab_left_right_keys(&mut app, DEFAULT_KEYBINDINGS.right.alt.unwrap()); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + block.into() + ); + assert_eq!(app.get_current_route(), block.into()); + } + + #[rstest] + fn test_delegates_library_blocks_to_library_handler( + #[values( + ActiveReadarrBlock::AuthorsSortPrompt, + ActiveReadarrBlock::FilterAuthors, + ActiveReadarrBlock::FilterAuthorsError, + ActiveReadarrBlock::SearchAuthors, + ActiveReadarrBlock::SearchAuthorsError, + ActiveReadarrBlock::UpdateAllAuthorsPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::Authors, + active_readarr_block + ); + } + + #[rstest] + fn test_delegates_blocklist_blocks_to_blocklist_handler( + #[values( + ActiveReadarrBlock::BlocklistItemDetails, + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt, + ActiveReadarrBlock::BlocklistSortPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::Blocklist, + active_readarr_block + ); + } + + #[rstest] + fn test_delegates_downloads_blocks_to_downloads_handler( + #[values( + ActiveReadarrBlock::DeleteDownloadPrompt, + ActiveReadarrBlock::UpdateDownloadsPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::Downloads, + active_readarr_block + ); + } + + #[rstest] + fn test_delegates_history_blocks_to_history_handler( + #[values( + ActiveReadarrBlock::HistoryItemDetails, + ActiveReadarrBlock::HistorySortPrompt, + ActiveReadarrBlock::SearchHistory, + ActiveReadarrBlock::SearchHistoryError, + ActiveReadarrBlock::FilterHistory, + ActiveReadarrBlock::FilterHistoryError + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::History, + active_readarr_block + ); + } + + #[rstest] + fn test_delegates_root_folders_blocks_to_root_folders_handler( + #[values( + ActiveReadarrBlock::AddRootFolderPrompt, + ActiveReadarrBlock::DeleteRootFolderPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::RootFolders, + active_readarr_block + ); + } + + #[rstest] + fn test_delegates_indexers_blocks_to_indexers_handler( + #[values( + ActiveReadarrBlock::DeleteIndexerPrompt, + ActiveReadarrBlock::TestIndexer, + ActiveReadarrBlock::TestAllIndexers, + ActiveReadarrBlock::EditIndexerPrompt, + ActiveReadarrBlock::AllIndexerSettingsPrompt, + ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveReadarrBlock::IndexerSettingsRetentionInput, + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::Indexers, + active_readarr_block + ); + } + + #[rstest] + fn test_delegates_system_blocks_to_system_handler( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + ReadarrHandler, + ActiveReadarrBlock::System, + active_readarr_block + ); + } +} diff --git a/src/handlers/readarr_handlers/root_folders/add_root_folder_handler.rs b/src/handlers/readarr_handlers/root_folders/add_root_folder_handler.rs new file mode 100644 index 00000000..745d01aa --- /dev/null +++ b/src/handlers/readarr_handlers/root_folders/add_root_folder_handler.rs @@ -0,0 +1,534 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::models::readarr_models::AddReadarrRootFolderBody; +use crate::models::servarr_data::readarr::modals::AddReadarrRootFolderModal; +use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, +}; +use crate::models::{Route, Scrollable}; +use crate::network::readarr_network::ReadarrEvent; +use crate::{handle_text_box_keys, handle_text_box_left_right_keys, matches_key}; + +#[cfg(test)] +#[path = "add_root_folder_handler_tests.rs"] +mod add_root_folder_handler_tests; + +pub(super) struct AddRootFolderHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl AddRootFolderHandler<'_, '_> { + fn build_add_root_folder_body(&mut self) -> AddReadarrRootFolderBody { + let add_root_folder_modal = self + .app + .data + .readarr_data + .add_root_folder_modal + .take() + .expect("AddReadarrRootFolderModal is None"); + + let tags = add_root_folder_modal.tags.text.clone(); + + let AddReadarrRootFolderModal { + name, + path, + monitor_list, + monitor_new_items_list, + quality_profile_list, + metadata_profile_list, + .. + } = add_root_folder_modal; + + let quality_profile = quality_profile_list.current_selection(); + let quality_profile_id = *self + .app + .data + .readarr_data + .quality_profile_map + .iter() + .filter(|(_, value)| *value == quality_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + let metadata_profile = metadata_profile_list.current_selection(); + let metadata_profile_id = *self + .app + .data + .readarr_data + .metadata_profile_map + .iter() + .filter(|(_, value)| *value == metadata_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + AddReadarrRootFolderBody { + name: name.text, + path: path.text, + default_quality_profile_id: quality_profile_id, + default_metadata_profile_id: metadata_profile_id, + default_monitor_option: *monitor_list.current_selection(), + default_new_item_monitor_option: *monitor_new_items_list.current_selection(), + default_tags: Vec::new(), + tag_input_string: Some(tags), + } + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for AddRootFolderHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + ADD_ROOT_FOLDER_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> AddRootFolderHandler<'a, 'b> { + AddRootFolderHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && self.app.data.readarr_data.add_root_folder_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderSelectMonitor => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_up(), + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_up(), + ActiveReadarrBlock::AddRootFolderSelectQualityProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_up(), + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_up(), + ActiveReadarrBlock::AddRootFolderPrompt => self.app.data.readarr_data.selected_block.up(), + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderSelectMonitor => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_down(), + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_down(), + ActiveReadarrBlock::AddRootFolderSelectQualityProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_down(), + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_down(), + ActiveReadarrBlock::AddRootFolderPrompt => self.app.data.readarr_data.selected_block.down(), + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderSelectMonitor => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_top(), + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_to_top(), + ActiveReadarrBlock::AddRootFolderSelectQualityProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_top(), + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_top(), + ActiveReadarrBlock::AddRootFolderNameInput => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .name + .scroll_home(), + ActiveReadarrBlock::AddRootFolderPathInput => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .path + .scroll_home(), + ActiveReadarrBlock::AddRootFolderTagsInput => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .tags + .scroll_home(), + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderSelectMonitor => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddRootFolderSelectQualityProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .quality_profile_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .metadata_profile_list + .scroll_to_bottom(), + ActiveReadarrBlock::AddRootFolderNameInput => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .name + .reset_offset(), + ActiveReadarrBlock::AddRootFolderPathInput => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .path + .reset_offset(), + ActiveReadarrBlock::AddRootFolderTagsInput => self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .tags + .reset_offset(), + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderPrompt => handle_prompt_toggle(self.app, self.key), + ActiveReadarrBlock::AddRootFolderNameInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .name + ) + } + ActiveReadarrBlock::AddRootFolderPathInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .path + ) + } + ActiveReadarrBlock::AddRootFolderTagsInput => { + handle_text_box_left_right_keys!( + self, + self.key, + self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .tags + ) + } + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderPrompt => { + match self.app.data.readarr_data.selected_block.get_active_block() { + ActiveReadarrBlock::AddRootFolderConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::AddRootFolder( + self.build_add_root_folder_body(), + )); + self.app.should_refresh = true; + } + + self.app.pop_navigation_stack(); + } + ActiveReadarrBlock::AddRootFolderSelectMonitor + | ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems + | ActiveReadarrBlock::AddRootFolderSelectQualityProfile + | ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => { + self.app.push_navigation_stack( + ( + self.app.data.readarr_data.selected_block.get_active_block(), + self.context, + ) + .into(), + ) + } + ActiveReadarrBlock::AddRootFolderNameInput + | ActiveReadarrBlock::AddRootFolderPathInput + | ActiveReadarrBlock::AddRootFolderTagsInput => { + self.app.push_navigation_stack( + ( + self.app.data.readarr_data.selected_block.get_active_block(), + self.context, + ) + .into(), + ); + self.app.ignore_special_keys_for_textbox_input = true; + } + _ => (), + } + } + ActiveReadarrBlock::AddRootFolderSelectMonitor + | ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems + | ActiveReadarrBlock::AddRootFolderSelectQualityProfile + | ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => self.app.pop_navigation_stack(), + ActiveReadarrBlock::AddRootFolderNameInput + | ActiveReadarrBlock::AddRootFolderPathInput + | ActiveReadarrBlock::AddRootFolderTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderNameInput + | ActiveReadarrBlock::AddRootFolderPathInput + | ActiveReadarrBlock::AddRootFolderTagsInput => { + self.app.pop_navigation_stack(); + self.app.ignore_special_keys_for_textbox_input = false; + } + ActiveReadarrBlock::AddRootFolderPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.add_root_folder_modal = None; + self.app.data.readarr_data.prompt_confirm = false; + } + ActiveReadarrBlock::AddRootFolderSelectMonitor + | ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems + | ActiveReadarrBlock::AddRootFolderSelectQualityProfile + | ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => self.app.pop_navigation_stack(), + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::AddRootFolderNameInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .name + ) + } + ActiveReadarrBlock::AddRootFolderPathInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .path + ) + } + ActiveReadarrBlock::AddRootFolderTagsInput => { + handle_text_box_keys!( + self, + key, + self + .app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .tags + ) + } + ActiveReadarrBlock::AddRootFolderPrompt + if self.app.data.readarr_data.selected_block.get_active_block() + == ActiveReadarrBlock::AddRootFolderConfirmPrompt + && matches_key!(confirm, key) => + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::AddRootFolder( + self.build_add_root_folder_body(), + )); + self.app.should_refresh = true; + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/root_folders/add_root_folder_handler_tests.rs b/src/handlers/readarr_handlers/root_folders/add_root_folder_handler_tests.rs new file mode 100644 index 00000000..c063253c --- /dev/null +++ b/src/handlers/readarr_handlers/root_folders/add_root_folder_handler_tests.rs @@ -0,0 +1,1763 @@ +#[cfg(test)] +mod tests { + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_absent; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::root_folders::add_root_folder_handler::AddRootFolderHandler; + use crate::models::readarr_models::{AddReadarrRootFolderBody, MonitorType, NewItemMonitorType}; + use crate::models::servarr_data::readarr::modals::AddReadarrRootFolderModal; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, + }; + use crate::network::readarr_network::ReadarrEvent; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::ADD_ROOT_FOLDER_SELECTION_BLOCKS; + + use super::*; + + #[rstest] + fn test_add_root_folder_select_monitor_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let monitor_type_vec = Vec::from_iter(MonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_type_vec.clone()); + + if key == Key::Up { + for i in (0..monitor_type_vec.len()).rev() { + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[i] + ); + } + } else { + for i in 0..monitor_type_vec.len() { + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[(i + 1) % monitor_type_vec.len()] + ); + } + } + } + + #[rstest] + fn test_add_root_folder_select_monitor_new_items_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let monitor_type_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .set_items(monitor_type_vec.clone()); + + if key == Key::Up { + for i in (0..monitor_type_vec.len()).rev() { + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_type_vec[i] + ); + } + } else { + for i in 0..monitor_type_vec.len() { + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_type_vec[(i + 1) % monitor_type_vec.len()] + ); + } + } + } + + #[rstest] + fn test_add_root_folder_select_quality_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec!["EPUB".to_owned(), "MOBI".to_owned()]); + + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "MOBI" + ); + + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "EPUB" + ); + } + + #[rstest] + fn test_add_root_folder_select_metadata_profile_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec!["Standard".to_owned(), "Comprehensive".to_owned()]); + + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Comprehensive" + ); + + AddRootFolderHandler::new( + key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Standard" + ); + } + + #[rstest] + fn test_add_root_folder_prompt_scroll(#[values(Key::Up, Key::Down)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + AddRootFolderHandler::new(key, &mut app, ActiveReadarrBlock::AddRootFolderPrompt, None) + .handle(); + + if key == Key::Up { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddRootFolderNameInput + ); + } else { + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddRootFolderSelectMonitor + ); + } + } + + #[rstest] + fn test_add_root_folder_prompt_scroll_no_op_when_not_ready( + #[values(Key::Up, Key::Down)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.down(); + + AddRootFolderHandler::new(key, &mut app, ActiveReadarrBlock::AddRootFolderPrompt, None) + .handle(); + + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddRootFolderPathInput + ); + } + } + + mod test_handle_home_end { + use pretty_assertions::assert_eq; + use std::sync::atomic::Ordering; + use strum::IntoEnumIterator; + + use super::*; + + #[test] + fn test_add_root_folder_select_monitor_home_end() { + let monitor_type_vec = Vec::from_iter(MonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_list + .set_items(monitor_type_vec.clone()); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[monitor_type_vec.len() - 1] + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_list + .current_selection(), + &monitor_type_vec[0] + ); + } + + #[test] + fn test_add_root_folder_select_monitor_new_items_home_end() { + let monitor_type_vec = Vec::from_iter(NewItemMonitorType::iter()); + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .monitor_new_items_list + .set_items(monitor_type_vec.clone()); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_type_vec[monitor_type_vec.len() - 1] + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .monitor_new_items_list + .current_selection(), + &monitor_type_vec[0] + ); + } + + #[test] + fn test_add_root_folder_select_quality_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .quality_profile_list + .set_items(vec![ + "EPUB".to_owned(), + "MOBI".to_owned(), + "AZW3".to_owned(), + ]); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "AZW3" + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .quality_profile_list + .current_selection(), + "EPUB" + ); + } + + #[test] + fn test_add_root_folder_select_metadata_profile_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .unwrap() + .metadata_profile_list + .set_items(vec![ + "Standard".to_owned(), + "Comprehensive".to_owned(), + "Minimal".to_owned(), + ]); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Minimal" + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .metadata_profile_list + .current_selection(), + "Standard" + ); + } + + #[test] + fn test_add_root_folder_name_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + name: "Test".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 4 + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_root_folder_path_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + path: "Testing".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 7 + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_root_folder_tags_input_home_end_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + tags: "Testing tags".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 12 + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + use std::sync::atomic::Ordering; + + use super::*; + + #[rstest] + fn test_left_right_prompt_toggle(#[values(Key::Left, Key::Right)] key: Key) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + + AddRootFolderHandler::new(key, &mut app, ActiveReadarrBlock::AddRootFolderPrompt, None) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + AddRootFolderHandler::new(key, &mut app, ActiveReadarrBlock::AddRootFolderPrompt, None) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[test] + fn test_add_root_folder_name_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + name: "Test".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_root_folder_path_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + path: "Test".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .offset + .load(Ordering::SeqCst), + 0 + ); + } + + #[test] + fn test_add_root_folder_tags_input_left_right_keys() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + tags: "Test".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 1 + ); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .offset + .load(Ordering::SeqCst), + 0 + ); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::ADD_ROOT_FOLDER_SELECTION_BLOCKS; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_add_root_folder_name_input_submit() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + name: "Test Name".into(), + ..AddReadarrRootFolderModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderNameInput.into()); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .text, + "Test Name" + ); + assert_navigation_popped!(app, ActiveReadarrBlock::AddRootFolderPrompt.into()); + } + + #[test] + fn test_add_root_folder_path_input_submit() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + path: "/nfs/Test Path".into(), + ..AddReadarrRootFolderModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPathInput.into()); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .text, + "/nfs/Test Path" + ); + assert_navigation_popped!(app, ActiveReadarrBlock::AddRootFolderPrompt.into()); + } + + #[test] + fn test_add_root_folder_tags_input_submit() { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + tags: "usenet, testing".into(), + ..AddReadarrRootFolderModal::default() + }); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderTagsInput.into()); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .text, + "usenet, testing" + ); + assert_navigation_popped!(app, ActiveReadarrBlock::AddRootFolderPrompt.into()); + } + + #[test] + fn test_add_root_folder_prompt_prompt_decline_submit() { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, ADD_ROOT_FOLDER_SELECTION_BLOCKS.len() - 1); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + + #[test] + fn test_add_root_folder_confirm_prompt_prompt_confirmation_submit() { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(populated_add_root_folder_modal()); + seed_distinct_profile_maps(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, ADD_ROOT_FOLDER_SELECTION_BLOCKS.len() - 1); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::AddRootFolder(expected_add_root_folder_body())) + ); + assert_modal_absent!(app.data.readarr_data.add_root_folder_modal); + assert!(app.should_refresh); + } + + #[test] + fn test_add_root_folder_confirm_prompt_prompt_confirmation_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AddRootFolderPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.should_refresh); + } + + #[rstest] + #[case(ActiveReadarrBlock::AddRootFolderNameInput, 0)] + #[case(ActiveReadarrBlock::AddRootFolderPathInput, 1)] + #[case(ActiveReadarrBlock::AddRootFolderSelectMonitor, 2)] + #[case(ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, 3)] + #[case(ActiveReadarrBlock::AddRootFolderSelectQualityProfile, 4)] + #[case(ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, 5)] + #[case(ActiveReadarrBlock::AddRootFolderTagsInput, 6)] + fn test_add_root_folder_prompt_selected_block_submit( + #[case] selected_block: ActiveReadarrBlock, + #[case] y_index: usize, + ) { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack( + ( + ActiveReadarrBlock::AddRootFolderPrompt, + Some(ActiveReadarrBlock::RootFolders), + ) + .into(), + ); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + Some(ActiveReadarrBlock::RootFolders), + ) + .handle(); + + assert_navigation_pushed!( + app, + (selected_block, Some(ActiveReadarrBlock::RootFolders)).into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + + if selected_block == ActiveReadarrBlock::AddRootFolderNameInput + || selected_block == ActiveReadarrBlock::AddRootFolderPathInput + || selected_block == ActiveReadarrBlock::AddRootFolderTagsInput + { + assert!(app.ignore_special_keys_for_textbox_input); + } + } + + #[rstest] + fn test_add_root_folder_prompt_selected_block_submit_no_op_when_not_ready( + #[values(0, 1, 2, 3, 4, 5, 6)] y_index: usize, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack( + ( + ActiveReadarrBlock::AddRootFolderPrompt, + Some(ActiveReadarrBlock::RootFolders), + ) + .into(), + ); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app.data.readarr_data.selected_block.set_index(0, y_index); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + Some(ActiveReadarrBlock::RootFolders), + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ( + ActiveReadarrBlock::AddRootFolderPrompt, + Some(ActiveReadarrBlock::RootFolders), + ) + .into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert!(!app.ignore_special_keys_for_textbox_input); + } + + #[rstest] + fn test_add_root_folder_prompt_selecting_preferences_blocks_submit( + #[values( + ActiveReadarrBlock::AddRootFolderSelectMonitor, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + ActiveReadarrBlock::AddRootFolderNameInput, + ActiveReadarrBlock::AddRootFolderPathInput, + ActiveReadarrBlock::AddRootFolderTagsInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AddRootFolderHandler::new( + SUBMIT_KEY, + &mut app, + active_readarr_block, + Some(ActiveReadarrBlock::RootFolders), + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::AddRootFolderPrompt.into()); + + if active_readarr_block == ActiveReadarrBlock::AddRootFolderNameInput + || active_readarr_block == ActiveReadarrBlock::AddRootFolderPathInput + || active_readarr_block == ActiveReadarrBlock::AddRootFolderTagsInput + { + assert!(!app.ignore_special_keys_for_textbox_input); + } + } + } + + mod test_handle_esc { + use rstest::rstest; + + use crate::assert_navigation_popped; + + use super::*; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_add_root_folder_input_esc( + #[values( + ActiveReadarrBlock::AddRootFolderTagsInput, + ActiveReadarrBlock::AddRootFolderPathInput, + ActiveReadarrBlock::AddRootFolderNameInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.ignore_special_keys_for_textbox_input = true; + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AddRootFolderHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert!(!app.ignore_special_keys_for_textbox_input); + assert_navigation_popped!(app, ActiveReadarrBlock::AddRootFolderPrompt.into()); + } + + #[test] + fn test_add_root_folder_prompt_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.data.readarr_data.prompt_confirm = true; + + AddRootFolderHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + assert_modal_absent!(app.data.readarr_data.add_root_folder_modal); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + fn test_add_root_folder_esc( + #[values( + ActiveReadarrBlock::AddRootFolderSelectMonitor, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile + )] + active_readarr_block: ActiveReadarrBlock, + #[values(true, false)] is_ready: bool, + ) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(active_readarr_block.into()); + + AddRootFolderHandler::new(ESC_KEY, &mut app, active_readarr_block, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::ADD_ROOT_FOLDER_SELECTION_BLOCKS; + + use super::*; + + #[test] + fn test_add_root_folder_name_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + name: "Test".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .text, + "Tes" + ); + } + + #[test] + fn test_add_root_folder_path_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + path: "Path".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .text, + "Pat" + ); + } + + #[test] + fn test_add_root_folder_tags_input_backspace() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal { + tags: "Tags".into(), + ..AddReadarrRootFolderModal::default() + }); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.backspace.key, + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .text, + "Tag" + ); + } + + #[test] + fn test_add_root_folder_name_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + + AddRootFolderHandler::new( + Key::Char('n'), + &mut app, + ActiveReadarrBlock::AddRootFolderNameInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .text, + "n" + ); + assert_is_empty!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .text + ); + assert_is_empty!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .text + ); + } + + #[test] + fn test_add_root_folder_path_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + + AddRootFolderHandler::new( + Key::Char('p'), + &mut app, + ActiveReadarrBlock::AddRootFolderPathInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .text, + "p" + ); + assert_is_empty!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .text + ); + assert_is_empty!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .text + ); + } + + #[test] + fn test_add_root_folder_tags_input_char_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + + AddRootFolderHandler::new( + Key::Char('t'), + &mut app, + ActiveReadarrBlock::AddRootFolderTagsInput, + None, + ) + .handle(); + + assert_str_eq!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .tags + .text, + "t" + ); + assert_is_empty!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .name + .text + ); + assert_is_empty!( + app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .unwrap() + .path + .text + ); + } + + #[test] + fn test_add_root_folder_confirm_prompt_prompt_confirm() { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(populated_add_root_folder_modal()); + seed_distinct_profile_maps(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app + .data + .readarr_data + .selected_block + .set_index(0, ADD_ROOT_FOLDER_SELECTION_BLOCKS.len() - 1); + + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::AddRootFolder(expected_add_root_folder_body())) + ); + assert_modal_absent!(app.data.readarr_data.add_root_folder_modal); + assert!(app.should_refresh); + } + } + + #[test] + fn test_add_root_folder_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) { + assert!( + AddRootFolderHandler::accepts(active_readarr_block), + "{active_readarr_block} is not accepted by the AddRootFolderHandler" + ); + } else { + assert!( + !AddRootFolderHandler::accepts(active_readarr_block), + "{active_readarr_block} is accepted by the AddRootFolderHandler" + ); + } + }); + } + + #[rstest] + fn test_add_root_folder_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_build_add_root_folder_body() { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(populated_add_root_folder_modal()); + seed_distinct_profile_maps(&mut app); + + let add_root_folder_body = AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .build_add_root_folder_body(); + + assert_eq!(add_root_folder_body, expected_add_root_folder_body()); + assert_modal_absent!(app.data.readarr_data.add_root_folder_modal); + } + + #[test] + fn test_build_add_root_folder_body_uses_the_selected_profiles() { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(populated_add_root_folder_modal()); + seed_distinct_profile_maps(&mut app); + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + None, + ) + .handle(); + AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + None, + ) + .handle(); + + let add_root_folder_body = AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ) + .build_add_root_folder_body(); + + assert_eq!(add_root_folder_body.default_quality_profile_id, 1111); + assert_eq!(add_root_folder_body.default_metadata_profile_id, 3333); + } + + #[test] + fn test_add_root_folder_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = true; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + + let handler = AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_add_root_folder_handler_is_not_ready_when_add_root_folder_modal_is_none() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = false; + + let handler = AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_add_root_folder_handler_is_ready_when_add_root_folder_modal_is_some() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = false; + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + + let handler = AddRootFolderHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + None, + ); + + assert!(handler.is_ready()); + } + + fn populated_add_root_folder_modal() -> AddReadarrRootFolderModal { + let mut add_root_folder_modal = AddReadarrRootFolderModal { + name: "Test Name".to_owned().into(), + path: "/nfs/Test Path".to_owned().into(), + tags: "usenet, testing".to_owned().into(), + ..AddReadarrRootFolderModal::default() + }; + add_root_folder_modal + .quality_profile_list + .set_items(vec!["EPUB".to_owned(), "MOBI".to_owned()]); + add_root_folder_modal + .quality_profile_list + .state + .select(Some(1)); + add_root_folder_modal + .metadata_profile_list + .set_items(vec!["Standard".to_owned(), "Comprehensive".to_owned()]); + add_root_folder_modal + .metadata_profile_list + .state + .select(Some(1)); + add_root_folder_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_root_folder_modal.monitor_list.state.select(Some(1)); + add_root_folder_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_root_folder_modal + .monitor_new_items_list + .state + .select(Some(1)); + + add_root_folder_modal + } + + fn seed_distinct_profile_maps(app: &mut App<'_>) { + app.data.readarr_data.quality_profile_map = + BiMap::from_iter([(1111, "EPUB".to_owned()), (2222, "MOBI".to_owned())]); + app.data.readarr_data.metadata_profile_map = BiMap::from_iter([ + (3333, "Standard".to_owned()), + (4444, "Comprehensive".to_owned()), + ]); + } + + fn expected_add_root_folder_body() -> AddReadarrRootFolderBody { + AddReadarrRootFolderBody { + name: "Test Name".to_owned(), + path: "/nfs/Test Path".to_owned(), + default_quality_profile_id: 2222, + default_metadata_profile_id: 4444, + default_monitor_option: MonitorType::Future, + default_new_item_monitor_option: NewItemMonitorType::None, + default_tags: Vec::new(), + tag_input_string: Some("usenet, testing".to_owned()), + } + } +} diff --git a/src/handlers/readarr_handlers/root_folders/mod.rs b/src/handlers/readarr_handlers/root_folders/mod.rs new file mode 100644 index 00000000..fc0cc20c --- /dev/null +++ b/src/handlers/readarr_handlers/root_folders/mod.rs @@ -0,0 +1,180 @@ +use add_root_folder_handler::AddRootFolderHandler; + +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; +use crate::handlers::{KeyEventHandler, handle_clear_errors, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::servarr_data::readarr::modals::AddReadarrRootFolderModal; +use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ADD_ROOT_FOLDER_SELECTION_BLOCKS, ActiveReadarrBlock, ROOT_FOLDERS_BLOCKS, +}; +use crate::models::{BlockSelectionState, Route}; +use crate::network::readarr_network::ReadarrEvent; + +mod add_root_folder_handler; + +#[cfg(test)] +#[path = "root_folders_handler_tests.rs"] +mod root_folders_handler_tests; + +pub(super) struct RootFoldersHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl RootFoldersHandler<'_, '_> { + fn extract_root_folder_id(&self) -> i64 { + self + .app + .data + .readarr_data + .root_folders + .current_selection() + .id + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for RootFoldersHandler<'a, 'b> { + fn handle(&mut self) { + let root_folders_table_handling_config = + TableHandlingConfig::new(ActiveReadarrBlock::RootFolders.into()); + + if AddRootFolderHandler::accepts(self.active_readarr_block) { + return AddRootFolderHandler::new( + self.key, + self.app, + self.active_readarr_block, + self.context, + ) + .handle(); + } + + if !handle_table( + self, + |app| &mut app.data.readarr_data.root_folders, + root_folders_table_handling_config, + ) { + self.handle_key_event(); + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + ROOT_FOLDERS_BLOCKS.contains(&active_block) || ADD_ROOT_FOLDER_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> RootFoldersHandler<'a, 'b> { + RootFoldersHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading && !self.app.data.readarr_data.root_folders.is_empty() + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::RootFolders { + self + .app + .push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()) + } + } + + fn handle_left_right_action(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::RootFolders => handle_change_tab_left_right_keys(self.app, self.key), + ActiveReadarrBlock::DeleteRootFolderPrompt => handle_prompt_toggle(self.app, self.key), + _ => (), + } + } + + fn handle_submit(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::DeleteRootFolderPrompt { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::DeleteRootFolder( + self.extract_root_folder_id(), + )); + } + + self.app.pop_navigation_stack(); + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::DeleteRootFolderPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + _ => handle_clear_errors(self.app), + } + } + + fn handle_char_key_event(&mut self) { + let key = self.key; + match self.active_readarr_block { + ActiveReadarrBlock::RootFolders => match self.key { + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ if matches_key!(add, key) => { + self.app.data.readarr_data.add_root_folder_modal = + Some(AddReadarrRootFolderModal::from(&self.app.data.readarr_data)); + self.app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + self + .app + .push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + } + _ => (), + }, + ActiveReadarrBlock::DeleteRootFolderPrompt => { + if matches_key!(confirm, key) { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::DeleteRootFolder( + self.extract_root_folder_id(), + )); + + self.app.pop_navigation_stack(); + } + } + _ => (), + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/readarr_handlers/root_folders/root_folders_handler_tests.rs new file mode 100644 index 00000000..0d220853 --- /dev/null +++ b/src/handlers/readarr_handlers/root_folders/root_folders_handler_tests.rs @@ -0,0 +1,574 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_modal_present; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::root_folders::RootFoldersHandler; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, ROOT_FOLDERS_BLOCKS, + }; + use crate::models::servarr_models::RootFolder; + + mod test_handle_delete { + use pretty_assertions::assert_eq; + + use super::*; + + const DELETE_KEY: Key = DEFAULT_KEYBINDINGS.delete.key; + + #[test] + fn test_delete_root_folder_prompt() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + + RootFoldersHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::RootFolders, None).handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + } + + #[test] + fn test_delete_root_folder_prompt_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + + RootFoldersHandler::new(DELETE_KEY, &mut app, ActiveReadarrBlock::RootFolders, None).handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::RootFolders.into() + ); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_root_folders_tab_left(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(4); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::History.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::History.into()); + } + + #[rstest] + fn test_root_folders_tab_right(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = is_ready; + app.data.readarr_data.main_tabs.set_index(4); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Indexers.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Indexers.into()); + } + + #[rstest] + fn test_left_right_delete_root_folder_prompt_toggle( + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + + RootFoldersHandler::new( + key, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + RootFoldersHandler::new( + key, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_navigation_popped; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::modals::AddReadarrRootFolderModal; + use crate::models::servarr_data::readarr::readarr_data::ADD_ROOT_FOLDER_SELECTION_BLOCKS; + use crate::network::readarr_network::ReadarrEvent; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_add_root_folder_prompt_submit_passes_the_context_to_the_add_root_folder_handler() { + let mut app = App::test_default(); + app.data.readarr_data.add_root_folder_modal = Some(AddReadarrRootFolderModal::default()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + + RootFoldersHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::AddRootFolderPrompt, + Some(ActiveReadarrBlock::RootFolders), + ) + .handle(); + + assert_navigation_pushed!( + app, + ( + ActiveReadarrBlock::AddRootFolderNameInput, + Some(ActiveReadarrBlock::RootFolders) + ) + .into() + ); + } + + #[test] + fn test_delete_root_folder_prompt_confirm_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.data.readarr_data.root_folders.select_index(Some(1)); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + + RootFoldersHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::DeleteRootFolder(2) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + } + + #[test] + fn test_delete_root_folder_prompt_decline_submit() { + let mut app = App::test_default(); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + + RootFoldersHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + } + + #[test] + fn test_delete_root_folder_prompt_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.data.readarr_data.prompt_confirm = true; + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + + RootFoldersHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::DeleteRootFolderPrompt.into() + ); + assert_none!(app.data.readarr_data.prompt_confirm_action); + } + } + + mod test_handle_esc { + use super::*; + use crate::assert_navigation_popped; + use rstest::rstest; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[test] + fn test_delete_root_folder_prompt_block_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + + RootFoldersHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + + #[rstest] + fn test_default_esc(#[values(true, false)] is_ready: bool) { + let mut app = App::test_default(); + app.is_loading = is_ready; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + + RootFoldersHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::RootFolders, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_navigation_popped; + use crate::network::readarr_network::ReadarrEvent; + + #[test] + fn test_root_folder_add() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::AddRootFolderPrompt.into()); + assert_modal_present!(app.data.readarr_data.add_root_folder_modal); + assert_eq!( + app.data.readarr_data.selected_block.get_active_block(), + ActiveReadarrBlock::AddRootFolderNameInput + ); + } + + #[test] + fn test_root_folder_add_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::RootFolders.into() + ); + assert_none!(app.data.readarr_data.add_root_folder_modal); + } + + #[test] + fn test_refresh_root_folders_key() { + let mut app = App::test_default(); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::RootFolders.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_root_folders_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::RootFolders.into() + ); + assert!(!app.should_refresh); + } + + #[test] + fn test_delete_root_folder_prompt_confirm() { + let mut app = App::test_default(); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.data.readarr_data.root_folders.select_index(Some(1)); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + + RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::DeleteRootFolder(2) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::RootFolders.into()); + } + } + + #[test] + fn test_root_folders_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if ROOT_FOLDERS_BLOCKS.contains(&active_readarr_block) + || ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) + { + assert!( + RootFoldersHandler::accepts(active_readarr_block), + "{active_readarr_block} is not accepted by the RootFoldersHandler" + ); + } else { + assert!( + !RootFoldersHandler::accepts(active_readarr_block), + "{active_readarr_block} is accepted by the RootFoldersHandler" + ); + } + }) + } + + #[rstest] + fn test_root_folders_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_extract_root_folder_id() { + let mut app = App::test_default(); + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + app.data.readarr_data.root_folders.select_index(Some(1)); + + let root_folder_id = RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::DeleteRootFolderPrompt, + None, + ) + .extract_root_folder_id(); + + assert_eq!(root_folder_id, 2); + } + + #[test] + fn test_root_folders_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = true; + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + + let handler = RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_root_folders_handler_not_ready_when_root_folders_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = false; + + let handler = RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_root_folders_handler_ready_when_not_loading_and_root_folders_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + app.is_loading = false; + app + .data + .readarr_data + .root_folders + .set_items(root_folders_vec()); + + let handler = RootFoldersHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::RootFolders, + None, + ); + + assert!(handler.is_ready()); + } + + fn root_folders_vec() -> Vec { + vec![ + RootFolder { + id: 1, + path: "/nfs/books".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs/audiobooks".to_owned(), + accessible: false, + free_space: 1024, + unmapped_folders: None, + }, + ] + } +} diff --git a/src/handlers/readarr_handlers/system/mod.rs b/src/handlers/readarr_handlers/system/mod.rs new file mode 100644 index 00000000..caffa715 --- /dev/null +++ b/src/handlers/readarr_handlers/system/mod.rs @@ -0,0 +1,135 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::readarr_handlers::handle_change_tab_left_right_keys; +use crate::handlers::readarr_handlers::system::system_details_handler::SystemDetailsHandler; +use crate::handlers::{KeyEventHandler, handle_clear_errors}; +use crate::matches_key; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::models::{Route, Scrollable}; + +mod system_details_handler; + +#[cfg(test)] +#[path = "system_handler_tests.rs"] +mod system_handler_tests; + +pub(super) struct SystemHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + context: Option, +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for SystemHandler<'a, 'b> { + fn handle(&mut self) { + match self.active_readarr_block { + _ if SystemDetailsHandler::accepts(self.active_readarr_block) => { + SystemDetailsHandler::new(self.key, self.app, self.active_readarr_block, self.context) + .handle() + } + _ => self.handle_key_event(), + } + } + + fn accepts(active_block: ActiveReadarrBlock) -> bool { + SystemDetailsHandler::accepts(active_block) || active_block == ActiveReadarrBlock::System + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> SystemHandler<'a, 'b> { + SystemHandler { + key, + app, + active_readarr_block: active_block, + context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + && !self.app.data.readarr_data.logs.is_empty() + && !self.app.data.readarr_data.queued_events.is_empty() + && !self.app.data.readarr_data.tasks.is_empty() + } + + fn handle_scroll_up(&mut self) {} + + fn handle_scroll_down(&mut self) {} + + fn handle_home(&mut self) {} + + fn handle_end(&mut self) {} + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::System { + handle_change_tab_left_right_keys(self.app, self.key); + } + } + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) { + handle_clear_errors(self.app) + } + + fn handle_char_key_event(&mut self) { + if self.active_readarr_block == ActiveReadarrBlock::System { + let key = self.key; + match self.key { + _ if matches_key!(refresh, key) => { + self.app.should_refresh = true; + } + _ if matches_key!(events, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::SystemQueuedEvents.into()); + } + _ if matches_key!(logs, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::SystemLogs.into()); + self + .app + .data + .readarr_data + .log_details + .set_items(self.app.data.readarr_data.logs.items.to_vec()); + self.app.data.readarr_data.log_details.scroll_to_bottom(); + } + _ if matches_key!(tasks, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + } + _ if matches_key!(update, key) => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::SystemUpdates.into()); + } + _ => (), + } + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/system/system_details_handler.rs b/src/handlers/readarr_handlers/system/system_details_handler.rs new file mode 100644 index 00000000..432a9bf1 --- /dev/null +++ b/src/handlers/readarr_handlers/system/system_details_handler.rs @@ -0,0 +1,211 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; +use crate::matches_key; +use crate::models::readarr_models::ReadarrTaskName; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, SYSTEM_DETAILS_BLOCKS, +}; +use crate::models::stateful_list::StatefulList; +use crate::models::{Route, Scrollable}; +use crate::network::readarr_network::ReadarrEvent; + +#[cfg(test)] +#[path = "system_details_handler_tests.rs"] +mod system_details_handler_tests; + +pub(super) struct SystemDetailsHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_readarr_block: ActiveReadarrBlock, + _context: Option, +} + +impl SystemDetailsHandler<'_, '_> { + fn extract_task_name(&self) -> ReadarrTaskName { + self + .app + .data + .readarr_data + .tasks + .current_selection() + .task_name + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveReadarrBlock> for SystemDetailsHandler<'a, 'b> { + fn accepts(active_block: ActiveReadarrBlock) -> bool { + SYSTEM_DETAILS_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveReadarrBlock, + context: Option, + ) -> SystemDetailsHandler<'a, 'b> { + SystemDetailsHandler { + key, + app, + active_readarr_block: active_block, + _context: context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + !self.app.is_loading + && (!self.app.data.readarr_data.log_details.is_empty() + || !self.app.data.readarr_data.tasks.is_empty() + || !self.app.data.readarr_data.updates.is_empty()) + } + + fn handle_scroll_up(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::SystemLogs => self.app.data.readarr_data.log_details.scroll_up(), + ActiveReadarrBlock::SystemTasks => self.app.data.readarr_data.tasks.scroll_up(), + ActiveReadarrBlock::SystemUpdates => self.app.data.readarr_data.updates.scroll_up(), + ActiveReadarrBlock::SystemQueuedEvents => { + self.app.data.readarr_data.queued_events.scroll_up() + } + _ => (), + } + } + + fn handle_scroll_down(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::SystemLogs => self.app.data.readarr_data.log_details.scroll_down(), + ActiveReadarrBlock::SystemTasks => self.app.data.readarr_data.tasks.scroll_down(), + ActiveReadarrBlock::SystemUpdates => self.app.data.readarr_data.updates.scroll_down(), + ActiveReadarrBlock::SystemQueuedEvents => { + self.app.data.readarr_data.queued_events.scroll_down() + } + _ => (), + } + } + + fn handle_home(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::SystemLogs => self.app.data.readarr_data.log_details.scroll_to_top(), + ActiveReadarrBlock::SystemTasks => self.app.data.readarr_data.tasks.scroll_to_top(), + ActiveReadarrBlock::SystemUpdates => self.app.data.readarr_data.updates.scroll_to_top(), + ActiveReadarrBlock::SystemQueuedEvents => { + self.app.data.readarr_data.queued_events.scroll_to_top() + } + _ => (), + } + } + + fn handle_end(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::SystemLogs => self.app.data.readarr_data.log_details.scroll_to_bottom(), + ActiveReadarrBlock::SystemTasks => self.app.data.readarr_data.tasks.scroll_to_bottom(), + ActiveReadarrBlock::SystemUpdates => self.app.data.readarr_data.updates.scroll_to_bottom(), + ActiveReadarrBlock::SystemQueuedEvents => { + self.app.data.readarr_data.queued_events.scroll_to_bottom() + } + _ => (), + } + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) { + let key = self.key; + + match self.active_readarr_block { + ActiveReadarrBlock::SystemLogs => match self.key { + _ if matches_key!(left, key) => { + self + .app + .data + .readarr_data + .log_details + .items + .iter() + .for_each(|log| log.scroll_right()); + } + _ if matches_key!(right, key) => { + self + .app + .data + .readarr_data + .log_details + .items + .iter() + .for_each(|log| log.scroll_left()); + } + _ => (), + }, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt => handle_prompt_toggle(self.app, self.key), + _ => (), + } + } + + fn handle_submit(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::SystemTasks => { + self + .app + .push_navigation_stack(ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + } + ActiveReadarrBlock::SystemTaskStartConfirmPrompt => { + if self.app.data.readarr_data.prompt_confirm { + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::StartTask(self.extract_task_name())); + } + + self.app.pop_navigation_stack(); + } + _ => (), + } + } + + fn handle_esc(&mut self) { + match self.active_readarr_block { + ActiveReadarrBlock::SystemLogs => { + self.app.data.readarr_data.log_details = StatefulList::default(); + self.app.pop_navigation_stack() + } + ActiveReadarrBlock::SystemQueuedEvents + | ActiveReadarrBlock::SystemTasks + | ActiveReadarrBlock::SystemUpdates => self.app.pop_navigation_stack(), + ActiveReadarrBlock::SystemTaskStartConfirmPrompt => { + self.app.pop_navigation_stack(); + self.app.data.readarr_data.prompt_confirm = false; + } + _ => (), + } + } + + fn handle_char_key_event(&mut self) { + if SYSTEM_DETAILS_BLOCKS.contains(&self.active_readarr_block) && matches_key!(refresh, self.key) + { + self.app.should_refresh = true; + } + + if self.active_readarr_block == ActiveReadarrBlock::SystemTaskStartConfirmPrompt + && matches_key!(confirm, self.key) + { + self.app.data.readarr_data.prompt_confirm = true; + self.app.data.readarr_data.prompt_confirm_action = + Some(ReadarrEvent::StartTask(self.extract_task_name())); + self.app.pop_navigation_stack(); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/readarr_handlers/system/system_details_handler_tests.rs b/src/handlers/readarr_handlers/system/system_details_handler_tests.rs new file mode 100644 index 00000000..27af3c9d --- /dev/null +++ b/src/handlers/readarr_handlers/system/system_details_handler_tests.rs @@ -0,0 +1,1151 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::system::system_details_handler::SystemDetailsHandler; + use crate::models::readarr_models::{ReadarrTask, ReadarrTaskName}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, SYSTEM_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::QueueEvent; + use crate::models::{HorizontallyScrollableText, ScrollableText}; + + mod test_handle_scroll_up_and_down { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::models::HorizontallyScrollableText; + use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; + + use super::*; + + test_iterable_scroll!( + test_log_details_scroll, + SystemDetailsHandler, + readarr_data, + log_details, + simple_stateful_iterable_vec!(HorizontallyScrollableText, String, text), + ActiveReadarrBlock::SystemLogs, + None, + text + ); + + #[rstest] + fn test_log_details_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app + .data + .readarr_data + .log_details + .set_items(simple_stateful_iterable_vec!( + HorizontallyScrollableText, + String, + text + )); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemLogs, None).handle(); + + assert_str_eq!( + app.data.readarr_data.log_details.current_selection().text, + "Test 1" + ); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemLogs, None).handle(); + + assert_str_eq!( + app.data.readarr_data.log_details.current_selection().text, + "Test 1" + ); + } + + #[rstest] + fn test_tasks_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.tasks.set_items(tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemTasks, None).handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Rss sync task" + ); + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemTasks, None).handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + } + + #[rstest] + fn test_tasks_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.tasks.set_items(tasks_vec()); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemTasks, None).handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemTasks, None).handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + } + + #[rstest] + fn test_queued_events_scroll( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.tasks.set_items(tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemQueuedEvents, None) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event two" + ); + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemQueuedEvents, None) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + } + + #[rstest] + fn test_queued_events_scroll_no_op_when_not_ready( + #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.tasks.set_items(tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemQueuedEvents, None) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + + SystemDetailsHandler::new(key, &mut app, ActiveReadarrBlock::SystemQueuedEvents, None) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + } + + #[test] + fn test_system_updates_scroll() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.updates = updates_text(); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 0); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 1); + } + + #[test] + fn test_system_updates_scroll_no_op_when_not_ready() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.updates = updates_text(); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 0); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 0); + } + } + + mod test_handle_home_end { + use crate::models::HorizontallyScrollableText; + use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; + + use super::*; + use pretty_assertions::assert_eq; + + test_iterable_home_and_end!( + test_log_details_home_end, + SystemDetailsHandler, + readarr_data, + log_details, + extended_stateful_iterable_vec!(HorizontallyScrollableText, String, text), + ActiveReadarrBlock::SystemLogs, + None, + text + ); + + #[test] + fn test_log_details_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app + .data + .readarr_data + .log_details + .set_items(extended_stateful_iterable_vec!( + HorizontallyScrollableText, + String, + text + )); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemLogs, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.log_details.current_selection().text, + "Test 1" + ); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemLogs, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.log_details.current_selection().text, + "Test 1" + ); + } + + #[test] + fn test_tasks_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.tasks.set_items(extended_tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(extended_queued_events_vec()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemTasks, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Housekeeping task" + ); + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemTasks, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + } + + #[test] + fn test_tasks_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.tasks.set_items(extended_tasks_vec()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemTasks, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemTasks, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + } + + #[test] + fn test_queued_events_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.tasks.set_items(extended_tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(extended_queued_events_vec()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event three" + ); + assert_str_eq!( + app.data.readarr_data.tasks.current_selection().name, + "Backup task" + ); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + } + + #[test] + fn test_queued_events_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.tasks.set_items(extended_tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(extended_queued_events_vec()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_str_eq!( + app.data.readarr_data.queued_events.current_selection().name, + "queued event one" + ); + } + + #[test] + fn test_system_updates_home_end() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.updates = updates_text(); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 1); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 0); + } + + #[test] + fn test_system_updates_home_end_no_op_when_not_ready() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.updates = updates_text(); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 0); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.updates.offset, 0); + } + } + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[test] + fn test_handle_log_details_left_right() { + let active_readarr_block = ActiveReadarrBlock::SystemLogs; + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app + .data + .readarr_data + .log_details + .set_items(vec!["t1".into(), "t22".into()]); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), "t1"); + assert_eq!( + app.data.readarr_data.log_details.items[1].to_string(), + "t22" + ); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), "1"); + assert_eq!(app.data.readarr_data.log_details.items[1].to_string(), "22"); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), ""); + assert_eq!(app.data.readarr_data.log_details.items[1].to_string(), "2"); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), ""); + assert_eq!(app.data.readarr_data.log_details.items[1].to_string(), ""); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), ""); + assert_eq!(app.data.readarr_data.log_details.items[1].to_string(), ""); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), "1"); + assert_eq!(app.data.readarr_data.log_details.items[1].to_string(), "2"); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), "t1"); + assert_eq!(app.data.readarr_data.log_details.items[1].to_string(), "22"); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.data.readarr_data.log_details.items[0].to_string(), "t1"); + assert_eq!( + app.data.readarr_data.log_details.items[1].to_string(), + "t22" + ); + } + + #[rstest] + fn test_left_right_prompt_toggle( + #[values(DEFAULT_KEYBINDINGS.left.key, DEFAULT_KEYBINDINGS.right.key)] key: Key, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + SystemDetailsHandler::new( + key, + &mut app, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + + SystemDetailsHandler::new( + key, + &mut app, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_submit { + use crate::assert_navigation_popped; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::assert_eq; + + use super::*; + + const SUBMIT_KEY: Key = DEFAULT_KEYBINDINGS.submit.key; + + #[test] + fn test_system_tasks_submit() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.updates = updates_text(); + + SystemDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::SystemTasks, None) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + } + + #[test] + fn test_system_tasks_submit_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + app.data.readarr_data.updates = updates_text(); + + SystemDetailsHandler::new(SUBMIT_KEY, &mut app, ActiveReadarrBlock::SystemTasks, None) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::SystemTasks.into() + ); + } + + #[test] + fn test_system_tasks_start_task_prompt_confirm_submit() { + let mut app = App::test_default(); + app.data.readarr_data.updates = updates_text(); + app.data.readarr_data.prompt_confirm = true; + app.data.readarr_data.tasks.set_items(tasks_vec()); + app.data.readarr_data.tasks.select_index(Some(1)); + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + + SystemDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_some_eq_x!( + &app.data.readarr_data.prompt_confirm_action, + &ReadarrEvent::StartTask(ReadarrTaskName::RssSync) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::SystemTasks.into()); + } + + #[test] + fn test_system_tasks_start_task_prompt_decline_submit() { + let mut app = App::test_default(); + app.data.readarr_data.updates = updates_text(); + app.data.readarr_data.tasks.set_items(tasks_vec()); + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + + SystemDetailsHandler::new( + SUBMIT_KEY, + &mut app, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(!app.data.readarr_data.prompt_confirm); + assert_none!(app.data.readarr_data.prompt_confirm_action); + assert_navigation_popped!(app, ActiveReadarrBlock::SystemTasks.into()); + } + } + + mod test_handle_esc { + use crate::models::HorizontallyScrollableText; + use rstest::rstest; + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_esc_system_logs(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app + .data + .readarr_data + .log_details + .set_items(vec![HorizontallyScrollableText::from("test")]); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemLogs.into()); + app + .data + .readarr_data + .log_details + .set_items(vec![HorizontallyScrollableText::default()]); + + SystemDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::SystemLogs, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::System.into()); + assert_is_empty!(app.data.readarr_data.log_details.items); + } + + #[rstest] + fn test_esc_system_tasks(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + app.data.readarr_data.tasks.set_items(tasks_vec()); + + SystemDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::SystemTasks, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::System.into()); + } + + #[rstest] + fn test_esc_system_queued_events(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemQueuedEvents.into()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + + SystemDetailsHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::SystemQueuedEvents, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::System.into()); + } + + #[rstest] + fn test_esc_system_updates(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemUpdates.into()); + + SystemDetailsHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::SystemUpdates, None) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::System.into()); + } + + #[test] + fn test_system_tasks_start_task_prompt_esc() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + app.data.readarr_data.prompt_confirm = true; + + SystemDetailsHandler::new( + ESC_KEY, + &mut app, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::SystemTasks.into()); + assert!(!app.data.readarr_data.prompt_confirm); + } + } + + mod test_handle_key_char { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use crate::network::readarr_network::ReadarrEvent; + + use super::*; + use crate::{assert_navigation_popped, assert_navigation_pushed}; + + #[rstest] + fn test_refresh_key( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.updates = updates_text(); + app.push_navigation_stack(active_readarr_block.into()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_navigation_pushed!(app, active_readarr_block.into()); + assert!(app.should_refresh); + } + + #[rstest] + fn test_refresh_key_no_op_when_not_ready( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.updates = updates_text(); + app.push_navigation_stack(active_readarr_block.into()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + active_readarr_block, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), active_readarr_block.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_system_tasks_start_task_prompt_confirm() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.updates = updates_text(); + app.data.readarr_data.tasks.set_items(tasks_vec()); + app.data.readarr_data.tasks.select_index(Some(1)); + app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into()); + app.push_navigation_stack(ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + + SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.confirm.key, + &mut app, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + None, + ) + .handle(); + + assert!(app.data.readarr_data.prompt_confirm); + assert_eq!( + app.data.readarr_data.prompt_confirm_action, + Some(ReadarrEvent::StartTask(ReadarrTaskName::RssSync)) + ); + assert_navigation_popped!(app, ActiveReadarrBlock::SystemTasks.into()); + } + } + + #[test] + fn test_system_details_handler_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if SYSTEM_DETAILS_BLOCKS.contains(&active_readarr_block) { + assert!(SystemDetailsHandler::accepts(active_readarr_block)); + } else { + assert!(!SystemDetailsHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_system_details_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_extract_task_name_reads_the_selected_task() { + let mut app = App::test_default(); + app.data.readarr_data.tasks.set_items(tasks_vec()); + app.data.readarr_data.tasks.select_index(Some(1)); + + let task_name = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::SystemTasks, + None, + ) + .extract_task_name(); + + assert_eq!(task_name, ReadarrTaskName::RssSync); + } + + #[test] + fn test_extract_task_name_reads_the_first_task_when_nothing_is_selected() { + let mut app = App::test_default(); + app.data.readarr_data.tasks.set_items(tasks_vec()); + + let task_name = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::SystemTasks, + None, + ) + .extract_task_name(); + + assert_eq!(task_name, ReadarrTaskName::Backup); + } + + #[test] + fn test_system_details_handler_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + app.data.readarr_data.updates = updates_text(); + + let handler = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_system_details_handler_not_ready_when_log_details_and_updates_and_tasks_are_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + + let handler = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_system_details_handler_ready_when_not_loading_and_log_details_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + app + .data + .readarr_data + .log_details + .set_items(vec![HorizontallyScrollableText::default()]); + + let handler = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_system_details_handler_ready_when_not_loading_and_tasks_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + app.data.readarr_data.tasks.set_items(tasks_vec()); + + let handler = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::SystemTasks, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_system_details_handler_ready_when_not_loading_and_updates_is_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + app.data.readarr_data.updates = updates_text(); + + let handler = SystemDetailsHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::SystemUpdates, + None, + ); + + assert!(handler.is_ready()); + } + + fn updates_text() -> ScrollableText { + ScrollableText::with_string("update note one\nupdate note two".to_owned()) + } + + fn tasks_vec() -> Vec { + vec![ + ReadarrTask { + name: "Backup task".to_owned(), + task_name: ReadarrTaskName::Backup, + ..ReadarrTask::default() + }, + ReadarrTask { + name: "Rss sync task".to_owned(), + task_name: ReadarrTaskName::RssSync, + ..ReadarrTask::default() + }, + ] + } + + fn extended_tasks_vec() -> Vec { + let mut tasks = tasks_vec(); + tasks.push(ReadarrTask { + name: "Housekeeping task".to_owned(), + task_name: ReadarrTaskName::Housekeeping, + ..ReadarrTask::default() + }); + + tasks + } + + fn queued_events_vec() -> Vec { + vec![ + QueueEvent { + name: "queued event one".to_owned(), + ..QueueEvent::default() + }, + QueueEvent { + name: "queued event two".to_owned(), + ..QueueEvent::default() + }, + ] + } + + fn extended_queued_events_vec() -> Vec { + let mut queued_events = queued_events_vec(); + queued_events.push(QueueEvent { + name: "queued event three".to_owned(), + ..QueueEvent::default() + }); + + queued_events + } +} diff --git a/src/handlers/readarr_handlers/system/system_handler_tests.rs b/src/handlers/readarr_handlers/system/system_handler_tests.rs new file mode 100644 index 00000000..4ffe1b0f --- /dev/null +++ b/src/handlers/readarr_handlers/system/system_handler_tests.rs @@ -0,0 +1,496 @@ +#[cfg(test)] +mod tests { + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::assert_navigation_pushed; + use crate::event::Key; + use crate::handlers::KeyEventHandler; + use crate::handlers::readarr_handlers::system::SystemHandler; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{ReadarrTask, ReadarrTaskName}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, SYSTEM_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::QueueEvent; + use crate::test_handler_delegation; + + mod test_handle_left_right_action { + use pretty_assertions::assert_eq; + + use super::*; + use crate::assert_navigation_pushed; + + #[rstest] + fn test_system_tab_left(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = is_loading; + app.data.readarr_data.main_tabs.set_index(6); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.left.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Indexers.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Indexers.into()); + } + + #[rstest] + fn test_system_tab_right(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = is_loading; + app.data.readarr_data.main_tabs.set_index(6); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.right.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!( + app.data.readarr_data.main_tabs.get_active_route(), + ActiveReadarrBlock::Authors.into() + ); + assert_navigation_pushed!(app, ActiveReadarrBlock::Authors.into()); + } + } + + mod test_handle_esc { + + use super::*; + use crate::assert_navigation_popped; + + const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key; + + #[rstest] + fn test_default_esc(#[values(true, false)] is_loading: bool) { + let mut app = App::test_default(); + app.is_loading = is_loading; + app.error = "test error".to_owned().into(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + SystemHandler::new(ESC_KEY, &mut app, ActiveReadarrBlock::System, None).handle(); + + assert_navigation_popped!(app, ActiveReadarrBlock::System.into()); + assert_is_empty!(app.error.text); + } + } + + mod test_handle_key_char { + use pretty_assertions::{assert_eq, assert_str_eq}; + + use super::*; + + #[test] + fn test_update_system_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SystemUpdates.into()); + } + + #[test] + fn test_update_system_key_no_op_if_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::System.into()); + } + + #[test] + fn test_queued_events_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.events.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SystemQueuedEvents.into()); + } + + #[test] + fn test_queued_events_key_no_op_if_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.events.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::System.into()); + } + + #[test] + fn test_refresh_system_key() { + let mut app = App::test_default(); + seed_system_data(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::System.into()); + assert!(app.should_refresh); + } + + #[test] + fn test_refresh_system_key_no_op_if_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + seed_system_data(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::System.into()); + assert!(!app.should_refresh); + } + + #[test] + fn test_logs_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.logs.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SystemLogs.into()); + assert_eq!( + app.data.readarr_data.log_details.items, + app.data.readarr_data.logs.items + ); + assert_str_eq!( + app.data.readarr_data.log_details.current_selection().text, + "log line gamma" + ); + } + + #[test] + fn test_logs_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.logs.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::System.into()); + assert_is_empty!(app.data.readarr_data.log_details); + } + + #[test] + fn test_tasks_key() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.tasks.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveReadarrBlock::SystemTasks.into()); + } + + #[test] + fn test_tasks_key_no_op_when_not_ready() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.tasks.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::System.into()); + } + + #[test] + fn test_unmapped_key_is_a_no_op() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + seed_system_data(&mut app); + + SystemHandler::new( + DEFAULT_KEYBINDINGS.add.key, + &mut app, + ActiveReadarrBlock::System, + None, + ) + .handle(); + + assert_eq!(app.get_current_route(), ActiveReadarrBlock::System.into()); + assert!(!app.should_refresh); + assert_is_empty!(app.data.readarr_data.log_details); + } + } + + #[rstest] + fn test_delegates_system_details_blocks_to_system_details_handler( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + test_handler_delegation!( + SystemHandler, + ActiveReadarrBlock::System, + active_readarr_block + ); + } + + #[test] + fn test_system_handler_accepts() { + let mut system_blocks = vec![ActiveReadarrBlock::System]; + system_blocks.extend(SYSTEM_DETAILS_BLOCKS); + + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if system_blocks.contains(&active_readarr_block) { + assert!(SystemHandler::accepts(active_readarr_block)); + } else { + assert!(!SystemHandler::accepts(active_readarr_block)); + } + }) + } + + #[rstest] + fn test_system_handler_ignore_special_keys( + #[values(true, false)] ignore_special_keys_for_textbox_input: bool, + ) { + let mut app = App::test_default(); + app.ignore_special_keys_for_textbox_input = ignore_special_keys_for_textbox_input; + let handler = SystemHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveReadarrBlock::default(), + None, + ); + + assert_eq!( + handler.ignore_special_keys(), + ignore_special_keys_for_textbox_input + ); + } + + #[test] + fn test_system_handler_is_not_ready_when_loading() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + seed_system_data(&mut app); + + let system_handler = SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::System, + None, + ); + + assert!(!system_handler.is_ready()); + } + + #[test] + fn test_system_handler_is_not_ready_when_logs_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + app.data.readarr_data.tasks.set_items(tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + + let system_handler = SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::System, + None, + ); + + assert!(!system_handler.is_ready()); + } + + #[test] + fn test_system_handler_is_not_ready_when_tasks_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + app.data.readarr_data.logs.set_items(logs_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + + let system_handler = SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::System, + None, + ); + + assert!(!system_handler.is_ready()); + } + + #[test] + fn test_system_handler_is_not_ready_when_queued_events_is_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + app.data.readarr_data.logs.set_items(logs_vec()); + app.data.readarr_data.tasks.set_items(tasks_vec()); + + let system_handler = SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::System, + None, + ); + + assert!(!system_handler.is_ready()); + } + + #[test] + fn test_system_handler_is_ready_when_all_required_tables_are_not_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = false; + seed_system_data(&mut app); + + let system_handler = SystemHandler::new( + DEFAULT_KEYBINDINGS.update.key, + &mut app, + ActiveReadarrBlock::System, + None, + ); + + assert!(system_handler.is_ready()); + } + + fn seed_system_data(app: &mut App<'_>) { + app.data.readarr_data.logs.set_items(logs_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + app.data.readarr_data.tasks.set_items(tasks_vec()); + } + + fn logs_vec() -> Vec { + vec![ + HorizontallyScrollableText::from("log line alpha"), + HorizontallyScrollableText::from("log line beta"), + HorizontallyScrollableText::from("log line gamma"), + ] + } + + fn queued_events_vec() -> Vec { + vec![ + QueueEvent { + name: "queued event one".to_owned(), + ..QueueEvent::default() + }, + QueueEvent { + name: "queued event two".to_owned(), + ..QueueEvent::default() + }, + ] + } + + fn tasks_vec() -> Vec { + vec![ + ReadarrTask { + name: "Backup task".to_owned(), + task_name: ReadarrTaskName::Backup, + ..ReadarrTask::default() + }, + ReadarrTask { + name: "Rss sync task".to_owned(), + task_name: ReadarrTaskName::RssSync, + ..ReadarrTask::default() + }, + ] + } +} diff --git a/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs index 06c6adaa..bb26530a 100644 --- a/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/sonarr_handlers/blocklist/blocklist_handler_tests.rs @@ -158,7 +158,7 @@ mod tests { #[case( ActiveSonarrBlock::Blocklist, ActiveSonarrBlock::DeleteBlocklistItemPrompt, - SonarrEvent::DeleteBlocklistItem(3) + SonarrEvent::DeleteBlocklistItem(2) )] #[case( ActiveSonarrBlock::Blocklist, @@ -172,6 +172,7 @@ mod tests { ) { let mut app = App::test_default(); app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.data.sonarr_data.blocklist.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -356,7 +357,7 @@ mod tests { #[case( ActiveSonarrBlock::Blocklist, ActiveSonarrBlock::DeleteBlocklistItemPrompt, - SonarrEvent::DeleteBlocklistItem(3) + SonarrEvent::DeleteBlocklistItem(2) )] #[case( ActiveSonarrBlock::Blocklist, @@ -370,6 +371,7 @@ mod tests { ) { let mut app = App::test_default(); app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.data.sonarr_data.blocklist.select_index(Some(1)); app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -543,6 +545,7 @@ mod tests { fn test_extract_blocklist_item_id() { let mut app = App::test_default(); app.data.sonarr_data.blocklist.set_items(blocklist_vec()); + app.data.sonarr_data.blocklist.select_index(Some(1)); let blocklist_item_id = BlocklistHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -552,7 +555,7 @@ mod tests { ) .extract_blocklist_item_id(); - assert_eq!(blocklist_item_id, 3); + assert_eq!(blocklist_item_id, 2); } #[test] diff --git a/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs b/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs index 67923760..cedd12cf 100644 --- a/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs +++ b/src/handlers/sonarr_handlers/downloads/downloads_handler_tests.rs @@ -140,7 +140,7 @@ mod tests { #[case( ActiveSonarrBlock::Downloads, ActiveSonarrBlock::DeleteDownloadPrompt, - SonarrEvent::DeleteDownload(1) + SonarrEvent::DeleteDownload(9) )] #[case( ActiveSonarrBlock::Downloads, @@ -153,11 +153,14 @@ mod tests { #[case] expected_action: SonarrEvent, ) { let mut app = App::test_default(); - app - .data - .sonarr_data - .downloads - .set_items(vec![download_record()]); + app.data.sonarr_data.downloads.set_items(vec![ + DownloadRecord { + id: 999, + ..download_record() + }, + download_record(), + ]); + app.data.sonarr_data.downloads.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -337,7 +340,7 @@ mod tests { #[case( ActiveSonarrBlock::Downloads, ActiveSonarrBlock::DeleteDownloadPrompt, - SonarrEvent::DeleteDownload(1) + SonarrEvent::DeleteDownload(9) )] #[case( ActiveSonarrBlock::Downloads, @@ -350,11 +353,14 @@ mod tests { #[case] expected_action: SonarrEvent, ) { let mut app = App::test_default(); - app - .data - .sonarr_data - .downloads - .set_items(vec![download_record()]); + app.data.sonarr_data.downloads.set_items(vec![ + DownloadRecord { + id: 999, + ..download_record() + }, + download_record(), + ]); + app.data.sonarr_data.downloads.select_index(Some(1)); app.push_navigation_stack(base_route.into()); app.push_navigation_stack(prompt_block.into()); @@ -408,11 +414,14 @@ mod tests { #[test] fn test_extract_download_id() { let mut app = App::test_default(); - app - .data - .sonarr_data - .downloads - .set_items(vec![download_record()]); + app.data.sonarr_data.downloads.set_items(vec![ + DownloadRecord { + id: 999, + ..download_record() + }, + download_record(), + ]); + app.data.sonarr_data.downloads.select_index(Some(1)); let download_id = DownloadsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -422,7 +431,7 @@ mod tests { ) .extract_download_id(); - assert_eq!(download_id, 1); + assert_eq!(download_id, 9); } #[test] diff --git a/src/handlers/sonarr_handlers/history/history_handler_tests.rs b/src/handlers/sonarr_handlers/history/history_handler_tests.rs index 4a34d17c..e0053e63 100644 --- a/src/handlers/sonarr_handlers/history/history_handler_tests.rs +++ b/src/handlers/sonarr_handlers/history/history_handler_tests.rs @@ -199,6 +199,40 @@ mod tests { assert_eq!(app.get_current_route(), ActiveSonarrBlock::History.into()); assert!(!app.should_refresh); } + + #[test] + fn test_search_history_key() { + let mut app = App::test_default(); + app.data.sonarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SearchHistory.into()); + } + + #[test] + fn test_filter_history_key() { + let mut app = App::test_default(); + app.data.sonarr_data.history.set_items(history_vec()); + app.push_navigation_stack(ActiveSonarrBlock::History.into()); + + HistoryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::History, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::FilterHistory.into()); + } } #[test] diff --git a/src/handlers/sonarr_handlers/indexers/edit_indexer_handler_tests.rs b/src/handlers/sonarr_handlers/indexers/edit_indexer_handler_tests.rs index 6ebe7fb2..104fb3d4 100644 --- a/src/handlers/sonarr_handlers/indexers/edit_indexer_handler_tests.rs +++ b/src/handlers/sonarr_handlers/indexers/edit_indexer_handler_tests.rs @@ -11,7 +11,7 @@ mod tests { use crate::handlers::sonarr_handlers::sonarr_handler_test_utils::utils::indexer; use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EDIT_INDEXER_BLOCKS}; - use crate::models::servarr_models::EditIndexerParams; + use crate::models::servarr_models::{EditIndexerParams, Indexer}; use pretty_assertions::assert_eq; use rstest::rstest; use strum::IntoEnumIterator; @@ -916,7 +916,14 @@ mod tests { priority: 0, }; app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); - app.data.sonarr_data.indexers.set_items(vec![indexer()]); + app.data.sonarr_data.indexers.set_items(vec![ + Indexer { + id: 999, + ..indexer() + }, + indexer(), + ]); + app.data.sonarr_data.indexers.select_index(Some(1)); let expected_edit_indexer_params = EditIndexerParams { indexer_id: 1, name: Some("Test Update".to_owned()), @@ -1758,7 +1765,14 @@ mod tests { priority: 0, }; app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); - app.data.sonarr_data.indexers.set_items(vec![indexer()]); + app.data.sonarr_data.indexers.set_items(vec![ + Indexer { + id: 999, + ..indexer() + }, + indexer(), + ]); + app.data.sonarr_data.indexers.select_index(Some(1)); let expected_edit_indexer_params = EditIndexerParams { indexer_id: 1, name: Some("Test Update".to_owned()), @@ -1836,7 +1850,14 @@ mod tests { priority: 0, }; app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); - app.data.sonarr_data.indexers.set_items(vec![indexer()]); + app.data.sonarr_data.indexers.set_items(vec![ + Indexer { + id: 999, + ..indexer() + }, + indexer(), + ]); + app.data.sonarr_data.indexers.select_index(Some(1)); let expected_edit_indexer_params = EditIndexerParams { indexer_id: 1, name: Some("Test Update".to_owned()), diff --git a/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs index 81357dde..90838c7a 100644 --- a/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/sonarr_handlers/indexers/indexers_handler_tests.rs @@ -148,6 +148,11 @@ mod tests { } else { "usenet".to_owned() }; + let decoy_protocol = if torrent_protocol { + "usenet".to_owned() + } else { + "torrent".to_owned() + }; let mut expected_edit_indexer_modal = EditIndexerModal { name: "Test".into(), enable_rss: Some(true), @@ -191,7 +196,14 @@ mod tests { fields: Some(fields), ..Indexer::default() }; - sonarr_data.indexers.set_items(vec![indexer]); + sonarr_data.indexers.set_items(vec![ + Indexer { + protocol: decoy_protocol, + ..Indexer::default() + }, + indexer, + ]); + sonarr_data.indexers.select_index(Some(1)); app.data.sonarr_data = sonarr_data; IndexersHandler::new(SUBMIT_KEY, &mut app, ActiveSonarrBlock::Indexers, None).handle(); @@ -239,7 +251,14 @@ mod tests { #[test] fn test_delete_indexer_prompt_confirm_submit() { let mut app = App::test_default(); - app.data.sonarr_data.indexers.set_items(vec![indexer()]); + app.data.sonarr_data.indexers.set_items(vec![ + Indexer { + id: 999, + ..indexer() + }, + indexer(), + ]); + app.data.sonarr_data.indexers.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); app.push_navigation_stack(ActiveSonarrBlock::DeleteIndexerPrompt.into()); @@ -538,7 +557,14 @@ mod tests { #[test] fn test_delete_indexer_prompt_confirm() { let mut app = App::test_default(); - app.data.sonarr_data.indexers.set_items(vec![indexer()]); + app.data.sonarr_data.indexers.set_items(vec![ + Indexer { + id: 999, + ..indexer() + }, + indexer(), + ]); + app.data.sonarr_data.indexers.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::Indexers.into()); app.push_navigation_stack(ActiveSonarrBlock::DeleteIndexerPrompt.into()); @@ -649,7 +675,14 @@ mod tests { #[test] fn test_extract_indexer_id() { let mut app = App::test_default(); - app.data.sonarr_data.indexers.set_items(vec![indexer()]); + app.data.sonarr_data.indexers.set_items(vec![ + Indexer { + id: 999, + ..indexer() + }, + indexer(), + ]); + app.data.sonarr_data.indexers.select_index(Some(1)); let indexer_id = IndexersHandler::new( DEFAULT_KEYBINDINGS.esc.key, diff --git a/src/handlers/sonarr_handlers/library/add_series_handler_tests.rs b/src/handlers/sonarr_handlers/library/add_series_handler_tests.rs index ee28d97e..06f8f1f9 100644 --- a/src/handlers/sonarr_handlers/library/add_series_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/add_series_handler_tests.rs @@ -1144,22 +1144,35 @@ mod tests { add_series_modal.root_folder_list.state.select(Some(1)); add_series_modal .quality_profile_list - .set_items(vec!["HD - 1080p".to_owned()]); + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + add_series_modal.quality_profile_list.state.select(Some(1)); add_series_modal .language_profile_list - .set_items(vec!["English".to_owned()]); + .set_items(vec!["Any".to_owned(), "English".to_owned()]); + add_series_modal.language_profile_list.state.select(Some(1)); add_series_modal .monitor_list .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal.monitor_list.state.select(Some(1)); add_series_modal .series_type_list .set_items(Vec::from_iter(SeriesType::iter())); + add_series_modal.series_type_list.state.select(Some(1)); app.data.sonarr_data.add_series_modal = Some(add_series_modal); app.data.sonarr_data.quality_profile_map = - BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); - app.data.sonarr_data.language_profiles_map = BiMap::from_iter([(2222, "English".to_owned())]); + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = + BiMap::from_iter([(3333, "Any".to_owned()), (4444, "English".to_owned())]); let mut add_searched_series = StatefulTable::default(); - add_searched_series.set_items(vec![add_series_search_result()]); + add_searched_series.set_items(vec![ + AddSeriesSearchResult { + tvdb_id: 999, + title: HorizontallyScrollableText::from("Decoy"), + ..add_series_search_result() + }, + add_series_search_result(), + ]); + add_searched_series.select_index(Some(1)); app.data.sonarr_data.add_searched_series = Some(add_searched_series); let expected_add_series_body = AddSeriesBody { tvdb_id: 1234, @@ -1167,13 +1180,13 @@ mod tests { monitored: true, root_folder_path: "/nfs2".to_owned(), quality_profile_id: 2222, - language_profile_id: 2222, - series_type: SeriesType::Standard, + language_profile_id: 4444, + series_type: SeriesType::Daily, season_folder: true, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddSeriesOptions { - monitor: SeriesMonitor::All, + monitor: SeriesMonitor::Unknown, search_for_cutoff_unmet_episodes: true, search_for_missing_episodes: true, }, @@ -1622,22 +1635,35 @@ mod tests { add_series_modal.root_folder_list.state.select(Some(1)); add_series_modal .quality_profile_list - .set_items(vec!["HD - 1080p".to_owned()]); + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + add_series_modal.quality_profile_list.state.select(Some(1)); add_series_modal .language_profile_list - .set_items(vec!["English".to_owned()]); + .set_items(vec!["Any".to_owned(), "English".to_owned()]); + add_series_modal.language_profile_list.state.select(Some(1)); add_series_modal .monitor_list .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal.monitor_list.state.select(Some(1)); add_series_modal .series_type_list .set_items(Vec::from_iter(SeriesType::iter())); + add_series_modal.series_type_list.state.select(Some(1)); app.data.sonarr_data.add_series_modal = Some(add_series_modal); app.data.sonarr_data.quality_profile_map = - BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); - app.data.sonarr_data.language_profiles_map = BiMap::from_iter([(2222, "English".to_owned())]); + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = + BiMap::from_iter([(3333, "Any".to_owned()), (4444, "English".to_owned())]); let mut add_searched_series = StatefulTable::default(); - add_searched_series.set_items(vec![add_series_search_result()]); + add_searched_series.set_items(vec![ + AddSeriesSearchResult { + tvdb_id: 999, + title: HorizontallyScrollableText::from("Decoy"), + ..add_series_search_result() + }, + add_series_search_result(), + ]); + add_searched_series.select_index(Some(1)); app.data.sonarr_data.add_searched_series = Some(add_searched_series); let expected_add_series_body = AddSeriesBody { tvdb_id: 1234, @@ -1645,13 +1671,13 @@ mod tests { monitored: true, root_folder_path: "/nfs2".to_owned(), quality_profile_id: 2222, - language_profile_id: 2222, - series_type: SeriesType::Standard, + language_profile_id: 4444, + series_type: SeriesType::Daily, season_folder: true, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddSeriesOptions { - monitor: SeriesMonitor::All, + monitor: SeriesMonitor::Unknown, search_for_cutoff_unmet_episodes: true, search_for_missing_episodes: true, }, @@ -1753,21 +1779,35 @@ mod tests { add_series_modal.root_folder_list.state.select(Some(1)); add_series_modal .quality_profile_list - .set_items(vec!["HD - 1080p".to_owned()]); + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + add_series_modal.quality_profile_list.state.select(Some(1)); add_series_modal .language_profile_list - .set_items(vec!["English".to_owned()]); + .set_items(vec!["Any".to_owned(), "English".to_owned()]); + add_series_modal.language_profile_list.state.select(Some(1)); add_series_modal .monitor_list .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal.monitor_list.state.select(Some(1)); add_series_modal .series_type_list .set_items(Vec::from_iter(SeriesType::iter())); + add_series_modal.series_type_list.state.select(Some(1)); app.data.sonarr_data.add_series_modal = Some(add_series_modal); - app.data.sonarr_data.quality_profile_map = BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); - app.data.sonarr_data.language_profiles_map = BiMap::from_iter([(2222, "English".to_owned())]); + app.data.sonarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = + BiMap::from_iter([(3333, "Any".to_owned()), (4444, "English".to_owned())]); let mut add_searched_series = StatefulTable::default(); - add_searched_series.set_items(vec![add_series_search_result()]); + add_searched_series.set_items(vec![ + AddSeriesSearchResult { + tvdb_id: 999, + title: HorizontallyScrollableText::from("Decoy"), + ..add_series_search_result() + }, + add_series_search_result(), + ]); + add_searched_series.select_index(Some(1)); app.data.sonarr_data.add_searched_series = Some(add_searched_series); let expected_add_series_body = AddSeriesBody { tvdb_id: 1234, @@ -1775,13 +1815,13 @@ mod tests { monitored: true, root_folder_path: "/nfs2".to_owned(), quality_profile_id: 2222, - language_profile_id: 2222, - series_type: SeriesType::Standard, + language_profile_id: 4444, + series_type: SeriesType::Daily, season_folder: true, tags: Vec::default(), tag_input_string: Some("usenet, testing".to_owned()), add_options: AddSeriesOptions { - monitor: SeriesMonitor::All, + monitor: SeriesMonitor::Unknown, search_for_cutoff_unmet_episodes: true, search_for_missing_episodes: true, }, diff --git a/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs b/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs index 50a65206..d9825a03 100644 --- a/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/delete_series_handler_tests.rs @@ -11,7 +11,7 @@ mod tests { use crate::handlers::sonarr_handlers::library::delete_series_handler::DeleteSeriesHandler; use crate::handlers::sonarr_handlers::sonarr_handler_test_utils::utils::series; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, DELETE_SERIES_BLOCKS}; - use crate::models::sonarr_models::DeleteSeriesParams; + use crate::models::sonarr_models::{DeleteSeriesParams, Series}; mod test_handle_scroll_up_and_down { use pretty_assertions::assert_eq; @@ -131,12 +131,19 @@ mod tests { app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app.push_navigation_stack(ActiveSonarrBlock::DeleteSeriesPrompt.into()); app.data.sonarr_data.prompt_confirm = true; - app.data.sonarr_data.delete_series_files = true; + app.data.sonarr_data.delete_series_files = false; app.data.sonarr_data.add_list_exclusion = true; - app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); let expected_delete_series_params = DeleteSeriesParams { id: 1, - delete_series_files: true, + delete_series_files: false, add_list_exclusion: true, }; app.data.sonarr_data.selected_block = @@ -277,12 +284,19 @@ mod tests { app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app.push_navigation_stack(ActiveSonarrBlock::DeleteSeriesPrompt.into()); app.data.sonarr_data.delete_series_files = true; - app.data.sonarr_data.add_list_exclusion = true; - app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.add_list_exclusion = false; + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); let expected_delete_series_params = DeleteSeriesParams { id: 1, delete_series_files: true, - add_list_exclusion: true, + add_list_exclusion: false, }; app.data.sonarr_data.selected_block = BlockSelectionState::new(DELETE_SERIES_SELECTION_BLOCKS); @@ -345,13 +359,20 @@ mod tests { #[test] fn test_build_delete_series_params() { let mut app = App::test_default(); - app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.data.sonarr_data.delete_series_files = true; - app.data.sonarr_data.add_list_exclusion = true; + app.data.sonarr_data.add_list_exclusion = false; let expected_delete_series_params = DeleteSeriesParams { id: 1, delete_series_files: true, - add_list_exclusion: true, + add_list_exclusion: false, }; let delete_series_params = DeleteSeriesHandler::new( diff --git a/src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs b/src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs index 6eb48b70..0992f666 100644 --- a/src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/edit_series_handler_tests.rs @@ -785,29 +785,39 @@ mod tests { edit_series .quality_profile_list .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + edit_series.quality_profile_list.state.select(Some(1)); edit_series .language_profile_list .set_items(vec!["Any".to_owned(), "English".to_owned()]); + edit_series.language_profile_list.state.select(Some(1)); edit_series .series_type_list .set_items(Vec::from_iter(SeriesType::iter())); + edit_series.series_type_list.state.select(Some(1)); app.data.sonarr_data.edit_series_modal = Some(edit_series); - app.data.sonarr_data.series.set_items(vec![Series { - monitored: false, - season_folder: false, - ..series() - }]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + Series { + monitored: false, + season_folder: false, + ..series() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.data.sonarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); app.data.sonarr_data.language_profiles_map = - BiMap::from_iter([(1111, "Any".to_owned()), (2222, "English".to_owned())]); + BiMap::from_iter([(3333, "Any".to_owned()), (4444, "English".to_owned())]); let expected_edit_series_params = EditSeriesParams { series_id: 1, monitored: Some(false), use_season_folders: Some(false), - series_type: Some(SeriesType::Standard), - quality_profile_id: Some(1111), - language_profile_id: Some(1111), + series_type: Some(SeriesType::Daily), + quality_profile_id: Some(2222), + language_profile_id: Some(4444), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".to_owned()), ..EditSeriesParams::default() @@ -1298,29 +1308,39 @@ mod tests { edit_series .quality_profile_list .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + edit_series.quality_profile_list.state.select(Some(1)); edit_series .language_profile_list .set_items(vec!["Any".to_owned(), "English".to_owned()]); + edit_series.language_profile_list.state.select(Some(1)); edit_series .series_type_list .set_items(Vec::from_iter(SeriesType::iter())); + edit_series.series_type_list.state.select(Some(1)); app.data.sonarr_data.edit_series_modal = Some(edit_series); - app.data.sonarr_data.series.set_items(vec![Series { - monitored: false, - season_folder: false, - ..series() - }]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + Series { + monitored: false, + season_folder: false, + ..series() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.data.sonarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); app.data.sonarr_data.language_profiles_map = - BiMap::from_iter([(1111, "Any".to_owned()), (2222, "English".to_owned())]); + BiMap::from_iter([(3333, "Any".to_owned()), (4444, "English".to_owned())]); let expected_edit_series_params = EditSeriesParams { series_id: 1, monitored: Some(false), use_season_folders: Some(false), - series_type: Some(SeriesType::Standard), - quality_profile_id: Some(1111), - language_profile_id: Some(1111), + series_type: Some(SeriesType::Daily), + quality_profile_id: Some(2222), + language_profile_id: Some(4444), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".to_owned()), ..EditSeriesParams::default() @@ -1395,29 +1415,39 @@ mod tests { edit_series .quality_profile_list .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + edit_series.quality_profile_list.state.select(Some(1)); edit_series .language_profile_list .set_items(vec!["Any".to_owned(), "English".to_owned()]); + edit_series.language_profile_list.state.select(Some(1)); edit_series .series_type_list .set_items(Vec::from_iter(SeriesType::iter())); + edit_series.series_type_list.state.select(Some(1)); app.data.sonarr_data.edit_series_modal = Some(edit_series); - app.data.sonarr_data.series.set_items(vec![Series { - monitored: false, - season_folder: false, - ..series() - }]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + Series { + monitored: false, + season_folder: false, + ..series() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.data.sonarr_data.quality_profile_map = BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); app.data.sonarr_data.language_profiles_map = - BiMap::from_iter([(1111, "Any".to_owned()), (2222, "English".to_owned())]); + BiMap::from_iter([(3333, "Any".to_owned()), (4444, "English".to_owned())]); let expected_edit_series_params = EditSeriesParams { series_id: 1, monitored: Some(false), use_season_folders: Some(false), - series_type: Some(SeriesType::Standard), - quality_profile_id: Some(1111), - language_profile_id: Some(1111), + series_type: Some(SeriesType::Daily), + quality_profile_id: Some(2222), + language_profile_id: Some(4444), root_folder_path: Some("/nfs/Test Path".to_owned()), tag_input_string: Some("usenet, testing".to_owned()), ..EditSeriesParams::default() diff --git a/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs index 1de4098d..1b57bfb8 100644 --- a/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/episode_details_handler_tests.rs @@ -11,7 +11,7 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, }; - use crate::models::sonarr_models::SonarrReleaseDownloadBody; + use crate::models::sonarr_models::{Episode, SonarrRelease, SonarrReleaseDownloadBody}; use crate::models::stateful_table::StatefulTable; use pretty_assertions::assert_eq; use rstest::rstest; @@ -207,7 +207,7 @@ mod tests { #[rstest] #[case( ActiveSonarrBlock::AutomaticallySearchEpisodePrompt, - SonarrEvent::TriggerAutomaticEpisodeSearch(1) + SonarrEvent::TriggerAutomaticEpisodeSearch(11) )] fn test_episode_details_prompt_confirm_submit( #[case] prompt_block: ActiveSonarrBlock, @@ -229,7 +229,21 @@ mod tests { .as_mut() .unwrap() .episodes - .set_items(vec![episode()]); + .set_items(vec![ + Episode { + id: 999, + ..episode() + }, + episode(), + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(active_sonarr_block.into()); app.push_navigation_stack(prompt_block.into()); @@ -248,6 +262,61 @@ mod tests { fn test_manual_episode_search_confirm_prompt_confirm_submit() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_releases + .set_items(vec![ + SonarrRelease { + guid: "decoy guid".to_owned(), + indexer_id: 999, + ..SonarrRelease::default() + }, + SonarrRelease { + guid: "test guid".to_owned(), + indexer_id: 2, + ..SonarrRelease::default() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_releases + .select_index(Some(1)); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![ + Episode { + id: 999, + ..episode() + }, + Episode { id: 5, ..episode() }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearch.into()); app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt.into()); @@ -265,9 +334,9 @@ mod tests { assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, &SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { - guid: String::new(), - indexer_id: 0, - episode_id: Some(0), + guid: "test guid".to_owned(), + indexer_id: 2, + episode_id: Some(5), ..SonarrReleaseDownloadBody::default() }) ); @@ -546,7 +615,21 @@ mod tests { .as_mut() .unwrap() .episodes - .set_items(vec![episode()]); + .set_items(vec![ + Episode { + id: 999, + ..episode() + }, + episode(), + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(active_sonarr_block.into()); app.push_navigation_stack(ActiveSonarrBlock::AutomaticallySearchEpisodePrompt.into()); @@ -563,7 +646,7 @@ mod tests { assert_navigation_popped!(app, active_sonarr_block.into()); assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, - &SonarrEvent::TriggerAutomaticEpisodeSearch(1) + &SonarrEvent::TriggerAutomaticEpisodeSearch(11) ); } @@ -571,6 +654,61 @@ mod tests { fn test_episode_details_manual_search_confirm_prompt_confirm_confirm_key() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_releases + .set_items(vec![ + SonarrRelease { + guid: "decoy guid".to_owned(), + indexer_id: 999, + ..SonarrRelease::default() + }, + SonarrRelease { + guid: "test guid".to_owned(), + indexer_id: 2, + ..SonarrRelease::default() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_releases + .select_index(Some(1)); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![ + Episode { + id: 999, + ..episode() + }, + Episode { id: 5, ..episode() }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearch.into()); app.push_navigation_stack(ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt.into()); @@ -588,9 +726,9 @@ mod tests { assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, &SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { - guid: String::new(), - indexer_id: 0, - episode_id: Some(0), + guid: "test guid".to_owned(), + indexer_id: 2, + episode_id: Some(5), ..SonarrReleaseDownloadBody::default() }) ); @@ -631,7 +769,14 @@ mod tests { fn test_extract_episode_id() { let mut app = App::test_default(); let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episodes.set_items(vec![episode()]); + season_details_modal.episodes.set_items(vec![ + Episode { + id: 999, + ..episode() + }, + episode(), + ]); + season_details_modal.episodes.select_index(Some(1)); app.data.sonarr_data.season_details_modal = Some(season_details_modal); let episode_id = EpisodeDetailsHandler::new( @@ -642,7 +787,7 @@ mod tests { ) .extract_episode_id(); - assert_eq!(episode_id, 1); + assert_eq!(episode_id, 11); } #[test] diff --git a/src/handlers/sonarr_handlers/library/library_handler_tests.rs b/src/handlers/sonarr_handlers/library/library_handler_tests.rs index 712168b2..62bb1f05 100644 --- a/src/handlers/sonarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/library_handler_tests.rs @@ -17,6 +17,7 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ADD_SERIES_BLOCKS, ActiveSonarrBlock, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, EPISODE_DETAILS_BLOCKS, LIBRARY_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, + SERIES_OVERVIEW_BLOCKS, }; use crate::models::sonarr_models::{Series, SeriesStatistics, SeriesStatus, SeriesType}; use crate::test_handler_delegation; @@ -379,6 +380,14 @@ mod tests { fn test_toggle_monitoring_key() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::Series.into()); app.is_routing = false; @@ -395,7 +404,7 @@ mod tests { assert!(app.is_routing); assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, - &SonarrEvent::ToggleSeriesMonitoring(0) + &SonarrEvent::ToggleSeriesMonitoring(1) ); } @@ -507,6 +516,48 @@ mod tests { assert!(!app.should_refresh); } + #[test] + fn test_search_series_key() { + let mut app = App::test_default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SearchSeries.into()); + } + + #[test] + fn test_filter_series_key() { + let mut app = App::test_default(); + app + .data + .sonarr_data + .series + .set_items(vec![Series::default()]); + app.push_navigation_stack(ActiveSonarrBlock::Series.into()); + + LibraryHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::Series, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::FilterSeries.into()); + } + #[test] fn test_update_all_series_prompt_confirm() { let mut app = App::test_default(); @@ -662,7 +713,14 @@ mod tests { #[test] fn test_extract_series_id() { let mut app = App::test_default(); - app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); let series_id = LibraryHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -891,6 +949,7 @@ mod tests { library_handler_blocks.extend(DELETE_SERIES_BLOCKS); library_handler_blocks.extend(EDIT_SERIES_BLOCKS); library_handler_blocks.extend(SERIES_DETAILS_BLOCKS); + library_handler_blocks.extend(SERIES_OVERVIEW_BLOCKS); library_handler_blocks.extend(SEASON_DETAILS_BLOCKS); library_handler_blocks.extend(EPISODE_DETAILS_BLOCKS); diff --git a/src/handlers/sonarr_handlers/library/mod.rs b/src/handlers/sonarr_handlers/library/mod.rs index 01934a4e..ab8837ec 100644 --- a/src/handlers/sonarr_handlers/library/mod.rs +++ b/src/handlers/sonarr_handlers/library/mod.rs @@ -36,6 +36,7 @@ mod episode_details_handler; mod library_handler_tests; mod season_details_handler; mod series_details_handler; +mod series_overview_handler; pub(super) struct LibraryHandler<'a, 'b> { key: Key, diff --git a/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs index da8d1594..471644ce 100644 --- a/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/season_details_handler_tests.rs @@ -16,7 +16,9 @@ mod tests { ActiveSonarrBlock, SEASON_DETAILS_BLOCKS, }; use crate::models::servarr_models::{Language, Quality, QualityWrapper}; - use crate::models::sonarr_models::{SonarrRelease, SonarrReleaseDownloadBody}; + use crate::models::sonarr_models::{ + Episode, Season, Series, SonarrRelease, SonarrReleaseDownloadBody, + }; use pretty_assertions::{assert_eq, assert_str_eq}; use rstest::rstest; use serde_json::Number; @@ -268,11 +270,11 @@ mod tests { #[rstest] #[case( ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, - SonarrEvent::TriggerAutomaticSeasonSearch(0, 0) + SonarrEvent::TriggerAutomaticSeasonSearch(7, 3) )] #[case( ActiveSonarrBlock::DeleteEpisodeFilePrompt, - SonarrEvent::DeleteEpisodeFile(0) + SonarrEvent::DeleteEpisodeFile(9) )] fn test_season_details_prompt_confirm_submit( #[case] prompt_block: ActiveSonarrBlock, @@ -282,6 +284,55 @@ mod tests { ) { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![ + Episode { + id: 999, + episode_file_id: 999, + ..episode() + }, + Episode { + id: 5, + episode_file_id: 9, + ..episode() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..Series::default() + }, + Series { + id: 7, + ..Series::default() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..Season::default() + }, + Season { + season_number: 3, + ..Season::default() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(active_sonarr_block.into()); app.push_navigation_stack(prompt_block.into()); @@ -300,6 +351,55 @@ mod tests { fn test_season_details_manual_search_confirm_prompt_confirm_submit() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases + .set_items(vec![ + SonarrRelease { + guid: "decoy guid".to_owned(), + indexer_id: 999, + ..SonarrRelease::default() + }, + SonarrRelease { + guid: "test guid".to_owned(), + indexer_id: 2, + ..SonarrRelease::default() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases + .select_index(Some(1)); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..Series::default() + }, + Series { + id: 7, + ..Series::default() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..Season::default() + }, + Season { + season_number: 3, + ..Season::default() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt.into()); @@ -317,10 +417,10 @@ mod tests { assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, &SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { - guid: String::new(), - indexer_id: 0, - series_id: Some(0), - season_number: Some(0), + guid: "test guid".to_owned(), + indexer_id: 2, + series_id: Some(7), + season_number: Some(3), ..SonarrReleaseDownloadBody::default() }) ); @@ -539,7 +639,21 @@ mod tests { .as_mut() .unwrap() .episodes - .set_items(vec![episode()]); + .set_items(vec![ + Episode { + id: 999, + ..episode() + }, + episode(), + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); app.is_routing = false; @@ -559,7 +673,7 @@ mod tests { assert!(app.is_routing); assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, - &SonarrEvent::ToggleEpisodeMonitoring(1) + &SonarrEvent::ToggleEpisodeMonitoring(11) ); } @@ -691,14 +805,65 @@ mod tests { assert!(!app.is_routing); } + #[test] + fn test_search_episodes_key() { + let mut app = App::test_default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + + SeasonDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::SeasonDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SearchEpisodes.into()); + } + + #[test] + fn test_search_season_history_key() { + let mut app = App::test_default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + + SeasonDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::SeasonHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SearchSeasonHistory.into()); + } + + #[test] + fn test_filter_season_history_key() { + let mut app = App::test_default(); + app.data.sonarr_data = create_test_sonarr_data(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonHistory.into()); + + SeasonDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::SeasonHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::FilterSeasonHistory.into()); + } + #[rstest] #[case( ActiveSonarrBlock::AutomaticallySearchSeasonPrompt, - SonarrEvent::TriggerAutomaticSeasonSearch(0, 0) + SonarrEvent::TriggerAutomaticSeasonSearch(7, 3) )] #[case( ActiveSonarrBlock::DeleteEpisodeFilePrompt, - SonarrEvent::DeleteEpisodeFile(0) + SonarrEvent::DeleteEpisodeFile(9) )] fn test_season_details_prompt_confirm_confirm_key( #[case] prompt_block: ActiveSonarrBlock, @@ -708,6 +873,55 @@ mod tests { ) { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![ + Episode { + id: 999, + episode_file_id: 999, + ..episode() + }, + Episode { + id: 5, + episode_file_id: 9, + ..episode() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..Series::default() + }, + Series { + id: 7, + ..Series::default() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..Season::default() + }, + Season { + season_number: 3, + ..Season::default() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(active_sonarr_block.into()); app.push_navigation_stack(prompt_block.into()); @@ -732,6 +946,55 @@ mod tests { fn test_season_details_manual_search_confirm_prompt_confirm_confirm_key() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases + .set_items(vec![ + SonarrRelease { + guid: "decoy guid".to_owned(), + indexer_id: 999, + ..SonarrRelease::default() + }, + SonarrRelease { + guid: "test guid".to_owned(), + indexer_id: 2, + ..SonarrRelease::default() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases + .select_index(Some(1)); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..Series::default() + }, + Series { + id: 7, + ..Series::default() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..Season::default() + }, + Season { + season_number: 3, + ..Season::default() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearch.into()); app.push_navigation_stack(ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt.into()); @@ -749,10 +1012,10 @@ mod tests { assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, &SonarrEvent::DownloadRelease(SonarrReleaseDownloadBody { - guid: String::new(), - indexer_id: 0, - series_id: Some(0), - season_number: Some(0), + guid: "test guid".to_owned(), + indexer_id: 2, + series_id: Some(7), + season_number: Some(3), ..SonarrReleaseDownloadBody::default() }) ); @@ -793,6 +1056,31 @@ mod tests { fn test_extract_episode_file_id() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![ + Episode { + episode_file_id: 999, + ..episode() + }, + Episode { + episode_file_id: 9, + ..episode() + }, + ]); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .select_index(Some(1)); let episode_file_id = SeasonDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -802,7 +1090,7 @@ mod tests { ) .extract_episode_file_id(); - assert_eq!(episode_file_id, 0); + assert_eq!(episode_file_id, 9); } #[test] @@ -823,7 +1111,14 @@ mod tests { fn test_extract_episode_id() { let mut app = App::test_default(); let mut season_details_modal = SeasonDetailsModal::default(); - season_details_modal.episodes.set_items(vec![episode()]); + season_details_modal.episodes.set_items(vec![ + Episode { + id: 999, + ..episode() + }, + episode(), + ]); + season_details_modal.episodes.select_index(Some(1)); app.data.sonarr_data.season_details_modal = Some(season_details_modal); let episode_id = SeasonDetailsHandler::new( @@ -834,7 +1129,7 @@ mod tests { ) .extract_episode_id(); - assert_eq!(episode_id, 1); + assert_eq!(episode_id, 11); } #[test] @@ -855,6 +1150,28 @@ mod tests { fn test_extract_series_id_season_number_tuple() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..Series::default() + }, + Series { + id: 7, + ..Series::default() + }, + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..Season::default() + }, + Season { + season_number: 3, + ..Season::default() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); let (series_id, season_number) = SeasonDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -864,8 +1181,8 @@ mod tests { ) .extract_series_id_season_number_tuple(); - assert_eq!(series_id, 0); - assert_eq!(season_number, 0); + assert_eq!(series_id, 7); + assert_eq!(season_number, 3); } #[test] diff --git a/src/handlers/sonarr_handlers/library/series_details_handler.rs b/src/handlers/sonarr_handlers/library/series_details_handler.rs index f5132ba8..47faeafe 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler.rs @@ -1,14 +1,16 @@ +use super::series_overview_handler::SeriesOverviewHandler; use crate::app::App; use crate::event::Key; use crate::handlers::sonarr_handlers::history::history_sorting_options; use crate::handlers::table_handler::{TableHandlingConfig, handle_table}; use crate::handlers::{KeyEventHandler, handle_prompt_toggle}; use crate::matches_key; +use crate::models::servarr_data::sonarr::modals::SeriesOverviewModal; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EDIT_SERIES_SELECTION_BLOCKS, SERIES_DETAILS_BLOCKS, }; use crate::models::sonarr_models::{Season, SonarrHistoryItem}; -use crate::models::{BlockSelectionState, Route}; +use crate::models::{BlockSelectionState, Route, ScrollableText}; use crate::network::sonarr_network::SonarrEvent; #[cfg(test)] @@ -39,10 +41,36 @@ impl SeriesDetailsHandler<'_, '_> { fn extract_series_id(&self) -> i64 { self.app.data.sonarr_data.series.current_selection().id } + + fn build_series_overview_modal(&mut self) { + let overview = self + .app + .data + .sonarr_data + .series + .current_selection() + .overview + .clone() + .unwrap_or_default(); + + self.app.data.sonarr_data.series_overview_modal = Some(SeriesOverviewModal { + overview: ScrollableText::with_string(overview), + }); + } } impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler<'a, 'b> { fn handle(&mut self) { + if SeriesOverviewHandler::accepts(self.active_sonarr_block) { + return SeriesOverviewHandler::new( + self.key, + self.app, + self.active_sonarr_block, + self._context, + ) + .handle(); + } + let season_table_handling_config = TableHandlingConfig::new(ActiveSonarrBlock::SeriesDetails.into()) .searching_block(ActiveSonarrBlock::SearchSeason.into()) @@ -85,7 +113,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler } fn accepts(active_block: ActiveSonarrBlock) -> bool { - SERIES_DETAILS_BLOCKS.contains(&active_block) + SeriesOverviewHandler::accepts(active_block) || SERIES_DETAILS_BLOCKS.contains(&active_block) } fn ignore_special_keys(&self) -> bool { @@ -276,6 +304,12 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesDetailsHandler self.app.data.sonarr_data.selected_block = BlockSelectionState::new(EDIT_SERIES_SELECTION_BLOCKS); } + _ if matches_key!(view, key) => { + self.build_series_overview_modal(); + self + .app + .push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + } _ if matches_key!(toggle_monitoring, key) => { self.app.data.sonarr_data.prompt_confirm = true; let (series_id, season_number) = self.extract_series_id_season_number_tuple(); diff --git a/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs index 152966a4..d744e065 100644 --- a/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/library/series_details_handler_tests.rs @@ -9,11 +9,12 @@ mod tests { use crate::handlers::sonarr_handlers::library::series_details_handler::SeriesDetailsHandler; use crate::handlers::sonarr_handlers::sonarr_handler_test_utils::utils::{season, series}; use crate::models::servarr_data::sonarr::sonarr_data::{ - ActiveSonarrBlock, SERIES_DETAILS_BLOCKS, + ActiveSonarrBlock, SERIES_DETAILS_BLOCKS, SERIES_OVERVIEW_BLOCKS, }; - use crate::models::sonarr_models::Season; use crate::models::sonarr_models::SonarrHistoryItem; + use crate::models::sonarr_models::{Season, Series}; use crate::models::stateful_table::StatefulTable; + use crate::test_handler_delegation; use pretty_assertions::assert_eq; use rstest::rstest; use strum::IntoEnumIterator; @@ -189,7 +190,14 @@ mod tests { ) { let mut app = App::test_default(); app.data.sonarr_data.prompt_confirm = true; - app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); app.push_navigation_stack(prompt_block.into()); @@ -313,11 +321,12 @@ mod tests { mod test_handle_key_char { use super::*; + use crate::handlers::sonarr_handlers::library::series_overview_handler::SeriesOverviewHandler; use crate::models::servarr_data::sonarr::sonarr_data::SonarrData; use crate::models::servarr_data::sonarr::sonarr_data::sonarr_test_utils::utils::create_test_sonarr_data; use crate::models::sonarr_models::{Series, SeriesType}; use crate::network::sonarr_network::SonarrEvent; - use crate::{assert_navigation_popped, test_edit_series_key}; + use crate::{assert_modal_present, assert_navigation_popped, test_edit_series_key}; use pretty_assertions::{assert_eq, assert_str_eq}; use serde_json::Number; use strum::IntoEnumIterator; @@ -355,10 +364,184 @@ mod tests { assert_modal_absent!(app.data.sonarr_data.edit_series_modal); } + #[test] + fn test_series_details_view_key_opens_the_series_overview() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series_overview_modal = None; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SeriesOverview.into()); + assert_modal_present!(app.data.sonarr_data.series_overview_modal); + } + + #[test] + fn test_series_details_view_key_builds_the_overview_from_the_selected_series() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series_overview_modal = None; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + let overview = &app + .data + .sonarr_data + .series_overview_modal + .as_ref() + .unwrap() + .overview; + assert_eq!(overview.offset, 0); + assert_eq!( + overview.get_text(), + app + .data + .sonarr_data + .series + .current_selection() + .overview + .clone() + .unwrap() + ); + } + + #[test] + fn test_series_details_view_key_for_a_series_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series_overview_modal = None; + let mut series = app.data.sonarr_data.series.current_selection().clone(); + series.overview = None; + app.data.sonarr_data.series.set_items(vec![series]); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SeriesOverview.into()); + assert_str_eq!( + app + .data + .sonarr_data + .series_overview_modal + .as_ref() + .unwrap() + .overview + .get_text(), + "" + ); + } + + #[test] + fn test_series_details_view_key_reopens_the_series_overview_at_the_top() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series_overview_modal = None; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SeriesOverview.into()); + assert_eq!( + app + .data + .sonarr_data + .series_overview_modal + .as_ref() + .unwrap() + .overview + .offset, + 0 + ); + } + + #[test] + fn test_series_details_view_key_no_op_when_not_ready() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series_overview_modal = None; + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.view.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_eq!( + app.get_current_route(), + ActiveSonarrBlock::SeriesDetails.into() + ); + assert_modal_absent!(app.data.sonarr_data.series_overview_modal); + } + #[test] fn test_toggle_monitoring_key() { let mut app = App::test_default(); app.data.sonarr_data = create_test_sonarr_data(); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..season() + }, + Season { + season_number: 2, + ..season() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); app.is_routing = false; @@ -378,7 +561,7 @@ mod tests { assert!(app.is_routing); assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, - &SonarrEvent::ToggleSeasonMonitoring(0, 0) + &SonarrEvent::ToggleSeasonMonitoring(1, 2) ); } @@ -539,6 +722,61 @@ mod tests { assert!(!app.is_routing); } + #[test] + fn test_search_seasons_key() { + let mut app = App::test_default(); + app.data.sonarr_data.seasons.set_items(vec![season()]); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::SeriesDetails, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SearchSeason.into()); + } + + #[test] + fn test_search_series_history_key() { + let mut app = App::test_default(); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.series_history = Some(series_history); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.search.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SearchSeriesHistory.into()); + } + + #[test] + fn test_filter_series_history_key() { + let mut app = App::test_default(); + let mut series_history = StatefulTable::default(); + series_history.set_items(vec![SonarrHistoryItem::default()]); + app.data.sonarr_data.series_history = Some(series_history); + app.push_navigation_stack(ActiveSonarrBlock::SeriesHistory.into()); + + SeriesDetailsHandler::new( + DEFAULT_KEYBINDINGS.filter.key, + &mut app, + ActiveSonarrBlock::SeriesHistory, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::FilterSeriesHistory.into()); + } + #[rstest] #[case( ActiveSonarrBlock::AutomaticallySearchSeriesPrompt, @@ -555,7 +793,14 @@ mod tests { active_sonarr_block: ActiveSonarrBlock, ) { let mut app = App::test_default(); - app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); app.push_navigation_stack(active_sonarr_block.into()); app.push_navigation_stack(prompt_block.into()); @@ -578,8 +823,11 @@ mod tests { #[test] fn test_series_details_handler_accepts() { + let mut series_details_blocks = SERIES_DETAILS_BLOCKS.to_vec(); + series_details_blocks.extend(SERIES_OVERVIEW_BLOCKS); + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { - if SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) { + if series_details_blocks.contains(&active_sonarr_block) { assert!(SeriesDetailsHandler::accepts(active_sonarr_block)); } else { assert!(!SeriesDetailsHandler::accepts(active_sonarr_block)); @@ -587,6 +835,15 @@ mod tests { }); } + #[test] + fn test_delegates_series_overview_blocks_to_series_overview_handler() { + test_handler_delegation!( + SeriesDetailsHandler, + ActiveSonarrBlock::SeriesDetails, + ActiveSonarrBlock::SeriesOverview + ); + } + #[rstest] fn test_series_details_handler_ignore_special_keys( #[values(true, false)] ignore_special_keys_for_textbox_input: bool, @@ -609,8 +866,25 @@ mod tests { #[test] fn test_extract_series_id_season_number_tuple() { let mut app = App::test_default(); - app.data.sonarr_data.series.set_items(vec![series()]); - app.data.sonarr_data.seasons.set_items(vec![season()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); + app.data.sonarr_data.seasons.set_items(vec![ + Season { + season_number: 999, + ..season() + }, + Season { + season_number: 2, + ..season() + }, + ]); + app.data.sonarr_data.seasons.select_index(Some(1)); let series_id_season_number_tuple = SeriesDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -620,13 +894,20 @@ mod tests { ) .extract_series_id_season_number_tuple(); - assert_eq!(series_id_season_number_tuple, (1, 1)); + assert_eq!(series_id_season_number_tuple, (1, 2)); } #[test] fn test_extract_series_id() { let mut app = App::test_default(); - app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.series.set_items(vec![ + Series { + id: 999, + ..series() + }, + series(), + ]); + app.data.sonarr_data.series.select_index(Some(1)); let series_id = SeriesDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, diff --git a/src/handlers/sonarr_handlers/library/series_overview_handler.rs b/src/handlers/sonarr_handlers/library/series_overview_handler.rs new file mode 100644 index 00000000..b7819990 --- /dev/null +++ b/src/handlers/sonarr_handlers/library/series_overview_handler.rs @@ -0,0 +1,109 @@ +use crate::app::App; +use crate::event::Key; +use crate::handlers::KeyEventHandler; +use crate::matches_key; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_OVERVIEW_BLOCKS}; +use crate::models::{Route, Scrollable, ScrollableText}; + +#[cfg(test)] +#[path = "series_overview_handler_tests.rs"] +mod series_overview_handler_tests; + +pub(super) struct SeriesOverviewHandler<'a, 'b> { + key: Key, + app: &'a mut App<'b>, + active_sonarr_block: ActiveSonarrBlock, + _context: Option, +} + +impl SeriesOverviewHandler<'_, '_> { + fn overview(&mut self) -> &mut ScrollableText { + &mut self + .app + .data + .sonarr_data + .series_overview_modal + .as_mut() + .expect("Series overview modal is undefined") + .overview + } +} + +impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveSonarrBlock> for SeriesOverviewHandler<'a, 'b> { + fn accepts(active_block: ActiveSonarrBlock) -> bool { + SERIES_OVERVIEW_BLOCKS.contains(&active_block) + } + + fn ignore_special_keys(&self) -> bool { + self.app.ignore_special_keys_for_textbox_input + } + + fn new( + key: Key, + app: &'a mut App<'b>, + active_block: ActiveSonarrBlock, + _context: Option, + ) -> SeriesOverviewHandler<'a, 'b> { + SeriesOverviewHandler { + key, + app, + active_sonarr_block: active_block, + _context, + } + } + + fn get_key(&self) -> Key { + self.key + } + + fn is_ready(&self) -> bool { + if self.app.is_loading { + return false; + } + + self.app.data.sonarr_data.series_overview_modal.is_some() + } + + fn handle_scroll_up(&mut self) { + self.overview().scroll_up(); + } + + fn handle_scroll_down(&mut self) { + self.overview().scroll_down(); + } + + fn handle_home(&mut self) { + self.overview().scroll_to_top(); + } + + fn handle_end(&mut self) { + self.overview().scroll_to_bottom(); + } + + fn handle_delete(&mut self) {} + + fn handle_left_right_action(&mut self) {} + + fn handle_submit(&mut self) {} + + fn handle_esc(&mut self) { + self.app.pop_navigation_stack(); + self.app.data.sonarr_data.series_overview_modal = None; + } + + fn handle_char_key_event(&mut self) { + if matches_key!(refresh, self.key) { + self + .app + .pop_and_push_navigation_stack(self.active_sonarr_block.into()); + } + } + + fn app_mut(&mut self) -> &mut App<'b> { + self.app + } + + fn current_route(&self) -> Route { + self.app.get_current_route() + } +} diff --git a/src/handlers/sonarr_handlers/library/series_overview_handler_tests.rs b/src/handlers/sonarr_handlers/library/series_overview_handler_tests.rs new file mode 100644 index 00000000..97df075e --- /dev/null +++ b/src/handlers/sonarr_handlers/library/series_overview_handler_tests.rs @@ -0,0 +1,393 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::app::key_binding::DEFAULT_KEYBINDINGS; + use crate::handlers::KeyEventHandler; + use crate::handlers::sonarr_handlers::library::series_overview_handler::SeriesOverviewHandler; + use crate::models::ScrollableText; + use crate::models::servarr_data::sonarr::modals::SeriesOverviewModal; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SERIES_OVERVIEW_BLOCKS, + }; + + mod test_handle_scroll_up_and_down { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_series_overview_scroll_up() { + let mut app = app_with_series_overview(); + set_offset(&mut app, 2); + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_series_overview_scroll_up_no_op_when_not_ready() { + let mut app = app_with_series_overview(); + set_offset(&mut app, 2); + app.is_loading = true; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 2); + } + + #[test] + fn test_series_overview_scroll_down() { + let mut app = app_with_series_overview(); + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + } + + #[test] + fn test_series_overview_scroll_down_no_op_when_not_ready() { + let mut app = app_with_series_overview(); + app.is_loading = true; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_home_end { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_series_overview_home() { + let mut app = app_with_series_overview(); + set_offset(&mut app, 3); + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + + #[test] + fn test_series_overview_end() { + let mut app = app_with_series_overview(); + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 5); + } + + #[test] + fn test_series_overview_home_no_op_when_not_ready() { + let mut app = app_with_series_overview(); + set_offset(&mut app, 3); + app.is_loading = true; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.home.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 3); + } + + #[test] + fn test_series_overview_end_no_op_when_not_ready() { + let mut app = app_with_series_overview(); + app.is_loading = true; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.end.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 0); + } + } + + mod test_handle_esc { + use super::*; + use crate::{assert_modal_absent, assert_navigation_popped}; + + #[test] + fn test_series_overview_esc_tears_down_the_series_overview_modal() { + let mut app = app_with_series_overview(); + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveSonarrBlock::SeriesDetails.into()); + assert_modal_absent!(app.data.sonarr_data.series_overview_modal); + } + + #[test] + fn test_series_overview_esc_tears_down_the_modal_even_when_loading() { + let mut app = app_with_series_overview(); + app.is_loading = true; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.esc.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_navigation_popped!(app, ActiveSonarrBlock::SeriesDetails.into()); + assert_modal_absent!(app.data.sonarr_data.series_overview_modal); + } + } + + mod test_handle_key_char { + use super::*; + use crate::assert_navigation_pushed; + + #[test] + fn test_series_overview_refresh_key() { + let mut app = app_with_series_overview(); + app.is_routing = false; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_navigation_pushed!(app, ActiveSonarrBlock::SeriesOverview.into()); + assert!(app.is_routing); + } + + #[test] + fn test_series_overview_refresh_key_no_op_when_not_ready() { + let mut app = app_with_series_overview(); + app.is_loading = true; + app.is_routing = false; + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.refresh.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert!(!app.is_routing); + } + } + + #[test] + fn test_scrolling_the_overview_does_not_mutate_the_series_record() { + let mut app = app_with_series_overview(); + let overview_before = app + .data + .sonarr_data + .series + .current_selection() + .overview + .clone(); + + SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.down.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ) + .handle(); + + assert_eq!(overview_offset(&app), 1); + assert_eq!( + app.data.sonarr_data.series.current_selection().overview, + overview_before + ); + } + + #[test] + fn test_series_overview_handler_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if SERIES_OVERVIEW_BLOCKS.contains(&active_sonarr_block) { + assert!( + SeriesOverviewHandler::accepts(active_sonarr_block), + "{active_sonarr_block} is not accepted by the SeriesOverviewHandler" + ); + } else { + assert!( + !SeriesOverviewHandler::accepts(active_sonarr_block), + "{active_sonarr_block} is wrongly accepted by the SeriesOverviewHandler" + ); + } + }); + } + + #[test] + fn test_series_overview_handler_dispatches_no_network_events() { + let mut app = app_with_series_overview(); + + for key in [ + DEFAULT_KEYBINDINGS.up.key, + DEFAULT_KEYBINDINGS.down.key, + DEFAULT_KEYBINDINGS.home.key, + DEFAULT_KEYBINDINGS.end.key, + DEFAULT_KEYBINDINGS.delete.key, + DEFAULT_KEYBINDINGS.left.key, + DEFAULT_KEYBINDINGS.right.key, + DEFAULT_KEYBINDINGS.submit.key, + DEFAULT_KEYBINDINGS.refresh.key, + ] { + SeriesOverviewHandler::new(key, &mut app, ActiveSonarrBlock::SeriesOverview, None).handle(); + + assert_none!(app.data.sonarr_data.prompt_confirm_action); + assert!(!app.data.sonarr_data.prompt_confirm); + } + } + + #[test] + fn test_series_overview_handler_is_not_ready_when_loading() { + let mut app = app_with_series_overview(); + app.is_loading = true; + + let handler = SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_series_overview_handler_is_not_ready_when_the_modal_is_absent() { + let mut app = app_with_series_overview(); + app.data.sonarr_data.series_overview_modal = None; + + let handler = SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ); + + assert!(!handler.is_ready()); + } + + #[test] + fn test_series_overview_handler_is_ready() { + let mut app = app_with_series_overview(); + + let handler = SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ); + + assert!(handler.is_ready()); + } + + #[test] + fn test_series_overview_handler_ignores_special_keys_for_textbox_input() { + let mut app = app_with_series_overview(); + app.ignore_special_keys_for_textbox_input = true; + + let handler = SeriesOverviewHandler::new( + DEFAULT_KEYBINDINGS.up.key, + &mut app, + ActiveSonarrBlock::SeriesOverview, + None, + ); + + assert!(handler.ignore_special_keys()); + } + + fn app_with_series_overview() -> App<'static> { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series_overview_modal = Some(SeriesOverviewModal { + overview: ScrollableText::with_string( + "Blah blah blah\r\n\ + \r\n\ + It was filmed in Madison, Wisconsin: a city the crew never really left.\r\n\ + \r\n\ + The first season took seven years to finish.\r\n" + .to_owned(), + ), + }); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + + app + } + + fn overview_offset(app: &App<'_>) -> u16 { + app + .data + .sonarr_data + .series_overview_modal + .as_ref() + .unwrap() + .overview + .offset + } + + fn set_offset(app: &mut App<'_>, offset: u16) { + app + .data + .sonarr_data + .series_overview_modal + .as_mut() + .unwrap() + .overview + .offset = offset; + } +} diff --git a/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs index 4710f67b..04aa511e 100644 --- a/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs +++ b/src/handlers/sonarr_handlers/root_folders/root_folders_handler_tests.rs @@ -323,11 +323,15 @@ mod tests { #[test] fn test_delete_root_folder_prompt_confirm_submit() { let mut app = App::test_default(); - app - .data - .sonarr_data - .root_folders - .set_items(vec![root_folder()]); + app.data.sonarr_data.root_folders.set_items(vec![ + RootFolder { + id: 999, + path: "/decoy".to_owned(), + ..root_folder() + }, + root_folder(), + ]); + app.data.sonarr_data.root_folders.select_index(Some(1)); app.data.sonarr_data.prompt_confirm = true; app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); app.push_navigation_stack(ActiveSonarrBlock::DeleteRootFolderPrompt.into()); @@ -595,11 +599,15 @@ mod tests { #[test] fn test_delete_root_folder_prompt_confirm() { let mut app = App::test_default(); - app - .data - .sonarr_data - .root_folders - .set_items(vec![root_folder()]); + app.data.sonarr_data.root_folders.set_items(vec![ + RootFolder { + id: 999, + path: "/decoy".to_owned(), + ..root_folder() + }, + root_folder(), + ]); + app.data.sonarr_data.root_folders.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::RootFolders.into()); app.push_navigation_stack(ActiveSonarrBlock::DeleteRootFolderPrompt.into()); @@ -653,11 +661,15 @@ mod tests { #[test] fn test_extract_root_folder_id() { let mut app = App::test_default(); - app - .data - .sonarr_data - .root_folders - .set_items(vec![root_folder()]); + app.data.sonarr_data.root_folders.set_items(vec![ + RootFolder { + id: 999, + path: "/decoy".to_owned(), + ..root_folder() + }, + root_folder(), + ]); + app.data.sonarr_data.root_folders.select_index(Some(1)); let root_folder_id = RootFoldersHandler::new( DEFAULT_KEYBINDINGS.esc.key, diff --git a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs index 39e54352..24f6e166 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_test_utils.rs @@ -4,12 +4,12 @@ pub(in crate::handlers::sonarr_handlers) mod utils { use crate::models::HorizontallyScrollableText; use crate::models::servarr_models::IndexerSettings; use crate::models::servarr_models::{ - Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, + DownloadStatus, Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, }; use crate::models::sonarr_models::{ - AddSeriesSearchResult, AddSeriesSearchResultStatistics, DownloadRecord, DownloadStatus, - Episode, EpisodeFile, MediaInfo, Rating, Season, SeasonStatistics, Series, SeriesStatistics, - SeriesStatus, SeriesType, + AddSeriesSearchResult, AddSeriesSearchResultStatistics, DownloadRecord, Episode, EpisodeFile, + MediaInfo, Rating, Season, SeasonStatistics, Series, SeriesStatistics, SeriesStatus, + SeriesType, }; use chrono::DateTime; use serde_json::{Number, json}; @@ -191,8 +191,8 @@ pub(in crate::handlers::sonarr_handlers) mod utils { DownloadRecord { title: "Test Download Title".to_owned(), status: DownloadStatus::Downloading, - id: 1, - episode_id: Some(Number::from(1i64)), + id: 9, + episode_id: Some(Number::from(11i64)), size: 3543348019f64, sizeleft: 1771674009f64, output_path: Some(HorizontallyScrollableText::from( @@ -205,10 +205,10 @@ pub(in crate::handlers::sonarr_handlers) mod utils { pub fn episode() -> Episode { Episode { - id: 1, + id: 11, series_id: 1, tvdb_id: 1234, - episode_file_id: 1, + episode_file_id: 7, season_number: 1, episode_number: 1, title: "Something cool".to_owned(), @@ -224,7 +224,7 @@ pub(in crate::handlers::sonarr_handlers) mod utils { pub fn episode_file() -> EpisodeFile { EpisodeFile { - id: 1, + id: 7, relative_path: "/season 1/episode 1.mkv".to_owned(), path: "/nfs/tv/series/season 1/episode 1.mkv".to_owned(), size: 3543348019, @@ -274,9 +274,9 @@ pub(in crate::handlers::sonarr_handlers) mod utils { pub fn indexer_settings() -> IndexerSettings { IndexerSettings { - id: 1, - minimum_age: 1, - retention: 1, + id: 7, + minimum_age: 13, + retention: 21, maximum_size: 12345, rss_sync_interval: 60, } @@ -338,7 +338,7 @@ pub(in crate::handlers::sonarr_handlers) mod utils { DateTime::parse_from_rfc3339("2022-10-24T01:00:00Z").unwrap(), )), next_airing: None, - episode_file_count: 10, + episode_file_count: 7, episode_count: 10, total_episode_count: 10, size_on_disk: 36708563419, @@ -351,7 +351,14 @@ pub(in crate::handlers::sonarr_handlers) mod utils { title: "Test".to_owned().into(), status: SeriesStatus::Continuing, ended: false, - overview: Some("Blah blah blah".to_owned()), + overview: Some( + "Blah blah blah\r\n\ + \r\n\ + It was filmed in Madison, Wisconsin: a city the crew never really left.\r\n\ + \r\n\ + The first season took seven years to finish.\r\n" + .to_owned(), + ), network: Some("HBO".to_owned()), seasons: Some(vec![season()]), year: 2022, diff --git a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs index 8cc007ee..208e6ded 100644 --- a/src/handlers/sonarr_handlers/sonarr_handler_tests.rs +++ b/src/handlers/sonarr_handlers/sonarr_handler_tests.rs @@ -165,7 +165,6 @@ mod tests { ActiveSonarrBlock::SearchSeriesHistory, ActiveSonarrBlock::SearchSeriesHistoryError, ActiveSonarrBlock::SeasonDetails, - ActiveSonarrBlock::Series, ActiveSonarrBlock::SeriesDetails, ActiveSonarrBlock::SeriesHistory, ActiveSonarrBlock::SeriesHistorySortPrompt, @@ -186,7 +185,6 @@ mod tests { #[rstest] fn test_delegates_downloads_blocks_to_downloads_handler( #[values( - ActiveSonarrBlock::Downloads, ActiveSonarrBlock::DeleteDownloadPrompt, ActiveSonarrBlock::UpdateDownloadsPrompt )] @@ -202,7 +200,6 @@ mod tests { #[rstest] fn test_delegates_blocklist_blocks_to_blocklist_handler( #[values( - ActiveSonarrBlock::Blocklist, ActiveSonarrBlock::BlocklistItemDetails, ActiveSonarrBlock::DeleteBlocklistItemPrompt, ActiveSonarrBlock::BlocklistClearAllItemsPrompt, @@ -220,7 +217,6 @@ mod tests { #[rstest] fn test_delegates_history_blocks_to_history_handler( #[values( - ActiveSonarrBlock::History, ActiveSonarrBlock::HistoryItemDetails, ActiveSonarrBlock::HistorySortPrompt, ActiveSonarrBlock::FilterHistory, @@ -240,7 +236,6 @@ mod tests { #[rstest] fn test_delegates_root_folders_blocks_to_root_folders_handler( #[values( - ActiveSonarrBlock::RootFolders, ActiveSonarrBlock::AddRootFolderPrompt, ActiveSonarrBlock::DeleteRootFolderPrompt )] @@ -257,7 +252,6 @@ mod tests { fn test_delegates_indexers_blocks_to_indexers_handler( #[values( ActiveSonarrBlock::DeleteIndexerPrompt, - ActiveSonarrBlock::Indexers, ActiveSonarrBlock::AllIndexerSettingsPrompt, ActiveSonarrBlock::IndexerSettingsConfirmPrompt, ActiveSonarrBlock::IndexerSettingsMaximumSizeInput, @@ -277,7 +271,6 @@ mod tests { #[rstest] fn test_delegates_system_blocks_to_system_handler( #[values( - ActiveSonarrBlock::System, ActiveSonarrBlock::SystemLogs, ActiveSonarrBlock::SystemQueuedEvents, ActiveSonarrBlock::SystemTasks, diff --git a/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs b/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs index a7031dae..49d54511 100644 --- a/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/sonarr_handlers/system/system_details_handler_tests.rs @@ -694,11 +694,17 @@ mod tests { let mut app = App::test_default(); app.data.sonarr_data.updates = ScrollableText::with_string("Test".to_owned()); app.data.sonarr_data.prompt_confirm = true; - app - .data - .sonarr_data - .tasks - .set_items(vec![SonarrTask::default()]); + app.data.sonarr_data.tasks.set_items(vec![ + SonarrTask { + task_name: SonarrTaskName::Backup, + ..SonarrTask::default() + }, + SonarrTask { + task_name: SonarrTaskName::RefreshMonitoredDownloads, + ..SonarrTask::default() + }, + ]); + app.data.sonarr_data.tasks.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::SystemTasks.into()); app.push_navigation_stack(ActiveSonarrBlock::SystemTaskStartConfirmPrompt.into()); @@ -713,7 +719,7 @@ mod tests { assert!(app.data.sonarr_data.prompt_confirm); assert_some_eq_x!( &app.data.sonarr_data.prompt_confirm_action, - &SonarrEvent::StartTask(SonarrTaskName::default()) + &SonarrEvent::StartTask(SonarrTaskName::RefreshMonitoredDownloads) ); assert_navigation_popped!(app, ActiveSonarrBlock::SystemTasks.into()); } @@ -912,11 +918,17 @@ mod tests { let mut app = App::test_default(); app.push_navigation_stack(ActiveSonarrBlock::System.into()); app.data.sonarr_data.updates = ScrollableText::with_string("Test".to_owned()); - app - .data - .sonarr_data - .tasks - .set_items(vec![SonarrTask::default()]); + app.data.sonarr_data.tasks.set_items(vec![ + SonarrTask { + task_name: SonarrTaskName::Backup, + ..SonarrTask::default() + }, + SonarrTask { + task_name: SonarrTaskName::RefreshMonitoredDownloads, + ..SonarrTask::default() + }, + ]); + app.data.sonarr_data.tasks.select_index(Some(1)); app.push_navigation_stack(ActiveSonarrBlock::SystemTasks.into()); app.push_navigation_stack(ActiveSonarrBlock::SystemTaskStartConfirmPrompt.into()); @@ -931,7 +943,9 @@ mod tests { assert!(app.data.sonarr_data.prompt_confirm); assert_eq!( app.data.sonarr_data.prompt_confirm_action, - Some(SonarrEvent::StartTask(SonarrTaskName::default())) + Some(SonarrEvent::StartTask( + SonarrTaskName::RefreshMonitoredDownloads + )) ); assert_navigation_popped!(app, ActiveSonarrBlock::SystemTasks.into()); } @@ -970,11 +984,17 @@ mod tests { #[test] fn test_extract_task_name() { let mut app = App::test_default(); - app - .data - .sonarr_data - .tasks - .set_items(vec![SonarrTask::default()]); + app.data.sonarr_data.tasks.set_items(vec![ + SonarrTask { + task_name: SonarrTaskName::Backup, + ..SonarrTask::default() + }, + SonarrTask { + task_name: SonarrTaskName::RefreshSeries, + ..SonarrTask::default() + }, + ]); + app.data.sonarr_data.tasks.select_index(Some(1)); let task_name = SystemDetailsHandler::new( DEFAULT_KEYBINDINGS.esc.key, @@ -984,7 +1004,7 @@ mod tests { ) .extract_task_name(); - assert_eq!(task_name, SonarrTaskName::default()); + assert_eq!(task_name, SonarrTaskName::RefreshSeries); } #[test] diff --git a/src/handlers/table_handler_tests.rs b/src/handlers/table_handler_tests.rs index 45dc7b45..b234fdb1 100644 --- a/src/handlers/table_handler_tests.rs +++ b/src/handlers/table_handler_tests.rs @@ -606,17 +606,18 @@ mod tests { .take(100) .collect(); app.data.radarr_data.movies.set_items(movies_vec); + app.data.radarr_data.movies.state.select(Some(50)); TableHandlerUnit::new(key, &mut app, ActiveRadarrBlock::Movies, None).handle(); if key == Key::PgUp { assert_str_eq!( app.data.radarr_data.movies.current_selection().title.text, - "Test 79" + "Test 30" ); } else { assert_str_eq!( app.data.radarr_data.movies.current_selection().title.text, - "Test 20" + "Test 70" ); } } diff --git a/src/logos.rs b/src/logos.rs index 0205aff5..fdbfd084 100644 --- a/src/logos.rs +++ b/src/logos.rs @@ -25,8 +25,6 @@ pub const BAZARR_LOGO: &str = "⠀⠀⠀⠀⠀⠀⠀⣀⠠⠄⠠⠄⣀⠀⠀⠀ ⠀⠀⠀⠀⠀⠑⢌⠉⠉⠉⠉⠉⠉⡠⠊⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠉⠒⠂⠐⠒⠉⠀⠀⠀⠀⠀⠀⠀ "; -// Allowing this code for now since we'll eventually be implementing additional Servarr support and we'll need it then -#[allow(dead_code)] pub const READARR_LOGO: &str = "⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ diff --git a/src/main.rs b/src/main.rs index d9dfcfef..a3fcb839 100644 --- a/src/main.rs +++ b/src/main.rs @@ -184,7 +184,7 @@ async fn main() -> Result<()> { match args.command { Some(command) => match command { - Command::Radarr(_) | Command::Sonarr(_) | Command::Lidarr(_) => { + Command::Radarr(_) | Command::Sonarr(_) | Command::Lidarr(_) | Command::Readarr(_) => { if spinner_disabled { start_cli_no_spinner( config, diff --git a/src/models/lidarr_models.rs b/src/models/lidarr_models.rs index 72cdd3c8..1b466d55 100644 --- a/src/models/lidarr_models.rs +++ b/src/models/lidarr_models.rs @@ -1,8 +1,8 @@ use super::{ HorizontallyScrollableText, Serdeable, servarr_models::{ - DiskSpace, HostConfig, Indexer, IndexerTestResult, QualityProfile, QualityWrapper, RootFolder, - SecurityConfig, Tag, + DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerTestResult, MetadataProfile, + QualityProfile, QualityWrapper, RootFolder, SecurityConfig, SystemStatus, Tag, }, }; use crate::models::servarr_models::{IndexerSettings, LogResponse, QueueEvent, Update}; @@ -27,6 +27,7 @@ pub struct Artist { pub id: i64, pub artist_name: HorizontallyScrollableText, pub foreign_artist_id: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub status: ArtistStatus, pub overview: Option, pub artist_type: Option, @@ -38,6 +39,7 @@ pub struct Artist { #[serde(deserialize_with = "super::from_i64")] pub metadata_profile_id: i64, pub monitored: bool, + #[serde(deserialize_with = "super::from_json_or_default")] pub monitor_new_items: NewItemMonitorType, pub genres: Vec, pub tags: Vec, @@ -97,22 +99,6 @@ pub struct ArtistStatistics { impl Eq for ArtistStatistics {} -#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct MetadataProfile { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub name: String, -} - -impl From<(&i64, &String)> for MetadataProfile { - fn from(value: (&i64, &String)) -> Self { - MetadataProfile { - id: *value.0, - name: value.1.clone(), - } - } -} - #[derive( Serialize, Deserialize, @@ -177,6 +163,7 @@ pub enum MonitorType { #[serde(rename_all = "camelCase")] pub struct DownloadRecord { pub title: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub status: DownloadStatus, #[serde(deserialize_with = "super::from_i64")] pub id: i64, @@ -194,54 +181,18 @@ pub struct DownloadRecord { impl Eq for DownloadRecord {} -#[derive( - Serialize, - Deserialize, - Default, - PartialEq, - Eq, - Clone, - Copy, - Debug, - EnumIter, - Display, - EnumDisplayStyle, -)] -#[serde(rename_all = "camelCase")] -#[strum(serialize_all = "camelCase")] -pub enum DownloadStatus { - #[default] - Unknown, - Queued, - Paused, - Downloading, - Completed, - Failed, - Warning, - Delay, - #[display_style(name = "Download Client Unavailable")] - DownloadClientUnavailable, - Fallback, -} - #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DownloadsResponse { pub records: Vec, } -#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SystemStatus { - pub version: String, - pub start_time: DateTime, -} - #[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AddArtistSearchResult { pub foreign_artist_id: String, pub artist_name: HorizontallyScrollableText, + #[serde(deserialize_with = "super::from_json_or_default")] pub status: ArtistStatus, pub overview: Option, pub artist_type: Option, @@ -427,6 +378,7 @@ pub struct LidarrHistoryItem { #[serde(default)] pub quality: QualityWrapper, pub date: DateTime, + #[serde(deserialize_with = "super::from_json_or_default")] pub event_type: LidarrHistoryEventType, #[serde(default)] pub data: LidarrHistoryData, @@ -436,6 +388,7 @@ pub struct LidarrHistoryItem { #[serde(rename_all = "camelCase")] pub struct LidarrTask { pub name: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub task_name: LidarrTaskName, #[serde(deserialize_with = "super::from_i64")] pub interval: i64, @@ -492,13 +445,6 @@ pub struct LidarrRelease { pub quality: QualityWrapper, } -#[derive(Default, Serialize, Debug, PartialEq, Eq, Clone)] -#[serde(rename_all = "camelCase")] -pub struct LidarrReleaseDownloadBody { - pub guid: String, - pub indexer_id: i64, -} - #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct BlocklistItem { diff --git a/src/models/lidarr_models_tests.rs b/src/models/lidarr_models_tests.rs index 7a31e9a4..5abbd6a2 100644 --- a/src/models/lidarr_models_tests.rs +++ b/src/models/lidarr_models_tests.rs @@ -2,22 +2,27 @@ mod tests { use chrono::Utc; use pretty_assertions::{assert_eq, assert_str_eq}; - use serde_json::json; + use serde::de::DeserializeOwned; + use serde_json::{Value, json}; use crate::models::lidarr_models::{ AddArtistSearchResult, Album, AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, - DownloadStatus, DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem, - LidarrHistoryWrapper, LidarrRelease, LidarrTask, MediaInfo, Member, MetadataProfile, - MonitorType, NewItemMonitorType, SystemStatus, Track, TrackFile, + DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, + LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member, MonitorType, NewItemMonitorType, + Track, TrackFile, }; use crate::models::servarr_models::{ - DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse, - QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, + DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, + LogResponse, MetadataProfile, QualityProfile, QueueEvent, RootFolder, SecurityConfig, + SystemStatus, Tag, Update, }; use crate::models::{ Serdeable, lidarr_models::{Artist, ArtistStatistics, ArtistStatus, LidarrSerdeable, Ratings}, }; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{ + ADD_ARTIST_SEARCH_RESULT_JSON, ARTIST_JSON, download_record, task, + }; #[test] fn test_artist_status_default() { @@ -654,40 +659,6 @@ mod tests { assert_str_eq!(ArtistStatus::Deleted.to_display_str(), "Deleted"); } - #[test] - fn test_download_status_display() { - assert_str_eq!(DownloadStatus::Unknown.to_string(), "unknown"); - assert_str_eq!(DownloadStatus::Queued.to_string(), "queued"); - assert_str_eq!(DownloadStatus::Paused.to_string(), "paused"); - assert_str_eq!(DownloadStatus::Downloading.to_string(), "downloading"); - assert_str_eq!(DownloadStatus::Completed.to_string(), "completed"); - assert_str_eq!(DownloadStatus::Failed.to_string(), "failed"); - assert_str_eq!(DownloadStatus::Warning.to_string(), "warning"); - assert_str_eq!(DownloadStatus::Delay.to_string(), "delay"); - assert_str_eq!( - DownloadStatus::DownloadClientUnavailable.to_string(), - "downloadClientUnavailable" - ); - assert_str_eq!(DownloadStatus::Fallback.to_string(), "fallback"); - } - - #[test] - fn test_download_status_to_display_str() { - assert_str_eq!(DownloadStatus::Unknown.to_display_str(), "Unknown"); - assert_str_eq!(DownloadStatus::Queued.to_display_str(), "Queued"); - assert_str_eq!(DownloadStatus::Paused.to_display_str(), "Paused"); - assert_str_eq!(DownloadStatus::Downloading.to_display_str(), "Downloading"); - assert_str_eq!(DownloadStatus::Completed.to_display_str(), "Completed"); - assert_str_eq!(DownloadStatus::Failed.to_display_str(), "Failed"); - assert_str_eq!(DownloadStatus::Warning.to_display_str(), "Warning"); - assert_str_eq!(DownloadStatus::Delay.to_display_str(), "Delay"); - assert_str_eq!( - DownloadStatus::DownloadClientUnavailable.to_display_str(), - "Download Client Unavailable" - ); - assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback"); - } - #[test] fn test_lidarr_history_event_type_display() { assert_str_eq!(LidarrHistoryEventType::Unknown.to_string(), "unknown"); @@ -772,6 +743,126 @@ mod tests { ); } + #[test] + fn test_lidarr_history_event_type_deserialization() { + assert_eq!( + deserialize_history_event_type(json!("unknown")), + LidarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!("grabbed")), + LidarrHistoryEventType::Grabbed + ); + assert_eq!( + deserialize_history_event_type(json!("artistFolderImported")), + LidarrHistoryEventType::ArtistFolderImported + ); + assert_eq!( + deserialize_history_event_type(json!("albumImportIncomplete")), + LidarrHistoryEventType::AlbumImportIncomplete + ); + assert_eq!( + deserialize_history_event_type(json!("downloadIgnored")), + LidarrHistoryEventType::DownloadIgnored + ); + assert_eq!( + deserialize_history_event_type(json!("downloadImported")), + LidarrHistoryEventType::DownloadImported + ); + assert_eq!( + deserialize_history_event_type(json!("downloadFailed")), + LidarrHistoryEventType::DownloadFailed + ); + assert_eq!( + deserialize_history_event_type(json!("trackFileDeleted")), + LidarrHistoryEventType::TrackFileDeleted + ); + assert_eq!( + deserialize_history_event_type(json!("trackFileImported")), + LidarrHistoryEventType::TrackFileImported + ); + assert_eq!( + deserialize_history_event_type(json!("trackFileRenamed")), + LidarrHistoryEventType::TrackFileRenamed + ); + assert_eq!( + deserialize_history_event_type(json!("trackFileRetagged")), + LidarrHistoryEventType::TrackFileRetagged + ); + } + + #[test] + fn test_lidarr_history_event_type_deserialization_falls_back_to_unknown() { + assert_eq!( + deserialize_history_event_type(json!("albumImportPartiallyComplete")), + LidarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(2)), + LidarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(11)), + LidarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(null)), + LidarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!({ "id": 2 })), + LidarrHistoryEventType::Unknown + ); + } + + #[test] + fn test_lidarr_history_event_type_serialization() { + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::Unknown), + "unknown" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::Grabbed), + "grabbed" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::ArtistFolderImported), + "artistFolderImported" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::AlbumImportIncomplete), + "albumImportIncomplete" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::DownloadIgnored), + "downloadIgnored" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::DownloadImported), + "downloadImported" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::DownloadFailed), + "downloadFailed" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::TrackFileDeleted), + "trackFileDeleted" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::TrackFileImported), + "trackFileImported" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::TrackFileRenamed), + "trackFileRenamed" + ); + assert_str_eq!( + serialize_history_event_type(LidarrHistoryEventType::TrackFileRetagged), + "trackFileRetagged" + ); + } + #[test] fn test_add_artist_search_result_deserialization() { let search_result_json = json!({ @@ -824,4 +915,134 @@ mod tests { assert!(search_result.genres.is_empty()); assert_none!(&search_result.ratings); } + + #[test] + fn test_artist_status_deserialization_falls_back_to_default() { + let expected = Artist { + status: ArtistStatus::default(), + ..serde_json::from_str(ARTIST_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(ARTIST_JSON, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(ARTIST_JSON, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_artist_monitor_new_items_deserialization_falls_back_to_default() { + let expected = Artist { + monitor_new_items: NewItemMonitorType::default(), + ..serde_json::from_str(ARTIST_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(ARTIST_JSON, "monitorNewItems", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(ARTIST_JSON, "monitorNewItems", json!("notAThing")), + expected + ); + } + + #[test] + fn test_add_artist_search_result_status_deserialization_falls_back_to_default() { + let expected = AddArtistSearchResult { + status: ArtistStatus::default(), + ..serde_json::from_str(ADD_ARTIST_SEARCH_RESULT_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::( + ADD_ARTIST_SEARCH_RESULT_JSON, + "status", + json!(2) + ), + expected + ); + assert_eq!( + deserialize_with_field::( + ADD_ARTIST_SEARCH_RESULT_JSON, + "status", + json!("notAThing") + ), + expected + ); + } + + #[test] + fn test_download_record_status_deserialization_falls_back_to_default() { + let download_record_json = serde_json::to_string(&download_record()).unwrap(); + let expected = DownloadRecord { + status: DownloadStatus::default(), + ..download_record() + }; + + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_lidarr_task_task_name_deserialization_falls_back_to_default() { + let task_json = serde_json::to_string(&task()).unwrap(); + let expected = LidarrTask { + task_name: LidarrTaskName::default(), + ..task() + }; + + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!("notAThing")), + expected + ); + } + + fn deserialize_with_field(json: &str, field: &str, value: Value) -> T { + let mut fixture_json: Value = serde_json::from_str(json).unwrap(); + fixture_json[field] = value; + + serde_json::from_value(fixture_json).unwrap() + } + + fn deserialize_history_event_type(event_type: Value) -> LidarrHistoryEventType { + let history_item_json = json!({ + "id": 1, + "sourceTitle": "Test Source Title", + "albumId": 1, + "artistId": 1, + "trackId": 1, + "date": "2024-01-01T00:00:00Z", + "eventType": event_type + }); + + serde_json::from_value::(history_item_json) + .unwrap() + .event_type + } + + fn serialize_history_event_type(event_type: LidarrHistoryEventType) -> String { + let history_item = LidarrHistoryItem { + event_type, + ..LidarrHistoryItem::default() + }; + + serde_json::to_value(history_item).unwrap()["eventType"] + .as_str() + .unwrap() + .to_owned() + } } diff --git a/src/models/mod.rs b/src/models/mod.rs index 3eea7c34..870be97d 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -5,16 +5,19 @@ use crate::app::ServarrConfig; use crate::app::context_clues::ContextClue; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use lidarr_models::LidarrSerdeable; use radarr_models::RadarrSerdeable; +use readarr_models::ReadarrSerdeable; use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; -use serde_json::Number; +use serde_json::{Number, Value}; use servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use sonarr_models::SonarrSerdeable; pub mod lidarr_models; pub mod radarr_models; +pub mod readarr_models; pub mod servarr_data; pub mod servarr_models; pub mod sonarr_models; @@ -32,7 +35,7 @@ mod model_tests; pub enum Route { Radarr(ActiveRadarrBlock, Option), Sonarr(ActiveSonarrBlock, Option), - Readarr, + Readarr(ActiveReadarrBlock, Option), Lidarr(ActiveLidarrBlock, Option), Whisparr, Bazarr, @@ -47,6 +50,7 @@ pub enum Serdeable { Radarr(RadarrSerdeable), Sonarr(SonarrSerdeable), Lidarr(LidarrSerdeable), + Readarr(ReadarrSerdeable), } pub trait Scrollable { @@ -438,6 +442,15 @@ where ))) } +pub fn from_json_or_default<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: Deserialize<'de> + Default, +{ + let value: Value = Deserialize::deserialize(deserializer)?; + Ok(T::deserialize(value).unwrap_or_default()) +} + pub fn strip_non_search_characters(input: &str) -> String { Regex::new(r"[^a-zA-Z0-9.,/'\-:\s]") .unwrap() diff --git a/src/models/model_tests.rs b/src/models/model_tests.rs index 1b8dc3e7..3f572369 100644 --- a/src/models/model_tests.rs +++ b/src/models/model_tests.rs @@ -11,12 +11,13 @@ mod tests { use crate::models::{ BlockSelectionState, HorizontallyScrollableText, Scrollable, ScrollableText, TabRoute, TabState, }; - use crate::models::{from_i64, strip_non_search_characters}; + use crate::models::{from_i64, from_json_or_default, strip_non_search_characters}; use pretty_assertions::{assert_eq, assert_str_eq}; use serde::de::IntoDeserializer; use serde::de::value::Error as ValueError; use serde::de::value::F64Deserializer; use serde::de::value::I64Deserializer; + use serde::de::value::StrDeserializer; use serde_json::to_string; const BLOCKS: &[&[i32]] = &[&[11, 12], &[21, 22], &[31, 32]]; @@ -803,6 +804,20 @@ mod tests { assert_ok_eq_x!(from_f64(deserializer), 1.0); } + #[test] + fn test_from_json_or_default() { + let deserializer: I64Deserializer = 1i64.into_deserializer(); + + assert_ok_eq_x!(from_json_or_default::<_, i64>(deserializer), 1); + } + + #[test] + fn test_from_json_or_default_falls_back_to_default() { + let deserializer: StrDeserializer<'_, ValueError> = "unexpected".into_deserializer(); + + assert_ok_eq_x!(from_json_or_default::<_, i64>(deserializer), 0); + } + #[test] fn test_horizontally_scrollable_serialize() { let text = HorizontallyScrollableText::from("Test"); diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index baf1c627..8be82e62 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -12,8 +12,8 @@ use strum_macros::{Display, EnumIter}; use super::Serdeable; use super::servarr_models::{ - DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, - QueueEvent, RootFolder, SecurityConfig, Tag, Update, + DiskSpace, DownloadStatus, HostConfig, Indexer, Language, LogResponse, QualityProfile, + QualityWrapper, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, Update, }; #[cfg(test)] @@ -42,7 +42,8 @@ pub struct AddMovieSearchResult { pub tmdb_id: i64, pub title: HorizontallyScrollableText, pub original_language: Language, - pub status: String, + #[serde(deserialize_with = "super::from_json_or_default")] + pub status: MovieStatus, pub overview: String, pub genres: Vec, #[serde(deserialize_with = "super::from_i64")] @@ -98,6 +99,7 @@ pub struct Collection { pub search_on_add: bool, pub monitored: bool, pub overview: Option, + #[serde(deserialize_with = "super::from_json_or_default")] pub minimum_availability: MinimumAvailability, #[serde(deserialize_with = "super::from_i64")] pub quality_profile_id: i64, @@ -126,7 +128,7 @@ pub struct Credit { pub character: Option, pub department: Option, pub job: Option, - #[serde(rename(deserialize = "type"))] + #[serde(rename = "type", deserialize_with = "super::from_json_or_default")] pub credit_type: CreditType, } @@ -150,7 +152,8 @@ pub struct DeleteMovieParams { #[serde(rename_all = "camelCase")] pub struct DownloadRecord { pub title: String, - pub status: String, + #[serde(deserialize_with = "super::from_json_or_default")] + pub status: DownloadStatus, #[serde(deserialize_with = "super::from_i64")] pub id: i64, #[serde(deserialize_with = "super::from_i64")] @@ -301,7 +304,8 @@ pub struct Movie { pub original_language: Language, #[serde(deserialize_with = "super::from_i64")] pub size_on_disk: i64, - pub status: String, + #[serde(deserialize_with = "super::from_json_or_default")] + pub status: MovieStatus, pub overview: String, pub path: String, pub studio: Option, @@ -316,6 +320,7 @@ pub struct Movie { pub tmdb_id: i64, #[serde(deserialize_with = "super::from_i64")] pub quality_profile_id: i64, + #[serde(deserialize_with = "super::from_json_or_default")] pub minimum_availability: MinimumAvailability, pub certification: Option, pub tags: Vec, @@ -324,6 +329,32 @@ pub struct Movie { pub collection: Option, } +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + EnumIter, + Display, + EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum MovieStatus { + #[default] + #[display_style(name = "TBA")] + Tba, + Announced, + #[display_style(name = "In Cinemas")] + InCinemas, + Released, + Deleted, +} + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct MovieCollection { @@ -401,13 +432,6 @@ pub struct RadarrReleaseDownloadBody { pub movie_id: i64, } -#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SystemStatus { - pub version: String, - pub start_time: DateTime, -} - #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct RadarrHistoryWrapper { @@ -466,6 +490,7 @@ pub struct RadarrHistoryItem { pub quality: QualityWrapper, pub languages: Vec, pub date: DateTime, + #[serde(deserialize_with = "super::from_json_or_default")] pub event_type: RadarrHistoryEventType, #[serde(default)] pub data: RadarrHistoryData, @@ -475,6 +500,7 @@ pub struct RadarrHistoryItem { #[serde(rename_all = "camelCase")] pub struct RadarrTask { pub name: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub task_name: RadarrTaskName, #[serde(deserialize_with = "super::from_i64")] pub interval: i64, diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index d7843dbe..bd67b6e6 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -1,7 +1,8 @@ #[cfg(test)] mod tests { use pretty_assertions::{assert_eq, assert_str_eq}; - use serde_json::json; + use serde::de::DeserializeOwned; + use serde_json::{Value, json}; use crate::models::radarr_models::{ RadarrHistoryEventType, RadarrHistoryItem, RadarrHistoryWrapper, @@ -9,12 +10,18 @@ mod tests { use crate::models::{ Serdeable, radarr_models::{ - AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace, - DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, - MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, QualityProfile, RadarrRelease, - RadarrSerdeable, RadarrTask, RadarrTaskName, SystemStatus, Tag, Update, + AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, CreditType, + DiskSpace, DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, + MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, MovieStatus, QualityProfile, + RadarrRelease, RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update, }, - servarr_models::{HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig}, + servarr_models::{ + DownloadStatus, HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig, + SystemStatus, + }, + }; + use crate::network::radarr_network::radarr_network_test_utils::test_utils::{ + MOVIE_JSON, add_movie_search_result, collection, crew_credit, download_record, task, }; #[test] @@ -44,6 +51,24 @@ mod tests { assert_str_eq!(MinimumAvailability::Released.to_display_str(), "Released"); } + #[test] + fn test_movie_status_display() { + assert_str_eq!(MovieStatus::Tba.to_string(), "tba"); + assert_str_eq!(MovieStatus::Announced.to_string(), "announced"); + assert_str_eq!(MovieStatus::InCinemas.to_string(), "inCinemas"); + assert_str_eq!(MovieStatus::Released.to_string(), "released"); + assert_str_eq!(MovieStatus::Deleted.to_string(), "deleted"); + } + + #[test] + fn test_movie_status_to_display_str() { + assert_str_eq!(MovieStatus::Tba.to_display_str(), "TBA"); + assert_str_eq!(MovieStatus::Announced.to_display_str(), "Announced"); + assert_str_eq!(MovieStatus::InCinemas.to_display_str(), "In Cinemas"); + assert_str_eq!(MovieStatus::Released.to_display_str(), "Released"); + assert_str_eq!(MovieStatus::Deleted.to_display_str(), "Deleted"); + } + #[test] fn test_monitor_display() { assert_str_eq!(MovieMonitor::MovieOnly.to_string(), "movieOnly"); @@ -124,11 +149,119 @@ mod tests { ); } + #[test] + fn test_radarr_history_event_type_deserialization() { + assert_eq!( + deserialize_history_event_type(json!("unknown")), + RadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!("grabbed")), + RadarrHistoryEventType::Grabbed + ); + assert_eq!( + deserialize_history_event_type(json!("downloadFolderImported")), + RadarrHistoryEventType::DownloadFolderImported + ); + assert_eq!( + deserialize_history_event_type(json!("downloadFailed")), + RadarrHistoryEventType::DownloadFailed + ); + assert_eq!( + deserialize_history_event_type(json!("movieFileDeleted")), + RadarrHistoryEventType::MovieFileDeleted + ); + assert_eq!( + deserialize_history_event_type(json!("movieFolderImported")), + RadarrHistoryEventType::MovieFolderImported + ); + assert_eq!( + deserialize_history_event_type(json!("movieFileRenamed")), + RadarrHistoryEventType::MovieFileRenamed + ); + assert_eq!( + deserialize_history_event_type(json!("downloadIgnored")), + RadarrHistoryEventType::DownloadIgnored + ); + } + + #[test] + fn test_radarr_history_event_type_deserialization_falls_back_to_unknown() { + assert_eq!( + deserialize_history_event_type(json!("movieFileImportFailed")), + RadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(2)), + RadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(11)), + RadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(null)), + RadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!({ "id": 2 })), + RadarrHistoryEventType::Unknown + ); + } + + #[test] + fn test_radarr_history_event_type_serialization() { + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::Unknown), + "unknown" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::Grabbed), + "grabbed" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::DownloadFolderImported), + "downloadFolderImported" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::DownloadFailed), + "downloadFailed" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::MovieFileDeleted), + "movieFileDeleted" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::MovieFolderImported), + "movieFolderImported" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::MovieFileRenamed), + "movieFileRenamed" + ); + assert_str_eq!( + serialize_history_event_type(RadarrHistoryEventType::DownloadIgnored), + "downloadIgnored" + ); + } + + #[test] + fn test_credit_serializes_credit_type_under_the_type_key() { + let credit_json = serde_json::to_value(crew_credit()).unwrap(); + + assert_eq!(credit_json["type"], json!("crew")); + assert_none!(credit_json.get("creditType")); + assert_eq!( + serde_json::from_value::(credit_json).unwrap(), + crew_credit() + ); + } + #[test] fn test_download_record_default_indexer_value() { let json = r#"{ "title": "test", - "status": "test", + "status": "downloading", "id": 0, "movieId": 0, "size": 0, @@ -137,7 +270,7 @@ mod tests { }"#; let expected_record = DownloadRecord { title: "test".to_owned(), - status: "test".to_owned(), + status: DownloadStatus::Downloading, id: 0, movie_id: 0, size: 0, @@ -515,4 +648,175 @@ mod tests { RadarrSerdeable::IndexerTestResults(indexer_test_results) ); } + + #[test] + fn test_movie_minimum_availability_deserialization_falls_back_to_default() { + let expected = Movie { + minimum_availability: MinimumAvailability::default(), + ..serde_json::from_str(MOVIE_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(MOVIE_JSON, "minimumAvailability", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(MOVIE_JSON, "minimumAvailability", json!("notAThing")), + expected + ); + } + + #[test] + fn test_movie_status_deserialization_falls_back_to_default() { + let expected = Movie { + status: MovieStatus::default(), + ..serde_json::from_str(MOVIE_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(MOVIE_JSON, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(MOVIE_JSON, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_add_movie_search_result_status_deserialization_falls_back_to_default() { + let add_movie_search_result_json = serde_json::to_string(&add_movie_search_result()).unwrap(); + let expected = AddMovieSearchResult { + status: MovieStatus::default(), + ..add_movie_search_result() + }; + + assert_eq!( + deserialize_with_field::( + &add_movie_search_result_json, + "status", + json!(2) + ), + expected + ); + assert_eq!( + deserialize_with_field::( + &add_movie_search_result_json, + "status", + json!("notAThing") + ), + expected + ); + } + + #[test] + fn test_collection_minimum_availability_deserialization_falls_back_to_default() { + let collection_json = serde_json::to_string(&collection()).unwrap(); + let expected = Collection { + minimum_availability: MinimumAvailability::default(), + ..collection() + }; + + assert_eq!( + deserialize_with_field::(&collection_json, "minimumAvailability", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::( + &collection_json, + "minimumAvailability", + json!("notAThing") + ), + expected + ); + } + + #[test] + fn test_credit_credit_type_deserialization_falls_back_to_default() { + let credit_json = serde_json::to_string(&crew_credit()).unwrap(); + let expected = Credit { + credit_type: CreditType::default(), + ..crew_credit() + }; + + assert_eq!( + deserialize_with_field::(&credit_json, "type", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&credit_json, "type", json!("notAThing")), + expected + ); + } + + #[test] + fn test_radarr_task_task_name_deserialization_falls_back_to_default() { + let task_json = serde_json::to_string(&task()).unwrap(); + let expected = RadarrTask { + task_name: RadarrTaskName::default(), + ..task() + }; + + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!("notAThing")), + expected + ); + } + + #[test] + fn test_download_record_status_deserialization_falls_back_to_default() { + let download_record_json = serde_json::to_string(&download_record()).unwrap(); + let expected = DownloadRecord { + status: DownloadStatus::default(), + ..download_record() + }; + + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!("notAThing")), + expected + ); + } + + fn deserialize_with_field(json: &str, field: &str, value: Value) -> T { + let mut fixture_json: Value = serde_json::from_str(json).unwrap(); + fixture_json[field] = value; + + serde_json::from_value(fixture_json).unwrap() + } + + fn deserialize_history_event_type(event_type: Value) -> RadarrHistoryEventType { + let history_item_json = json!({ + "id": 1, + "sourceTitle": "Test Source Title", + "movieId": 1, + "quality": { "quality": { "name": "HD - 1080p" } }, + "languages": [], + "date": "2024-01-01T00:00:00Z", + "eventType": event_type + }); + + serde_json::from_value::(history_item_json) + .unwrap() + .event_type + } + + fn serialize_history_event_type(event_type: RadarrHistoryEventType) -> String { + let history_item = RadarrHistoryItem { + event_type, + ..RadarrHistoryItem::default() + }; + + serde_json::to_value(history_item).unwrap()["eventType"] + .as_str() + .unwrap() + .to_owned() + } } diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs new file mode 100644 index 00000000..59e58026 --- /dev/null +++ b/src/models/readarr_models.rs @@ -0,0 +1,573 @@ +use super::{ + HorizontallyScrollableText, Serdeable, + servarr_models::{ + DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerTestResult, MetadataProfile, + QualityProfile, QualityWrapper, RootFolder, SecurityConfig, SystemStatus, Tag, + }, +}; +use crate::models::servarr_models::{IndexerSettings, LogResponse, QueueEvent, Update}; +use crate::serde_enum_from; +use chrono::{DateTime, Utc}; +use clap::ValueEnum; +use derivative::Derivative; +use enum_display_style_derive::EnumDisplayStyle; +use serde::{Deserialize, Serialize}; +use serde_json::{Number, Value}; +use std::fmt::{Display, Formatter}; +use strum::{Display, EnumIter}; + +#[cfg(test)] +#[path = "readarr_models_tests.rs"] +mod readarr_models_tests; + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Author { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub author_name: HorizontallyScrollableText, + pub foreign_author_id: String, + #[serde(deserialize_with = "super::from_json_or_default")] + pub status: AuthorStatus, + pub overview: Option, + pub author_type: Option, + pub disambiguation: Option, + pub path: String, + pub root_folder_path: Option, + #[serde(deserialize_with = "super::from_i64")] + pub quality_profile_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub metadata_profile_id: i64, + pub monitored: bool, + #[serde(deserialize_with = "super::from_json_or_default")] + pub monitor_new_items: NewItemMonitorType, + pub genres: Vec, + pub tags: Vec, + pub ratings: Option, + pub statistics: Option, +} + +#[derive( + Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, Display, EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum AuthorStatus { + #[default] + Continuing, + Ended, + Deleted, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Ratings { + #[serde(deserialize_with = "super::from_i64")] + pub votes: i64, + #[serde(deserialize_with = "super::from_f64")] + pub value: f64, + pub popularity: Option, +} + +impl Eq for Ratings {} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AuthorStatistics { + #[serde(deserialize_with = "super::from_i64")] + pub book_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub book_file_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub total_book_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub available_book_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size_on_disk: i64, + #[serde(deserialize_with = "super::from_f64")] + pub percent_of_books: f64, +} + +impl Eq for AuthorStatistics {} + +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + EnumIter, + clap::ValueEnum, + Display, + EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum NewItemMonitorType { + #[default] + #[display_style(name = "All Books")] + All, + #[display_style(name = "No New Books")] + None, + #[display_style(name = "New Books")] + New, +} + +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + EnumIter, + clap::ValueEnum, + Display, + EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum MonitorType { + #[default] + #[display_style(name = "All Books")] + All, + #[display_style(name = "Future Books")] + Future, + #[display_style(name = "Missing Books")] + Missing, + #[display_style(name = "Existing Books")] + Existing, + #[display_style(name = "First Book")] + First, + #[display_style(name = "Latest Book")] + Latest, + None, + Unknown, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DownloadRecord { + pub title: String, + #[serde(deserialize_with = "super::from_json_or_default")] + pub status: DownloadStatus, + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub book_id: Option, + pub author_id: Option, + #[serde(deserialize_with = "super::from_f64")] + pub size: f64, + #[serde(deserialize_with = "super::from_f64")] + pub sizeleft: f64, + pub output_path: Option, + #[serde(default)] + pub indexer: String, + pub download_client: Option, +} + +impl Eq for DownloadRecord {} + +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DownloadsResponse { + pub records: Vec, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddAuthorSearchResult { + pub foreign_author_id: String, + pub author_name: HorizontallyScrollableText, + #[serde(deserialize_with = "super::from_json_or_default")] + pub status: AuthorStatus, + #[serde(default)] + pub ended: bool, + pub overview: Option, + pub author_type: Option, + pub disambiguation: Option, + pub genres: Vec, + pub ratings: Option, +} + +#[derive(Default, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ReadarrCommandBody { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub author_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub book_ids: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub struct DeleteParams { + pub id: i64, + pub delete_files: bool, + pub add_import_list_exclusion: bool, +} + +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddAuthorBody { + pub foreign_author_id: String, + pub author_name: String, + pub monitored: bool, + pub root_folder_path: String, + pub quality_profile_id: i64, + pub metadata_profile_id: i64, + pub tags: Vec, + // Readarr-Resurrected persists the add body directly and rejects it when the images list is absent + pub images: Vec, + #[serde(skip_serializing, skip_deserializing)] + pub tag_input_string: Option, + pub add_options: AddAuthorOptions, +} + +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddAuthorOptions { + pub monitor: MonitorType, + pub monitor_new_items: NewItemMonitorType, + pub search_for_missing_books: bool, +} + +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditAuthorParams { + pub author_id: i64, + pub monitored: Option, + pub monitor_new_items: Option, + pub quality_profile_id: Option, + pub metadata_profile_id: Option, + pub root_folder_path: Option, + pub tags: Option>, + #[serde(skip_serializing, skip_deserializing)] + pub tag_input_string: Option, + pub clear_tags: bool, +} + +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddReadarrRootFolderBody { + pub name: String, + pub path: String, + pub default_quality_profile_id: i64, + pub default_metadata_profile_id: i64, + pub default_monitor_option: MonitorType, + pub default_new_item_monitor_option: NewItemMonitorType, + pub default_tags: Vec, + #[serde(skip_serializing, skip_deserializing)] + pub tag_input_string: Option, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Book { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub title: HorizontallyScrollableText, + #[serde(deserialize_with = "super::from_i64")] + pub author_id: i64, + pub foreign_book_id: String, + pub monitored: bool, + #[serde(default)] + pub any_edition_ok: bool, + pub page_count: Option, + pub ratings: Option, + pub release_date: Option>, + pub statistics: Option, + #[serde(default)] + pub grabbed: bool, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BookStatistics { + #[serde(deserialize_with = "super::from_i64")] + pub book_file_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub total_book_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size_on_disk: i64, + #[serde(deserialize_with = "super::from_f64")] + pub percent_of_books: f64, +} + +impl Eq for BookStatistics {} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Edition { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub book_id: i64, + pub foreign_edition_id: String, + pub monitored: bool, + pub is_ebook: bool, + pub title: String, + pub language: Option, + pub overview: Option, + pub format: Option, + pub publisher: Option, + pub page_count: Option, + pub release_date: Option>, + pub isbn13: Option, + pub asin: Option, + pub ratings: Option, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReadarrHistoryWrapper { + pub records: Vec, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReadarrHistoryData { + pub indexer: Option, + pub release_group: Option, + pub nzb_info_url: Option, + pub download_client_name: Option, + pub download_client: Option, + pub age: Option, + pub published_date: Option>, + pub message: Option, + pub reason: Option, + pub dropped_path: Option, + pub imported_path: Option, + pub source_path: Option, + pub path: Option, + pub status_messages: Option, +} + +#[derive( + Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Display, EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum ReadarrHistoryEventType { + #[default] + Unknown, + Grabbed, + #[display_style(name = "Author Folder Imported")] + AuthorFolderImported, + #[display_style(name = "Book Import Incomplete")] + BookImportIncomplete, + #[display_style(name = "Download Ignored")] + DownloadIgnored, + #[display_style(name = "Download Imported")] + DownloadImported, + #[display_style(name = "Download Failed")] + DownloadFailed, + #[display_style(name = "Book File Deleted")] + BookFileDeleted, + #[display_style(name = "Book File Imported")] + BookFileImported, + #[display_style(name = "Book File Renamed")] + BookFileRenamed, + #[display_style(name = "Book File Retagged")] + BookFileRetagged, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReadarrHistoryItem { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub author_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub book_id: i64, + pub source_title: HorizontallyScrollableText, + #[serde(default)] + pub quality: QualityWrapper, + pub date: DateTime, + #[serde(deserialize_with = "super::from_json_or_default")] + pub event_type: ReadarrHistoryEventType, + #[serde(default)] + pub data: ReadarrHistoryData, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReadarrTask { + pub name: String, + #[serde(deserialize_with = "super::from_json_or_default")] + pub task_name: ReadarrTaskName, + #[serde(deserialize_with = "super::from_i64")] + pub interval: i64, + pub last_execution: DateTime, + pub last_duration: String, + pub next_execution: DateTime, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy, ValueEnum)] +#[serde(rename_all = "PascalCase")] +pub enum ReadarrTaskName { + #[default] + ApplicationUpdateCheck, + Backup, + CheckHealth, + Housekeeping, + ImportListSync, + MessagingCleanup, + RefreshAuthor, + RefreshMonitoredDownloads, + RescanFolders, + RssSync, +} + +impl Display for ReadarrTaskName { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let task_name = serde_json::to_string(&self) + .expect("Unable to serialize task name") + .replace('"', ""); + write!(f, "{task_name}") + } +} + +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[serde(default)] +pub struct ReadarrRelease { + pub guid: String, + pub protocol: String, + #[serde(deserialize_with = "super::from_i64")] + pub age: i64, + pub title: HorizontallyScrollableText, + pub author_name: Option, + pub book_title: Option, + pub indexer: String, + #[serde(deserialize_with = "super::from_i64")] + pub indexer_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + pub rejected: bool, + pub rejections: Option>, + pub seeders: Option, + pub leechers: Option, + pub quality: QualityWrapper, +} + +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(untagged)] +pub enum ReadarrReleaseResponse { + Releases(Vec), + // Chaptarr wraps the release list in a filter summary envelope, even behind its Readarr facade + Envelope { releases: Vec }, +} + +impl Default for ReadarrReleaseResponse { + fn default() -> Self { + ReadarrReleaseResponse::Releases(Vec::new()) + } +} + +impl ReadarrReleaseResponse { + pub fn into_releases(self) -> Vec { + match self { + ReadarrReleaseResponse::Releases(releases) + | ReadarrReleaseResponse::Envelope { releases } => releases, + } + } +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BlocklistItem { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub author_id: i64, + pub book_ids: Option>, + pub source_title: String, + pub quality: QualityWrapper, + pub date: DateTime, + pub protocol: String, + pub indexer: String, + pub message: String, + pub author: Author, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct BlocklistResponse { + pub records: Vec, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BookFile { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub author_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub book_id: i64, + pub path: String, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + pub date_added: DateTime, + pub quality: QualityWrapper, + pub media_info: Option, + pub quality_cutoff_not_met: bool, +} + +#[derive(Serialize, Deserialize, Derivative, Debug, Clone, PartialEq, Eq)] +#[derivative(Default)] +#[serde(rename_all = "camelCase")] +pub struct MediaInfo { + pub audio_bit_rate: Option, + #[serde(deserialize_with = "super::from_i64")] + pub audio_channels: i64, + pub audio_codec: Option, + pub audio_bits: Option, + pub audio_sample_rate: Option, +} + +impl From for Serdeable { + fn from(value: ReadarrSerdeable) -> Serdeable { + Serdeable::Readarr(value) + } +} + +serde_enum_from!( + ReadarrSerdeable { + AddAuthorSearchResults(Vec), + Author(Author), + Authors(Vec), + BlocklistResponse(BlocklistResponse), + Book(Book), + BookFiles(Vec), + Books(Vec), + DiskSpaces(Vec), + DownloadsResponse(DownloadsResponse), + Editions(Vec), + HostConfig(HostConfig), + Indexers(Vec), + IndexerSettings(IndexerSettings), + IndexerTestResults(Vec), + LogResponse(LogResponse), + MetadataProfiles(Vec), + QualityProfiles(Vec), + QueueEvents(Vec), + ReadarrHistoryItems(Vec), + ReadarrHistoryWrapper(ReadarrHistoryWrapper), + Releases(Vec), + RootFolders(Vec), + SecurityConfig(SecurityConfig), + SystemStatus(SystemStatus), + Tag(Tag), + Tags(Vec), + Tasks(Vec), + Updates(Vec), + Value(Value), + } +); diff --git a/src/models/readarr_models_tests.rs b/src/models/readarr_models_tests.rs new file mode 100644 index 00000000..86457b00 --- /dev/null +++ b/src/models/readarr_models_tests.rs @@ -0,0 +1,1285 @@ +#[cfg(test)] +mod tests { + use chrono::Utc; + use pretty_assertions::{assert_eq, assert_str_eq}; + use serde::de::DeserializeOwned; + use serde_json::{Value, json}; + + use crate::models::readarr_models::{ + AddAuthorSearchResult, BlocklistItem, BlocklistResponse, Book, BookFile, DownloadRecord, + DownloadsResponse, Edition, MediaInfo, MonitorType, NewItemMonitorType, + ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrHistoryWrapper, ReadarrRelease, + ReadarrReleaseResponse, ReadarrTask, ReadarrTaskName, + }; + use crate::models::servarr_models::{ + DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, + LogResponse, MetadataProfile, QualityProfile, QueueEvent, RootFolder, SecurityConfig, + SystemStatus, Tag, Update, + }; + use crate::models::{ + Serdeable, + readarr_models::{Author, AuthorStatistics, AuthorStatus, Ratings, ReadarrSerdeable}, + }; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, download_record, + }; + + #[test] + fn test_author_status_default() { + assert_eq!(AuthorStatus::default(), AuthorStatus::Continuing); + } + + #[test] + fn test_new_item_monitor_type_display() { + assert_str_eq!(NewItemMonitorType::All.to_string(), "all"); + assert_str_eq!(NewItemMonitorType::None.to_string(), "none"); + assert_str_eq!(NewItemMonitorType::New.to_string(), "new"); + } + + #[test] + fn test_new_item_monitor_type_to_display_str() { + assert_str_eq!(NewItemMonitorType::All.to_display_str(), "All Books"); + assert_str_eq!(NewItemMonitorType::None.to_display_str(), "No New Books"); + assert_str_eq!(NewItemMonitorType::New.to_display_str(), "New Books"); + } + + #[test] + fn test_monitor_type_display() { + assert_str_eq!(MonitorType::All.to_string(), "all"); + assert_str_eq!(MonitorType::Future.to_string(), "future"); + assert_str_eq!(MonitorType::Missing.to_string(), "missing"); + assert_str_eq!(MonitorType::Existing.to_string(), "existing"); + assert_str_eq!(MonitorType::First.to_string(), "first"); + assert_str_eq!(MonitorType::Latest.to_string(), "latest"); + assert_str_eq!(MonitorType::None.to_string(), "none"); + assert_str_eq!(MonitorType::Unknown.to_string(), "unknown"); + } + + #[test] + fn test_monitor_type_to_display_str() { + assert_str_eq!(MonitorType::All.to_display_str(), "All Books"); + assert_str_eq!(MonitorType::Future.to_display_str(), "Future Books"); + assert_str_eq!(MonitorType::Missing.to_display_str(), "Missing Books"); + assert_str_eq!(MonitorType::Existing.to_display_str(), "Existing Books"); + assert_str_eq!(MonitorType::First.to_display_str(), "First Book"); + assert_str_eq!(MonitorType::Latest.to_display_str(), "Latest Book"); + assert_str_eq!(MonitorType::None.to_display_str(), "None"); + assert_str_eq!(MonitorType::Unknown.to_display_str(), "Unknown"); + } + + #[test] + fn test_readarr_serdeable_from() { + let readarr_serdeable = ReadarrSerdeable::Value(json!({})); + + let serdeable: Serdeable = Serdeable::from(readarr_serdeable.clone()); + + assert_eq!(serdeable, Serdeable::Readarr(readarr_serdeable)); + } + + #[test] + fn test_readarr_serdeable_from_unit() { + let readarr_serdeable = ReadarrSerdeable::from(()); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Value(json!({}))); + } + + #[test] + fn test_readarr_serdeable_from_value() { + let value = json!({"test": "test"}); + + let readarr_serdeable: ReadarrSerdeable = value.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Value(value)); + } + + #[test] + fn test_readarr_serdeable_from_authors() { + let authors = vec![Author { + id: 1, + ..Author::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = authors.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Authors(authors)); + } + + #[test] + fn test_author_deserialization() { + let author_json = json!({ + "id": 1, + "authorName": "Test Author", + "foreignAuthorId": "test-foreign-id", + "status": "continuing", + "overview": "Test overview", + "authorType": "Person", + "disambiguation": "American Novelist", + "path": "/nfs/books/Test Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["Fantasy", "Fiction"], + "tags": [1, 2], + "ratings": { + "votes": 100, + "value": 4.5, + "popularity": 450.0 + }, + "statistics": { + "bookCount": 14, + "bookFileCount": 5, + "totalBookCount": 14, + "availableBookCount": 5, + "sizeOnDisk": 51783488, + "percentOfBooks": 35.71 + } + }); + + let author: Author = serde_json::from_value(author_json).unwrap(); + + assert_eq!(author.id, 1); + assert_str_eq!(author.author_name.text, "Test Author"); + assert_str_eq!(author.foreign_author_id, "test-foreign-id"); + assert_eq!(author.status, AuthorStatus::Continuing); + assert_some_eq_x!(&author.overview, "Test overview"); + assert_some_eq_x!(&author.author_type, "Person"); + assert_some_eq_x!(&author.disambiguation, "American Novelist"); + assert_str_eq!(author.path, "/nfs/books/Test Author"); + assert_some_eq_x!(&author.root_folder_path, "/nfs/books/"); + assert_eq!(author.quality_profile_id, 1); + assert_eq!(author.metadata_profile_id, 1); + assert!(author.monitored); + assert_eq!(author.monitor_new_items, NewItemMonitorType::All); + assert_eq!(author.genres, vec!["Fantasy", "Fiction"]); + assert_eq!(author.tags.len(), 2); + assert_some!(&author.ratings); + assert_some!(&author.statistics); + + let ratings = author.ratings.unwrap(); + assert_eq!(ratings.votes, 100); + assert_eq!(ratings.value, 4.5); + assert_some_eq_x!(ratings.popularity, 450.0); + + let stats = author.statistics.unwrap(); + assert_eq!(stats.book_count, 14); + assert_eq!(stats.book_file_count, 5); + assert_eq!(stats.total_book_count, 14); + assert_eq!(stats.available_book_count, 5); + assert_eq!(stats.size_on_disk, 51783488); + assert_eq!(stats.percent_of_books, 35.71); + } + + #[test] + fn test_author_status_deserialization() { + assert_eq!( + serde_json::from_str::("\"continuing\"").unwrap(), + AuthorStatus::Continuing + ); + assert_eq!( + serde_json::from_str::("\"ended\"").unwrap(), + AuthorStatus::Ended + ); + assert_eq!( + serde_json::from_str::("\"deleted\"").unwrap(), + AuthorStatus::Deleted + ); + } + + #[test] + fn test_ratings_equality() { + let ratings1 = Ratings { + votes: 100, + value: 4.5, + popularity: Some(450.0), + }; + let ratings2 = Ratings { + votes: 100, + value: 4.5, + popularity: Some(450.0), + }; + let ratings3 = Ratings { + votes: 50, + value: 3.0, + popularity: None, + }; + + assert_eq!(ratings1, ratings2); + assert_ne!(ratings1, ratings3); + } + + #[test] + fn test_author_statistics_equality() { + let stats1 = AuthorStatistics { + book_count: 14, + book_file_count: 5, + total_book_count: 14, + available_book_count: 5, + size_on_disk: 51783488, + percent_of_books: 35.71, + }; + let stats2 = AuthorStatistics { + book_count: 14, + book_file_count: 5, + total_book_count: 14, + available_book_count: 5, + size_on_disk: 51783488, + percent_of_books: 35.71, + }; + let stats3 = AuthorStatistics::default(); + + assert_eq!(stats1, stats2); + assert_ne!(stats1, stats3); + } + + #[test] + fn test_author_with_optional_fields_none() { + let author_json = json!({ + "id": 1, + "authorName": "Test Author", + "foreignAuthorId": "", + "status": "continuing", + "path": "", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": false, + "monitorNewItems": "all", + "genres": [], + "tags": [] + }); + + let author: Author = serde_json::from_value(author_json).unwrap(); + + assert_none!(&author.overview); + assert_none!(&author.author_type); + assert_none!(&author.disambiguation); + assert_none!(&author.root_folder_path); + assert_eq!(author.monitor_new_items, NewItemMonitorType::All); + assert_none!(&author.ratings); + assert_none!(&author.statistics); + } + + #[test] + fn test_readarr_serdeable_from_author() { + let author = Author { + id: 1, + ..Author::default() + }; + + let readarr_serdeable: ReadarrSerdeable = author.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Author(author)); + } + + #[test] + fn test_readarr_serdeable_from_blocklist_response() { + let blocklist_response = BlocklistResponse { + records: vec![BlocklistItem { + id: 1, + ..BlocklistItem::default() + }], + }; + + let readarr_serdeable: ReadarrSerdeable = blocklist_response.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::BlocklistResponse(blocklist_response) + ); + } + + #[test] + fn test_readarr_serdeable_from_disk_spaces() { + let disk_spaces = vec![DiskSpace { + path: Some("/path".to_owned()), + free_space: 1, + total_space: 1, + }]; + + let readarr_serdeable: ReadarrSerdeable = disk_spaces.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::DiskSpaces(disk_spaces)); + } + + #[test] + fn test_readarr_serdeable_from_downloads_response() { + let downloads_response = DownloadsResponse { + records: vec![DownloadRecord { + id: 1, + ..DownloadRecord::default() + }], + }; + + let readarr_serdeable: ReadarrSerdeable = downloads_response.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::DownloadsResponse(downloads_response) + ); + } + + #[test] + fn test_readarr_serdeable_from_readarr_history_wrapper() { + let history_wrapper = ReadarrHistoryWrapper { + records: vec![ReadarrHistoryItem { + id: 1, + ..ReadarrHistoryItem::default() + }], + }; + + let readarr_serdeable: ReadarrSerdeable = history_wrapper.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::ReadarrHistoryWrapper(history_wrapper) + ); + } + + #[test] + fn test_readarr_serdeable_from_readarr_history_items() { + let history_items = vec![ReadarrHistoryItem { + id: 1, + ..ReadarrHistoryItem::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = history_items.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::ReadarrHistoryItems(history_items) + ); + } + + #[test] + fn test_readarr_serdeable_from_indexers() { + let indexers = vec![Indexer { + id: 1, + ..Indexer::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = indexers.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Indexers(indexers)); + } + + #[test] + fn test_readarr_serdeable_from_indexer_settings() { + let indexer_settings = IndexerSettings { + id: 1, + ..IndexerSettings::default() + }; + + let readarr_serdeable: ReadarrSerdeable = indexer_settings.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::IndexerSettings(indexer_settings) + ); + } + + #[test] + fn test_readarr_serdeable_from_indexer_test_results() { + let indexer_test_results = vec![IndexerTestResult { + id: 1, + ..IndexerTestResult::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = indexer_test_results.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::IndexerTestResults(indexer_test_results) + ); + } + + #[test] + fn test_readarr_serdeable_from_log_response() { + let log_response = LogResponse { + records: vec![Log { + level: "info".to_owned(), + ..Log::default() + }], + }; + + let readarr_serdeable: ReadarrSerdeable = log_response.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::LogResponse(log_response) + ); + } + + #[test] + fn test_readarr_serdeable_from_metadata_profiles() { + let metadata_profiles = vec![MetadataProfile { + id: 1, + name: "Standard".to_owned(), + }]; + + let readarr_serdeable: ReadarrSerdeable = metadata_profiles.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::MetadataProfiles(metadata_profiles) + ); + } + + #[test] + fn test_readarr_serdeable_from_host_config() { + let host_config = HostConfig { + port: 8787, + ..HostConfig::default() + }; + + let readarr_serdeable: ReadarrSerdeable = host_config.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::HostConfig(host_config)); + } + + #[test] + fn test_readarr_serdeable_from_quality_profiles() { + let quality_profiles = vec![QualityProfile { + id: 1, + name: "Any".to_owned(), + }]; + + let readarr_serdeable: ReadarrSerdeable = quality_profiles.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::QualityProfiles(quality_profiles) + ); + } + + #[test] + fn test_readarr_serdeable_from_queue_events() { + let queue_events = vec![QueueEvent { + trigger: "test".to_owned(), + ..QueueEvent::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = queue_events.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::QueueEvents(queue_events) + ); + } + + #[test] + fn test_readarr_serdeable_from_root_folders() { + let root_folders = vec![RootFolder { + id: 1, + path: "/nfs/books".to_owned(), + accessible: true, + free_space: 1000000, + unmapped_folders: None, + }]; + + let readarr_serdeable: ReadarrSerdeable = root_folders.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::RootFolders(root_folders) + ); + } + + #[test] + fn test_readarr_serdeable_from_releases() { + let releases = vec![ReadarrRelease { + guid: "test".to_owned(), + ..ReadarrRelease::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = releases.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Releases(releases)); + } + + #[test] + fn test_readarr_release_response_default_is_an_empty_release_list() { + assert_eq!( + ReadarrReleaseResponse::default(), + ReadarrReleaseResponse::Releases(Vec::new()) + ); + } + + #[test] + fn test_readarr_release_response_into_releases() { + let releases = vec![ReadarrRelease { + guid: "test".to_owned(), + ..ReadarrRelease::default() + }]; + let list = ReadarrReleaseResponse::Releases(releases.clone()); + let envelope = ReadarrReleaseResponse::Envelope { + releases: releases.clone(), + }; + + assert_eq!(list.into_releases(), releases); + assert_eq!(envelope.into_releases(), releases); + } + + #[test] + fn test_readarr_serdeable_from_security_config() { + let security_config = SecurityConfig { + api_key: "test-key".to_owned(), + ..SecurityConfig::default() + }; + + let readarr_serdeable: ReadarrSerdeable = security_config.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::SecurityConfig(security_config) + ); + } + + #[test] + fn test_readarr_serdeable_from_system_status() { + let system_status = SystemStatus { + version: "1.0.0".to_owned(), + start_time: Utc::now(), + }; + + let readarr_serdeable: ReadarrSerdeable = system_status.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::SystemStatus(system_status) + ); + } + + #[test] + fn test_readarr_serdeable_from_tags() { + let tags = vec![Tag { + id: 1, + label: "fantasy".to_owned(), + }]; + + let readarr_serdeable: ReadarrSerdeable = tags.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Tags(tags)); + } + + #[test] + fn test_readarr_serdeable_from_add_author_search_results() { + let search_results = vec![AddAuthorSearchResult { + foreign_author_id: "test-id".to_owned(), + ..AddAuthorSearchResult::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = search_results.clone().into(); + + assert_eq!( + readarr_serdeable, + ReadarrSerdeable::AddAuthorSearchResults(search_results) + ); + } + + #[test] + fn test_readarr_serdeable_from_books() { + let books = vec![Book { + id: 1, + ..Book::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = books.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Books(books)); + } + + #[test] + fn test_readarr_serdeable_from_book() { + let book = Book { + id: 1, + ..Book::default() + }; + + let readarr_serdeable: ReadarrSerdeable = book.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Book(book)); + } + + #[test] + fn test_readarr_serdeable_from_editions() { + let editions = vec![Edition { + id: 1, + ..Edition::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = editions.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Editions(editions)); + } + + #[test] + fn test_readarr_serdeable_from_book_files() { + let book_files = vec![BookFile { + id: 1, + media_info: Some(MediaInfo { + audio_channels: 2, + ..MediaInfo::default() + }), + ..BookFile::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = book_files.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::BookFiles(book_files)); + } + + #[test] + fn test_readarr_serdeable_from_tasks() { + let tasks = vec![ReadarrTask { + name: "test".to_owned(), + ..ReadarrTask::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = tasks.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Tasks(tasks)); + } + + #[test] + fn test_readarr_serdeable_from_updates() { + let updates = vec![Update { + version: "test".to_owned(), + ..Update::default() + }]; + + let readarr_serdeable: ReadarrSerdeable = updates.clone().into(); + + assert_eq!(readarr_serdeable, ReadarrSerdeable::Updates(updates)); + } + + #[test] + fn test_author_status_display() { + assert_str_eq!(AuthorStatus::Continuing.to_string(), "continuing"); + assert_str_eq!(AuthorStatus::Ended.to_string(), "ended"); + assert_str_eq!(AuthorStatus::Deleted.to_string(), "deleted"); + } + + #[test] + fn test_author_status_to_display_str() { + assert_str_eq!(AuthorStatus::Continuing.to_display_str(), "Continuing"); + assert_str_eq!(AuthorStatus::Ended.to_display_str(), "Ended"); + assert_str_eq!(AuthorStatus::Deleted.to_display_str(), "Deleted"); + } + + #[test] + fn test_readarr_history_event_type_display() { + assert_str_eq!(ReadarrHistoryEventType::Unknown.to_string(), "unknown"); + assert_str_eq!(ReadarrHistoryEventType::Grabbed.to_string(), "grabbed"); + assert_str_eq!( + ReadarrHistoryEventType::AuthorFolderImported.to_string(), + "authorFolderImported" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookImportIncomplete.to_string(), + "bookImportIncomplete" + ); + assert_str_eq!( + ReadarrHistoryEventType::DownloadIgnored.to_string(), + "downloadIgnored" + ); + assert_str_eq!( + ReadarrHistoryEventType::DownloadImported.to_string(), + "downloadImported" + ); + assert_str_eq!( + ReadarrHistoryEventType::DownloadFailed.to_string(), + "downloadFailed" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileDeleted.to_string(), + "bookFileDeleted" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileImported.to_string(), + "bookFileImported" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileRenamed.to_string(), + "bookFileRenamed" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileRetagged.to_string(), + "bookFileRetagged" + ); + } + + #[test] + fn test_readarr_history_event_type_to_display_str() { + assert_str_eq!(ReadarrHistoryEventType::Unknown.to_display_str(), "Unknown"); + assert_str_eq!(ReadarrHistoryEventType::Grabbed.to_display_str(), "Grabbed"); + assert_str_eq!( + ReadarrHistoryEventType::AuthorFolderImported.to_display_str(), + "Author Folder Imported" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookImportIncomplete.to_display_str(), + "Book Import Incomplete" + ); + assert_str_eq!( + ReadarrHistoryEventType::DownloadIgnored.to_display_str(), + "Download Ignored" + ); + assert_str_eq!( + ReadarrHistoryEventType::DownloadImported.to_display_str(), + "Download Imported" + ); + assert_str_eq!( + ReadarrHistoryEventType::DownloadFailed.to_display_str(), + "Download Failed" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileDeleted.to_display_str(), + "Book File Deleted" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileImported.to_display_str(), + "Book File Imported" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileRenamed.to_display_str(), + "Book File Renamed" + ); + assert_str_eq!( + ReadarrHistoryEventType::BookFileRetagged.to_display_str(), + "Book File Retagged" + ); + } + + #[test] + fn test_readarr_history_event_type_deserialization() { + assert_eq!( + deserialize_history_event_type(json!("unknown")), + ReadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!("grabbed")), + ReadarrHistoryEventType::Grabbed + ); + assert_eq!( + deserialize_history_event_type(json!("authorFolderImported")), + ReadarrHistoryEventType::AuthorFolderImported + ); + assert_eq!( + deserialize_history_event_type(json!("bookImportIncomplete")), + ReadarrHistoryEventType::BookImportIncomplete + ); + assert_eq!( + deserialize_history_event_type(json!("downloadIgnored")), + ReadarrHistoryEventType::DownloadIgnored + ); + assert_eq!( + deserialize_history_event_type(json!("downloadImported")), + ReadarrHistoryEventType::DownloadImported + ); + assert_eq!( + deserialize_history_event_type(json!("downloadFailed")), + ReadarrHistoryEventType::DownloadFailed + ); + assert_eq!( + deserialize_history_event_type(json!("bookFileDeleted")), + ReadarrHistoryEventType::BookFileDeleted + ); + assert_eq!( + deserialize_history_event_type(json!("bookFileImported")), + ReadarrHistoryEventType::BookFileImported + ); + assert_eq!( + deserialize_history_event_type(json!("bookFileRenamed")), + ReadarrHistoryEventType::BookFileRenamed + ); + assert_eq!( + deserialize_history_event_type(json!("bookFileRetagged")), + ReadarrHistoryEventType::BookFileRetagged + ); + } + + #[test] + fn test_readarr_history_event_type_deserialization_falls_back_to_unknown() { + assert_eq!( + deserialize_history_event_type(json!("bookImportPartiallyComplete")), + ReadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(2)), + ReadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(11)), + ReadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(null)), + ReadarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!({ "id": 2 })), + ReadarrHistoryEventType::Unknown + ); + } + + #[test] + fn test_readarr_history_event_type_serialization() { + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::Unknown), + "unknown" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::Grabbed), + "grabbed" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::AuthorFolderImported), + "authorFolderImported" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::BookImportIncomplete), + "bookImportIncomplete" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::DownloadIgnored), + "downloadIgnored" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::DownloadImported), + "downloadImported" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::DownloadFailed), + "downloadFailed" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::BookFileDeleted), + "bookFileDeleted" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::BookFileImported), + "bookFileImported" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::BookFileRenamed), + "bookFileRenamed" + ); + assert_str_eq!( + serialize_history_event_type(ReadarrHistoryEventType::BookFileRetagged), + "bookFileRetagged" + ); + } + + #[test] + fn test_readarr_task_name_display() { + assert_str_eq!( + ReadarrTaskName::ApplicationUpdateCheck.to_string(), + "ApplicationUpdateCheck" + ); + assert_str_eq!(ReadarrTaskName::Backup.to_string(), "Backup"); + assert_str_eq!(ReadarrTaskName::CheckHealth.to_string(), "CheckHealth"); + assert_str_eq!(ReadarrTaskName::Housekeeping.to_string(), "Housekeeping"); + assert_str_eq!( + ReadarrTaskName::ImportListSync.to_string(), + "ImportListSync" + ); + assert_str_eq!( + ReadarrTaskName::MessagingCleanup.to_string(), + "MessagingCleanup" + ); + assert_str_eq!(ReadarrTaskName::RefreshAuthor.to_string(), "RefreshAuthor"); + assert_str_eq!( + ReadarrTaskName::RefreshMonitoredDownloads.to_string(), + "RefreshMonitoredDownloads" + ); + assert_str_eq!(ReadarrTaskName::RescanFolders.to_string(), "RescanFolders"); + assert_str_eq!(ReadarrTaskName::RssSync.to_string(), "RssSync"); + } + + #[test] + fn test_book_deserialization() { + let book_json = json!({ + "id": 1, + "title": "The Name of the Wind", + "authorId": 3, + "foreignBookId": "test-foreign-book-id", + "monitored": true, + "anyEditionOk": true, + "pageCount": 662, + "releaseDate": "2007-03-27T00:00:00Z", + "grabbed": false, + "ratings": { + "votes": 100, + "value": 4.5, + "popularity": 450.0 + }, + "statistics": { + "bookFileCount": 1, + "totalBookCount": 1, + "sizeOnDisk": 51783488, + "percentOfBooks": 100.0 + } + }); + + let book: Book = serde_json::from_value(book_json).unwrap(); + + assert_eq!(book.id, 1); + assert_str_eq!(book.title.text, "The Name of the Wind"); + assert_eq!(book.author_id, 3); + assert_str_eq!(book.foreign_book_id, "test-foreign-book-id"); + assert!(book.monitored); + assert!(book.any_edition_ok); + assert_some_eq_x!(book.page_count, 662); + assert_some!(&book.release_date); + assert!(!book.grabbed); + assert_some!(&book.ratings); + + let stats = book.statistics.unwrap(); + assert_eq!(stats.book_file_count, 1); + assert_eq!(stats.total_book_count, 1); + assert_eq!(stats.size_on_disk, 51783488); + assert_eq!(stats.percent_of_books, 100.0); + } + + #[test] + fn test_book_with_optional_fields_none() { + let book_json = json!({ + "id": 1, + "title": "The Name of the Wind", + "authorId": 3, + "foreignBookId": "", + "monitored": false, + "grabbed": false + }); + + let book: Book = serde_json::from_value(book_json).unwrap(); + + assert!(!book.any_edition_ok); + assert_none!(&book.page_count); + assert_none!(&book.release_date); + assert_none!(&book.ratings); + assert_none!(&book.statistics); + } + + #[test] + fn test_book_deserialization_defaults_grabbed_when_absent() { + let book_json = json!({ + "id": 1, + "title": "The Name of the Wind", + "authorId": 3, + "foreignBookId": "test-foreign-book-id", + "monitored": true + }); + + let book: Book = serde_json::from_value(book_json).unwrap(); + + assert!(!book.grabbed); + assert!(book.monitored); + } + + #[test] + fn test_edition_deserialization() { + let edition_json = json!({ + "id": 106, + "bookId": 1, + "foreignEditionId": "test-foreign-edition-id", + "monitored": true, + "isEbook": false, + "title": "The Name of the Wind", + "language": "eng", + "overview": "Test overview", + "format": "Hardcover", + "publisher": "DAW Books", + "pageCount": 662, + "releaseDate": "2007-03-27T00:00:00Z", + "isbn13": "9780756404079", + "asin": "0756404070", + "ratings": { + "votes": 100, + "value": 4.5, + "popularity": 450.0 + } + }); + + let edition: Edition = serde_json::from_value(edition_json).unwrap(); + + assert_eq!(edition.id, 106); + assert_eq!(edition.book_id, 1); + assert_str_eq!(edition.foreign_edition_id, "test-foreign-edition-id"); + assert!(edition.monitored); + assert!(!edition.is_ebook); + assert_str_eq!(edition.title, "The Name of the Wind"); + assert_some_eq_x!(&edition.language, "eng"); + assert_some_eq_x!(&edition.overview, "Test overview"); + assert_some_eq_x!(&edition.format, "Hardcover"); + assert_some_eq_x!(&edition.publisher, "DAW Books"); + assert_some_eq_x!(edition.page_count, 662); + assert_some!(&edition.release_date); + assert_some_eq_x!(&edition.isbn13, "9780756404079"); + assert_some_eq_x!(&edition.asin, "0756404070"); + assert_some!(&edition.ratings); + } + + #[test] + fn test_edition_with_optional_fields_none() { + let edition_json = json!({ + "id": 106, + "bookId": 1, + "foreignEditionId": "", + "monitored": false, + "isEbook": true, + "title": "" + }); + + let edition: Edition = serde_json::from_value(edition_json).unwrap(); + + assert_none!(&edition.language); + assert_none!(&edition.overview); + assert_none!(&edition.format); + assert_none!(&edition.publisher); + assert_none!(&edition.page_count); + assert_none!(&edition.release_date); + assert_none!(&edition.isbn13); + assert_none!(&edition.asin); + assert_none!(&edition.ratings); + } + + #[test] + fn test_media_info_deserialization() { + let media_info_json = json!({ + "audioChannels": 2, + "audioBitRate": "99 kbps", + "audioCodec": "AAC", + "audioBits": "", + "audioSampleRate": "44.1kHz" + }); + + let media_info: MediaInfo = serde_json::from_value(media_info_json).unwrap(); + + assert_eq!(media_info.audio_channels, 2); + assert_some_eq_x!(&media_info.audio_bit_rate, "99 kbps"); + assert_some_eq_x!(&media_info.audio_codec, "AAC"); + assert_some_eq_x!(&media_info.audio_bits, ""); + assert_some_eq_x!(&media_info.audio_sample_rate, "44.1kHz"); + } + + #[test] + fn test_download_record_deserialization() { + let download_record_json = json!({ + "title": "Test Download", + "status": "downloading", + "id": 1, + "bookId": 2, + "authorId": 3, + "size": 3543348019.0, + "sizeleft": 1771674009.5, + "outputPath": "/nfs/books/Test Author", + "downloadClient": "transmission" + }); + + let download_record: DownloadRecord = serde_json::from_value(download_record_json).unwrap(); + + assert_str_eq!(download_record.title, "Test Download"); + assert_eq!(download_record.status, DownloadStatus::Downloading); + assert_eq!(download_record.id, 1); + assert_some!(&download_record.book_id); + assert_some!(&download_record.author_id); + assert_eq!(download_record.size, 3543348019.0); + assert_eq!(download_record.sizeleft, 1771674009.5); + assert_some!(&download_record.output_path); + assert!(download_record.indexer.is_empty()); + assert_some_eq_x!(&download_record.download_client, "transmission"); + } + + #[test] + fn test_add_author_search_result_deserialization() { + let search_result_json = json!({ + "foreignAuthorId": "test-foreign-id", + "authorName": "Test Author", + "status": "continuing", + "ended": false, + "overview": "Test overview", + "authorType": "Person", + "disambiguation": "American Novelist", + "genres": ["Fantasy", "Fiction"], + "ratings": { + "votes": 100, + "value": 4.5, + "popularity": 450.0 + } + }); + + let search_result: AddAuthorSearchResult = serde_json::from_value(search_result_json).unwrap(); + + assert_str_eq!(search_result.foreign_author_id, "test-foreign-id"); + assert_str_eq!(search_result.author_name.text, "Test Author"); + assert_eq!(search_result.status, AuthorStatus::Continuing); + assert!(!search_result.ended); + assert_some_eq_x!(&search_result.overview, "Test overview"); + assert_some_eq_x!(&search_result.author_type, "Person"); + assert_some_eq_x!(&search_result.disambiguation, "American Novelist"); + assert_eq!(search_result.genres, vec!["Fantasy", "Fiction"]); + assert_some!(&search_result.ratings); + + let ratings = search_result.ratings.unwrap(); + assert_eq!(ratings.votes, 100); + assert_eq!(ratings.value, 4.5); + } + + #[test] + fn test_add_author_search_result_with_optional_fields_none() { + let search_result_json = json!({ + "foreignAuthorId": "test-foreign-id", + "authorName": "Test Author", + "status": "ended", + "genres": [] + }); + + let search_result: AddAuthorSearchResult = serde_json::from_value(search_result_json).unwrap(); + + assert_str_eq!(search_result.foreign_author_id, "test-foreign-id"); + assert_str_eq!(search_result.author_name.text, "Test Author"); + assert_eq!(search_result.status, AuthorStatus::Ended); + assert!(!search_result.ended); + assert_none!(&search_result.overview); + assert_none!(&search_result.author_type); + assert_none!(&search_result.disambiguation); + assert!(search_result.genres.is_empty()); + assert_none!(&search_result.ratings); + } + + #[test] + fn test_author_status_deserialization_falls_back_to_default() { + let expected = Author { + status: AuthorStatus::default(), + ..serde_json::from_str(AUTHOR_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(AUTHOR_JSON, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(AUTHOR_JSON, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_author_monitor_new_items_deserialization_falls_back_to_default() { + let expected = Author { + monitor_new_items: NewItemMonitorType::default(), + ..serde_json::from_str(AUTHOR_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(AUTHOR_JSON, "monitorNewItems", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(AUTHOR_JSON, "monitorNewItems", json!("notAThing")), + expected + ); + } + + #[test] + fn test_add_author_search_result_status_deserialization_falls_back_to_default() { + let expected = AddAuthorSearchResult { + status: AuthorStatus::default(), + ..serde_json::from_str(ADD_AUTHOR_SEARCH_RESULT_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::( + ADD_AUTHOR_SEARCH_RESULT_JSON, + "status", + json!(2) + ), + expected + ); + assert_eq!( + deserialize_with_field::( + ADD_AUTHOR_SEARCH_RESULT_JSON, + "status", + json!("notAThing") + ), + expected + ); + } + + #[test] + fn test_download_record_status_deserialization_falls_back_to_default() { + let download_record_json = serde_json::to_string(&download_record()).unwrap(); + let expected = DownloadRecord { + status: DownloadStatus::default(), + ..download_record() + }; + + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_readarr_task_task_name_deserialization_falls_back_to_default() { + let task = ReadarrTask { + name: "Backup".to_owned(), + task_name: ReadarrTaskName::Backup, + ..ReadarrTask::default() + }; + let task_json = serde_json::to_string(&task).unwrap(); + let expected = ReadarrTask { + task_name: ReadarrTaskName::default(), + ..task + }; + + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!("notAThing")), + expected + ); + } + + fn deserialize_with_field(json: &str, field: &str, value: Value) -> T { + let mut fixture_json: Value = serde_json::from_str(json).unwrap(); + fixture_json[field] = value; + + serde_json::from_value(fixture_json).unwrap() + } + + fn deserialize_history_event_type(event_type: Value) -> ReadarrHistoryEventType { + let history_item_json = json!({ + "id": 1, + "authorId": 1, + "bookId": 1, + "sourceTitle": "Test Source Title", + "date": "2024-01-01T00:00:00Z", + "eventType": event_type + }); + + serde_json::from_value::(history_item_json) + .unwrap() + .event_type + } + + fn serialize_history_event_type(event_type: ReadarrHistoryEventType) -> String { + let history_item = ReadarrHistoryItem { + event_type, + ..ReadarrHistoryItem::default() + }; + + serde_json::to_value(history_item).unwrap()["eventType"] + .as_str() + .unwrap() + .to_owned() + } +} diff --git a/src/models/servarr_data/lidarr/lidarr_data.rs b/src/models/servarr_data/lidarr/lidarr_data.rs index 497c828e..0314f1ea 100644 --- a/src/models/servarr_data/lidarr/lidarr_data.rs +++ b/src/models/servarr_data/lidarr/lidarr_data.rs @@ -1,6 +1,8 @@ use serde_json::Number; -use super::modals::{AddArtistModal, AddRootFolderModal, AlbumDetailsModal, EditArtistModal}; +use super::modals::{ + AddArtistModal, AddRootFolderModal, AlbumDetailsModal, ArtistOverviewModal, EditArtistModal, +}; use crate::app::context_clues::{ BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, @@ -64,6 +66,7 @@ pub struct LidarrData<'a> { pub album_details_modal: Option, pub artist_history: StatefulTable, pub artist_info_tabs: TabState, + pub artist_overview_modal: Option, pub artists: StatefulTable, pub blocklist: StatefulTable, pub delete_files: bool, @@ -150,6 +153,7 @@ impl<'a> Default for LidarrData<'a> { albums: StatefulTable::default(), album_details_modal: None, artist_history: StatefulTable::default(), + artist_overview_modal: None, artists: StatefulTable::default(), blocklist: StatefulTable::default(), delete_files: false, @@ -354,8 +358,13 @@ impl LidarrData<'_> { let mut indexer_test_all_results = StatefulTable::default(); indexer_test_all_results.set_items(vec![indexer_test_result()]); + let artist_overview_modal = ArtistOverviewModal { + overview: ScrollableText::with_string(artist().overview.unwrap_or_default()), + }; + let mut lidarr_data = LidarrData { album_details_modal: Some(album_details_modal), + artist_overview_modal: Some(artist_overview_modal), delete_files: true, disk_space_vec: vec![diskspace()], quality_profile_map: quality_profile_map(), @@ -428,6 +437,7 @@ pub enum ActiveLidarrBlock { ArtistHistory, ArtistHistoryDetails, ArtistHistorySortPrompt, + ArtistOverview, ArtistsSortPrompt, AddArtistAlreadyInLibrary, AddArtistConfirmPrompt, @@ -579,6 +589,8 @@ pub static ARTIST_DETAILS_BLOCKS: [ActiveLidarrBlock; 15] = [ ActiveLidarrBlock::UpdateAndScanArtistPrompt, ]; +pub static ARTIST_OVERVIEW_BLOCKS: [ActiveLidarrBlock; 1] = [ActiveLidarrBlock::ArtistOverview]; + pub static ALBUM_DETAILS_BLOCKS: [ActiveLidarrBlock; 15] = [ ActiveLidarrBlock::AlbumDetails, ActiveLidarrBlock::AlbumHistory, diff --git a/src/models/servarr_data/lidarr/lidarr_data_tests.rs b/src/models/servarr_data/lidarr/lidarr_data_tests.rs index 308024b3..2bdb8807 100644 --- a/src/models/servarr_data/lidarr/lidarr_data_tests.rs +++ b/src/models/servarr_data/lidarr/lidarr_data_tests.rs @@ -11,12 +11,12 @@ mod tests { use crate::models::lidarr_models::{Album, LidarrHistoryItem, LidarrRelease}; use crate::models::servarr_data::lidarr::lidarr_data::{ ADD_ARTIST_BLOCKS, ADD_ARTIST_SELECTION_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, ALBUM_DETAILS_BLOCKS, - ARTIST_DETAILS_BLOCKS, BLOCKLIST_BLOCKS, DELETE_ALBUM_BLOCKS, DELETE_ALBUM_SELECTION_BLOCKS, - DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, DOWNLOADS_BLOCKS, EDIT_ARTIST_BLOCKS, - EDIT_ARTIST_SELECTION_BLOCKS, EDIT_INDEXER_BLOCKS, EDIT_INDEXER_NZB_SELECTION_BLOCKS, - EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, HISTORY_BLOCKS, INDEXER_SETTINGS_BLOCKS, - INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, ROOT_FOLDERS_BLOCKS, SYSTEM_DETAILS_BLOCKS, - TRACK_DETAILS_BLOCKS, + ARTIST_DETAILS_BLOCKS, ARTIST_OVERVIEW_BLOCKS, BLOCKLIST_BLOCKS, DELETE_ALBUM_BLOCKS, + DELETE_ALBUM_SELECTION_BLOCKS, DELETE_ARTIST_BLOCKS, DELETE_ARTIST_SELECTION_BLOCKS, + DOWNLOADS_BLOCKS, EDIT_ARTIST_BLOCKS, EDIT_ARTIST_SELECTION_BLOCKS, EDIT_INDEXER_BLOCKS, + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, HISTORY_BLOCKS, + INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, + ROOT_FOLDERS_BLOCKS, SYSTEM_DETAILS_BLOCKS, TRACK_DETAILS_BLOCKS, }; use crate::models::{ BlockSelectionState, Route, @@ -149,6 +149,7 @@ mod tests { assert_none!(lidarr_data.album_details_modal); assert_is_empty!(lidarr_data.artists); assert_is_empty!(lidarr_data.artist_history); + assert_none!(lidarr_data.artist_overview_modal); assert_is_empty!(lidarr_data.blocklist); assert!(!lidarr_data.delete_files); assert_is_empty!(lidarr_data.disk_space_vec); @@ -318,6 +319,17 @@ mod tests { assert!(ARTIST_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::UpdateAndScanArtistPrompt)); } + #[test] + fn test_artist_details_blocks_excludes_artist_overview() { + assert!(!ARTIST_DETAILS_BLOCKS.contains(&ActiveLidarrBlock::ArtistOverview)); + } + + #[test] + fn test_artist_overview_blocks_contents() { + assert_eq!(ARTIST_OVERVIEW_BLOCKS.len(), 1); + assert!(ARTIST_OVERVIEW_BLOCKS.contains(&ActiveLidarrBlock::ArtistOverview)); + } + #[test] fn test_album_details_blocks_contents() { assert_eq!(ALBUM_DETAILS_BLOCKS.len(), 15); diff --git a/src/models/servarr_data/lidarr/modals.rs b/src/models/servarr_data/lidarr/modals.rs index c969c47e..48732dd6 100644 --- a/src/models/servarr_data/lidarr/modals.rs +++ b/src/models/servarr_data/lidarr/modals.rs @@ -294,3 +294,9 @@ impl Default for TrackDetailsModal { } } } + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct ArtistOverviewModal { + pub overview: ScrollableText, +} diff --git a/src/models/servarr_data/lidarr/modals_tests.rs b/src/models/servarr_data/lidarr/modals_tests.rs index 44193265..7f79ac35 100644 --- a/src/models/servarr_data/lidarr/modals_tests.rs +++ b/src/models/servarr_data/lidarr/modals_tests.rs @@ -7,7 +7,7 @@ mod tests { use crate::models::lidarr_models::{Artist, MonitorType, NewItemMonitorType}; use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData}; use crate::models::servarr_data::lidarr::modals::{ - AddArtistModal, AlbumDetailsModal, EditArtistModal, TrackDetailsModal, + AddArtistModal, AlbumDetailsModal, ArtistOverviewModal, EditArtistModal, TrackDetailsModal, }; use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_models::{Indexer, IndexerField, RootFolder}; @@ -307,4 +307,11 @@ mod tests { ); assert_eq!(track_details_modal.track_details_tabs.tabs[1].config, None); } + + #[test] + fn test_artist_overview_modal_default() { + let artist_overview_modal = ArtistOverviewModal::default(); + + assert_is_empty!(artist_overview_modal.overview); + } } diff --git a/src/models/servarr_data/mod.rs b/src/models/servarr_data/mod.rs index c0583806..c329e662 100644 --- a/src/models/servarr_data/mod.rs +++ b/src/models/servarr_data/mod.rs @@ -3,6 +3,7 @@ use crate::models::Route; pub mod lidarr; pub mod modals; pub mod radarr; +pub mod readarr; pub mod sonarr; #[cfg(test)] diff --git a/src/models/servarr_data/radarr/modals.rs b/src/models/servarr_data/radarr/modals.rs index 9dd2c599..b96cbfeb 100644 --- a/src/models/servarr_data/radarr/modals.rs +++ b/src/models/servarr_data/radarr/modals.rs @@ -247,3 +247,9 @@ impl From<&RadarrData<'_>> for EditCollectionModal { edit_collection_modal } } + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct MovieOverviewModal { + pub overview: ScrollableText, +} diff --git a/src/models/servarr_data/radarr/modals_tests.rs b/src/models/servarr_data/radarr/modals_tests.rs index dabac4da..f12c70d0 100644 --- a/src/models/servarr_data/radarr/modals_tests.rs +++ b/src/models/servarr_data/radarr/modals_tests.rs @@ -2,7 +2,7 @@ mod test { use crate::models::radarr_models::{Collection, MinimumAvailability, Movie, MovieMonitor}; use crate::models::servarr_data::radarr::modals::{ - AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, + AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, MovieOverviewModal, }; use crate::models::servarr_data::radarr::radarr_data::RadarrData; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; @@ -254,4 +254,11 @@ mod test { assert_eq!(edit_collection_modal.monitored, Some(true)); assert_eq!(edit_collection_modal.search_on_add, Some(true)); } + + #[test] + fn test_movie_overview_modal_default() { + let movie_overview_modal = MovieOverviewModal::default(); + + assert_is_empty!(movie_overview_modal.overview); + } } diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index 22fc4a1d..3282f3e3 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -12,7 +12,7 @@ use crate::models::radarr_models::{ }; use crate::models::servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}; use crate::models::servarr_data::radarr::modals::{ - AddMovieModal, EditCollectionModal, EditMovieModal, MovieDetailsModal, + AddMovieModal, EditCollectionModal, EditMovieModal, MovieDetailsModal, MovieOverviewModal, }; use crate::models::servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}; use crate::models::stateful_list::StatefulList; @@ -86,6 +86,7 @@ pub struct RadarrData<'a> { pub indexer_test_errors: Option, pub indexer_test_all_results: Option>, pub movie_details_modal: Option, + pub movie_overview_modal: Option, pub prompt_confirm: bool, pub prompt_confirm_action: Option, pub delete_movie_files: bool, @@ -158,6 +159,7 @@ impl<'a> Default for RadarrData<'a> { indexer_test_errors: None, indexer_test_all_results: None, movie_details_modal: None, + movie_overview_modal: None, prompt_confirm: false, prompt_confirm_action: None, delete_movie_files: false, @@ -360,6 +362,10 @@ impl RadarrData<'_> { .movie_releases .sorting(vec![sort_option!(indexer_id)]); + let movie_overview_modal = MovieOverviewModal { + overview: ScrollableText::with_string(collection_movie().overview.clone()), + }; + let mut radarr_data = RadarrData { disk_space_vec: vec![diskspace()], version: "1.2.3.4".to_owned(), @@ -378,6 +384,7 @@ impl RadarrData<'_> { indexer_test_errors: Some("error".into()), indexer_test_all_results: Some(indexer_test_results), movie_details_modal: Some(movie_details_modal), + movie_overview_modal: Some(movie_overview_modal), delete_movie_files: true, ..RadarrData::default() }; diff --git a/src/models/servarr_data/radarr/radarr_data_tests.rs b/src/models/servarr_data/radarr/radarr_data_tests.rs index 15b25f0e..67145dee 100644 --- a/src/models/servarr_data/radarr/radarr_data_tests.rs +++ b/src/models/servarr_data/radarr/radarr_data_tests.rs @@ -135,6 +135,7 @@ mod tests { assert_none!(&radarr_data.indexer_test_errors); assert_none!(&radarr_data.indexer_test_all_results); assert_none!(&radarr_data.movie_details_modal); + assert_none!(&radarr_data.movie_overview_modal); assert_none!(&radarr_data.prompt_confirm_action); assert!(!radarr_data.prompt_confirm); assert!(!radarr_data.delete_movie_files); diff --git a/src/models/servarr_data/readarr/mod.rs b/src/models/servarr_data/readarr/mod.rs new file mode 100644 index 00000000..84c3cdfd --- /dev/null +++ b/src/models/servarr_data/readarr/mod.rs @@ -0,0 +1,2 @@ +pub mod modals; +pub mod readarr_data; diff --git a/src/models/servarr_data/readarr/modals.rs b/src/models/servarr_data/readarr/modals.rs new file mode 100644 index 00000000..c9ff5cf4 --- /dev/null +++ b/src/models/servarr_data/readarr/modals.rs @@ -0,0 +1,284 @@ +use super::readarr_data::{ActiveReadarrBlock, ReadarrData}; +use crate::app::readarr::readarr_context_clues::{ + BOOK_DETAILS_CONTEXT_CLUES, BOOK_FILE_CONTEXT_CLUES, BOOK_HISTORY_CONTEXT_CLUES, + MANUAL_BOOK_SEARCH_CONTEXT_CLUES, +}; +use crate::models::readarr_models::{BookFile, Edition, ReadarrHistoryItem, ReadarrRelease}; +use crate::models::servarr_data::modals::EditIndexerModal; +use crate::models::servarr_models::Indexer; +use crate::models::stateful_table::StatefulTable; +use crate::models::{ + HorizontallyScrollableText, ScrollableText, TabRoute, TabState, + readarr_models::{MonitorType, NewItemMonitorType}, + servarr_models::RootFolder, + stateful_list::StatefulList, +}; +use strum::IntoEnumIterator; + +#[cfg(test)] +#[path = "modals_tests.rs"] +mod modals_tests; + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct AddAuthorModal { + pub root_folder_list: StatefulList, + pub monitor_list: StatefulList, + pub monitor_new_items_list: StatefulList, + pub quality_profile_list: StatefulList, + pub metadata_profile_list: StatefulList, + pub tags: HorizontallyScrollableText, +} + +impl From<&ReadarrData<'_>> for AddAuthorModal { + fn from(readarr_data: &ReadarrData<'_>) -> AddAuthorModal { + let mut add_author_modal = AddAuthorModal::default(); + add_author_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_author_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_author_modal + .quality_profile_list + .set_items(readarr_data.sorted_quality_profile_names()); + add_author_modal + .metadata_profile_list + .set_items(readarr_data.sorted_metadata_profile_names()); + add_author_modal + .root_folder_list + .set_items(readarr_data.root_folders.items.to_vec()); + + add_author_modal + } +} + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct EditAuthorModal { + pub monitor_list: StatefulList, + pub quality_profile_list: StatefulList, + pub metadata_profile_list: StatefulList, + pub monitored: Option, + pub path: HorizontallyScrollableText, + pub tags: HorizontallyScrollableText, +} + +impl From<&ReadarrData<'_>> for EditAuthorModal { + fn from(readarr_data: &ReadarrData<'_>) -> EditAuthorModal { + let mut edit_author_modal = EditAuthorModal::default(); + let author = readarr_data.authors.current_selection(); + + edit_author_modal + .monitor_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + edit_author_modal.path = author.path.clone().into(); + edit_author_modal.tags = readarr_data.tag_ids_to_display(&author.tags).into(); + edit_author_modal.monitored = Some(author.monitored); + + let monitor_index = edit_author_modal + .monitor_list + .items + .iter() + .position(|m| *m == author.monitor_new_items); + edit_author_modal.monitor_list.state.select(monitor_index); + + edit_author_modal + .quality_profile_list + .set_items(readarr_data.sorted_quality_profile_names()); + let quality_profile_name = readarr_data + .quality_profile_map + .get_by_left(&author.quality_profile_id) + .unwrap(); + let quality_profile_index = edit_author_modal + .quality_profile_list + .items + .iter() + .position(|profile| profile == quality_profile_name); + edit_author_modal + .quality_profile_list + .state + .select(quality_profile_index); + + edit_author_modal + .metadata_profile_list + .set_items(readarr_data.sorted_metadata_profile_names()); + let metadata_profile_name = readarr_data + .metadata_profile_map + .get_by_left(&author.metadata_profile_id) + .unwrap(); + let metadata_profile_index = edit_author_modal + .metadata_profile_list + .items + .iter() + .position(|profile| profile == metadata_profile_name); + edit_author_modal + .metadata_profile_list + .state + .select(metadata_profile_index); + + edit_author_modal + } +} + +impl From<&ReadarrData<'_>> for EditIndexerModal { + fn from(readarr_data: &ReadarrData<'_>) -> EditIndexerModal { + let mut edit_indexer_modal = EditIndexerModal::default(); + let Indexer { + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + tags, + fields, + priority, + .. + } = readarr_data.indexers.current_selection(); + let seed_ratio_field_option = fields + .as_ref() + .expect("indexer fields must exist") + .iter() + .find(|field| { + field.name.as_ref().expect("indexer field name must exist") == "seedCriteria.seedRatio" + }); + let seed_ratio_value_option = if let Some(seed_ratio_field) = seed_ratio_field_option { + seed_ratio_field.value.clone() + } else { + None + }; + + edit_indexer_modal.name = name.clone().expect("indexer name must exist").into(); + edit_indexer_modal.enable_rss = Some(*enable_rss); + edit_indexer_modal.enable_automatic_search = Some(*enable_automatic_search); + edit_indexer_modal.enable_interactive_search = Some(*enable_interactive_search); + edit_indexer_modal.priority = *priority; + edit_indexer_modal.url = fields + .as_ref() + .expect("indexer fields must exist") + .iter() + .find(|field| field.name.as_ref().expect("indexer field name must exist") == "baseUrl") + .expect("baseUrl field must exist") + .value + .clone() + .expect("baseUrl field value must exist") + .as_str() + .expect("baseUrl field value must be a string") + .into(); + edit_indexer_modal.api_key = fields + .as_ref() + .expect("indexer fields must exist") + .iter() + .find(|field| field.name.as_ref().expect("indexer field name must exist") == "apiKey") + .expect("apiKey field must exist") + .value + .clone() + .expect("apiKey field value must exist") + .as_str() + .expect("apiKey field value must be a string") + .into(); + + if let Some(seed_ratio_value) = seed_ratio_value_option { + edit_indexer_modal.seed_ratio = seed_ratio_value + .as_f64() + .expect("Seed ratio value must be a valid f64") + .to_string() + .into(); + } + + edit_indexer_modal.tags = readarr_data.tag_ids_to_display(tags).into(); + + edit_indexer_modal + } +} + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct AddReadarrRootFolderModal { + pub name: HorizontallyScrollableText, + pub path: HorizontallyScrollableText, + pub monitor_list: StatefulList, + pub monitor_new_items_list: StatefulList, + pub quality_profile_list: StatefulList, + pub metadata_profile_list: StatefulList, + pub tags: HorizontallyScrollableText, +} + +impl From<&ReadarrData<'_>> for AddReadarrRootFolderModal { + fn from(readarr_data: &ReadarrData<'_>) -> AddReadarrRootFolderModal { + let mut add_root_folder_modal = AddReadarrRootFolderModal::default(); + add_root_folder_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_root_folder_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_root_folder_modal + .quality_profile_list + .set_items(readarr_data.sorted_quality_profile_names()); + add_root_folder_modal + .metadata_profile_list + .set_items(readarr_data.sorted_metadata_profile_names()); + + add_root_folder_modal + } +} + +#[cfg_attr(test, derive(Debug))] +pub struct BookDetailsModal { + pub editions: StatefulTable, + pub book_files: StatefulTable, + pub edition_details_modal: Option, + pub book_history: StatefulTable, + pub book_releases: StatefulTable, + pub book_details_tabs: TabState, +} + +impl Default for BookDetailsModal { + fn default() -> BookDetailsModal { + BookDetailsModal { + editions: StatefulTable::default(), + edition_details_modal: None, + book_files: StatefulTable::default(), + book_releases: StatefulTable::default(), + book_history: StatefulTable::default(), + book_details_tabs: TabState::new(vec![ + TabRoute { + title: "Editions".to_string(), + route: ActiveReadarrBlock::BookDetails.into(), + contextual_help: Some(&BOOK_DETAILS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "History".to_string(), + route: ActiveReadarrBlock::BookHistory.into(), + contextual_help: Some(&BOOK_HISTORY_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "File".to_string(), + route: ActiveReadarrBlock::BookFileInfo.into(), + contextual_help: Some(&BOOK_FILE_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "Manual Search".to_string(), + route: ActiveReadarrBlock::ManualBookSearch.into(), + contextual_help: Some(&MANUAL_BOOK_SEARCH_CONTEXT_CLUES), + config: None, + }, + ]), + } + } +} + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct EditionDetailsModal { + pub edition_details: ScrollableText, +} + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct AuthorOverviewModal { + pub overview: ScrollableText, +} diff --git a/src/models/servarr_data/readarr/modals_tests.rs b/src/models/servarr_data/readarr/modals_tests.rs new file mode 100644 index 00000000..f790ac53 --- /dev/null +++ b/src/models/servarr_data/readarr/modals_tests.rs @@ -0,0 +1,341 @@ +#[cfg(test)] +mod tests { + use crate::app::readarr::readarr_context_clues::{ + BOOK_DETAILS_CONTEXT_CLUES, BOOK_FILE_CONTEXT_CLUES, BOOK_HISTORY_CONTEXT_CLUES, + MANUAL_BOOK_SEARCH_CONTEXT_CLUES, + }; + use crate::models::readarr_models::{Author, MonitorType, NewItemMonitorType}; + use crate::models::servarr_data::modals::EditIndexerModal; + use crate::models::servarr_data::readarr::modals::{ + AddAuthorModal, AddReadarrRootFolderModal, AuthorOverviewModal, BookDetailsModal, + EditAuthorModal, EditionDetailsModal, + }; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, ReadarrData}; + use crate::models::servarr_models::{Indexer, IndexerField, RootFolder}; + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use serde_json::{Number, Value}; + + #[test] + fn test_add_author_modal_from_readarr_data() { + let mut readarr_data = ReadarrData { + quality_profile_map: BiMap::from_iter([ + (2i64, "Lossless".to_owned()), + (1i64, "Standard".to_owned()), + ]), + metadata_profile_map: BiMap::from_iter([ + (2i64, "None".to_owned()), + (1i64, "Standard".to_owned()), + ]), + ..ReadarrData::default() + }; + let root_folder_1 = RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }; + readarr_data.root_folders.set_items(vec![ + root_folder_1.clone(), + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + accessible: true, + free_space: 21990232555520, + unmapped_folders: None, + }, + ]); + + let add_author_modal = AddAuthorModal::from(&readarr_data); + + assert_eq!( + *add_author_modal.monitor_list.current_selection(), + MonitorType::default() + ); + assert_eq!( + *add_author_modal.monitor_new_items_list.current_selection(), + NewItemMonitorType::default() + ); + assert_str_eq!( + add_author_modal.quality_profile_list.current_selection(), + "Standard" + ); + assert_str_eq!( + add_author_modal.metadata_profile_list.current_selection(), + "Standard" + ); + assert_eq!( + add_author_modal.root_folder_list.current_selection(), + &root_folder_1 + ); + assert_is_empty!(add_author_modal.tags.text); + } + + #[test] + fn test_edit_author_modal_from_readarr_data() { + let mut readarr_data = ReadarrData { + quality_profile_map: BiMap::from_iter([ + (1i64, "HD - 1080p".to_owned()), + (2i64, "Any".to_owned()), + ]), + metadata_profile_map: BiMap::from_iter([ + (1i64, "Standard".to_owned()), + (2i64, "None".to_owned()), + ]), + tags_map: BiMap::from_iter([(1i64, "usenet".to_owned())]), + ..ReadarrData::default() + }; + let author = Author { + id: 1, + monitored: true, + monitor_new_items: NewItemMonitorType::New, + quality_profile_id: 1, + metadata_profile_id: 1, + path: "/nfs/books/test_author".to_owned(), + tags: vec![Number::from(1)], + ..Author::default() + }; + readarr_data.authors.set_items(vec![author]); + + let edit_author_modal = EditAuthorModal::from(&readarr_data); + + assert_some_eq_x!(&edit_author_modal.monitored, &true); + assert_eq!( + *edit_author_modal.monitor_list.current_selection(), + NewItemMonitorType::New + ); + assert_str_eq!( + edit_author_modal.quality_profile_list.current_selection(), + "HD - 1080p" + ); + assert_str_eq!( + edit_author_modal.metadata_profile_list.current_selection(), + "Standard" + ); + assert_str_eq!(edit_author_modal.path.text, "/nfs/books/test_author"); + assert_str_eq!(edit_author_modal.tags.text, "usenet"); + } + + #[rstest] + fn test_edit_indexer_modal_from_readarr_data(#[values(true, false)] seed_ratio_present: bool) { + let mut readarr_data = ReadarrData { + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), + ..ReadarrData::default() + }; + let mut fields = vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(Value::String("https://test.com".to_owned())), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(Value::String("1234".to_owned())), + }, + ]; + + if seed_ratio_present { + fields.push(IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(Value::from(1.2f64)), + }); + } + + let indexer = Indexer { + name: Some("Test".to_owned()), + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + tags: vec![Number::from(1), Number::from(2)], + fields: Some(fields), + priority: 1, + ..Indexer::default() + }; + readarr_data.indexers.set_items(vec![indexer]); + + let edit_indexer_modal = EditIndexerModal::from(&readarr_data); + + assert_str_eq!(edit_indexer_modal.name.text, "Test"); + assert_some_eq_x!(&edit_indexer_modal.enable_rss, &true); + assert_some_eq_x!(&edit_indexer_modal.enable_automatic_search, &true); + assert_some_eq_x!(&edit_indexer_modal.enable_interactive_search, &true); + assert_eq!(edit_indexer_modal.priority, 1); + assert_str_eq!(edit_indexer_modal.url.text, "https://test.com"); + assert_str_eq!(edit_indexer_modal.api_key.text, "1234"); + + if seed_ratio_present { + assert_str_eq!(edit_indexer_modal.seed_ratio.text, "1.2"); + } else { + assert_is_empty!(edit_indexer_modal.seed_ratio.text); + } + } + + #[test] + fn test_edit_indexer_modal_from_readarr_data_seed_ratio_value_is_none() { + let mut readarr_data = ReadarrData { + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), + ..ReadarrData::default() + }; + let fields = vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(Value::String("https://test.com".to_owned())), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(Value::String("1234".to_owned())), + }, + IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: None, + }, + ]; + + let indexer = Indexer { + name: Some("Test".to_owned()), + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + tags: vec![Number::from(1), Number::from(2)], + fields: Some(fields), + priority: 1, + ..Indexer::default() + }; + readarr_data.indexers.set_items(vec![indexer]); + + let edit_indexer_modal = EditIndexerModal::from(&readarr_data); + + assert_str_eq!(edit_indexer_modal.name.text, "Test"); + assert_some_eq_x!(&edit_indexer_modal.enable_rss, &true); + assert_some_eq_x!(&edit_indexer_modal.enable_automatic_search, &true); + assert_some_eq_x!(&edit_indexer_modal.enable_interactive_search, &true); + assert_eq!(edit_indexer_modal.priority, 1); + assert_str_eq!(edit_indexer_modal.url.text, "https://test.com"); + assert_str_eq!(edit_indexer_modal.api_key.text, "1234"); + assert_is_empty!(edit_indexer_modal.seed_ratio.text); + } + + #[test] + fn test_add_readarr_root_folder_modal_from_readarr_data() { + let readarr_data = ReadarrData { + quality_profile_map: BiMap::from_iter([ + (2i64, "Lossless".to_owned()), + (1i64, "Standard".to_owned()), + ]), + metadata_profile_map: BiMap::from_iter([ + (2i64, "None".to_owned()), + (1i64, "Standard".to_owned()), + ]), + ..ReadarrData::default() + }; + + let add_root_folder_modal = AddReadarrRootFolderModal::from(&readarr_data); + + assert_eq!( + *add_root_folder_modal.monitor_list.current_selection(), + MonitorType::default() + ); + assert_eq!( + *add_root_folder_modal + .monitor_new_items_list + .current_selection(), + NewItemMonitorType::default() + ); + assert_str_eq!( + add_root_folder_modal + .quality_profile_list + .current_selection(), + "Standard" + ); + assert_str_eq!( + add_root_folder_modal + .metadata_profile_list + .current_selection(), + "Standard" + ); + assert_is_empty!(add_root_folder_modal.name.text); + assert_is_empty!(add_root_folder_modal.path.text); + assert_is_empty!(add_root_folder_modal.tags.text); + } + + #[test] + fn test_book_details_modal_default() { + let book_details_modal = BookDetailsModal::default(); + + assert_is_empty!(book_details_modal.editions); + assert_none!(book_details_modal.edition_details_modal); + assert_is_empty!(book_details_modal.book_files); + assert_is_empty!(book_details_modal.book_releases); + assert_is_empty!(book_details_modal.book_history); + + assert_eq!(book_details_modal.book_details_tabs.tabs.len(), 4); + + assert_str_eq!( + book_details_modal.book_details_tabs.tabs[0].title, + "Editions" + ); + assert_eq!( + book_details_modal.book_details_tabs.tabs[0].route, + ActiveReadarrBlock::BookDetails.into() + ); + assert_some_eq_x!( + &book_details_modal.book_details_tabs.tabs[0].contextual_help, + &BOOK_DETAILS_CONTEXT_CLUES + ); + assert_none!(book_details_modal.book_details_tabs.tabs[0].config); + + assert_str_eq!( + book_details_modal.book_details_tabs.tabs[1].title, + "History" + ); + assert_eq!( + book_details_modal.book_details_tabs.tabs[1].route, + ActiveReadarrBlock::BookHistory.into() + ); + assert_some_eq_x!( + &book_details_modal.book_details_tabs.tabs[1].contextual_help, + &BOOK_HISTORY_CONTEXT_CLUES + ); + assert_none!(book_details_modal.book_details_tabs.tabs[1].config); + + assert_str_eq!(book_details_modal.book_details_tabs.tabs[2].title, "File"); + assert_eq!( + book_details_modal.book_details_tabs.tabs[2].route, + ActiveReadarrBlock::BookFileInfo.into() + ); + assert_some_eq_x!( + &book_details_modal.book_details_tabs.tabs[2].contextual_help, + &BOOK_FILE_CONTEXT_CLUES + ); + assert_none!(book_details_modal.book_details_tabs.tabs[2].config); + + assert_str_eq!( + book_details_modal.book_details_tabs.tabs[3].title, + "Manual Search" + ); + assert_eq!( + book_details_modal.book_details_tabs.tabs[3].route, + ActiveReadarrBlock::ManualBookSearch.into() + ); + assert_some_eq_x!( + &book_details_modal.book_details_tabs.tabs[3].contextual_help, + &MANUAL_BOOK_SEARCH_CONTEXT_CLUES + ); + assert_none!(book_details_modal.book_details_tabs.tabs[3].config); + } + + #[test] + fn test_edition_details_modal_default() { + let edition_details_modal = EditionDetailsModal::default(); + + assert_is_empty!(edition_details_modal.edition_details); + } + + #[test] + fn test_author_overview_modal_default() { + let author_overview_modal = AuthorOverviewModal::default(); + + assert_is_empty!(author_overview_modal.overview); + } +} diff --git a/src/models/servarr_data/readarr/readarr_data.rs b/src/models/servarr_data/readarr/readarr_data.rs new file mode 100644 index 00000000..6d40006d --- /dev/null +++ b/src/models/servarr_data/readarr/readarr_data.rs @@ -0,0 +1,1061 @@ +use serde_json::Number; + +use super::modals::{ + AddAuthorModal, AddReadarrRootFolderModal, AuthorOverviewModal, BookDetailsModal, EditAuthorModal, +}; +use crate::app::context_clues::{ + BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, + ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, +}; +use crate::app::readarr::readarr_context_clues::{ + AUTHOR_DETAILS_CONTEXT_CLUES, AUTHOR_HISTORY_CONTEXT_CLUES, AUTHORS_CONTEXT_CLUES, + MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES, +}; +use crate::models::readarr_models::{BlocklistItem, ReadarrRelease, ReadarrTask}; +use crate::models::servarr_data::modals::EditIndexerModal; +use crate::models::servarr_models::{IndexerSettings, QueueEvent}; +use crate::models::stateful_list::StatefulList; +use crate::models::{ + BlockSelectionState, HorizontallyScrollableText, Route, ScrollableText, TabRoute, TabState, + readarr_models::{AddAuthorSearchResult, Author, Book, DownloadRecord, ReadarrHistoryItem}, + servarr_data::modals::IndexerTestResultModalItem, + servarr_models::{DiskSpace, Indexer, RootFolder}, + stateful_table::StatefulTable, +}; +use crate::network::readarr_network::ReadarrEvent; +use bimap::BiMap; +use chrono::{DateTime, Utc}; +use itertools::Itertools; +use strum::EnumIter; +#[cfg(test)] +use { + super::modals::EditionDetailsModal, + crate::models::readarr_models::{ + AuthorStatus, BookFile, BookStatistics, Edition, MediaInfo, MonitorType, NewItemMonitorType, + Ratings, ReadarrHistoryEventType, ReadarrTaskName, + }, + crate::models::servarr_models::{DownloadStatus, IndexerField, Quality, QualityWrapper}, + crate::models::stateful_table::SortOption, + crate::network::servarr_test_utils::{ + diskspace, indexer_settings, indexer_test_result, queued_event, + }, + crate::network::sonarr_network::sonarr_network_test_utils::test_utils::updates, + crate::sort_option, + serde_json::json, + strum::{Display, EnumString, IntoEnumIterator}, +}; + +#[cfg(test)] +#[path = "readarr_data_tests.rs"] +mod readarr_data_tests; + +pub struct ReadarrData<'a> { + pub add_author_modal: Option, + pub add_author_search: Option, + pub add_import_list_exclusion: bool, + pub add_root_folder_modal: Option, + pub add_searched_authors: Option>, + pub author_history: StatefulTable, + pub author_info_tabs: TabState, + pub author_overview_modal: Option, + pub author_releases: StatefulTable, + pub authors: StatefulTable, + pub blocklist: StatefulTable, + pub book_details_modal: Option, + pub books: StatefulTable, + pub delete_files: bool, + pub disk_space_vec: Vec, + pub downloads: StatefulTable, + pub edit_author_modal: Option, + pub edit_indexer_modal: Option, + pub history: StatefulTable, + pub indexer_settings: Option, + pub indexer_test_all_results: Option>, + pub indexer_test_errors: Option, + pub indexers: StatefulTable, + pub log_details: StatefulList, + pub logs: StatefulList, + pub main_tabs: TabState, + pub metadata_profile_map: BiMap, + pub prompt_confirm: bool, + pub prompt_confirm_action: Option, + pub quality_profile_map: BiMap, + pub queued_events: StatefulTable, + pub root_folders: StatefulTable, + pub selected_block: BlockSelectionState<'a, ActiveReadarrBlock>, + pub start_time: DateTime, + pub tags_map: BiMap, + pub tasks: StatefulTable, + pub updates: ScrollableText, + pub version: String, +} + +impl ReadarrData<'_> { + pub fn reset_delete_preferences(&mut self) { + self.delete_files = false; + self.add_import_list_exclusion = false; + } + + pub fn reset_author_info_tabs(&mut self) { + self.books = StatefulTable::default(); + self.author_releases = StatefulTable::default(); + self.author_history = StatefulTable::default(); + self.author_info_tabs.index = 0; + } + + pub fn tag_ids_to_display(&self, tag_ids: &[Number]) -> String { + tag_ids + .iter() + .filter_map(|id| { + let id = id.as_i64()?; + self.tags_map.get_by_left(&id).cloned() + }) + .collect::>() + .join(", ") + } + + pub fn sorted_quality_profile_names(&self) -> Vec { + self + .quality_profile_map + .iter() + .sorted_by_key(|(id, _)| *id) + .map(|(_, name)| name) + .cloned() + .collect() + } + + pub fn sorted_metadata_profile_names(&self) -> Vec { + self + .metadata_profile_map + .iter() + .sorted_by_key(|(id, _)| *id) + .map(|(_, name)| name) + .cloned() + .collect() + } +} + +impl<'a> Default for ReadarrData<'a> { + fn default() -> ReadarrData<'a> { + ReadarrData { + add_author_modal: None, + add_author_search: None, + add_import_list_exclusion: false, + add_root_folder_modal: None, + add_searched_authors: None, + author_history: StatefulTable::default(), + author_overview_modal: None, + author_releases: StatefulTable::default(), + authors: StatefulTable::default(), + blocklist: StatefulTable::default(), + book_details_modal: None, + books: StatefulTable::default(), + delete_files: false, + disk_space_vec: Vec::new(), + downloads: StatefulTable::default(), + edit_author_modal: None, + edit_indexer_modal: None, + history: StatefulTable::default(), + indexer_settings: None, + indexer_test_all_results: None, + indexer_test_errors: None, + indexers: StatefulTable::default(), + log_details: StatefulList::default(), + logs: StatefulList::default(), + metadata_profile_map: BiMap::new(), + prompt_confirm: false, + prompt_confirm_action: None, + quality_profile_map: BiMap::new(), + queued_events: StatefulTable::default(), + root_folders: StatefulTable::default(), + selected_block: BlockSelectionState::default(), + start_time: DateTime::default(), + tags_map: BiMap::new(), + tasks: StatefulTable::default(), + updates: ScrollableText::default(), + version: String::new(), + main_tabs: TabState::new(vec![ + TabRoute { + title: "Library".to_string(), + route: ActiveReadarrBlock::Authors.into(), + contextual_help: Some(&AUTHORS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "Downloads".to_string(), + route: ActiveReadarrBlock::Downloads.into(), + contextual_help: Some(&DOWNLOADS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "Blocklist".to_string(), + route: ActiveReadarrBlock::Blocklist.into(), + contextual_help: Some(&BLOCKLIST_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "History".to_string(), + route: ActiveReadarrBlock::History.into(), + contextual_help: Some(&HISTORY_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "Root Folders".to_string(), + route: ActiveReadarrBlock::RootFolders.into(), + contextual_help: Some(&ROOT_FOLDERS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "Indexers".to_string(), + route: ActiveReadarrBlock::Indexers.into(), + contextual_help: Some(&INDEXERS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "System".to_string(), + route: ActiveReadarrBlock::System.into(), + contextual_help: Some(&SYSTEM_CONTEXT_CLUES), + config: None, + }, + ]), + author_info_tabs: TabState::new(vec![ + TabRoute { + title: "Books".to_string(), + route: ActiveReadarrBlock::AuthorDetails.into(), + contextual_help: Some(&AUTHOR_DETAILS_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "History".to_string(), + route: ActiveReadarrBlock::AuthorHistory.into(), + contextual_help: Some(&AUTHOR_HISTORY_CONTEXT_CLUES), + config: None, + }, + TabRoute { + title: "Author Search".to_string(), + route: ActiveReadarrBlock::ManualAuthorSearch.into(), + contextual_help: Some(&MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES), + config: None, + }, + ]), + } + } +} + +#[cfg(test)] +impl ReadarrData<'_> { + pub fn test_default_fully_populated() -> Self { + let quality_profile_map = BiMap::from_iter([(1111i64, "Standard".to_owned())]); + let metadata_profile_map = BiMap::from_iter([(3333i64, "Comprehensive".to_owned())]); + let tags_map = BiMap::from_iter([(1i64, "alex".to_owned())]); + let root_folder = RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }; + let quality = QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }; + let date = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let author = Author { + id: 1, + author_name: "Test Author".into(), + foreign_author_id: "test-foreign-id".to_owned(), + status: AuthorStatus::Continuing, + overview: Some( + "some interesting description of the author\r\n\ + \r\n\ + \tHe was born in Madison, Wisconsin: a city he has never really left. \r\n\ + \r\n\ + His first novel took him seven years to finish.\r\n" + .to_owned(), + ), + path: "/nfs/books/Test Author".to_owned(), + quality_profile_id: 1111, + metadata_profile_id: 3333, + monitored: true, + monitor_new_items: NewItemMonitorType::All, + genres: vec!["science fiction".to_owned()], + tags: vec![Number::from(1)], + ..Author::default() + }; + let book = Book { + id: 1, + title: "Test Book".into(), + author_id: 1, + foreign_book_id: "test-foreign-book-id".to_owned(), + monitored: true, + any_edition_ok: true, + page_count: Some(288), + release_date: Some(date), + statistics: Some(BookStatistics { + book_file_count: 3, + total_book_count: 7, + size_on_disk: 2457600, + percent_of_books: 42.86, + }), + ..Book::default() + }; + let readarr_history_item = ReadarrHistoryItem { + id: 1, + author_id: 1, + book_id: 1, + source_title: "Test Source Title".into(), + quality: quality.clone(), + date, + event_type: ReadarrHistoryEventType::Grabbed, + ..ReadarrHistoryItem::default() + }; + let torrent_release = ReadarrRelease { + guid: "1234".to_owned(), + protocol: "torrent".to_owned(), + age: 1, + title: "Test Torrent Release".into(), + author_name: Some("Test Author".to_owned()), + book_title: Some("Test Book".to_owned()), + indexer: "kickass torrents".to_owned(), + indexer_id: 1, + size: 1234, + rejected: true, + rejections: Some(vec!["Unknown quality profile".to_owned()]), + seeders: Some(Number::from(2)), + leechers: Some(Number::from(1)), + quality: quality.clone(), + }; + let usenet_release = ReadarrRelease { + guid: "5678".to_owned(), + protocol: "usenet".to_owned(), + title: "Test Usenet Release".into(), + indexer: "DrunkenSlug".to_owned(), + indexer_id: 2, + seeders: None, + leechers: None, + ..torrent_release.clone() + }; + let blocklist_item = BlocklistItem { + id: 1, + author_id: 1, + book_ids: Some(vec![Number::from(1)]), + source_title: "Test Source Title".to_owned(), + quality: quality.clone(), + date, + protocol: "usenet".to_owned(), + indexer: "DrunkenSlug".to_owned(), + message: "test message".to_owned(), + author: author.clone(), + }; + let download_record = DownloadRecord { + title: "Test Download Title".to_owned(), + status: DownloadStatus::Downloading, + id: 1, + book_id: Some(Number::from(1)), + author_id: Some(Number::from(1)), + size: 3543348019f64, + sizeleft: 1771674009f64, + output_path: Some("/nfs/books/Test Author".into()), + indexer: "kickass torrents".to_owned(), + download_client: Some("transmission".to_owned()), + }; + let indexer = Indexer { + id: 1, + name: Some("Test Indexer".to_owned()), + implementation: Some("Torznab".to_owned()), + implementation_name: Some("Torznab".to_owned()), + config_contract: Some("TorznabSettings".to_owned()), + supports_rss: true, + supports_search: true, + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + protocol: "torrent".to_owned(), + priority: 25, + download_client_id: 0, + tags: vec![Number::from(1)], + fields: Some(vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(json!("https://test.com")), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(json!("")), + }, + IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(json!("1.2")), + }, + ]), + }; + let add_author_search_result = AddAuthorSearchResult { + foreign_author_id: "test-foreign-id".to_owned(), + author_name: "Test Author".into(), + status: AuthorStatus::Continuing, + overview: Some( + "Test Author is an American novelist whose science fiction has been translated into more \ + than thirty languages. Born in a small river town, the author spent a decade as a marine \ + biologist before turning to fiction, and the sea remains a constant presence in the work. \ + The debut novel won several awards and introduced the sprawling shared universe that later \ + books would return to again and again. Critics have praised the meticulous world building \ + and the quiet, humane attention to ordinary people caught up in extraordinary events. The \ + author also writes essays on the history of science, teaches an annual writing workshop, \ + and lives with a large and opinionated cat. A new trilogy is currently in progress and is \ + expected to conclude the long running saga that began with the first book. The final \ + volume has already been announced for next year." + .to_owned(), + ), + author_type: Some("Person".to_owned()), + disambiguation: Some("American novelist".to_owned()), + ratings: Some(Ratings { + votes: 1024, + value: 4.5, + popularity: Some(9.75), + }), + genres: vec!["science fiction".to_owned()], + ..AddAuthorSearchResult::default() + }; + let unadded_author_search_result = AddAuthorSearchResult { + foreign_author_id: "unadded-foreign-id".to_owned(), + author_name: "Unadded Author".into(), + status: AuthorStatus::Ended, + ended: true, + overview: Some("an author who is not yet in the library".to_owned()), + author_type: Some("Group".to_owned()), + genres: vec!["fantasy".to_owned(), "horror".to_owned()], + ..AddAuthorSearchResult::default() + }; + let task = ReadarrTask { + name: "Backup".to_owned(), + task_name: ReadarrTaskName::Backup, + interval: 60, + last_execution: date, + last_duration: "00:00:00.5111547".to_owned(), + next_execution: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T22:29:16Z").unwrap()), + }; + let log_line = "2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|No list items to process"; + + let mut add_author_modal = AddAuthorModal { + tags: "usenet, testing".into(), + ..AddAuthorModal::default() + }; + add_author_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_author_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_author_modal + .metadata_profile_list + .set_items(vec!["Standard".to_owned()]); + add_author_modal + .quality_profile_list + .set_items(vec!["Standard".to_owned()]); + add_author_modal + .root_folder_list + .set_items(vec![root_folder.clone()]); + + let mut edit_author_modal = EditAuthorModal { + monitored: Some(true), + path: "/nfs/books".into(), + tags: "alex".into(), + ..EditAuthorModal::default() + }; + edit_author_modal + .monitor_list + .set_items(NewItemMonitorType::iter().collect()); + edit_author_modal + .quality_profile_list + .set_items(vec!["Standard".to_owned()]); + edit_author_modal + .metadata_profile_list + .set_items(vec!["Standard".to_owned()]); + + let mut add_root_folder_modal = AddReadarrRootFolderModal { + name: "Test Root Folder".into(), + path: "/nfs/books".into(), + tags: "test".into(), + ..AddReadarrRootFolderModal::default() + }; + add_root_folder_modal + .monitor_list + .set_items(Vec::from_iter(MonitorType::iter())); + add_root_folder_modal + .monitor_new_items_list + .set_items(Vec::from_iter(NewItemMonitorType::iter())); + add_root_folder_modal + .quality_profile_list + .set_items(vec!["Standard".to_owned()]); + add_root_folder_modal + .metadata_profile_list + .set_items(vec!["Standard".to_owned()]); + + let edition_details_modal = EditionDetailsModal { + edition_details: ScrollableText::with_string( + "Title: Test Edition\n\ + Format: Hardcover\n\ + Language: English\n\ + Publisher: DAW Books\n\ + Page Count: 288\n\ + Release Date: 2023-05-20 00:00:00 UTC\n\ + ISBN13: 9780756404079\n\ + ASIN: B0043RSJ9S\n\ + Ratings: 4.5 (1000 votes)\n\ + Monitored: true\n\ + Overview: original cover of ISBN 075640407X\r\n\ + \r\n\ + My name is Kvothe, pronounced nearly the same as \"quothe.\"\r\n\ + \r\n\ + The Adem call me Maedre: which means The Flame.\r\n" + .to_owned(), + ), + }; + + let mut book_details_modal = BookDetailsModal { + edition_details_modal: Some(edition_details_modal), + ..BookDetailsModal::default() + }; + book_details_modal.editions.set_items(vec![Edition { + id: 1, + book_id: 1, + foreign_edition_id: "test-foreign-edition-id".to_owned(), + monitored: true, + is_ebook: true, + title: "Test Edition".to_owned(), + format: Some("Hardcover".to_owned()), + publisher: Some("DAW Books".to_owned()), + isbn13: Some("9780756404079".to_owned()), + asin: Some("B0043RSJ9S".to_owned()), + page_count: Some(288), + release_date: Some(date), + ..Edition::default() + }]); + book_details_modal.editions.search = Some("edition search".into()); + book_details_modal.book_files.set_items(vec![BookFile { + id: 1, + author_id: 1, + book_id: 1, + path: "/nfs/books/Test Author/Test Book.epub".to_owned(), + size: 3355443, + date_added: date, + quality: quality.clone(), + media_info: Some(MediaInfo { + audio_bit_rate: Some("128 kbps".to_owned()), + audio_channels: 2, + audio_codec: Some("MP3".to_owned()), + audio_bits: Some("16".to_owned()), + audio_sample_rate: Some("44100".to_owned()), + }), + quality_cutoff_not_met: false, + }]); + book_details_modal + .book_history + .set_items(vec![readarr_history_item.clone()]); + book_details_modal.book_history.search = Some("book history search".into()); + book_details_modal.book_history.filter = Some("book history filter".into()); + book_details_modal + .book_history + .sorting(vec![sort_option!(id)]); + book_details_modal + .book_releases + .set_items(vec![torrent_release.clone(), usenet_release.clone()]); + book_details_modal + .book_releases + .sorting(vec![sort_option!(indexer_id)]); + + let edit_indexer_modal = EditIndexerModal { + name: "DrunkenSlug".into(), + enable_rss: Some(true), + enable_automatic_search: Some(true), + enable_interactive_search: Some(true), + url: "http://127.0.0.1:9696/1/".into(), + api_key: "someApiKey".into(), + seed_ratio: "ratio".into(), + tags: "25".into(), + priority: 1, + }; + + let mut indexer_test_all_results = StatefulTable::default(); + indexer_test_all_results.set_items(vec![indexer_test_result()]); + + let author_overview_modal = AuthorOverviewModal { + overview: ScrollableText::with_string(author.overview.clone().unwrap_or_default()), + }; + + let mut readarr_data = ReadarrData { + add_author_modal: Some(add_author_modal), + add_root_folder_modal: Some(add_root_folder_modal), + author_overview_modal: Some(author_overview_modal), + book_details_modal: Some(book_details_modal), + delete_files: true, + disk_space_vec: vec![diskspace()], + edit_author_modal: Some(edit_author_modal), + edit_indexer_modal: Some(edit_indexer_modal), + indexer_settings: Some(indexer_settings()), + indexer_test_all_results: Some(indexer_test_all_results), + indexer_test_errors: Some("error".to_string()), + metadata_profile_map, + quality_profile_map, + start_time: date, + tags_map, + updates: updates(), + version: "1.2.3.4".to_owned(), + ..ReadarrData::default() + }; + readarr_data + .author_history + .set_items(vec![readarr_history_item.clone()]); + readarr_data.author_history.sorting(vec![sort_option!(id)]); + readarr_data.author_history.search = Some("author history search".into()); + readarr_data.author_history.filter = Some("author history filter".into()); + readarr_data + .author_releases + .set_items(vec![torrent_release, usenet_release]); + readarr_data + .author_releases + .sorting(vec![sort_option!(indexer_id)]); + readarr_data.authors.set_items(vec![author]); + readarr_data.authors.sorting(vec![SortOption { + name: "Name", + cmp_fn: Some(|a: &Author, b: &Author| a.author_name.text.cmp(&b.author_name.text)), + }]); + readarr_data.authors.search = Some("author search".into()); + readarr_data.authors.filter = Some("author filter".into()); + readarr_data.blocklist.set_items(vec![blocklist_item]); + readarr_data.blocklist.sorting(vec![sort_option!(id)]); + readarr_data.books.set_items(vec![book]); + readarr_data.books.search = Some("book search".into()); + readarr_data.downloads.set_items(vec![download_record]); + readarr_data.history.set_items(vec![readarr_history_item]); + readarr_data.history.sorting(vec![SortOption { + name: "Date", + cmp_fn: Some(|a: &ReadarrHistoryItem, b: &ReadarrHistoryItem| a.date.cmp(&b.date)), + }]); + readarr_data.history.search = Some("test search".into()); + readarr_data.history.filter = Some("test filter".into()); + readarr_data.indexers.set_items(vec![indexer]); + readarr_data.root_folders.set_items(vec![root_folder]); + readarr_data.queued_events.set_items(vec![queued_event()]); + readarr_data.add_author_search = Some("Test Author".into()); + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(vec![add_author_search_result, unadded_author_search_result]); + readarr_data.add_searched_authors = Some(add_searched_authors); + readarr_data.logs.set_items(vec![log_line.into()]); + readarr_data.log_details.set_items(vec![log_line.into()]); + readarr_data.tasks.set_items(vec![task]); + + readarr_data + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] +#[cfg_attr(test, derive(Display, EnumString))] +pub enum ActiveReadarrBlock { + #[default] + Authors, + AddAuthorAlreadyInLibrary, + AddAuthorConfirmPrompt, + AddAuthorEmptySearchResults, + AddAuthorPrompt, + AddAuthorSearchInput, + AddAuthorSearchResults, + AddAuthorSelectMetadataProfile, + AddAuthorSelectMonitor, + AddAuthorSelectMonitorNewItems, + AddAuthorSelectQualityProfile, + AddAuthorSelectRootFolder, + AddAuthorTagsInput, + AddRootFolderConfirmPrompt, + AddRootFolderNameInput, + AddRootFolderPathInput, + AddRootFolderPrompt, + AddRootFolderSelectMetadataProfile, + AddRootFolderSelectMonitor, + AddRootFolderSelectMonitorNewItems, + AddRootFolderSelectQualityProfile, + AddRootFolderTagsInput, + AllIndexerSettingsPrompt, + AuthorDetails, + AuthorHistory, + AuthorHistoryDetails, + AuthorHistorySortPrompt, + AuthorOverview, + AuthorsSortPrompt, + AutomaticallySearchAuthorPrompt, + AutomaticallySearchBookPrompt, + Blocklist, + BlocklistClearAllItemsPrompt, + BlocklistItemDetails, + BlocklistSortPrompt, + BookDetails, + BookEditionDetails, + BookFileInfo, + BookHistory, + BookHistoryDetails, + BookHistorySortPrompt, + DeleteAuthorConfirmPrompt, + DeleteAuthorPrompt, + DeleteAuthorToggleAddListExclusion, + DeleteAuthorToggleDeleteFile, + DeleteBlocklistItemPrompt, + DeleteBookConfirmPrompt, + DeleteBookFilePrompt, + DeleteBookPrompt, + DeleteBookToggleAddListExclusion, + DeleteBookToggleDeleteFile, + DeleteDownloadPrompt, + DeleteIndexerPrompt, + DeleteRootFolderPrompt, + Downloads, + EditAuthorConfirmPrompt, + EditAuthorPathInput, + EditAuthorPrompt, + EditAuthorSelectMetadataProfile, + EditAuthorSelectMonitorNewItems, + EditAuthorSelectQualityProfile, + EditAuthorTagsInput, + EditAuthorToggleMonitored, + EditIndexerApiKeyInput, + EditIndexerConfirmPrompt, + EditIndexerNameInput, + EditIndexerPriorityInput, + EditIndexerPrompt, + EditIndexerSeedRatioInput, + EditIndexerTagsInput, + EditIndexerToggleEnableAutomaticSearch, + EditIndexerToggleEnableInteractiveSearch, + EditIndexerToggleEnableRss, + EditIndexerUrlInput, + FilterAuthorHistory, + FilterAuthorHistoryError, + FilterAuthors, + FilterAuthorsError, + FilterBookHistory, + FilterBookHistoryError, + FilterHistory, + FilterHistoryError, + History, + HistoryItemDetails, + HistorySortPrompt, + Indexers, + IndexerSettingsConfirmPrompt, + IndexerSettingsMaximumSizeInput, + IndexerSettingsMinimumAgeInput, + IndexerSettingsRetentionInput, + IndexerSettingsRssSyncIntervalInput, + ManualAuthorSearch, + ManualAuthorSearchConfirmPrompt, + ManualAuthorSearchSortPrompt, + ManualBookSearch, + ManualBookSearchConfirmPrompt, + ManualBookSearchSortPrompt, + RootFolders, + SearchAuthorHistory, + SearchAuthorHistoryError, + SearchAuthors, + SearchAuthorsError, + SearchBookHistory, + SearchBookHistoryError, + SearchBooks, + SearchBooksError, + SearchEditions, + SearchEditionsError, + SearchHistory, + SearchHistoryError, + System, + SystemLogs, + SystemQueuedEvents, + SystemTasks, + SystemTaskStartConfirmPrompt, + SystemUpdates, + TestAllIndexers, + TestIndexer, + UpdateAllAuthorsPrompt, + UpdateAndScanAuthorPrompt, + UpdateDownloadsPrompt, +} + +pub static LIBRARY_BLOCKS: [ActiveReadarrBlock; 7] = [ + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::AuthorsSortPrompt, + ActiveReadarrBlock::FilterAuthors, + ActiveReadarrBlock::FilterAuthorsError, + ActiveReadarrBlock::SearchAuthors, + ActiveReadarrBlock::SearchAuthorsError, + ActiveReadarrBlock::UpdateAllAuthorsPrompt, +]; + +pub static AUTHOR_DETAILS_BLOCKS: [ActiveReadarrBlock; 15] = [ + ActiveReadarrBlock::AuthorDetails, + ActiveReadarrBlock::AuthorHistory, + ActiveReadarrBlock::AuthorHistoryDetails, + ActiveReadarrBlock::AuthorHistorySortPrompt, + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, + ActiveReadarrBlock::FilterAuthorHistory, + ActiveReadarrBlock::FilterAuthorHistoryError, + ActiveReadarrBlock::ManualAuthorSearch, + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, + ActiveReadarrBlock::ManualAuthorSearchSortPrompt, + ActiveReadarrBlock::SearchBooks, + ActiveReadarrBlock::SearchBooksError, + ActiveReadarrBlock::SearchAuthorHistory, + ActiveReadarrBlock::SearchAuthorHistoryError, + ActiveReadarrBlock::UpdateAndScanAuthorPrompt, +]; + +pub static AUTHOR_OVERVIEW_BLOCKS: [ActiveReadarrBlock; 1] = [ActiveReadarrBlock::AuthorOverview]; + +pub static BOOK_DETAILS_BLOCKS: [ActiveReadarrBlock; 16] = [ + ActiveReadarrBlock::BookDetails, + ActiveReadarrBlock::BookHistory, + ActiveReadarrBlock::BookHistoryDetails, + ActiveReadarrBlock::BookHistorySortPrompt, + ActiveReadarrBlock::AutomaticallySearchBookPrompt, + ActiveReadarrBlock::DeleteBookFilePrompt, + ActiveReadarrBlock::FilterBookHistory, + ActiveReadarrBlock::FilterBookHistoryError, + ActiveReadarrBlock::BookFileInfo, + ActiveReadarrBlock::ManualBookSearch, + ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + ActiveReadarrBlock::ManualBookSearchSortPrompt, + ActiveReadarrBlock::SearchBookHistory, + ActiveReadarrBlock::SearchBookHistoryError, + ActiveReadarrBlock::SearchEditions, + ActiveReadarrBlock::SearchEditionsError, +]; + +pub static EDITION_DETAILS_BLOCKS: [ActiveReadarrBlock; 1] = + [ActiveReadarrBlock::BookEditionDetails]; + +pub static ADD_AUTHOR_BLOCKS: [ActiveReadarrBlock; 12] = [ + ActiveReadarrBlock::AddAuthorAlreadyInLibrary, + ActiveReadarrBlock::AddAuthorConfirmPrompt, + ActiveReadarrBlock::AddAuthorEmptySearchResults, + ActiveReadarrBlock::AddAuthorPrompt, + ActiveReadarrBlock::AddAuthorSearchInput, + ActiveReadarrBlock::AddAuthorSearchResults, + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + ActiveReadarrBlock::AddAuthorSelectMonitor, + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + ActiveReadarrBlock::AddAuthorSelectRootFolder, + ActiveReadarrBlock::AddAuthorTagsInput, +]; + +pub const ADD_AUTHOR_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ActiveReadarrBlock::AddAuthorSelectRootFolder], + &[ActiveReadarrBlock::AddAuthorSelectMonitor], + &[ActiveReadarrBlock::AddAuthorSelectMonitorNewItems], + &[ActiveReadarrBlock::AddAuthorSelectQualityProfile], + &[ActiveReadarrBlock::AddAuthorSelectMetadataProfile], + &[ActiveReadarrBlock::AddAuthorTagsInput], + &[ActiveReadarrBlock::AddAuthorConfirmPrompt], +]; + +pub static DELETE_AUTHOR_BLOCKS: [ActiveReadarrBlock; 4] = [ + ActiveReadarrBlock::DeleteAuthorPrompt, + ActiveReadarrBlock::DeleteAuthorConfirmPrompt, + ActiveReadarrBlock::DeleteAuthorToggleDeleteFile, + ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion, +]; + +pub const DELETE_AUTHOR_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ActiveReadarrBlock::DeleteAuthorToggleDeleteFile], + &[ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion], + &[ActiveReadarrBlock::DeleteAuthorConfirmPrompt], +]; + +pub static DELETE_BOOK_BLOCKS: [ActiveReadarrBlock; 4] = [ + ActiveReadarrBlock::DeleteBookPrompt, + ActiveReadarrBlock::DeleteBookConfirmPrompt, + ActiveReadarrBlock::DeleteBookToggleDeleteFile, + ActiveReadarrBlock::DeleteBookToggleAddListExclusion, +]; + +pub const DELETE_BOOK_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ActiveReadarrBlock::DeleteBookToggleDeleteFile], + &[ActiveReadarrBlock::DeleteBookToggleAddListExclusion], + &[ActiveReadarrBlock::DeleteBookConfirmPrompt], +]; + +pub static EDIT_AUTHOR_BLOCKS: [ActiveReadarrBlock; 8] = [ + ActiveReadarrBlock::EditAuthorPrompt, + ActiveReadarrBlock::EditAuthorConfirmPrompt, + ActiveReadarrBlock::EditAuthorPathInput, + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + ActiveReadarrBlock::EditAuthorTagsInput, + ActiveReadarrBlock::EditAuthorToggleMonitored, +]; + +pub const EDIT_AUTHOR_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ActiveReadarrBlock::EditAuthorToggleMonitored], + &[ActiveReadarrBlock::EditAuthorSelectMonitorNewItems], + &[ActiveReadarrBlock::EditAuthorSelectQualityProfile], + &[ActiveReadarrBlock::EditAuthorSelectMetadataProfile], + &[ActiveReadarrBlock::EditAuthorPathInput], + &[ActiveReadarrBlock::EditAuthorTagsInput], + &[ActiveReadarrBlock::EditAuthorConfirmPrompt], +]; + +pub static ADD_ROOT_FOLDER_BLOCKS: [ActiveReadarrBlock; 9] = [ + ActiveReadarrBlock::AddRootFolderPrompt, + ActiveReadarrBlock::AddRootFolderConfirmPrompt, + ActiveReadarrBlock::AddRootFolderNameInput, + ActiveReadarrBlock::AddRootFolderPathInput, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + ActiveReadarrBlock::AddRootFolderTagsInput, +]; + +pub const ADD_ROOT_FOLDER_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ActiveReadarrBlock::AddRootFolderNameInput], + &[ActiveReadarrBlock::AddRootFolderPathInput], + &[ActiveReadarrBlock::AddRootFolderSelectMonitor], + &[ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems], + &[ActiveReadarrBlock::AddRootFolderSelectQualityProfile], + &[ActiveReadarrBlock::AddRootFolderSelectMetadataProfile], + &[ActiveReadarrBlock::AddRootFolderTagsInput], + &[ActiveReadarrBlock::AddRootFolderConfirmPrompt], +]; + +pub static ROOT_FOLDERS_BLOCKS: [ActiveReadarrBlock; 2] = [ + ActiveReadarrBlock::RootFolders, + ActiveReadarrBlock::DeleteRootFolderPrompt, +]; + +pub static BLOCKLIST_BLOCKS: [ActiveReadarrBlock; 5] = [ + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::BlocklistItemDetails, + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt, + ActiveReadarrBlock::BlocklistSortPrompt, +]; + +pub static DOWNLOADS_BLOCKS: [ActiveReadarrBlock; 3] = [ + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt, + ActiveReadarrBlock::UpdateDownloadsPrompt, +]; + +pub static HISTORY_BLOCKS: [ActiveReadarrBlock; 7] = [ + ActiveReadarrBlock::History, + ActiveReadarrBlock::HistoryItemDetails, + ActiveReadarrBlock::HistorySortPrompt, + ActiveReadarrBlock::SearchHistory, + ActiveReadarrBlock::SearchHistoryError, + ActiveReadarrBlock::FilterHistory, + ActiveReadarrBlock::FilterHistoryError, +]; + +pub static EDIT_INDEXER_BLOCKS: [ActiveReadarrBlock; 11] = [ + ActiveReadarrBlock::EditIndexerPrompt, + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ActiveReadarrBlock::EditIndexerApiKeyInput, + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerPriorityInput, + ActiveReadarrBlock::EditIndexerUrlInput, + ActiveReadarrBlock::EditIndexerTagsInput, +]; + +pub const EDIT_INDEXER_TORRENT_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput, + ], + &[ + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerApiKeyInput, + ], + &[ + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerSeedRatioInput, + ], + &[ + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerTagsInput, + ], + &[ + ActiveReadarrBlock::EditIndexerPriorityInput, + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ], + &[ + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ], +]; + +pub const EDIT_INDEXER_NZB_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput, + ], + &[ + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerApiKeyInput, + ], + &[ + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerTagsInput, + ], + &[ + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerPriorityInput, + ], + &[ + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ], +]; + +pub static INDEXER_SETTINGS_BLOCKS: [ActiveReadarrBlock; 6] = [ + ActiveReadarrBlock::AllIndexerSettingsPrompt, + ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveReadarrBlock::IndexerSettingsRetentionInput, + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput, +]; + +pub const INDEXER_SETTINGS_SELECTION_BLOCKS: &[&[ActiveReadarrBlock]] = &[ + &[ActiveReadarrBlock::IndexerSettingsMinimumAgeInput], + &[ActiveReadarrBlock::IndexerSettingsRetentionInput], + &[ActiveReadarrBlock::IndexerSettingsMaximumSizeInput], + &[ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput], + &[ActiveReadarrBlock::IndexerSettingsConfirmPrompt], +]; + +pub static INDEXERS_BLOCKS: [ActiveReadarrBlock; 3] = [ + ActiveReadarrBlock::Indexers, + ActiveReadarrBlock::DeleteIndexerPrompt, + ActiveReadarrBlock::TestIndexer, +]; + +pub static SYSTEM_DETAILS_BLOCKS: [ActiveReadarrBlock; 5] = [ + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + ActiveReadarrBlock::SystemUpdates, +]; + +impl From for Route { + fn from(active_readarr_block: ActiveReadarrBlock) -> Route { + Route::Readarr(active_readarr_block, None) + } +} + +impl From<(ActiveReadarrBlock, Option)> for Route { + fn from(value: (ActiveReadarrBlock, Option)) -> Route { + Route::Readarr(value.0, value.1) + } +} diff --git a/src/models/servarr_data/readarr/readarr_data_tests.rs b/src/models/servarr_data/readarr/readarr_data_tests.rs new file mode 100644 index 00000000..679dc605 --- /dev/null +++ b/src/models/servarr_data/readarr/readarr_data_tests.rs @@ -0,0 +1,802 @@ +#[cfg(test)] +mod tests { + use crate::app::context_clues::{ + BLOCKLIST_CONTEXT_CLUES, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, + INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, + }; + use crate::app::readarr::readarr_context_clues::{ + AUTHOR_DETAILS_CONTEXT_CLUES, AUTHOR_HISTORY_CONTEXT_CLUES, AUTHORS_CONTEXT_CLUES, + MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES, + }; + use crate::models::readarr_models::{Book, ReadarrHistoryItem, ReadarrRelease}; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_AUTHOR_BLOCKS, ADD_AUTHOR_SELECTION_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, + ADD_ROOT_FOLDER_SELECTION_BLOCKS, AUTHOR_DETAILS_BLOCKS, AUTHOR_OVERVIEW_BLOCKS, + ActiveReadarrBlock, BLOCKLIST_BLOCKS, BOOK_DETAILS_BLOCKS, DELETE_AUTHOR_BLOCKS, + DELETE_AUTHOR_SELECTION_BLOCKS, DELETE_BOOK_BLOCKS, DELETE_BOOK_SELECTION_BLOCKS, + DOWNLOADS_BLOCKS, EDIT_AUTHOR_BLOCKS, EDIT_AUTHOR_SELECTION_BLOCKS, EDIT_INDEXER_BLOCKS, + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + EDITION_DETAILS_BLOCKS, HISTORY_BLOCKS, INDEXER_SETTINGS_BLOCKS, + INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, LIBRARY_BLOCKS, ROOT_FOLDERS_BLOCKS, + ReadarrData, SYSTEM_DETAILS_BLOCKS, + }; + use crate::models::{BlockSelectionState, Route}; + use bimap::BiMap; + use chrono::{DateTime, Utc}; + use pretty_assertions::{assert_eq, assert_str_eq}; + use serde_json::Number; + + #[test] + fn test_from_active_readarr_block_to_route() { + assert_eq!( + Route::from(ActiveReadarrBlock::Authors), + Route::Readarr(ActiveReadarrBlock::Authors, None) + ); + } + + #[test] + fn test_from_tuple_to_route_with_context() { + assert_eq!( + Route::from(( + ActiveReadarrBlock::Authors, + Some(ActiveReadarrBlock::Authors) + )), + Route::Readarr( + ActiveReadarrBlock::Authors, + Some(ActiveReadarrBlock::Authors), + ) + ); + } + + #[test] + fn test_reset_delete_preferences() { + let mut readarr_data = ReadarrData { + delete_files: true, + add_import_list_exclusion: true, + ..ReadarrData::default() + }; + + readarr_data.reset_delete_preferences(); + + assert!(!readarr_data.delete_files); + assert!(!readarr_data.add_import_list_exclusion); + } + + #[test] + fn test_reset_author_info_tabs() { + let mut readarr_data = ReadarrData::default(); + readarr_data.books.set_items(vec![Book::default()]); + readarr_data + .author_releases + .set_items(vec![ReadarrRelease::default()]); + readarr_data + .author_history + .set_items(vec![ReadarrHistoryItem::default()]); + readarr_data.author_info_tabs.index = 1; + + readarr_data.reset_author_info_tabs(); + + assert_is_empty!(readarr_data.books); + assert_is_empty!(readarr_data.author_releases); + assert_is_empty!(readarr_data.author_history); + assert_eq!(readarr_data.author_info_tabs.index, 0); + } + + #[test] + fn test_tag_ids_to_display() { + let mut tags_map = BiMap::new(); + tags_map.insert(3, "test 3".to_owned()); + tags_map.insert(2, "test 2".to_owned()); + tags_map.insert(1, "test 1".to_owned()); + let readarr_data = ReadarrData { + tags_map, + ..ReadarrData::default() + }; + + assert_str_eq!( + readarr_data.tag_ids_to_display(&[Number::from(1), Number::from(2)]), + "test 1, test 2" + ); + } + + #[test] + fn test_sorted_quality_profile_names() { + let mut quality_profile_map = BiMap::new(); + quality_profile_map.insert(3, "test 1".to_owned()); + quality_profile_map.insert(2, "test 2".to_owned()); + quality_profile_map.insert(1, "test 3".to_owned()); + let readarr_data = ReadarrData { + quality_profile_map, + ..ReadarrData::default() + }; + let expected_quality_profile_vec = vec![ + "test 3".to_owned(), + "test 2".to_owned(), + "test 1".to_owned(), + ]; + + assert_iter_eq!( + readarr_data.sorted_quality_profile_names(), + expected_quality_profile_vec + ); + } + + #[test] + fn test_sorted_metadata_profile_names() { + let mut metadata_profile_map = BiMap::new(); + metadata_profile_map.insert(3, "test 1".to_owned()); + metadata_profile_map.insert(2, "test 2".to_owned()); + metadata_profile_map.insert(1, "test 3".to_owned()); + let readarr_data = ReadarrData { + metadata_profile_map, + ..ReadarrData::default() + }; + let expected_metadata_profile_vec = vec![ + "test 3".to_owned(), + "test 2".to_owned(), + "test 1".to_owned(), + ]; + + assert_iter_eq!( + readarr_data.sorted_metadata_profile_names(), + expected_metadata_profile_vec + ); + } + + #[test] + fn test_readarr_data_default() { + let readarr_data = ReadarrData::default(); + + assert_none!(readarr_data.add_author_modal); + assert_none!(readarr_data.add_author_search); + assert!(!readarr_data.add_import_list_exclusion); + assert_none!(readarr_data.add_root_folder_modal); + assert_none!(readarr_data.add_searched_authors); + assert_is_empty!(readarr_data.author_history); + assert_none!(readarr_data.author_overview_modal); + assert_is_empty!(readarr_data.author_releases); + assert_is_empty!(readarr_data.authors); + assert_is_empty!(readarr_data.blocklist); + assert_none!(readarr_data.book_details_modal); + assert_is_empty!(readarr_data.books); + assert!(!readarr_data.delete_files); + assert_is_empty!(readarr_data.disk_space_vec); + assert_is_empty!(readarr_data.downloads); + assert_none!(readarr_data.edit_author_modal); + assert_none!(readarr_data.edit_indexer_modal); + assert_is_empty!(readarr_data.history); + assert_none!(readarr_data.indexer_settings); + assert_none!(readarr_data.indexer_test_all_results); + assert_none!(readarr_data.indexer_test_errors); + assert_is_empty!(readarr_data.indexers); + assert_is_empty!(readarr_data.log_details); + assert_is_empty!(readarr_data.logs); + assert_is_empty!(readarr_data.metadata_profile_map); + assert!(!readarr_data.prompt_confirm); + assert_none!(readarr_data.prompt_confirm_action); + assert_is_empty!(readarr_data.quality_profile_map); + assert_is_empty!(readarr_data.queued_events); + assert_is_empty!(readarr_data.root_folders); + assert_eq!(readarr_data.selected_block, BlockSelectionState::default()); + assert_eq!(readarr_data.start_time, >::default()); + assert_is_empty!(readarr_data.tags_map); + assert_is_empty!(readarr_data.tasks); + assert_is_empty!(readarr_data.updates); + assert_is_empty!(readarr_data.version); + + assert_eq!(readarr_data.main_tabs.tabs.len(), 7); + + assert_str_eq!(readarr_data.main_tabs.tabs[0].title, "Library"); + assert_eq!( + readarr_data.main_tabs.tabs[0].route, + ActiveReadarrBlock::Authors.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[0].contextual_help, + &AUTHORS_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[0].config); + + assert_str_eq!(readarr_data.main_tabs.tabs[1].title, "Downloads"); + assert_eq!( + readarr_data.main_tabs.tabs[1].route, + ActiveReadarrBlock::Downloads.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[1].contextual_help, + &DOWNLOADS_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[1].config); + + assert_str_eq!(readarr_data.main_tabs.tabs[2].title, "Blocklist"); + assert_eq!( + readarr_data.main_tabs.tabs[2].route, + ActiveReadarrBlock::Blocklist.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[2].contextual_help, + &BLOCKLIST_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[2].config); + + assert_str_eq!(readarr_data.main_tabs.tabs[3].title, "History"); + assert_eq!( + readarr_data.main_tabs.tabs[3].route, + ActiveReadarrBlock::History.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[3].contextual_help, + &HISTORY_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[3].config); + + assert_str_eq!(readarr_data.main_tabs.tabs[4].title, "Root Folders"); + assert_eq!( + readarr_data.main_tabs.tabs[4].route, + ActiveReadarrBlock::RootFolders.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[4].contextual_help, + &ROOT_FOLDERS_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[4].config); + + assert_str_eq!(readarr_data.main_tabs.tabs[5].title, "Indexers"); + assert_eq!( + readarr_data.main_tabs.tabs[5].route, + ActiveReadarrBlock::Indexers.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[5].contextual_help, + &INDEXERS_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[5].config); + + assert_str_eq!(readarr_data.main_tabs.tabs[6].title, "System"); + assert_eq!( + readarr_data.main_tabs.tabs[6].route, + ActiveReadarrBlock::System.into() + ); + assert_some_eq_x!( + &readarr_data.main_tabs.tabs[6].contextual_help, + &SYSTEM_CONTEXT_CLUES + ); + assert_none!(readarr_data.main_tabs.tabs[6].config); + + assert_eq!(readarr_data.author_info_tabs.tabs.len(), 3); + + assert_str_eq!(readarr_data.author_info_tabs.tabs[0].title, "Books"); + assert_eq!( + readarr_data.author_info_tabs.tabs[0].route, + ActiveReadarrBlock::AuthorDetails.into() + ); + assert_some_eq_x!( + &readarr_data.author_info_tabs.tabs[0].contextual_help, + &AUTHOR_DETAILS_CONTEXT_CLUES + ); + assert_none!(readarr_data.author_info_tabs.tabs[0].config); + + assert_str_eq!(readarr_data.author_info_tabs.tabs[1].title, "History"); + assert_eq!( + readarr_data.author_info_tabs.tabs[1].route, + ActiveReadarrBlock::AuthorHistory.into() + ); + assert_some_eq_x!( + &readarr_data.author_info_tabs.tabs[1].contextual_help, + &AUTHOR_HISTORY_CONTEXT_CLUES + ); + assert_none!(readarr_data.author_info_tabs.tabs[1].config); + + assert_str_eq!(readarr_data.author_info_tabs.tabs[2].title, "Author Search"); + assert_eq!( + readarr_data.author_info_tabs.tabs[2].route, + ActiveReadarrBlock::ManualAuthorSearch.into() + ); + assert_some_eq_x!( + &readarr_data.author_info_tabs.tabs[2].contextual_help, + &MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES + ); + assert_none!(readarr_data.author_info_tabs.tabs[2].config); + } + + #[test] + fn test_library_blocks_contains_expected_blocks() { + assert_eq!(LIBRARY_BLOCKS.len(), 7); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::Authors)); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::AuthorsSortPrompt)); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::FilterAuthors)); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::FilterAuthorsError)); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::SearchAuthors)); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::SearchAuthorsError)); + assert!(LIBRARY_BLOCKS.contains(&ActiveReadarrBlock::UpdateAllAuthorsPrompt)); + } + + #[test] + fn test_author_details_blocks_contains_expected_blocks() { + assert_eq!(AUTHOR_DETAILS_BLOCKS.len(), 15); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AuthorDetails)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AuthorHistory)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AuthorHistoryDetails)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AuthorHistorySortPrompt)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AutomaticallySearchAuthorPrompt)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::FilterAuthorHistory)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::FilterAuthorHistoryError)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::ManualAuthorSearch)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::ManualAuthorSearchSortPrompt)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchBooks)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchBooksError)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchAuthorHistory)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchAuthorHistoryError)); + assert!(AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::UpdateAndScanAuthorPrompt)); + } + + #[test] + fn test_book_details_blocks_contents() { + assert_eq!(BOOK_DETAILS_BLOCKS.len(), 16); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookDetails)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookHistory)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookHistoryDetails)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookHistorySortPrompt)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AutomaticallySearchBookPrompt)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::DeleteBookFilePrompt)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::FilterBookHistory)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::FilterBookHistoryError)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookFileInfo)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::ManualBookSearch)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::ManualBookSearchConfirmPrompt)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::ManualBookSearchSortPrompt)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchBookHistory)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchBookHistoryError)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchEditions)); + assert!(BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SearchEditionsError)); + } + + #[test] + fn test_book_details_blocks_excludes_book_edition_details() { + assert!(!BOOK_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookEditionDetails)); + } + + #[test] + fn test_edition_details_blocks_contents() { + assert_eq!(EDITION_DETAILS_BLOCKS.len(), 1); + assert!(EDITION_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::BookEditionDetails)); + } + + #[test] + fn test_author_details_blocks_excludes_author_overview() { + assert!(!AUTHOR_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::AuthorOverview)); + } + + #[test] + fn test_author_overview_blocks_contents() { + assert_eq!(AUTHOR_OVERVIEW_BLOCKS.len(), 1); + assert!(AUTHOR_OVERVIEW_BLOCKS.contains(&ActiveReadarrBlock::AuthorOverview)); + } + + #[test] + fn test_blocklist_blocks_contents() { + assert_eq!(BLOCKLIST_BLOCKS.len(), 5); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveReadarrBlock::Blocklist)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveReadarrBlock::BlocklistItemDetails)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveReadarrBlock::DeleteBlocklistItemPrompt)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveReadarrBlock::BlocklistClearAllItemsPrompt)); + assert!(BLOCKLIST_BLOCKS.contains(&ActiveReadarrBlock::BlocklistSortPrompt)); + } + + #[test] + fn test_downloads_blocks_contains_expected_blocks() { + assert_eq!(DOWNLOADS_BLOCKS.len(), 3); + assert!(DOWNLOADS_BLOCKS.contains(&ActiveReadarrBlock::Downloads)); + assert!(DOWNLOADS_BLOCKS.contains(&ActiveReadarrBlock::DeleteDownloadPrompt)); + assert!(DOWNLOADS_BLOCKS.contains(&ActiveReadarrBlock::UpdateDownloadsPrompt)); + } + + #[test] + fn test_history_blocks_contains_expected_blocks() { + assert_eq!(HISTORY_BLOCKS.len(), 7); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::History)); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::HistoryItemDetails)); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::HistorySortPrompt)); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::SearchHistory)); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::SearchHistoryError)); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::FilterHistory)); + assert!(HISTORY_BLOCKS.contains(&ActiveReadarrBlock::FilterHistoryError)); + } + + #[test] + fn test_add_author_blocks_contents() { + assert_eq!(ADD_AUTHOR_BLOCKS.len(), 12); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorAlreadyInLibrary)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorConfirmPrompt)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorEmptySearchResults)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorPrompt)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSearchInput)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSearchResults)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSelectMetadataProfile)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSelectMonitor)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSelectMonitorNewItems)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSelectQualityProfile)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorSelectRootFolder)); + assert!(ADD_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::AddAuthorTagsInput)); + } + + #[test] + fn test_add_author_selection_blocks_ordering() { + let mut add_author_block_iter = ADD_AUTHOR_SELECTION_BLOCKS.iter(); + + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorSelectRootFolder] + ); + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorSelectMonitor] + ); + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorSelectMonitorNewItems] + ); + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorSelectQualityProfile] + ); + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorSelectMetadataProfile] + ); + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorTagsInput] + ); + assert_eq!( + add_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddAuthorConfirmPrompt] + ); + assert_none!(add_author_block_iter.next()); + } + + #[test] + fn test_delete_author_blocks_contents() { + assert_eq!(DELETE_AUTHOR_BLOCKS.len(), 4); + assert!(DELETE_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::DeleteAuthorPrompt)); + assert!(DELETE_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::DeleteAuthorConfirmPrompt)); + assert!(DELETE_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::DeleteAuthorToggleDeleteFile)); + assert!(DELETE_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion)); + } + + #[test] + fn test_delete_author_selection_blocks_ordering() { + let mut delete_author_block_iter = DELETE_AUTHOR_SELECTION_BLOCKS.iter(); + + assert_eq!( + delete_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::DeleteAuthorToggleDeleteFile] + ); + assert_eq!( + delete_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion] + ); + assert_eq!( + delete_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::DeleteAuthorConfirmPrompt] + ); + assert_none!(delete_author_block_iter.next()); + } + + #[test] + fn test_delete_book_blocks_contents() { + assert_eq!(DELETE_BOOK_BLOCKS.len(), 4); + assert!(DELETE_BOOK_BLOCKS.contains(&ActiveReadarrBlock::DeleteBookPrompt)); + assert!(DELETE_BOOK_BLOCKS.contains(&ActiveReadarrBlock::DeleteBookConfirmPrompt)); + assert!(DELETE_BOOK_BLOCKS.contains(&ActiveReadarrBlock::DeleteBookToggleDeleteFile)); + assert!(DELETE_BOOK_BLOCKS.contains(&ActiveReadarrBlock::DeleteBookToggleAddListExclusion)); + } + + #[test] + fn test_delete_book_selection_blocks_ordering() { + let mut delete_book_block_iter = DELETE_BOOK_SELECTION_BLOCKS.iter(); + + assert_eq!( + delete_book_block_iter.next().unwrap(), + &[ActiveReadarrBlock::DeleteBookToggleDeleteFile] + ); + assert_eq!( + delete_book_block_iter.next().unwrap(), + &[ActiveReadarrBlock::DeleteBookToggleAddListExclusion] + ); + assert_eq!( + delete_book_block_iter.next().unwrap(), + &[ActiveReadarrBlock::DeleteBookConfirmPrompt] + ); + assert_none!(delete_book_block_iter.next()); + } + + #[test] + fn test_edit_author_blocks() { + assert_eq!(EDIT_AUTHOR_BLOCKS.len(), 8); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorPrompt)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorConfirmPrompt)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorPathInput)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorSelectMetadataProfile)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorSelectMonitorNewItems)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorSelectQualityProfile)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorTagsInput)); + assert!(EDIT_AUTHOR_BLOCKS.contains(&ActiveReadarrBlock::EditAuthorToggleMonitored)); + } + + #[test] + fn test_edit_author_selection_blocks_ordering() { + let mut edit_author_block_iter = EDIT_AUTHOR_SELECTION_BLOCKS.iter(); + + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorToggleMonitored] + ); + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorSelectMonitorNewItems] + ); + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorSelectQualityProfile] + ); + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorSelectMetadataProfile] + ); + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorPathInput] + ); + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorTagsInput] + ); + assert_eq!( + edit_author_block_iter.next().unwrap(), + &[ActiveReadarrBlock::EditAuthorConfirmPrompt] + ); + assert_none!(edit_author_block_iter.next()); + } + + #[test] + fn test_root_folders_blocks_contents() { + assert_eq!(ROOT_FOLDERS_BLOCKS.len(), 2); + assert!(ROOT_FOLDERS_BLOCKS.contains(&ActiveReadarrBlock::RootFolders)); + assert!(ROOT_FOLDERS_BLOCKS.contains(&ActiveReadarrBlock::DeleteRootFolderPrompt)); + } + + #[test] + fn test_add_root_folder_blocks_contents() { + assert_eq!(ADD_ROOT_FOLDER_BLOCKS.len(), 9); + assert!(ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderPrompt)); + assert!(ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderConfirmPrompt)); + assert!(ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderNameInput)); + assert!(ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderPathInput)); + assert!(ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderSelectMonitor)); + assert!( + ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems) + ); + assert!( + ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderSelectQualityProfile) + ); + assert!( + ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderSelectMetadataProfile) + ); + assert!(ADD_ROOT_FOLDER_BLOCKS.contains(&ActiveReadarrBlock::AddRootFolderTagsInput)); + } + + #[test] + fn test_add_root_folder_selection_blocks_ordering() { + let mut add_root_folder_block_iter = ADD_ROOT_FOLDER_SELECTION_BLOCKS.iter(); + + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderNameInput] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderPathInput] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderSelectMonitor] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderSelectQualityProfile] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderSelectMetadataProfile] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderTagsInput] + ); + assert_eq!( + add_root_folder_block_iter.next().unwrap(), + &[ActiveReadarrBlock::AddRootFolderConfirmPrompt] + ); + assert_none!(add_root_folder_block_iter.next()); + } + + #[test] + fn test_edit_indexer_blocks_contents() { + assert_eq!(EDIT_INDEXER_BLOCKS.len(), 11); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerPrompt)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerConfirmPrompt)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerApiKeyInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerNameInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerSeedRatioInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerToggleEnableRss)); + assert!( + EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch) + ); + assert!( + EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch) + ); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerPriorityInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerUrlInput)); + assert!(EDIT_INDEXER_BLOCKS.contains(&ActiveReadarrBlock::EditIndexerTagsInput)); + } + + #[test] + fn test_edit_indexer_torrent_selection_blocks_ordering() { + let mut edit_indexer_block_iter = EDIT_INDEXER_TORRENT_SELECTION_BLOCKS.iter(); + + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerApiKeyInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerSeedRatioInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerTagsInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerPriorityInput, + ActiveReadarrBlock::EditIndexerConfirmPrompt + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ActiveReadarrBlock::EditIndexerConfirmPrompt + ] + ); + assert_none!(edit_indexer_block_iter.next()); + } + + #[test] + fn test_edit_indexer_nzb_selection_blocks_ordering() { + let mut edit_indexer_block_iter = EDIT_INDEXER_NZB_SELECTION_BLOCKS.iter(); + + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerNameInput, + ActiveReadarrBlock::EditIndexerUrlInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerToggleEnableRss, + ActiveReadarrBlock::EditIndexerApiKeyInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + ActiveReadarrBlock::EditIndexerTagsInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + ActiveReadarrBlock::EditIndexerPriorityInput + ] + ); + assert_eq!( + edit_indexer_block_iter.next().unwrap(), + &[ + ActiveReadarrBlock::EditIndexerConfirmPrompt, + ActiveReadarrBlock::EditIndexerConfirmPrompt + ] + ); + assert_none!(edit_indexer_block_iter.next()); + } + + #[test] + fn test_indexer_settings_blocks_contents() { + assert_eq!(INDEXER_SETTINGS_BLOCKS.len(), 6); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveReadarrBlock::AllIndexerSettingsPrompt)); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveReadarrBlock::IndexerSettingsConfirmPrompt)); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveReadarrBlock::IndexerSettingsMaximumSizeInput)); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveReadarrBlock::IndexerSettingsMinimumAgeInput)); + assert!(INDEXER_SETTINGS_BLOCKS.contains(&ActiveReadarrBlock::IndexerSettingsRetentionInput)); + assert!( + INDEXER_SETTINGS_BLOCKS.contains(&ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput) + ); + } + + #[test] + fn test_indexer_settings_selection_blocks_ordering() { + let mut indexer_settings_block_iter = INDEXER_SETTINGS_SELECTION_BLOCKS.iter(); + + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveReadarrBlock::IndexerSettingsMinimumAgeInput] + ); + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveReadarrBlock::IndexerSettingsRetentionInput] + ); + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveReadarrBlock::IndexerSettingsMaximumSizeInput] + ); + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput] + ); + assert_eq!( + indexer_settings_block_iter.next().unwrap(), + &[ActiveReadarrBlock::IndexerSettingsConfirmPrompt] + ); + assert_none!(indexer_settings_block_iter.next()); + } + + #[test] + fn test_indexers_blocks_contents() { + assert_eq!(INDEXERS_BLOCKS.len(), 3); + assert!(INDEXERS_BLOCKS.contains(&ActiveReadarrBlock::Indexers)); + assert!(INDEXERS_BLOCKS.contains(&ActiveReadarrBlock::DeleteIndexerPrompt)); + assert!(INDEXERS_BLOCKS.contains(&ActiveReadarrBlock::TestIndexer)); + } + + #[test] + fn test_indexers_blocks_excludes_test_all_indexers() { + assert!(!INDEXERS_BLOCKS.contains(&ActiveReadarrBlock::TestAllIndexers)); + } + + #[test] + fn test_system_details_blocks_contents() { + assert_eq!(SYSTEM_DETAILS_BLOCKS.len(), 5); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SystemLogs)); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SystemQueuedEvents)); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SystemTasks)); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SystemTaskStartConfirmPrompt)); + assert!(SYSTEM_DETAILS_BLOCKS.contains(&ActiveReadarrBlock::SystemUpdates)); + } +} diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs index d1d12d8c..867aa516 100644 --- a/src/models/servarr_data/sonarr/modals.rs +++ b/src/models/servarr_data/sonarr/modals.rs @@ -303,3 +303,9 @@ impl Default for SeasonDetailsModal { } } } + +#[derive(Default)] +#[cfg_attr(test, derive(Debug))] +pub struct SeriesOverviewModal { + pub overview: ScrollableText, +} diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs index 0a867c84..b8a9616a 100644 --- a/src/models/servarr_data/sonarr/modals_tests.rs +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -11,7 +11,7 @@ mod tests { SELECTABLE_EPISODE_DETAILS_CONTEXT_CLUES, }; use crate::models::servarr_data::sonarr::modals::{ - EditSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, + EditSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, SeriesOverviewModal, }; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::models::servarr_models::{Indexer, IndexerField}; @@ -428,4 +428,11 @@ mod tests { None ); } + + #[test] + fn test_series_overview_modal_default() { + let series_overview_modal = SeriesOverviewModal::default(); + + assert_is_empty!(series_overview_modal.overview); + } } diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs index c4702807..cc20151b 100644 --- a/src/models/servarr_data/sonarr/sonarr_data.rs +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -1,4 +1,4 @@ -use super::modals::{AddSeriesModal, EditSeriesModal, SeasonDetailsModal}; +use super::modals::{AddSeriesModal, EditSeriesModal, SeasonDetailsModal, SeriesOverviewModal}; use crate::{ app::{ context_clues::{ @@ -89,6 +89,7 @@ pub struct SonarrData<'a> { pub series: StatefulTable, pub series_history: Option>, pub series_info_tabs: TabState, + pub series_overview_modal: Option, pub start_time: DateTime, pub tags_map: BiMap, pub tasks: StatefulTable, @@ -172,6 +173,7 @@ impl<'a> Default for SonarrData<'a> { selected_block: BlockSelectionState::default(), series: StatefulTable::default(), series_history: None, + series_overview_modal: None, start_time: DateTime::default(), tags_map: BiMap::default(), tasks: StatefulTable::default(), @@ -347,6 +349,10 @@ impl SonarrData<'_> { series_history.search = Some("series history search".into()); series_history.filter = Some("series history filter".into()); + let series_overview_modal = SeriesOverviewModal { + overview: ScrollableText::with_string(series().overview.clone().unwrap_or_default()), + }; + let mut sonarr_data = SonarrData { add_list_exclusion: true, add_searched_series: Some(add_searched_series), @@ -364,6 +370,7 @@ impl SonarrData<'_> { quality_profile_map: quality_profile_map(), season_details_modal: Some(season_details_modal), series_history: Some(series_history), + series_overview_modal: Some(series_overview_modal), start_time: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()), tags_map: tags_map(), updates: updates(), @@ -503,6 +510,7 @@ pub enum ActiveSonarrBlock { SeriesHistory, SeriesHistoryDetails, SeriesHistorySortPrompt, + SeriesOverview, SeriesSortPrompt, System, SystemLogs, @@ -542,6 +550,8 @@ pub static SERIES_DETAILS_BLOCKS: [ActiveSonarrBlock; 12] = [ ActiveSonarrBlock::SeriesHistoryDetails, ]; +pub static SERIES_OVERVIEW_BLOCKS: [ActiveSonarrBlock; 1] = [ActiveSonarrBlock::SeriesOverview]; + pub static SEASON_DETAILS_BLOCKS: [ActiveSonarrBlock; 15] = [ ActiveSonarrBlock::SeasonDetails, ActiveSonarrBlock::SeasonHistory, diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs index 25c5756b..4cba45d6 100644 --- a/src/models/servarr_data/sonarr/sonarr_data_tests.rs +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -171,6 +171,7 @@ mod tests { assert_eq!(sonarr_data.selected_block, BlockSelectionState::default()); assert_is_empty!(sonarr_data.series); assert_none!(sonarr_data.series_history); + assert_none!(sonarr_data.series_overview_modal); assert_eq!(sonarr_data.start_time, >::default()); assert_is_empty!(sonarr_data.tags_map); assert_is_empty!(sonarr_data.tasks); @@ -289,7 +290,8 @@ mod tests { EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, EDIT_SERIES_BLOCKS, EDIT_SERIES_SELECTION_BLOCKS, EPISODE_DETAILS_BLOCKS, HISTORY_BLOCKS, INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, INDEXERS_BLOCKS, LIBRARY_BLOCKS, - ROOT_FOLDERS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, SYSTEM_DETAILS_BLOCKS, + ROOT_FOLDERS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, SERIES_OVERVIEW_BLOCKS, + SYSTEM_DETAILS_BLOCKS, }; #[test] @@ -671,6 +673,17 @@ mod tests { assert!(SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesHistoryDetails)); } + #[test] + fn test_series_details_blocks_excludes_series_overview() { + assert!(!SERIES_DETAILS_BLOCKS.contains(&ActiveSonarrBlock::SeriesOverview)); + } + + #[test] + fn test_series_overview_blocks_contents() { + assert_eq!(SERIES_OVERVIEW_BLOCKS.len(), 1); + assert!(SERIES_OVERVIEW_BLOCKS.contains(&ActiveSonarrBlock::SeriesOverview)); + } + #[test] fn test_season_details_blocks_contents() { assert_eq!(SEASON_DETAILS_BLOCKS.len(), 15); diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs index 36a00330..ee6d562a 100644 --- a/src/models/servarr_models.rs +++ b/src/models/servarr_models.rs @@ -2,8 +2,10 @@ use std::fmt::{Display, Formatter, Result}; use chrono::{DateTime, Utc}; use clap::ValueEnum; +use enum_display_style_derive::EnumDisplayStyle; use serde::{Deserialize, Serialize}; use serde_json::{Number, Value}; +use strum::{Display, EnumIter}; use super::HorizontallyScrollableText; @@ -90,6 +92,36 @@ pub struct DiskSpace { pub total_space: i64, } +#[derive( + Serialize, + Deserialize, + Default, + PartialEq, + Eq, + Clone, + Copy, + Debug, + EnumIter, + Display, + EnumDisplayStyle, +)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +pub enum DownloadStatus { + #[default] + Unknown, + Queued, + Paused, + Downloading, + Completed, + Failed, + Warning, + Delay, + #[display_style(name = "Download Client Unavailable")] + DownloadClientUnavailable, + Fallback, +} + #[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct IndexerSettings { @@ -238,6 +270,22 @@ pub struct LogResponse { pub records: Vec, } +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct MetadataProfile { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub name: String, +} + +impl From<(&i64, &String)> for MetadataProfile { + fn from(value: (&i64, &String)) -> Self { + MetadataProfile { + id: *value.0, + name: value.1.clone(), + } + } +} + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] pub struct Quality { pub name: String, @@ -277,6 +325,13 @@ pub struct QueueEvent { pub duration: Option, } +#[derive(Default, Serialize, Debug, PartialEq, Eq, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ReleaseDownloadBody { + pub guid: String, + pub indexer_id: i64, +} + #[derive(Default, Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RootFolder { @@ -292,7 +347,9 @@ pub struct RootFolder { #[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SecurityConfig { + #[serde(deserialize_with = "super::from_json_or_default")] pub authentication_method: AuthenticationMethod, + #[serde(default, deserialize_with = "super::from_json_or_default")] #[serde(skip_serializing_if = "Option::is_none")] pub authentication_required: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -300,9 +357,17 @@ pub struct SecurityConfig { #[serde(skip_serializing_if = "Option::is_none")] pub password: Option, pub api_key: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub certificate_validation: CertificateValidation, } +#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SystemStatus { + pub version: String, + pub start_time: DateTime, +} + #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct Tag { #[serde(deserialize_with = "super::from_i64")] @@ -324,6 +389,7 @@ pub struct Update { pub installed: bool, pub latest: bool, pub installed_on: Option>, + #[serde(default)] pub changes: UpdateChanges, } diff --git a/src/models/servarr_models_tests.rs b/src/models/servarr_models_tests.rs index e57dac7d..2678aa49 100644 --- a/src/models/servarr_models_tests.rs +++ b/src/models/servarr_models_tests.rs @@ -1,9 +1,12 @@ #[cfg(test)] mod tests { use pretty_assertions::{assert_eq, assert_str_eq}; + use serde::de::DeserializeOwned; + use serde_json::{Value, json}; use crate::models::servarr_models::{ - AuthenticationMethod, AuthenticationRequired, CertificateValidation, Indexer, QualityProfile, + AuthenticationMethod, AuthenticationRequired, CertificateValidation, DownloadStatus, Indexer, + QualityProfile, SecurityConfig, Update, UpdateChanges, }; #[test] @@ -53,6 +56,40 @@ mod tests { assert_str_eq!(CertificateValidation::Disabled.to_string(), "disabled"); } + #[test] + fn test_download_status_display() { + assert_str_eq!(DownloadStatus::Unknown.to_string(), "unknown"); + assert_str_eq!(DownloadStatus::Queued.to_string(), "queued"); + assert_str_eq!(DownloadStatus::Paused.to_string(), "paused"); + assert_str_eq!(DownloadStatus::Downloading.to_string(), "downloading"); + assert_str_eq!(DownloadStatus::Completed.to_string(), "completed"); + assert_str_eq!(DownloadStatus::Failed.to_string(), "failed"); + assert_str_eq!(DownloadStatus::Warning.to_string(), "warning"); + assert_str_eq!(DownloadStatus::Delay.to_string(), "delay"); + assert_str_eq!( + DownloadStatus::DownloadClientUnavailable.to_string(), + "downloadClientUnavailable" + ); + assert_str_eq!(DownloadStatus::Fallback.to_string(), "fallback"); + } + + #[test] + fn test_download_status_to_display_str() { + assert_str_eq!(DownloadStatus::Unknown.to_display_str(), "Unknown"); + assert_str_eq!(DownloadStatus::Queued.to_display_str(), "Queued"); + assert_str_eq!(DownloadStatus::Paused.to_display_str(), "Paused"); + assert_str_eq!(DownloadStatus::Downloading.to_display_str(), "Downloading"); + assert_str_eq!(DownloadStatus::Completed.to_display_str(), "Completed"); + assert_str_eq!(DownloadStatus::Failed.to_display_str(), "Failed"); + assert_str_eq!(DownloadStatus::Warning.to_display_str(), "Warning"); + assert_str_eq!(DownloadStatus::Delay.to_display_str(), "Delay"); + assert_str_eq!( + DownloadStatus::DownloadClientUnavailable.to_display_str(), + "Download Client Unavailable" + ); + assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback"); + } + #[test] fn test_quality_profile_from_tuple_ref() { let id = 2; @@ -67,4 +104,140 @@ mod tests { assert_eq!(expected_quality_profile, quality_profile); } + + #[test] + fn test_update_deserializes_when_changes_is_absent() { + let update_json = r#"{ + "version": "0.4.18.2805", + "releaseDate": "2025-06-15T06:22:22Z", + "installed": false, + "latest": true, + "installedOn": "2025-06-22T05:09:11Z" + }"#; + + let update = serde_json::from_str::(update_json); + + assert_ok!(&update); + assert_eq!( + update.unwrap().changes, + UpdateChanges { + new: None, + fixed: None, + } + ); + } + + #[test] + fn test_security_config_authentication_method_deserialization_falls_back_to_default() { + let security_config_json = serde_json::to_string(&security_config()).unwrap(); + let expected = SecurityConfig { + authentication_method: AuthenticationMethod::default(), + ..security_config() + }; + + assert_eq!( + deserialize_with_field::( + &security_config_json, + "authenticationMethod", + json!(2) + ), + expected + ); + assert_eq!( + deserialize_with_field::( + &security_config_json, + "authenticationMethod", + json!("notAThing") + ), + expected + ); + } + + #[test] + fn test_security_config_authentication_required_deserialization_falls_back_to_none() { + let security_config_json = serde_json::to_string(&security_config()).unwrap(); + let expected = SecurityConfig { + authentication_required: None, + ..security_config() + }; + + assert_eq!( + deserialize_with_field::( + &security_config_json, + "authenticationRequired", + json!(2) + ), + expected + ); + assert_eq!( + deserialize_with_field::( + &security_config_json, + "authenticationRequired", + json!("notAThing") + ), + expected + ); + } + + #[test] + fn test_security_config_deserializes_when_authentication_required_is_absent() { + let mut security_config_json = serde_json::to_value(security_config()).unwrap(); + security_config_json + .as_object_mut() + .unwrap() + .remove("authenticationRequired"); + let expected = SecurityConfig { + authentication_required: None, + ..security_config() + }; + + let deserialized = serde_json::from_value::(security_config_json); + + assert_ok!(&deserialized); + assert_eq!(deserialized.unwrap(), expected); + } + + #[test] + fn test_security_config_certificate_validation_deserialization_falls_back_to_default() { + let security_config_json = serde_json::to_string(&security_config()).unwrap(); + let expected = SecurityConfig { + certificate_validation: CertificateValidation::default(), + ..security_config() + }; + + assert_eq!( + deserialize_with_field::( + &security_config_json, + "certificateValidation", + json!(2) + ), + expected + ); + assert_eq!( + deserialize_with_field::( + &security_config_json, + "certificateValidation", + json!("notAThing") + ), + expected + ); + } + + fn deserialize_with_field(json: &str, field: &str, value: Value) -> T { + let mut fixture_json: Value = serde_json::from_str(json).unwrap(); + fixture_json[field] = value; + + serde_json::from_value(fixture_json).unwrap() + } + + fn security_config() -> SecurityConfig { + SecurityConfig { + authentication_method: AuthenticationMethod::Forms, + authentication_required: Some(AuthenticationRequired::Enabled), + username: Some("admin".to_owned()), + password: Some("password".to_owned()), + api_key: "test-api-key".to_owned(), + certificate_validation: CertificateValidation::Disabled, + } + } } diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs index 3de2d111..89d37451 100644 --- a/src/models/sonarr_models.rs +++ b/src/models/sonarr_models.rs @@ -16,8 +16,8 @@ use strum_macros::Display; use super::{ HorizontallyScrollableText, Serdeable, servarr_models::{ - DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, - QueueEvent, RootFolder, SecurityConfig, Tag, Update, + DiskSpace, DownloadStatus, HostConfig, Indexer, Language, LogResponse, QualityProfile, + QualityWrapper, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, Update, }, }; @@ -112,6 +112,7 @@ pub struct DeleteSeriesParams { #[serde(rename_all = "camelCase")] pub struct DownloadRecord { pub title: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub status: DownloadStatus, #[serde(deserialize_with = "super::from_i64")] pub id: i64, @@ -128,36 +129,6 @@ pub struct DownloadRecord { impl Eq for DownloadRecord {} -#[derive( - Serialize, - Deserialize, - Default, - PartialEq, - Eq, - Clone, - Copy, - Debug, - EnumIter, - Display, - EnumDisplayStyle, -)] -#[serde(rename_all = "camelCase")] -#[strum(serialize_all = "camelCase")] -pub enum DownloadStatus { - #[default] - Unknown, - Queued, - Paused, - Downloading, - Completed, - Failed, - Warning, - Delay, - #[display_style(name = "Download Client Unavailable")] - DownloadClientUnavailable, - Fallback, -} - #[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DownloadsResponse { @@ -314,12 +285,14 @@ pub struct Series { #[serde(deserialize_with = "super::from_i64")] pub year: i64, pub monitored: bool, + #[serde(deserialize_with = "super::from_json_or_default")] pub series_type: SeriesType, pub path: String, pub genres: Vec, pub tags: Vec, pub ratings: Rating, pub ended: bool, + #[serde(deserialize_with = "super::from_json_or_default")] pub status: SeriesStatus, pub overview: Option, pub network: Option, @@ -499,6 +472,7 @@ pub struct SonarrHistoryItem { pub quality: QualityWrapper, pub languages: Vec>, pub date: DateTime, + #[serde(deserialize_with = "super::from_json_or_default")] pub event_type: SonarrHistoryEventType, pub data: SonarrHistoryData, } @@ -555,6 +529,7 @@ pub struct SonarrReleaseDownloadBody { #[serde(rename_all = "camelCase")] pub struct SonarrTask { pub name: String, + #[serde(deserialize_with = "super::from_json_or_default")] pub task_name: SonarrTaskName, #[serde(deserialize_with = "super::from_i64")] pub interval: i64, @@ -626,10 +601,3 @@ serde_enum_from!( Value(Value), } ); - -#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SystemStatus { - pub version: String, - pub start_time: DateTime, -} diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs index 205e2e7b..7517a15d 100644 --- a/src/models/sonarr_models_tests.rs +++ b/src/models/sonarr_models_tests.rs @@ -1,21 +1,26 @@ #[cfg(test)] mod tests { use pretty_assertions::{assert_eq, assert_str_eq}; - use serde_json::json; + use serde::de::DeserializeOwned; + use serde_json::{Value, json}; use crate::models::{ Serdeable, servarr_models::{ - DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Language, Log, - LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, + DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Language, + Log, LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, + Update, }, sonarr_models::{ - AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, - DownloadsResponse, Episode, EpisodeFile, Series, SeriesMonitor, SeriesStatus, SeriesType, + AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, + Episode, EpisodeFile, Series, SeriesMonitor, SeriesStatus, SeriesType, SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, SonarrSerdeable, SonarrTask, - SonarrTaskName, SystemStatus, + SonarrTaskName, }, }; + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{ + SERIES_JSON, download_record, task, + }; #[test] fn test_episode_display() { @@ -118,40 +123,6 @@ mod tests { assert_str_eq!(SeriesType::Anime.to_display_str(), "Anime"); } - #[test] - fn test_download_status_display() { - assert_str_eq!(DownloadStatus::Unknown.to_string(), "unknown"); - assert_str_eq!(DownloadStatus::Queued.to_string(), "queued"); - assert_str_eq!(DownloadStatus::Paused.to_string(), "paused"); - assert_str_eq!(DownloadStatus::Downloading.to_string(), "downloading"); - assert_str_eq!(DownloadStatus::Completed.to_string(), "completed"); - assert_str_eq!(DownloadStatus::Failed.to_string(), "failed"); - assert_str_eq!(DownloadStatus::Warning.to_string(), "warning"); - assert_str_eq!(DownloadStatus::Delay.to_string(), "delay"); - assert_str_eq!( - DownloadStatus::DownloadClientUnavailable.to_string(), - "downloadClientUnavailable" - ); - assert_str_eq!(DownloadStatus::Fallback.to_string(), "fallback"); - } - - #[test] - fn test_download_status_to_display_str() { - assert_str_eq!(DownloadStatus::Unknown.to_display_str(), "Unknown"); - assert_str_eq!(DownloadStatus::Queued.to_display_str(), "Queued"); - assert_str_eq!(DownloadStatus::Paused.to_display_str(), "Paused"); - assert_str_eq!(DownloadStatus::Downloading.to_display_str(), "Downloading"); - assert_str_eq!(DownloadStatus::Completed.to_display_str(), "Completed"); - assert_str_eq!(DownloadStatus::Failed.to_display_str(), "Failed"); - assert_str_eq!(DownloadStatus::Warning.to_display_str(), "Warning"); - assert_str_eq!(DownloadStatus::Delay.to_display_str(), "Delay"); - assert_str_eq!( - DownloadStatus::DownloadClientUnavailable.to_display_str(), - "Download Client Unavailable" - ); - assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback"); - } - #[test] fn test_sonarr_history_event_type_display() { assert_str_eq!(SonarrHistoryEventType::Unknown.to_string(), "unknown",); @@ -212,6 +183,102 @@ mod tests { ); } + #[test] + fn test_sonarr_history_event_type_deserialization() { + assert_eq!( + deserialize_history_event_type(json!("unknown")), + SonarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!("grabbed")), + SonarrHistoryEventType::Grabbed + ); + assert_eq!( + deserialize_history_event_type(json!("seriesFolderImported")), + SonarrHistoryEventType::SeriesFolderImported + ); + assert_eq!( + deserialize_history_event_type(json!("downloadFolderImported")), + SonarrHistoryEventType::DownloadFolderImported + ); + assert_eq!( + deserialize_history_event_type(json!("downloadFailed")), + SonarrHistoryEventType::DownloadFailed + ); + assert_eq!( + deserialize_history_event_type(json!("episodeFileDeleted")), + SonarrHistoryEventType::EpisodeFileDeleted + ); + assert_eq!( + deserialize_history_event_type(json!("episodeFileRenamed")), + SonarrHistoryEventType::EpisodeFileRenamed + ); + assert_eq!( + deserialize_history_event_type(json!("downloadIgnored")), + SonarrHistoryEventType::DownloadIgnored + ); + } + + #[test] + fn test_sonarr_history_event_type_deserialization_falls_back_to_unknown() { + assert_eq!( + deserialize_history_event_type(json!("episodeFileImportFailed")), + SonarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(2)), + SonarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(11)), + SonarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!(null)), + SonarrHistoryEventType::Unknown + ); + assert_eq!( + deserialize_history_event_type(json!({ "id": 2 })), + SonarrHistoryEventType::Unknown + ); + } + + #[test] + fn test_sonarr_history_event_type_serialization() { + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::Unknown), + "unknown" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::Grabbed), + "grabbed" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::SeriesFolderImported), + "seriesFolderImported" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::DownloadFolderImported), + "downloadFolderImported" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::DownloadFailed), + "downloadFailed" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::EpisodeFileDeleted), + "episodeFileDeleted" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::EpisodeFileRenamed), + "episodeFileRenamed" + ); + assert_str_eq!( + serialize_history_event_type(SonarrHistoryEventType::DownloadIgnored), + "downloadIgnored" + ); + } + #[test] fn test_task_name_display() { assert_str_eq!( @@ -600,4 +667,110 @@ mod tests { SonarrSerdeable::IndexerTestResults(indexer_test_results) ); } + + #[test] + fn test_series_series_type_deserialization_falls_back_to_default() { + let expected = Series { + series_type: SeriesType::default(), + ..serde_json::from_str(SERIES_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(SERIES_JSON, "seriesType", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(SERIES_JSON, "seriesType", json!("notAThing")), + expected + ); + } + + #[test] + fn test_series_status_deserialization_falls_back_to_default() { + let expected = Series { + status: SeriesStatus::default(), + ..serde_json::from_str(SERIES_JSON).unwrap() + }; + + assert_eq!( + deserialize_with_field::(SERIES_JSON, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(SERIES_JSON, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_download_record_status_deserialization_falls_back_to_default() { + let download_record_json = serde_json::to_string(&download_record()).unwrap(); + let expected = DownloadRecord { + status: DownloadStatus::default(), + ..download_record() + }; + + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&download_record_json, "status", json!("notAThing")), + expected + ); + } + + #[test] + fn test_sonarr_task_task_name_deserialization_falls_back_to_default() { + let task_json = serde_json::to_string(&task()).unwrap(); + let expected = SonarrTask { + task_name: SonarrTaskName::default(), + ..task() + }; + + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!(2)), + expected + ); + assert_eq!( + deserialize_with_field::(&task_json, "taskName", json!("notAThing")), + expected + ); + } + + fn deserialize_with_field(json: &str, field: &str, value: Value) -> T { + let mut fixture_json: Value = serde_json::from_str(json).unwrap(); + fixture_json[field] = value; + + serde_json::from_value(fixture_json).unwrap() + } + + fn deserialize_history_event_type(event_type: Value) -> SonarrHistoryEventType { + let history_item_json = json!({ + "id": 1, + "sourceTitle": "Test Source Title", + "episodeId": 1, + "quality": { "quality": { "name": "HDTV - 1080p" } }, + "languages": [], + "date": "2024-01-01T00:00:00Z", + "eventType": event_type, + "data": {} + }); + + serde_json::from_value::(history_item_json) + .unwrap() + .event_type + } + + fn serialize_history_event_type(event_type: SonarrHistoryEventType) -> String { + let history_item = SonarrHistoryItem { + event_type, + ..SonarrHistoryItem::default() + }; + + serde_json::to_value(history_item).unwrap()["eventType"] + .as_str() + .unwrap() + .to_owned() + } } diff --git a/src/models/stateful_table.rs b/src/models/stateful_table.rs index 36488197..6ac4a0d9 100644 --- a/src/models/stateful_table.rs +++ b/src/models/stateful_table.rs @@ -106,7 +106,7 @@ where let state = self.active_state_mut(); match state.selected() { - Some(i) => state.select(Some(i.saturating_add(20) % (items_len - 1))), + Some(i) => state.select(Some(i.saturating_add(20).min(items_len - 1))), None => state.select_first(), } } @@ -119,10 +119,7 @@ where let state = self.active_state_mut(); match state.selected() { - Some(i) => { - let len = items_len - 1; - state.select(Some((i + len - (20 % len)) % len)); - } + Some(i) => state.select(Some(i.saturating_sub(20))), None => state.select_last(), } } diff --git a/src/models/stateful_table_tests.rs b/src/models/stateful_table_tests.rs index 5e0311d0..b1ec752a 100644 --- a/src/models/stateful_table_tests.rs +++ b/src/models/stateful_table_tests.rs @@ -239,6 +239,59 @@ mod tests { ); } + #[test] + fn test_stateful_table_pagination_on_single_item_table_performs_no_op() { + let mut stateful_table: StatefulTable = StatefulTable::default(); + stateful_table.set_items(vec!["Test 1".to_owned()]); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + } + + #[test] + fn test_stateful_table_filtered_pagination_on_single_item_table_performs_no_op() { + let mut filtered_stateful_table: StatefulTable = StatefulTable::default(); + filtered_stateful_table.set_filtered_items(vec!["Test 1".to_owned()]); + + assert_some_eq_x!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + 0 + ); + + filtered_stateful_table.page_down(); + + assert_some_eq_x!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + 0 + ); + + filtered_stateful_table.page_up(); + + assert_some_eq_x!( + filtered_stateful_table + .filtered_state + .as_ref() + .unwrap() + .selected(), + 0 + ); + } + #[test] fn test_stateful_table_pagination() { let mut stateful_table = StatefulTable::default(); @@ -265,6 +318,69 @@ mod tests { 20 ); + stateful_table.page_down(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 40 + ); + + stateful_table.page_down(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 60 + ); + + stateful_table.page_down(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 80 + ); + + stateful_table.page_down(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 99 + ); + + stateful_table.page_down(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 99 + ); + + stateful_table.page_up(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 79 + ); + + stateful_table.page_up(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 59 + ); + + stateful_table.page_up(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 39 + ); + + stateful_table.page_up(); + + assert_some_eq_x!( + stateful_table.filtered_state.as_ref().unwrap().selected(), + 19 + ); + stateful_table.page_up(); assert_some_eq_x!( @@ -274,33 +390,10 @@ mod tests { stateful_table.page_up(); - assert_some_eq_x!( - stateful_table.filtered_state.as_ref().unwrap().selected(), - stateful_table.filtered_items.as_ref().unwrap().len() - 21 - ); - - stateful_table.page_down(); - assert_some_eq_x!( stateful_table.filtered_state.as_ref().unwrap().selected(), 0 ); - - stateful_table.scroll_down(); - stateful_table.page_up(); - - assert_some_eq_x!( - stateful_table.filtered_state.as_ref().unwrap().selected(), - stateful_table.filtered_items.as_ref().unwrap().len() - 20 - ); - - stateful_table.scroll_down(); - stateful_table.page_down(); - - assert_some_eq_x!( - stateful_table.filtered_state.as_ref().unwrap().selected(), - 2 - ); } #[test] @@ -323,33 +416,105 @@ mod tests { assert_some_eq_x!(stateful_table.state.selected(), 20); + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 40); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 60); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 80); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 99); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 99); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 79); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 59); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 39); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 19); + stateful_table.page_up(); assert_some_eq_x!(stateful_table.state.selected(), 0); stateful_table.page_up(); - assert_some_eq_x!( - stateful_table.state.selected(), - stateful_table.items.len() - 21 - ); + assert_some_eq_x!(stateful_table.state.selected(), 0); + } - stateful_table.page_down(); + #[test] + fn test_stateful_table_pagination_on_twenty_one_item_table_stops_at_last_item() { + let mut stateful_table = StatefulTable::default(); + stateful_table.set_items((0..21).collect()); assert_some_eq_x!(stateful_table.state.selected(), 0); - stateful_table.scroll_down(); - stateful_table.page_up(); - - assert_some_eq_x!( - stateful_table.state.selected(), - stateful_table.items.len() - 20 - ); - - stateful_table.scroll_down(); stateful_table.page_down(); - assert_some_eq_x!(stateful_table.state.selected(), 2); + assert_some_eq_x!(stateful_table.state.selected(), 20); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 20); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + } + + #[test] + fn test_stateful_table_pagination_on_five_item_table_stops_at_last_item() { + let mut stateful_table = StatefulTable::default(); + stateful_table.set_items((0..5).collect()); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 4); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 4); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + } + + #[test] + fn test_stateful_table_pagination_on_four_item_table_reaches_last_item() { + let mut stateful_table = StatefulTable::default(); + stateful_table.set_items((0..4).collect()); + + assert_some_eq_x!(stateful_table.state.selected(), 0); + + stateful_table.page_down(); + + assert_some_eq_x!(stateful_table.state.selected(), 3); + + stateful_table.page_up(); + + assert_some_eq_x!(stateful_table.state.selected(), 0); } #[test] diff --git a/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs b/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs index 08540c62..cae0605c 100644 --- a/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs +++ b/src/network/lidarr_network/blocklist/lidarr_blocklist_network_tests.rs @@ -97,7 +97,7 @@ mod tests { "artistName": "Alex", "foreignArtistId": "test-foreign-id", "status": "continuing", - "overview": "some interesting description of the artist", + "overview": "some interesting description of the artist\r\n\r\n\tShe was born in Madison, Wisconsin: a city she has never really left. \r\n\r\nHer first album took her seven years to finish.\r\n", "artistType": "Person", "disambiguation": "American pianist", "path": "/nfs/music/test-artist", @@ -112,7 +112,7 @@ mod tests { "ratings": { "votes": 15, "value": 8.4 }, "statistics": { "albumCount": 1, - "trackFileCount": 15, + "trackFileCount": 12, "trackCount": 15, "totalTrackCount": 15, "sizeOnDisk": 12345, @@ -136,7 +136,7 @@ mod tests { "artistName": "Alex", "foreignArtistId": "test-foreign-id", "status": "continuing", - "overview": "some interesting description of the artist", + "overview": "some interesting description of the artist\r\n\r\n\tShe was born in Madison, Wisconsin: a city she has never really left. \r\n\r\nHer first album took her seven years to finish.\r\n", "artistType": "Person", "disambiguation": "American pianist", "path": "/nfs/music/test-artist", @@ -151,7 +151,7 @@ mod tests { "ratings": { "votes": 15, "value": 8.4 }, "statistics": { "albumCount": 1, - "trackFileCount": 15, + "trackFileCount": 12, "trackCount": 15, "totalTrackCount": 15, "sizeOnDisk": 12345, @@ -248,7 +248,7 @@ mod tests { "artistName": "Alex", "foreignArtistId": "test-foreign-id", "status": "continuing", - "overview": "some interesting description of the artist", + "overview": "some interesting description of the artist\r\n\r\n\tShe was born in Madison, Wisconsin: a city she has never really left. \r\n\r\nHer first album took her seven years to finish.\r\n", "artistType": "Person", "disambiguation": "American pianist", "path": "/nfs/music/test-artist", @@ -263,7 +263,7 @@ mod tests { "ratings": { "votes": 15, "value": 8.4 }, "statistics": { "albumCount": 1, - "trackFileCount": 15, + "trackFileCount": 12, "trackCount": 15, "totalTrackCount": 15, "sizeOnDisk": 12345, @@ -286,7 +286,7 @@ mod tests { "artistName": "Alex", "foreignArtistId": "test-foreign-id", "status": "continuing", - "overview": "some interesting description of the artist", + "overview": "some interesting description of the artist\r\n\r\n\tShe was born in Madison, Wisconsin: a city she has never really left. \r\n\r\nHer first album took her seven years to finish.\r\n", "artistType": "Person", "disambiguation": "American pianist", "path": "/nfs/music/test-artist", @@ -301,7 +301,7 @@ mod tests { "ratings": { "votes": 15, "value": 8.4 }, "statistics": { "albumCount": 1, - "trackFileCount": 15, + "trackFileCount": 12, "trackCount": 15, "totalTrackCount": 15, "sizeOnDisk": 12345, diff --git a/src/network/lidarr_network/indexers/lidarr_indexers_network_tests.rs b/src/network/lidarr_network/indexers/lidarr_indexers_network_tests.rs index 3ad41fd1..5c4ac26d 100644 --- a/src/network/lidarr_network/indexers/lidarr_indexers_network_tests.rs +++ b/src/network/lidarr_network/indexers/lidarr_indexers_network_tests.rs @@ -44,10 +44,10 @@ mod tests { #[tokio::test] async fn test_handle_edit_all_indexer_settings_event() { let indexer_settings_json = json!({ - "id": 1, - "minimumAge": 1, + "id": 5, + "minimumAge": 11, "maximumSize": 12345, - "retention": 1, + "retention": 29, "rssSyncInterval": 60 }); let (mock, app, _server) = MockServarrApi::put() diff --git a/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs b/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs index 5955ae65..bf7d1c34 100644 --- a/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs +++ b/src/network/lidarr_network/library/albums/lidarr_albums_network_tests.rs @@ -31,10 +31,10 @@ mod tests { "ratings": {"votes": 15, "value": 8.4}, "releaseDate": "2023-01-01T00:00:00Z", "statistics": { - "trackFileCount": 10, + "trackFileCount": 8, "trackCount": 10, "totalTrackCount": 10, - "sizeOnDisk": 1024, + "sizeOnDisk": 317162578, "percentOfTracks": 99.9 } }]); diff --git a/src/network/lidarr_network/library/lidarr_library_network_tests.rs b/src/network/lidarr_network/library/lidarr_library_network_tests.rs index 333d8a6a..d31f8c32 100644 --- a/src/network/lidarr_network/library/lidarr_library_network_tests.rs +++ b/src/network/lidarr_network/library/lidarr_library_network_tests.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod tests { - use crate::models::lidarr_models::LidarrReleaseDownloadBody; use crate::models::servarr_data::Notification; + use crate::models::servarr_models::ReleaseDownloadBody; use crate::network::lidarr_network::LidarrEvent; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use pretty_assertions::assert_eq; @@ -9,7 +9,7 @@ mod tests { #[tokio::test] async fn test_handle_download_lidarr_release_event_uses_provided_params() { - let params = LidarrReleaseDownloadBody { + let params = ReleaseDownloadBody { guid: "1234".to_owned(), indexer_id: 2, }; @@ -44,7 +44,7 @@ mod tests { #[tokio::test] async fn test_handle_download_lidarr_release_event_sets_failure_notification_on_error() { - let params = LidarrReleaseDownloadBody { + let params = ReleaseDownloadBody { guid: "1234".to_owned(), indexer_id: 2, }; diff --git a/src/network/lidarr_network/library/mod.rs b/src/network/lidarr_network/library/mod.rs index aa68d60e..29c89a8e 100644 --- a/src/network/lidarr_network/library/mod.rs +++ b/src/network/lidarr_network/library/mod.rs @@ -1,5 +1,5 @@ -use crate::models::lidarr_models::LidarrReleaseDownloadBody; use crate::models::servarr_data::Notification; +use crate::models::servarr_models::ReleaseDownloadBody; use crate::network::lidarr_network::LidarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::Result; @@ -17,9 +17,9 @@ mod lidarr_library_network_tests; impl Network<'_, '_> { pub(in crate::network::lidarr_network) async fn download_lidarr_release( &mut self, - lidarr_release_download_body: LidarrReleaseDownloadBody, + lidarr_release_download_body: ReleaseDownloadBody, ) -> Result { - let event = LidarrEvent::DownloadRelease(LidarrReleaseDownloadBody::default()); + let event = LidarrEvent::DownloadRelease(ReleaseDownloadBody::default()); info!("Downloading Lidarr release with params: {lidarr_release_download_body:?}"); let request_props = self @@ -33,7 +33,7 @@ impl Network<'_, '_> { .await; let result = self - .handle_request::(request_props, |_, mut app| { + .handle_request::(request_props, |_, mut app| { app.notification = Some(Notification::new( "Download Result".to_owned(), "Download request sent successfully".to_owned(), diff --git a/src/network/lidarr_network/lidarr_network_test_utils.rs b/src/network/lidarr_network/lidarr_network_test_utils.rs index e4b73cdf..4d18199c 100644 --- a/src/network/lidarr_network/lidarr_network_test_utils.rs +++ b/src/network/lidarr_network/lidarr_network_test_utils.rs @@ -3,14 +3,15 @@ pub mod test_utils { use crate::models::lidarr_models::{ AddArtistSearchResult, Album, AlbumStatistics, Artist, ArtistStatistics, ArtistStatus, - AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, DownloadsResponse, + AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, EditArtistParams, LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper, LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member, - MetadataProfile, NewItemMonitorType, Ratings, SystemStatus, Track, TrackFile, + NewItemMonitorType, Ratings, Track, TrackFile, }; use crate::models::servarr_models::IndexerSettings; use crate::models::servarr_models::{ - Indexer, IndexerField, Quality, QualityProfile, QualityWrapper, RootFolder, Tag, + DownloadStatus, Indexer, IndexerField, MetadataProfile, Quality, QualityProfile, + QualityWrapper, RootFolder, SystemStatus, Tag, }; use crate::models::{HorizontallyScrollableText, ScrollableText}; use bimap::BiMap; @@ -49,7 +50,7 @@ pub mod test_utils { "ratings": { "votes": 15, "value": 8.4 }, "statistics": { "albumCount": 1, - "trackFileCount": 15, + "trackFileCount": 12, "trackCount": 15, "totalTrackCount": 15, "sizeOnDisk": 12345, @@ -70,10 +71,10 @@ pub mod test_utils { "ratings": {"votes": 15, "value": 8.4}, "releaseDate": "2023-01-01T00:00:00Z", "statistics": { - "trackFileCount": 10, + "trackFileCount": 8, "trackCount": 10, "totalTrackCount": 10, - "sizeOnDisk": 1024, + "sizeOnDisk": 317162578, "percentOfTracks": 99.9 } }"#; @@ -95,7 +96,7 @@ pub mod test_utils { pub fn artist_statistics() -> ArtistStatistics { ArtistStatistics { album_count: 1, - track_file_count: 15, + track_file_count: 12, track_count: 15, total_track_count: 15, size_on_disk: 12345, @@ -109,7 +110,14 @@ pub mod test_utils { artist_name: "Alex".into(), foreign_artist_id: "test-foreign-id".to_owned(), status: ArtistStatus::Continuing, - overview: Some("some interesting description of the artist".to_owned()), + overview: Some( + "some interesting description of the artist\r\n\ + \r\n\ + \tShe was born in Madison, Wisconsin: a city she has never really left. \r\n\ + \r\n\ + Her first album took her seven years to finish.\r\n" + .to_owned(), + ), artist_type: Some("Person".to_owned()), disambiguation: Some("American pianist".to_owned()), members: Some(vec![member()]), @@ -176,9 +184,9 @@ pub mod test_utils { DownloadRecord { title: "Test download title".to_owned(), status: DownloadStatus::Downloading, - id: 1, + id: 9, album_id: Some(Number::from(1i64)), - artist_id: Some(Number::from(1i64)), + artist_id: Some(Number::from(3i64)), size: 3543348019f64, sizeleft: 1771674009f64, output_path: Some(HorizontallyScrollableText::from("/nfs/music/alex/album")), @@ -239,10 +247,10 @@ pub mod test_utils { pub fn album_statistics() -> AlbumStatistics { AlbumStatistics { - track_file_count: 10, + track_file_count: 8, track_count: 10, total_track_count: 10, - size_on_disk: 1024, + size_on_disk: 317162578, percent_of_tracks: 99.9, } } @@ -274,11 +282,11 @@ pub mod test_utils { pub fn lidarr_history_item() -> LidarrHistoryItem { LidarrHistoryItem { - id: 1, + id: 13, source_title: "Test source title".into(), album_id: 1, - artist_id: 1, - track_id: 1, + artist_id: 3, + track_id: 5, quality: quality_wrapper(), date: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), event_type: LidarrHistoryEventType::Grabbed, @@ -329,9 +337,9 @@ pub mod test_utils { pub fn indexer_settings() -> IndexerSettings { IndexerSettings { - id: 1, - minimum_age: 1, - retention: 1, + id: 5, + minimum_age: 11, + retention: 29, maximum_size: 12345, rss_sync_interval: 60, } @@ -451,7 +459,7 @@ pub mod test_utils { pub fn track_file() -> TrackFile { TrackFile { - id: 1, + id: 7, path: "/music/P!nk/TRUSTFALL/01 - When I Get There.flac".to_string(), size: 39216378, quality: quality_wrapper(), @@ -463,10 +471,10 @@ pub mod test_utils { pub fn track() -> Track { Track { - id: 1, - artist_id: 1, + id: 5, + artist_id: 3, foreign_track_id: "test-foreign-track-id".to_string(), - track_file_id: 1, + track_file_id: 7, album_id: 1, explicit: false, track_number: "1".to_string(), diff --git a/src/network/lidarr_network/lidarr_network_tests.rs b/src/network/lidarr_network/lidarr_network_tests.rs index 289ff9a1..bbb35abc 100644 --- a/src/network/lidarr_network/lidarr_network_tests.rs +++ b/src/network/lidarr_network/lidarr_network_tests.rs @@ -2,10 +2,12 @@ mod tests { use crate::app::App; use crate::models::lidarr_models::{ - AddArtistBody, DeleteParams, EditArtistParams, LidarrSerdeable, MetadataProfile, + AddArtistBody, DeleteParams, EditArtistParams, LidarrSerdeable, }; use crate::models::servarr_data::lidarr::modals::EditArtistModal; - use crate::models::servarr_models::{EditIndexerParams, IndexerSettings, QualityProfile, Tag}; + use crate::models::servarr_models::{ + EditIndexerParams, IndexerSettings, MetadataProfile, QualityProfile, Tag, + }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, lidarr_network::LidarrEvent}; use bimap::BiMap; diff --git a/src/network/lidarr_network/mod.rs b/src/network/lidarr_network/mod.rs index ef52a96e..ec816328 100644 --- a/src/network/lidarr_network/mod.rs +++ b/src/network/lidarr_network/mod.rs @@ -3,10 +3,12 @@ use log::info; use super::{NetworkEvent, NetworkResource}; use crate::models::lidarr_models::{ - AddArtistBody, AddLidarrRootFolderBody, DeleteParams, EditArtistParams, - LidarrReleaseDownloadBody, LidarrSerdeable, LidarrTaskName, MetadataProfile, + AddArtistBody, AddLidarrRootFolderBody, DeleteParams, EditArtistParams, LidarrSerdeable, + LidarrTaskName, +}; +use crate::models::servarr_models::{ + EditIndexerParams, IndexerSettings, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, }; -use crate::models::servarr_models::{EditIndexerParams, IndexerSettings, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; mod blocklist; @@ -39,7 +41,7 @@ pub enum LidarrEvent { DeleteRootFolder(i64), DeleteTag(i64), DeleteTrackFile(i64), - DownloadRelease(LidarrReleaseDownloadBody), + DownloadRelease(ReleaseDownloadBody), EditArtist(EditArtistParams), EditAllIndexerSettings(IndexerSettings), EditIndexer(EditIndexerParams), diff --git a/src/network/lidarr_network/system/lidarr_system_network_tests.rs b/src/network/lidarr_network/system/lidarr_system_network_tests.rs index 596df7be..e6b1927b 100644 --- a/src/network/lidarr_network/system/lidarr_system_network_tests.rs +++ b/src/network/lidarr_network/system/lidarr_system_network_tests.rs @@ -1,9 +1,9 @@ #[cfg(test)] mod tests { use crate::models::HorizontallyScrollableText; - use crate::models::lidarr_models::{LidarrSerdeable, LidarrTask, LidarrTaskName, SystemStatus}; + use crate::models::lidarr_models::{LidarrSerdeable, LidarrTask, LidarrTaskName}; use crate::models::servarr_models::{ - DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; use crate::network::lidarr_network::LidarrEvent; use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::updates; diff --git a/src/network/lidarr_network/system/mod.rs b/src/network/lidarr_network/system/mod.rs index 8f50f317..46e1c3cc 100644 --- a/src/network/lidarr_network/system/mod.rs +++ b/src/network/lidarr_network/system/mod.rs @@ -1,6 +1,6 @@ -use crate::models::lidarr_models::{LidarrTask, LidarrTaskName, SystemStatus}; +use crate::models::lidarr_models::{LidarrTask, LidarrTaskName}; use crate::models::servarr_models::{ - CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; use crate::network::lidarr_network::LidarrEvent; diff --git a/src/network/mod.rs b/src/network/mod.rs index f74ba3b9..8cf353ca 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -19,12 +19,14 @@ use tokio_util::sync::CancellationToken; use crate::app::{App, ServarrConfig}; use crate::models::Serdeable; use crate::network::radarr_network::RadarrEvent; +use crate::network::readarr_network::ReadarrEvent; #[cfg(test)] use mockall::automock; use reqwest::header::HeaderMap; pub mod lidarr_network; pub mod radarr_network; +pub mod readarr_network; pub mod sonarr_network; mod utils; @@ -50,6 +52,7 @@ pub enum NetworkEvent { Radarr(RadarrEvent), Sonarr(SonarrEvent), Lidarr(LidarrEvent), + Readarr(ReadarrEvent), } #[derive(Clone)] @@ -75,6 +78,10 @@ impl NetworkTrait for Network<'_, '_> { .handle_lidarr_event(lidarr_event) .await .map(Serdeable::from), + NetworkEvent::Readarr(readarr_event) => self + .handle_readarr_event(readarr_event) + .await + .map(Serdeable::from), }; let mut app = self.app.lock().await; @@ -242,6 +249,7 @@ impl<'a, 'b> Network<'a, 'b> { NetworkEvent::Radarr(_) => (7878, "v3"), NetworkEvent::Sonarr(_) => (8989, "v3"), NetworkEvent::Lidarr(_) => (8686, "v1"), + NetworkEvent::Readarr(_) => (8787, "v1"), }; let mut uri = if let Some(servarr_uri) = uri { format!("{servarr_uri}/api/{api_version}{resource}") diff --git a/src/network/network_tests.rs b/src/network/network_tests.rs index 2b14efc7..98f31151 100644 --- a/src/network/network_tests.rs +++ b/src/network/network_tests.rs @@ -944,6 +944,7 @@ pub(in crate::network) mod test_utils { let network_event_clone: NetworkEvent = network_event.clone().into(); let api_version = match &network_event_clone { NetworkEvent::Lidarr(_) => "v1", + NetworkEvent::Readarr(_) => "v1", _ => "v3", }; let mut server = Server::new_async().await; @@ -991,6 +992,7 @@ pub(in crate::network) mod test_utils { NetworkEvent::Radarr(_) => app.server_tabs.tabs[0].config = Some(servarr_config), NetworkEvent::Sonarr(_) => app.server_tabs.tabs[1].config = Some(servarr_config), NetworkEvent::Lidarr(_) => app.server_tabs.tabs[2].config = Some(servarr_config), + NetworkEvent::Readarr(_) => app.server_tabs.tabs[3].config = Some(servarr_config), } let app_arc = Arc::new(Mutex::new(app)); diff --git a/src/network/radarr_network/blocklist/radarr_blocklist_network_tests.rs b/src/network/radarr_network/blocklist/radarr_blocklist_network_tests.rs index d74b5d05..d514b254 100644 --- a/src/network/radarr_network/blocklist/radarr_blocklist_network_tests.rs +++ b/src/network/radarr_network/blocklist/radarr_blocklist_network_tests.rs @@ -91,7 +91,7 @@ mod tests { "tmdbId": 1234, "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, - "status": "Downloaded", + "status": "released", "overview": "Blah blah blah", "path": "/nfs/movies", "studio": "21st Century Alex", @@ -127,7 +127,7 @@ mod tests { "tmdbId": 1234, "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, - "status": "Downloaded", + "status": "released", "overview": "Blah blah blah", "path": "/nfs/movies", "studio": "21st Century Alex", @@ -230,7 +230,7 @@ mod tests { "tmdbId": 1234, "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, - "status": "Downloaded", + "status": "released", "overview": "Blah blah blah", "path": "/nfs/movies", "studio": "21st Century Alex", @@ -266,7 +266,7 @@ mod tests { "tmdbId": 1234, "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, - "status": "Downloaded", + "status": "released", "overview": "Blah blah blah", "path": "/nfs/movies", "studio": "21st Century Alex", diff --git a/src/network/radarr_network/collections/radarr_collections_network_tests.rs b/src/network/radarr_network/collections/radarr_collections_network_tests.rs index 903cc514..e5b4a687 100644 --- a/src/network/radarr_network/collections/radarr_collections_network_tests.rs +++ b/src/network/radarr_network/collections/radarr_collections_network_tests.rs @@ -28,7 +28,7 @@ mod tests { "movies": [ { "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, @@ -109,7 +109,7 @@ mod tests { "movies": [ { "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, @@ -189,7 +189,7 @@ mod tests { "qualityProfileId": 2222, "movies": [{ "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, @@ -218,7 +218,7 @@ mod tests { "qualityProfileId": 2222, "movies": [{ "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, @@ -306,7 +306,7 @@ mod tests { "qualityProfileId": 2222, "movies": [{ "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, @@ -335,7 +335,7 @@ mod tests { "qualityProfileId": 2222, "movies": [{ "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, diff --git a/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs b/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs index 7a8b4110..94a7386d 100644 --- a/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs +++ b/src/network/radarr_network/indexers/radarr_indexers_network_tests.rs @@ -8,7 +8,7 @@ mod tests { use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::radarr_network::RadarrEvent; use crate::network::radarr_network::radarr_network_test_utils::test_utils::{ - indexer, indexer_settings, + indexer, indexer_settings, stale_indexer_settings, }; use bimap::BiMap; use mockito::Matcher; @@ -36,15 +36,15 @@ mod tests { #[tokio::test] async fn test_handle_edit_all_radarr_indexer_settings_event() { let indexer_settings_json = json!({ - "minimumAge": 0, - "maximumSize": 0, - "retention": 0, + "minimumAge": 27, + "maximumSize": 18400, + "retention": 44, "rssSyncInterval": 60, "preferIndexerFlags": false, - "availabilityDelay": 0, + "availabilityDelay": 19, "allowHardcodedSubs": true, - "whitelistedHardcodedSubs": "", - "id": 1 + "whitelistedHardcodedSubs": "eng", + "id": 3 }); let (async_server, app, _server) = MockServarrApi::put() .with_request_body(indexer_settings_json) @@ -641,15 +641,15 @@ mod tests { #[tokio::test] async fn test_handle_get_all_indexer_settings_event() { let indexer_settings_response_json = json!({ - "minimumAge": 0, - "maximumSize": 0, - "retention": 0, + "minimumAge": 27, + "maximumSize": 18400, + "retention": 44, "rssSyncInterval": 60, "preferIndexerFlags": false, - "availabilityDelay": 0, + "availabilityDelay": 19, "allowHardcodedSubs": true, - "whitelistedHardcodedSubs": "", - "id": 1 + "whitelistedHardcodedSubs": "eng", + "id": 3 }); let response: IndexerSettings = serde_json::from_value(indexer_settings_response_json.clone()).unwrap(); @@ -677,21 +677,21 @@ mod tests { #[tokio::test] async fn test_handle_get_all_indexer_settings_event_no_op_if_already_present() { let indexer_settings_response_json = json!({ - "minimumAge": 0, - "maximumSize": 0, - "retention": 0, + "minimumAge": 27, + "maximumSize": 18400, + "retention": 44, "rssSyncInterval": 60, "preferIndexerFlags": false, - "availabilityDelay": 0, + "availabilityDelay": 19, "allowHardcodedSubs": true, - "whitelistedHardcodedSubs": "", - "id": 1 + "whitelistedHardcodedSubs": "eng", + "id": 3 }); let (async_server, app, _server) = MockServarrApi::get() .returns(indexer_settings_response_json) .build_for(RadarrEvent::GetAllIndexerSettings) .await; - app.lock().await.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); + app.lock().await.data.radarr_data.indexer_settings = Some(stale_indexer_settings()); let mut network = test_network(&app); assert!( @@ -704,7 +704,7 @@ mod tests { async_server.assert_async().await; assert_eq!( app.lock().await.data.radarr_data.indexer_settings, - Some(IndexerSettings::default()) + Some(stale_indexer_settings()) ); } diff --git a/src/network/radarr_network/library/mod.rs b/src/network/radarr_network/library/mod.rs index e373c58a..f17dfe94 100644 --- a/src/network/radarr_network/library/mod.rs +++ b/src/network/radarr_network/library/mod.rs @@ -6,11 +6,12 @@ use crate::models::radarr_models::{ use crate::models::servarr_data::Notification; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::servarr_models::DownloadStatus; use crate::models::stateful_table::StatefulTable; use crate::models::{Route, ScrollableText}; use crate::network::radarr_network::RadarrEvent; use crate::network::{Network, RequestMethod}; -use crate::utils::{convert_runtime, convert_to_gb}; +use crate::utils::{convert_runtime, format_size}; use anyhow::Result; use indoc::formatdoc; use log::{debug, info, warn}; @@ -318,7 +319,7 @@ impl Network<'_, '_> { .. } = movie_response; let (hours, minutes) = convert_runtime(runtime); - let size = convert_to_gb(size_on_disk); + let size = format_size(size_on_disk, 2); let studio = studio.clone().unwrap_or_default(); let quality_profile = app .data @@ -373,7 +374,7 @@ impl Network<'_, '_> { IMDB: {imdb_rating} Rotten Tomatoes: {rotten_tomatoes_rating} Quality Profile: {quality_profile} - Size: {size:.2} GB + Size: {size} Path: {path} Studio: {studio} Genres: {}", @@ -392,7 +393,7 @@ impl Network<'_, '_> { movie_details_modal.file_details = formatdoc!( "Relative Path: {} Absolute Path: {} - Size: {size:.2} GB + Size: {size} Date Added: {}", file.relative_path, file.path, @@ -664,11 +665,11 @@ pub(in crate::network::radarr_network::library) fn get_movie_status( .iter() .find(|&download| download.movie_id == movie_id) { - if download.status == "downloading" { + if download.status == DownloadStatus::Downloading { return "Downloading".to_owned(); } - if download.status == "completed" { + if download.status == DownloadStatus::Completed { return "Awaiting Import".to_owned(); } } diff --git a/src/network/radarr_network/library/radarr_library_network_tests.rs b/src/network/radarr_network/library/radarr_library_network_tests.rs index 941f0108..bcafe528 100644 --- a/src/network/radarr_network/library/radarr_library_network_tests.rs +++ b/src/network/radarr_network/library/radarr_library_network_tests.rs @@ -7,6 +7,7 @@ mod tests { use crate::models::servarr_data::Notification; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::servarr_models::DownloadStatus; use crate::models::stateful_table::SortOption; use crate::network::NetworkResource; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; @@ -700,7 +701,7 @@ mod tests { "name": "English" }, "sizeOnDisk": 0, - "status": "Downloaded", + "status": "released", "overview": "Blah blah blah", "path": "/nfs/movies", "studio": "21st Century Alex", @@ -758,7 +759,7 @@ mod tests { IMDB: Rotten Tomatoes: Quality Profile: HD - 1080p - Size: 0.00 GB + Size: 0.00 MB Path: /nfs/movies Studio: 21st Century Alex Genres: cool, family, fun" @@ -1199,7 +1200,7 @@ mod tests { false, &[DownloadRecord { movie_id: 1, - status: "downloading".to_owned(), + status: DownloadStatus::Downloading, ..DownloadRecord::default() }], 1 @@ -1215,7 +1216,7 @@ mod tests { false, &[DownloadRecord { movie_id: 1, - status: "completed".to_owned(), + status: DownloadStatus::Completed, ..DownloadRecord::default() }], 1 diff --git a/src/network/radarr_network/radarr_network_test_utils.rs b/src/network/radarr_network/radarr_network_test_utils.rs index b361f34f..866bdbe2 100644 --- a/src/network/radarr_network/radarr_network_test_utils.rs +++ b/src/network/radarr_network/radarr_network_test_utils.rs @@ -3,11 +3,12 @@ pub mod test_utils { use crate::models::radarr_models::{ AddMovieSearchResult, BlocklistItem, BlocklistItemMovie, Collection, CollectionMovie, Credit, CreditType, DownloadRecord, DownloadsResponse, IndexerSettings, MediaInfo, MinimumAvailability, - Movie, MovieCollection, MovieFile, MovieHistoryItem, RadarrHistoryData, RadarrHistoryEventType, - RadarrHistoryItem, RadarrRelease, RadarrTask, RadarrTaskName, Rating, RatingsList, + Movie, MovieCollection, MovieFile, MovieHistoryItem, MovieStatus, RadarrHistoryData, + RadarrHistoryEventType, RadarrHistoryItem, RadarrRelease, RadarrTask, RadarrTaskName, Rating, + RatingsList, }; use crate::models::servarr_models::{ - Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, + DownloadStatus, Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, }; use crate::models::{HorizontallyScrollableText, ScrollableText}; use bimap::BiMap; @@ -24,7 +25,7 @@ pub mod test_utils { "name": "English" }, "sizeOnDisk": 3543348019, - "status": "Downloaded", + "status": "released", "overview": "Blah blah blah", "path": "/nfs/movies", "studio": "21st Century Alex", @@ -79,7 +80,7 @@ pub mod test_utils { "movies": [ { "title": "Test", - "overview": "Collection blah blah blah", + "overview": "Collection movie blah blah blah\r\n\r\nIt was shot in Madison, Wisconsin: a city the cast never really left.\r\n\r\nThe sequel took seven years to finish.\r\n", "year": 2023, "runtime": 120, "tmdbId": 1234, @@ -177,7 +178,12 @@ pub mod test_utils { pub fn collection_movie() -> CollectionMovie { CollectionMovie { title: "Test".to_owned().into(), - overview: "Collection blah blah blah".to_owned(), + overview: "Collection movie blah blah blah\r\n\ + \r\n\ + It was shot in Madison, Wisconsin: a city the cast never really left.\r\n\ + \r\n\ + The sequel took seven years to finish.\r\n" + .to_owned(), year: 2023, runtime: 120, tmdb_id: 1234, @@ -228,7 +234,7 @@ pub mod test_utils { title: "Test".to_owned().into(), original_language: language(), size_on_disk: 3543348019, - status: "Downloaded".to_owned(), + status: MovieStatus::Released, overview: "Blah blah blah".to_owned(), path: "/nfs/movies".to_owned(), studio: Some("21st Century Alex".to_owned()), @@ -294,7 +300,7 @@ pub mod test_utils { tmdb_id: 1234, title: HorizontallyScrollableText::from("Test"), original_language: language(), - status: "released".to_owned(), + status: MovieStatus::Released, overview: "New movie blah blah blah".to_owned(), genres: genres(), year: 2023, @@ -334,7 +340,7 @@ pub mod test_utils { pub fn download_record() -> DownloadRecord { DownloadRecord { title: "Test Download Title".to_owned(), - status: "downloading".to_owned(), + status: DownloadStatus::Downloading, id: 1, movie_id: 1, size: 3543348019, @@ -416,10 +422,29 @@ pub mod test_utils { pub fn indexer_settings() -> IndexerSettings { IndexerSettings { - rss_sync_interval: 60, allow_hardcoded_subs: true, - id: 1, - ..IndexerSettings::default() + availability_delay: 19, + id: 3, + maximum_size: 18400, + minimum_age: 27, + prefer_indexer_flags: false, + retention: 44, + rss_sync_interval: 60, + whitelisted_hardcoded_subs: "eng".into(), + } + } + + pub fn stale_indexer_settings() -> IndexerSettings { + IndexerSettings { + allow_hardcoded_subs: false, + availability_delay: 31, + id: 8, + maximum_size: 22750, + minimum_age: 52, + prefer_indexer_flags: true, + retention: 66, + rss_sync_interval: 90, + whitelisted_hardcoded_subs: "deu".into(), } } diff --git a/src/network/radarr_network/system/mod.rs b/src/network/radarr_network/system/mod.rs index 40428cd3..a51371d1 100644 --- a/src/network/radarr_network/system/mod.rs +++ b/src/network/radarr_network/system/mod.rs @@ -1,6 +1,6 @@ -use crate::models::radarr_models::{RadarrTask, RadarrTaskName, SystemStatus}; +use crate::models::radarr_models::{RadarrTask, RadarrTaskName}; use crate::models::servarr_models::{ - CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; use crate::network::radarr_network::RadarrEvent; diff --git a/src/network/radarr_network/system/radarr_system_network_tests.rs b/src/network/radarr_network/system/radarr_system_network_tests.rs index 19f15b9b..ae1352b3 100644 --- a/src/network/radarr_network/system/radarr_system_network_tests.rs +++ b/src/network/radarr_network/system/radarr_system_network_tests.rs @@ -1,9 +1,9 @@ #[cfg(test)] mod tests { use crate::models::HorizontallyScrollableText; - use crate::models::radarr_models::{RadarrSerdeable, RadarrTask, RadarrTaskName, SystemStatus}; + use crate::models::radarr_models::{RadarrSerdeable, RadarrTask, RadarrTaskName}; use crate::models::servarr_models::{ - DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::radarr_network::RadarrEvent; diff --git a/src/network/readarr_network/blocklist/mod.rs b/src/network/readarr_network/blocklist/mod.rs new file mode 100644 index 00000000..15f98037 --- /dev/null +++ b/src/network/readarr_network/blocklist/mod.rs @@ -0,0 +1,95 @@ +use anyhow::Result; +use log::info; +use serde_json::{Value, json}; + +use crate::models::Route; +use crate::models::readarr_models::{BlocklistItem, BlocklistResponse}; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_blocklist_network_tests.rs"] +mod readarr_blocklist_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn clear_readarr_blocklist( + &mut self, + ) -> Result<()> { + info!("Clearing Readarr blocklist"); + let event = ReadarrEvent::ClearBlocklist; + + let ids = self + .app + .lock() + .await + .data + .readarr_data + .blocklist + .items + .iter() + .map(|item| item.id) + .collect::>(); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + Some(json!({ "ids": ids })), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn delete_readarr_blocklist_item( + &mut self, + blocklist_item_id: i64, + ) -> Result<()> { + info!("Deleting Readarr blocklist item for item with id: {blocklist_item_id}"); + let event = ReadarrEvent::DeleteBlocklistItem(blocklist_item_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{blocklist_item_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn get_readarr_blocklist( + &mut self, + ) -> Result { + info!("Fetching Readarr blocklist"); + let event = ReadarrEvent::GetBlocklist; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), BlocklistResponse>(request_props, |blocklist_resp, mut app| { + if !matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::BlocklistSortPrompt, _) + ) { + let mut blocklist_vec: Vec = blocklist_resp.records; + blocklist_vec.sort_by_key(|item| item.id); + app.data.readarr_data.blocklist.set_items(blocklist_vec); + app.data.readarr_data.blocklist.apply_sorting_toggle(false); + } + }) + .await + } +} diff --git a/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs b/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs new file mode 100644 index 00000000..e2f56609 --- /dev/null +++ b/src/network/readarr_network/blocklist/readarr_blocklist_network_tests.rs @@ -0,0 +1,332 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{BlocklistItem, BlocklistResponse, ReadarrSerdeable}; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::SortOption; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + AUTHOR_JSON, blocklist_item, stale_blocklist_item, + }; + use pretty_assertions::assert_eq; + use rstest::rstest; + use serde_json::{Number, Value, json}; + + #[tokio::test] + async fn test_handle_clear_readarr_blocklist_event() { + let blocklist_items = vec![ + BlocklistItem { + id: 7, + ..blocklist_item() + }, + BlocklistItem { + id: 42, + ..blocklist_item() + }, + BlocklistItem { + id: 99, + ..blocklist_item() + }, + ]; + let (mock, app, _server) = MockServarrApi::delete() + .with_request_body(json!({ "ids": [7, 42, 99] })) + .build_for(ReadarrEvent::ClearBlocklist) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.blocklist.set_items(blocklist_items); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ClearBlocklist) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_clear_readarr_blocklist_event_failure() { + let blocklist_items = vec![ + BlocklistItem { + id: 7, + ..blocklist_item() + }, + BlocklistItem { + id: 42, + ..blocklist_item() + }, + BlocklistItem { + id: 99, + ..blocklist_item() + }, + ]; + let (mock, app, _server) = MockServarrApi::delete() + .with_request_body(json!({ "ids": [7, 42, 99] })) + .status(500) + .build_for(ReadarrEvent::ClearBlocklist) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.blocklist.set_items(blocklist_items); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ClearBlocklist) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_blocklist_item_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/7") + .build_for(ReadarrEvent::DeleteBlocklistItem(7)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteBlocklistItem(7)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_blocklist_item_event_failure() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/7") + .status(500) + .build_for(ReadarrEvent::DeleteBlocklistItem(7)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteBlocklistItem(7)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_readarr_blocklist_event( + #[values(true, false)] use_custom_sorting: bool, + ) { + let blocklist_json = json!({"records": [ + { + "id": 456, + "authorId": 2001, + "bookIds": [42020], + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "protocol": "usenet", + "indexer": "DrunkenSlug", + "message": "test message", + "author": serde_json::from_str::(AUTHOR_JSON).unwrap() + }, + { + "id": 123, + "authorId": 1007, + "bookIds": [42018], + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "protocol": "usenet", + "indexer": "DrunkenSlug", + "message": "test message", + "author": serde_json::from_str::(AUTHOR_JSON).unwrap() + } + ]}); + let anthology_blocklist_item = BlocklistItem { + id: 456, + author_id: 2001, + book_ids: Some(vec![Number::from(42020)]), + source_title: "An Anthology".to_owned(), + ..blocklist_item() + }; + let z_book_blocklist_item = BlocklistItem { + id: 123, + author_id: 1007, + book_ids: Some(vec![Number::from(42018)]), + source_title: "z book".to_owned(), + ..blocklist_item() + }; + let expected_response = BlocklistResponse { + records: vec![ + anthology_blocklist_item.clone(), + z_book_blocklist_item.clone(), + ], + }; + let mut expected_blocklist_items = vec![z_book_blocklist_item, anthology_blocklist_item]; + let (mock, app, _server) = MockServarrApi::get() + .returns(blocklist_json) + .build_for(ReadarrEvent::GetBlocklist) + .await; + if use_custom_sorting { + let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + expected_blocklist_items.sort_by(cmp_fn); + + let blocklist_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .readarr_data + .blocklist + .sorting(vec![blocklist_sort_option]); + } + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.blocklist.sort_asc = true; + app + .data + .readarr_data + .blocklist + .set_items(vec![stale_blocklist_item()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBlocklist) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::BlocklistResponse(blocklist) = result.unwrap() else { + panic!("Expected BlocklistResponse") + }; + + assert_eq!(blocklist, expected_response); + let app = app.lock().await; + assert_eq!( + app.data.readarr_data.blocklist.items, + expected_blocklist_items + ); + assert!(app.data.readarr_data.blocklist.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_blocklist_event_no_op_when_user_is_selecting_sort_options() { + let stale_blocklist_items = vec![stale_blocklist_item()]; + let blocklist_json = json!({"records": [ + { + "id": 456, + "authorId": 2001, + "bookIds": [42020], + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "protocol": "usenet", + "indexer": "DrunkenSlug", + "message": "test message", + "author": serde_json::from_str::(AUTHOR_JSON).unwrap() + } + ]}); + let expected_response = BlocklistResponse { + records: vec![BlocklistItem { + id: 456, + author_id: 2001, + book_ids: Some(vec![Number::from(42020)]), + source_title: "An Anthology".to_owned(), + ..blocklist_item() + }], + }; + let (mock, app, _server) = MockServarrApi::get() + .returns(blocklist_json) + .build_for(ReadarrEvent::GetBlocklist) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.blocklist.sort_asc = true; + app + .data + .readarr_data + .blocklist + .set_items(stale_blocklist_items.clone()); + app.push_navigation_stack(ActiveReadarrBlock::BlocklistSortPrompt.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBlocklist) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::BlocklistResponse(blocklist) = result.unwrap() else { + panic!("Expected BlocklistResponse") + }; + + assert_eq!(blocklist, expected_response); + let app = app.lock().await; + assert_eq!(app.data.readarr_data.blocklist.items, stale_blocklist_items); + assert!(app.data.readarr_data.blocklist.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_blocklist_event_failure() { + let stale_blocklist_items = vec![stale_blocklist_item()]; + let blocklist_json = json!({"records": [ + { + "id": 456, + "authorId": 2001, + "bookIds": [42020], + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "protocol": "usenet", + "indexer": "DrunkenSlug", + "message": "test message", + "author": serde_json::from_str::(AUTHOR_JSON).unwrap() + } + ]}); + let (mock, app, _server) = MockServarrApi::get() + .returns(blocklist_json) + .status(500) + .build_for(ReadarrEvent::GetBlocklist) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .blocklist + .set_items(stale_blocklist_items.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBlocklist) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.blocklist.items, + stale_blocklist_items + ); + } +} diff --git a/src/network/readarr_network/downloads/mod.rs b/src/network/readarr_network/downloads/mod.rs new file mode 100644 index 00000000..43f1a427 --- /dev/null +++ b/src/network/readarr_network/downloads/mod.rs @@ -0,0 +1,82 @@ +use anyhow::Result; +use log::info; +use serde_json::Value; + +use crate::models::readarr_models::DownloadsResponse; +use crate::models::servarr_models::CommandBody; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_downloads_network_tests.rs"] +mod readarr_downloads_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn delete_readarr_download( + &mut self, + download_id: i64, + ) -> Result<()> { + let event = ReadarrEvent::DeleteDownload(download_id); + info!("Deleting Readarr download for download with id: {download_id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{download_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn get_readarr_downloads( + &mut self, + count: u64, + ) -> Result { + info!("Fetching Readarr downloads"); + let event = ReadarrEvent::GetDownloads(count); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("pageSize={count}")), + ) + .await; + + self + .handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| { + app + .data + .readarr_data + .downloads + .set_items(queue_response.records); + }) + .await + } + + pub(in crate::network::readarr_network) async fn update_readarr_downloads( + &mut self, + ) -> Result { + info!("Updating Readarr downloads"); + let event = ReadarrEvent::UpdateDownloads; + let body = CommandBody { + name: "RefreshMonitoredDownloads".to_owned(), + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } +} diff --git a/src/network/readarr_network/downloads/readarr_downloads_network_tests.rs b/src/network/readarr_network/downloads/readarr_downloads_network_tests.rs new file mode 100644 index 00000000..d6e4d53b --- /dev/null +++ b/src/network/readarr_network/downloads/readarr_downloads_network_tests.rs @@ -0,0 +1,164 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::ReadarrSerdeable; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + DOWNLOAD_RECORD_JSON, download_record, downloads_response, stale_download_record, + }; + use pretty_assertions::assert_eq; + use serde_json::{Value, json}; + + #[tokio::test] + async fn test_handle_delete_readarr_download_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/7") + .build_for(ReadarrEvent::DeleteDownload(7)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteDownload(7)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_download_event_failure() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/7") + .status(500) + .build_for(ReadarrEvent::DeleteDownload(7)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteDownload(7)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_get_downloads_event() { + let downloads_json = json!({ + "records": [serde_json::from_str::(DOWNLOAD_RECORD_JSON).unwrap()] + }); + let (mock, app, _server) = MockServarrApi::get() + .returns(downloads_json) + .query("pageSize=250") + .build_for(ReadarrEvent::GetDownloads(250)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .downloads + .set_items(vec![stale_download_record()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetDownloads(250)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::DownloadsResponse(downloads) = result.unwrap() else { + panic!("Expected DownloadsResponse") + }; + + assert_eq!(downloads, downloads_response()); + assert_eq!( + app.lock().await.data.readarr_data.downloads.items, + vec![download_record()] + ); + } + + #[tokio::test] + async fn test_handle_get_downloads_event_failure() { + let stale_downloads = vec![stale_download_record()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({ + "records": [serde_json::from_str::(DOWNLOAD_RECORD_JSON).unwrap()] + })) + .status(500) + .query("pageSize=500") + .build_for(ReadarrEvent::GetDownloads(500)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .downloads + .set_items(stale_downloads.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetDownloads(500)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.downloads.items, + stale_downloads + ); + } + + #[tokio::test] + async fn test_handle_update_readarr_downloads_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ "name": "RefreshMonitoredDownloads" })) + .returns(json!({ + "name": "RefreshMonitoredDownloads", + "commandName": "Refresh Monitored Downloads", + "status": "queued" + })) + .build_for(ReadarrEvent::UpdateDownloads) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateDownloads) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_update_readarr_downloads_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ "name": "RefreshMonitoredDownloads" })) + .returns(json!({ + "name": "RefreshMonitoredDownloads", + "commandName": "Refresh Monitored Downloads", + "status": "queued" + })) + .status(500) + .build_for(ReadarrEvent::UpdateDownloads) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateDownloads) + .await; + + mock.assert_async().await; + assert_err!(result); + } +} diff --git a/src/network/readarr_network/history/mod.rs b/src/network/readarr_network/history/mod.rs new file mode 100644 index 00000000..65def3da --- /dev/null +++ b/src/network/readarr_network/history/mod.rs @@ -0,0 +1,64 @@ +use anyhow::Result; +use log::info; +use serde_json::Value; + +use crate::models::Route; +use crate::models::readarr_models::ReadarrHistoryWrapper; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_history_network_tests.rs"] +mod readarr_history_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_readarr_history( + &mut self, + events: u64, + ) -> Result { + info!("Fetching all Readarr history events"); + let event = ReadarrEvent::GetHistory(events); + + let params = format!("pageSize={events}&sortDirection=descending&sortKey=date"); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), ReadarrHistoryWrapper>(request_props, |history_response, mut app| { + if !matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::HistorySortPrompt, _) + ) { + let mut history_vec = history_response.records; + history_vec.sort_by_key(|item| item.id); + app.data.readarr_data.history.set_items(history_vec); + app.data.readarr_data.history.apply_sorting_toggle(false); + } + }) + .await + } + + pub(in crate::network::readarr_network) async fn mark_readarr_history_item_as_failed( + &mut self, + history_item_id: i64, + ) -> Result { + info!("Marking the Readarr history item with ID: {history_item_id} as 'failed'"); + let event = ReadarrEvent::MarkHistoryItemAsFailed(history_item_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + None, + Some(format!("/{history_item_id}")), + None, + ) + .await; + + self + .handle_request::<(), Value>(request_props, |_, _| ()) + .await + } +} diff --git a/src/network/readarr_network/history/readarr_history_network_tests.rs b/src/network/readarr_network/history/readarr_history_network_tests.rs new file mode 100644 index 00000000..dd76afd7 --- /dev/null +++ b/src/network/readarr_network/history/readarr_history_network_tests.rs @@ -0,0 +1,307 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{ + ReadarrHistoryItem, ReadarrHistoryWrapper, ReadarrSerdeable, + }; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::SortOption; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + readarr_history_item, stale_readarr_history_item, + }; + use pretty_assertions::assert_eq; + use rstest::rstest; + use serde_json::json; + + #[rstest] + #[tokio::test] + async fn test_handle_get_readarr_history_event(#[values(true, false)] use_custom_sorting: bool) { + let history_json = json!({"records": [{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]}); + let anthology_history_item = ReadarrHistoryItem { + id: 456, + author_id: 2001, + book_id: 2001, + source_title: "An Anthology".into(), + ..readarr_history_item() + }; + let z_book_history_item = ReadarrHistoryItem { + id: 123, + author_id: 1007, + book_id: 1007, + source_title: "z book".into(), + ..readarr_history_item() + }; + let expected_response = ReadarrHistoryWrapper { + records: vec![anthology_history_item.clone(), z_book_history_item.clone()], + }; + let mut expected_history_items = vec![z_book_history_item, anthology_history_item]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("pageSize=250&sortDirection=descending&sortKey=date") + .build_for(ReadarrEvent::GetHistory(250)) + .await; + if use_custom_sorting { + let cmp_fn = |a: &ReadarrHistoryItem, b: &ReadarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + expected_history_items.sort_by(cmp_fn); + + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .readarr_data + .history + .sorting(vec![history_sort_option]); + } + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.history.sort_asc = true; + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHistory(250)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryWrapper(history) = result.unwrap() else { + panic!("Expected ReadarrHistoryWrapper") + }; + + assert_eq!(history, expected_response); + let app = app.lock().await; + assert_eq!(app.data.readarr_data.history.items, expected_history_items); + assert!(app.data.readarr_data.history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_history_event_no_op_when_user_is_selecting_sort_options() { + let history_json = json!({"records": [{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]}); + let expected_response = ReadarrHistoryWrapper { + records: vec![ + ReadarrHistoryItem { + id: 456, + author_id: 2001, + book_id: 2001, + source_title: "An Anthology".into(), + ..readarr_history_item() + }, + ReadarrHistoryItem { + id: 123, + author_id: 1007, + book_id: 1007, + source_title: "z book".into(), + ..readarr_history_item() + }, + ], + }; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("pageSize=250&sortDirection=descending&sortKey=date") + .build_for(ReadarrEvent::GetHistory(250)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.history.sort_asc = true; + app.push_navigation_stack(ActiveReadarrBlock::HistorySortPrompt.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHistory(250)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryWrapper(history) = result.unwrap() else { + panic!("Expected ReadarrHistoryWrapper") + }; + + assert_eq!(history, expected_response); + let app = app.lock().await; + assert_is_empty!(app.data.readarr_data.history); + assert!(app.data.readarr_data.history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_history_event_empty_response() { + let stale_history_items = vec![stale_readarr_history_item()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({"records": []})) + .query("pageSize=250&sortDirection=descending&sortKey=date") + .build_for(ReadarrEvent::GetHistory(250)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .history + .set_items(stale_history_items.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHistory(250)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryWrapper(history) = result.unwrap() else { + panic!("Expected ReadarrHistoryWrapper") + }; + + assert_is_empty!(history.records); + assert_is_empty!(app.lock().await.data.readarr_data.history); + } + + #[tokio::test] + async fn test_handle_get_readarr_history_event_failure() { + let history_json = json!({"records": [{ + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]}); + let stale_history_items = vec![stale_readarr_history_item()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .status(500) + .query("pageSize=250&sortDirection=descending&sortKey=date") + .build_for(ReadarrEvent::GetHistory(250)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .history + .set_items(stale_history_items.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHistory(250)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.history.items, + stale_history_items + ); + } + + #[tokio::test] + async fn test_handle_mark_readarr_history_item_as_failed_event() { + let history_item_id = 1234i64; + let (mock, app, _server) = MockServarrApi::post() + .returns(json!({})) + .path("/1234") + .build_for(ReadarrEvent::MarkHistoryItemAsFailed(history_item_id)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::MarkHistoryItemAsFailed(history_item_id)) + .await; + + mock.assert_async().await; + + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_mark_readarr_history_item_as_failed_event_failure() { + let history_item_id = 1234i64; + let (mock, app, _server) = MockServarrApi::post() + .returns(json!({})) + .status(500) + .path("/1234") + .build_for(ReadarrEvent::MarkHistoryItemAsFailed(history_item_id)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::MarkHistoryItemAsFailed(history_item_id)) + .await; + + mock.assert_async().await; + + assert_err!(result); + } +} diff --git a/src/network/readarr_network/indexers/mod.rs b/src/network/readarr_network/indexers/mod.rs new file mode 100644 index 00000000..b7413dc0 --- /dev/null +++ b/src/network/readarr_network/indexers/mod.rs @@ -0,0 +1,421 @@ +use crate::models::servarr_data::modals::IndexerTestResultModalItem; +use crate::models::servarr_models::{ + EditIndexerParams, Indexer, IndexerSettings, IndexerTestResult, +}; +use crate::models::stateful_table::StatefulTable; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::{Context, Result}; +use log::{debug, info}; +use serde_json::{Value, json}; + +#[cfg(test)] +#[path = "readarr_indexers_network_tests.rs"] +mod readarr_indexers_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn delete_readarr_indexer( + &mut self, + indexer_id: i64, + ) -> Result<()> { + info!("Deleting Readarr indexer with id: {indexer_id}"); + let event = ReadarrEvent::DeleteIndexer(indexer_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{indexer_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn edit_all_readarr_indexer_settings( + &mut self, + params: IndexerSettings, + ) -> Result { + info!("Updating Readarr indexer settings"); + let event = ReadarrEvent::EditAllIndexerSettings(IndexerSettings::default()); + debug!("Indexer settings body: {params:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Put, Some(params), None, None) + .await; + + self + .handle_request::(request_props, |_, _| {}) + .await + } + + pub(in crate::network::readarr_network) async fn get_all_readarr_indexer_settings( + &mut self, + ) -> Result { + info!("Fetching Readarr indexer settings"); + let event = ReadarrEvent::GetAllIndexerSettings; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), IndexerSettings>(request_props, |indexer_settings, mut app| { + if app.data.readarr_data.indexer_settings.is_none() { + app.data.readarr_data.indexer_settings = Some(indexer_settings); + } else { + debug!("Indexer Settings are being modified. Ignoring update..."); + } + }) + .await + } + + pub(in crate::network::readarr_network) async fn edit_readarr_indexer( + &mut self, + mut edit_indexer_params: EditIndexerParams, + ) -> Result<()> { + if let Some(tag_input_str) = edit_indexer_params.tag_input_string.as_ref() { + let tag_ids_vec = self + .extract_and_add_readarr_tag_ids_vec(tag_input_str) + .await; + edit_indexer_params.tags = Some(tag_ids_vec); + } + let detail_event = ReadarrEvent::GetIndexers; + let event = ReadarrEvent::EditIndexer(EditIndexerParams::default()); + let id = edit_indexer_params.indexer_id; + info!("Updating Readarr indexer with ID: {id}"); + info!("Fetching indexer details for indexer with ID: {id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + response = detailed_indexer_body.to_string() + }) + .await?; + + info!("Constructing edit indexer body"); + + let mut detailed_indexer_body: Value = serde_json::from_str(&response)?; + + let ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) = { + let priority = detailed_indexer_body["priority"] + .as_i64() + .context("Failed to deserialize indexer 'priority' field")?; + let seed_ratio_field_option = detailed_indexer_body["fields"] + .as_array() + .context("Failed to get indexer 'fields' array")? + .iter() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + let name = edit_indexer_params.name.unwrap_or( + detailed_indexer_body["name"] + .as_str() + .context("Failed to deserialize indexer 'name' field")? + .to_owned(), + ); + let enable_rss = edit_indexer_params.enable_rss.unwrap_or( + detailed_indexer_body["enableRss"] + .as_bool() + .context("Failed to deserialize indexer 'enableRss' field")?, + ); + let enable_automatic_search = edit_indexer_params.enable_automatic_search.unwrap_or( + detailed_indexer_body["enableAutomaticSearch"] + .as_bool() + .context("Failed to deserialize indexer 'enableAutomaticSearch' field")?, + ); + let enable_interactive_search = edit_indexer_params.enable_interactive_search.unwrap_or( + detailed_indexer_body["enableInteractiveSearch"] + .as_bool() + .context("Failed to deserialize indexer 'enableInteractiveSearch' field")?, + ); + let url = edit_indexer_params.url.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .context("Failed to get indexer 'fields' array for baseUrl")? + .iter() + .find(|field| field["name"] == "baseUrl") + .context("Field 'baseUrl' was not found in the indexer fields array")? + .get("value") + .unwrap_or(&json!("")) + .as_str() + .context("Failed to deserialize indexer 'baseUrl' value")? + .to_owned(), + ); + let api_key = edit_indexer_params.api_key.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .context("Failed to get indexer 'fields' array for apiKey")? + .iter() + .find(|field| field["name"] == "apiKey") + .context("Field 'apiKey' was not found in the indexer fields array")? + .get("value") + .unwrap_or(&json!("")) + .as_str() + .context("Failed to deserialize indexer 'apiKey' value")? + .to_owned(), + ); + let seed_ratio = edit_indexer_params.seed_ratio.unwrap_or_else(|| { + if let Some(seed_ratio_field) = seed_ratio_field_option { + return seed_ratio_field + .get("value") + .unwrap_or(&json!("")) + .as_str() + .unwrap_or("") + .to_owned(); + } + + String::new() + }); + let tags = if edit_indexer_params.clear_tags { + vec![] + } else { + edit_indexer_params.tags.unwrap_or( + detailed_indexer_body["tags"] + .as_array() + .context("Failed to get indexer 'tags' array")? + .iter() + .map(|item| { + item + .as_i64() + .context("Failed to deserialize indexer tag ID") + }) + .collect::>>()?, + ) + }; + let priority = edit_indexer_params.priority.unwrap_or(priority); + + ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) + }; + + *detailed_indexer_body + .get_mut("name") + .context("Failed to get mutable reference to indexer 'name' field")? = json!(name); + *detailed_indexer_body + .get_mut("priority") + .context("Failed to get mutable reference to indexer 'priority' field")? = json!(priority); + *detailed_indexer_body + .get_mut("enableRss") + .context("Failed to get mutable reference to indexer 'enableRss' field")? = json!(enable_rss); + *detailed_indexer_body + .get_mut("enableAutomaticSearch") + .context("Failed to get mutable reference to indexer 'enableAutomaticSearch' field")? = + json!(enable_automatic_search); + *detailed_indexer_body + .get_mut("enableInteractiveSearch") + .context("Failed to get mutable reference to indexer 'enableInteractiveSearch' field")? = + json!(enable_interactive_search); + *detailed_indexer_body + .get_mut("fields") + .and_then(|f| f.as_array_mut()) + .context("Failed to get mutable reference to indexer 'fields' array")? + .iter_mut() + .find(|field| field["name"] == "baseUrl") + .context("Failed to find 'baseUrl' field in indexer fields array")? + .get_mut("value") + .context("Failed to get mutable reference to 'baseUrl' value")? = json!(url); + *detailed_indexer_body + .get_mut("fields") + .and_then(|f| f.as_array_mut()) + .context("Failed to get mutable reference to indexer 'fields' array for apiKey")? + .iter_mut() + .find(|field| field["name"] == "apiKey") + .context("Failed to find 'apiKey' field in indexer fields array")? + .get_mut("value") + .context("Failed to get mutable reference to 'apiKey' value")? = json!(api_key); + *detailed_indexer_body + .get_mut("tags") + .context("Failed to get mutable reference to indexer 'tags' field")? = json!(tags); + let seed_ratio_field_option = detailed_indexer_body + .get_mut("fields") + .and_then(|f| f.as_array_mut()) + .context("Failed to get mutable reference to indexer 'fields' array for seed ratio")? + .iter_mut() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + if let Some(seed_ratio_field) = seed_ratio_field_option { + seed_ratio_field + .as_object_mut() + .context("Failed to get mutable reference to 'seedCriteria.seedRatio' object")? + .insert("value".to_string(), json!(seed_ratio)); + } + + debug!("Edit indexer body: {detailed_indexer_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_indexer_body), + Some(format!("/{id}")), + Some("forceSave=true".to_owned()), + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn get_readarr_indexers( + &mut self, + ) -> Result> { + info!("Fetching Readarr indexers"); + let event = ReadarrEvent::GetIndexers; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |indexers, mut app| { + app.data.readarr_data.indexers.set_items(indexers); + }) + .await + } + + pub(in crate::network::readarr_network) async fn test_readarr_indexer( + &mut self, + indexer_id: i64, + ) -> Result { + let detail_event = ReadarrEvent::GetIndexers; + let event = ReadarrEvent::TestIndexer(indexer_id); + info!("Testing Readarr indexer with ID: {indexer_id}"); + + info!("Fetching indexer details for indexer with ID: {indexer_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{indexer_id}")), + None, + ) + .await; + + let mut test_body: Value = Value::default(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + test_body = detailed_indexer_body; + }) + .await?; + + info!("Testing indexer"); + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, Some(test_body), None, None) + .await; + request_props.ignore_status_code = true; + + self + .handle_request::(request_props, |test_results, mut app| { + if test_results.as_object().is_none() { + let error_message = test_results + .as_array() + .and_then(|arr| arr.first()) + .and_then(|item| item.get("errorMessage")) + .map(|msg| msg.to_string()) + .unwrap_or_else(|| "Unknown indexer test error".to_string()); + app.data.readarr_data.indexer_test_errors = Some(error_message); + } else { + app.data.readarr_data.indexer_test_errors = Some(String::new()); + }; + }) + .await + } + + pub(in crate::network::readarr_network) async fn test_all_readarr_indexers( + &mut self, + ) -> Result> { + info!("Testing all Readarr indexers"); + let event = ReadarrEvent::TestAllIndexers; + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, None, None, None) + .await; + request_props.ignore_status_code = true; + + let result = self + .handle_request::<(), Vec>(request_props, |test_results, mut app| { + let mut test_all_indexer_results = StatefulTable::default(); + let indexers = app.data.readarr_data.indexers.items.clone(); + let modal_test_results = test_results + .iter() + .map(|result| { + let name = indexers + .iter() + .filter(|&indexer| indexer.id == result.id) + .map(|indexer| indexer.name.clone()) + .nth(0) + .unwrap_or_default(); + let validation_failures = result + .validation_failures + .iter() + .map(|failure| { + format!( + "Failure for field '{}': {}", + failure.property_name, failure.error_message + ) + }) + .collect::>() + .join(", "); + + IndexerTestResultModalItem { + name: name.unwrap_or_default(), + is_valid: result.is_valid, + validation_failures: validation_failures.into(), + } + }) + .collect(); + test_all_indexer_results.set_items(modal_test_results); + app.data.readarr_data.indexer_test_all_results = Some(test_all_indexer_results); + }) + .await; + + if result.is_err() { + self + .app + .lock() + .await + .data + .readarr_data + .indexer_test_all_results = Some(StatefulTable::default()); + } + + result + } +} diff --git a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs new file mode 100644 index 00000000..02dbcbf1 --- /dev/null +++ b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs @@ -0,0 +1,753 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::ReadarrSerdeable; + use crate::models::servarr_models::{EditIndexerParams, Indexer, IndexerTestResult}; + use crate::models::stateful_table::StatefulTable; + use crate::network::NetworkResource; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + INDEXER_JSON, INDEXER_SETTINGS_JSON, INDEXER_TEST_RESULTS_JSON, indexer, indexer_settings, + indexer_test_results, stale_indexer, stale_indexer_settings, tested_indexers, + }; + use bimap::BiMap; + use mockito::Matcher; + use pretty_assertions::assert_eq; + use serde_json::{Value, json}; + + #[tokio::test] + async fn test_handle_delete_readarr_indexer_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/8") + .build_for(ReadarrEvent::DeleteIndexer(8)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteIndexer(8)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_indexer_event_failure() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/8") + .status(500) + .build_for(ReadarrEvent::DeleteIndexer(8)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteIndexer(8)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_get_readarr_indexers_event() { + let indexers_response_json = json!([serde_json::from_str::(INDEXER_JSON).unwrap()]); + let response: Vec = serde_json::from_value(indexers_response_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(indexers_response_json) + .build_for(ReadarrEvent::GetIndexers) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .indexers + .set_items(vec![stale_indexer()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetIndexers) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Indexers(indexers) = result.unwrap() else { + panic!("Expected Indexers") + }; + + assert_eq!(indexers, response); + assert_eq!( + app.lock().await.data.readarr_data.indexers.items, + vec![indexer()] + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_indexers_event_failure() { + let stale_indexers = vec![stale_indexer()]; + let indexers_response_json = json!([serde_json::from_str::(INDEXER_JSON).unwrap()]); + let (mock, app, _server) = MockServarrApi::get() + .returns(indexers_response_json) + .status(500) + .build_for(ReadarrEvent::GetIndexers) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .indexers + .set_items(stale_indexers.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetIndexers) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.indexers.items, + stale_indexers + ); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event() { + let mut expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + *expected_body.get_mut("name").unwrap() = json!("Test Update"); + *expected_body.get_mut("priority").unwrap() = json!(3); + *expected_body.get_mut("enableRss").unwrap() = json!(false); + *expected_body.get_mut("enableAutomaticSearch").unwrap() = json!(false); + *expected_body.get_mut("enableInteractiveSearch").unwrap() = json!(false); + *expected_body["fields"][0].get_mut("value").unwrap() = json!("https://localhost:9696/8/"); + *expected_body["fields"][1].get_mut("value").unwrap() = json!("test1234"); + *expected_body["fields"][2].get_mut("value").unwrap() = json!("1.3"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/8/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + priority: Some(3), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_defaults_to_previous_values() { + let expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_does_not_overwrite_tags_vec_if_tag_input_string_is_none() + { + let mut expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([2, 3]); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + tags: Some(vec![2, 3]), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_clears_tags_when_clear_tags_is_true() { + let mut expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([]); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + clear_tags: true, + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_does_not_add_seed_ratio_when_seed_ratio_field_is_absent_in_details() + { + let mut details_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + details_body["fields"].as_array_mut().unwrap().pop(); + let expected_body = details_body.clone(); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + seed_ratio: Some("1.3".to_owned()), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(details_body) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_populates_the_seed_ratio_value_when_it_is_missing_in_details() + { + let mut details_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + details_body["fields"][2] + .as_object_mut() + .unwrap() + .remove("value"); + let mut expected_body = details_body.clone(); + expected_body["fields"][2] + .as_object_mut() + .unwrap() + .insert("value".to_owned(), json!("1.3")); + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + seed_ratio: Some("1.3".to_owned()), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(details_body) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_readarr_indexer_event_failure() { + let edit_indexer_params = EditIndexerParams { + indexer_id: 8, + name: Some("Test Update".to_owned()), + ..EditIndexerParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/8?forceSave=true", + ReadarrEvent::EditIndexer(edit_indexer_params.clone()).resource() + ) + .as_str(), + ) + .with_status(500) + .match_header("X-Api-Key", "test1234") + .with_body(INDEXER_JSON) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditIndexer(edit_indexer_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_test_readarr_indexer_event() { + let expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v1{}", ReadarrEvent::TestIndexer(8).resource()).as_str(), + ) + .with_status(200) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .with_body("{}") + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_test_errors = Some("stale error".to_owned()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestIndexer(8)) + .await; + + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_ok!(&result); + let app = app.lock().await; + assert_some_eq_x!(&app.data.readarr_data.indexer_test_errors, &String::new()); + } + + #[tokio::test] + async fn test_handle_test_readarr_indexer_event_captures_validation_errors() { + let expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v1{}", ReadarrEvent::TestIndexer(8).resource()).as_str(), + ) + .with_status(400) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .with_body(json!([{ "errorMessage": "Unable to connect to indexer" }]).to_string()) + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_test_errors = Some("stale error".to_owned()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestIndexer(8)) + .await; + + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_ok!(&result); + let app = app.lock().await; + assert_some_eq_x!( + &app.data.readarr_data.indexer_test_errors, + &"\"Unable to connect to indexer\"".to_owned() + ); + } + + #[tokio::test] + async fn test_handle_test_readarr_indexer_event_falls_back_to_a_generic_error_message() { + let expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v1{}", ReadarrEvent::TestIndexer(8).resource()).as_str(), + ) + .with_status(400) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .with_body("[]") + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_test_errors = Some("stale error".to_owned()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestIndexer(8)) + .await; + + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_ok!(&result); + let app = app.lock().await; + assert_some_eq_x!( + &app.data.readarr_data.indexer_test_errors, + &"Unknown indexer test error".to_owned() + ); + } + + #[tokio::test] + async fn test_handle_test_readarr_indexer_event_failure() { + let (async_details_server, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .status(500) + .build_for(ReadarrEvent::GetIndexers) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_test_errors = Some("stale error".to_owned()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestIndexer(8)) + .await; + + async_details_server.assert_async().await; + assert_err!(result); + let app = app.lock().await; + assert_some_eq_x!( + &app.data.readarr_data.indexer_test_errors, + &"stale error".to_owned() + ); + } + + #[tokio::test] + async fn test_handle_test_all_readarr_indexers_event() { + let response_json: Value = serde_json::from_str(INDEXER_TEST_RESULTS_JSON).unwrap(); + let expected_results: Vec = + serde_json::from_str(INDEXER_TEST_RESULTS_JSON).unwrap(); + let (async_server, app, _server) = MockServarrApi::post() + .returns(response_json) + .status(400) + .build_for(ReadarrEvent::TestAllIndexers) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexers.set_items(tested_indexers()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestAllIndexers) + .await; + + async_server.assert_async().await; + let ReadarrSerdeable::IndexerTestResults(results) = assert_ok!(result) else { + panic!("Expected IndexerTestResults") + }; + assert_eq!(results, expected_results); + let app = app.lock().await; + assert_eq!( + app + .data + .readarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .items, + indexer_test_results() + ); + } + + #[tokio::test] + async fn test_handle_test_all_readarr_indexers_event_failure() { + let (async_server, app, _server) = MockServarrApi::post() + .returns(json!({ "message": "Internal Server Error" })) + .status(500) + .build_for(ReadarrEvent::TestAllIndexers) + .await; + { + let mut stale_test_all_results = StatefulTable::default(); + stale_test_all_results.set_items(indexer_test_results()); + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexers.set_items(tested_indexers()); + app.data.readarr_data.indexer_test_all_results = Some(stale_test_all_results); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestAllIndexers) + .await; + + async_server.assert_async().await; + assert_err!(result); + let app = app.lock().await; + assert_is_empty!( + app + .data + .readarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .items + ); + } + + #[tokio::test] + async fn test_handle_get_all_readarr_indexer_settings_event() { + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_SETTINGS_JSON).unwrap()) + .build_for(ReadarrEvent::GetAllIndexerSettings) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAllIndexerSettings) + .await; + + mock.assert_async().await; + let ReadarrSerdeable::IndexerSettings(settings) = assert_ok!(result) else { + panic!("Expected IndexerSettings") + }; + assert_eq!(settings, indexer_settings()); + let app = app.lock().await; + assert_some_eq_x!(&app.data.readarr_data.indexer_settings, &indexer_settings()); + } + + #[tokio::test] + async fn test_handle_get_all_readarr_indexer_settings_event_does_not_overwrite_pending_edits() { + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_SETTINGS_JSON).unwrap()) + .build_for(ReadarrEvent::GetAllIndexerSettings) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_settings = Some(stale_indexer_settings()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAllIndexerSettings) + .await; + + mock.assert_async().await; + assert_ok!(&result); + let app = app.lock().await; + assert_some_eq_x!( + &app.data.readarr_data.indexer_settings, + &stale_indexer_settings() + ); + } + + #[tokio::test] + async fn test_handle_get_all_readarr_indexer_settings_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_SETTINGS_JSON).unwrap()) + .status(500) + .build_for(ReadarrEvent::GetAllIndexerSettings) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAllIndexerSettings) + .await; + + mock.assert_async().await; + assert_err!(result); + let app = app.lock().await; + assert_none!(&app.data.readarr_data.indexer_settings); + } + + #[tokio::test] + async fn test_handle_edit_all_readarr_indexer_settings_event() { + let (mock, app, _server) = MockServarrApi::put() + .with_request_body(serde_json::from_str(INDEXER_SETTINGS_JSON).unwrap()) + .build_for(ReadarrEvent::EditAllIndexerSettings(indexer_settings())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAllIndexerSettings(indexer_settings())) + .await; + + mock.assert_async().await; + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_all_readarr_indexer_settings_event_failure() { + let (mock, app, _server) = MockServarrApi::put() + .with_request_body(serde_json::from_str(INDEXER_SETTINGS_JSON).unwrap()) + .returns(json!({ "message": "Internal Server Error" })) + .status(500) + .build_for(ReadarrEvent::EditAllIndexerSettings(indexer_settings())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAllIndexerSettings(indexer_settings())) + .await; + + mock.assert_async().await; + assert_err!(result); + } +} diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs new file mode 100644 index 00000000..9eff5cb5 --- /dev/null +++ b/src/network/readarr_network/library/authors/mod.rs @@ -0,0 +1,467 @@ +use anyhow::Result; +use log::{debug, info, warn}; +use serde_json::{Value, json}; + +use crate::models::Route; +use crate::models::readarr_models::{ + AddAuthorBody, AddAuthorSearchResult, Author, DeleteParams, EditAuthorParams, ReadarrCommandBody, + ReadarrHistoryItem, ReadarrRelease, ReadarrReleaseResponse, +}; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::models::stateful_table::StatefulTable; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; +use urlencoding::encode; + +#[cfg(test)] +#[path = "readarr_authors_network_tests.rs"] +mod readarr_authors_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn add_author( + &mut self, + mut add_author_body: AddAuthorBody, + ) -> Result { + info!("Adding Readarr author: {}", add_author_body.author_name); + if let Some(tag_input_str) = add_author_body.tag_input_string.as_ref() { + let tag_ids_vec = self + .extract_and_add_readarr_tag_ids_vec(tag_input_str) + .await; + add_author_body.tags = tag_ids_vec; + } + let event = ReadarrEvent::AddAuthor(AddAuthorBody::default()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(add_author_body), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn delete_author( + &mut self, + delete_author_params: DeleteParams, + ) -> Result<()> { + let event = ReadarrEvent::DeleteAuthor(DeleteParams::default()); + let DeleteParams { + id, + delete_files, + add_import_list_exclusion, + } = delete_author_params; + + info!( + "Deleting Readarr author with ID: {id} with deleteFiles={delete_files} and addImportListExclusion={add_import_list_exclusion}" + ); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + Some(format!( + "deleteFiles={delete_files}&addImportListExclusion={add_import_list_exclusion}" + )), + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn edit_author( + &mut self, + mut edit_author_params: EditAuthorParams, + ) -> Result<()> { + info!("Editing Readarr author"); + if let Some(tag_input_str) = edit_author_params.tag_input_string.as_ref() { + let tag_ids_vec = self + .extract_and_add_readarr_tag_ids_vec(tag_input_str) + .await; + edit_author_params.tags = Some(tag_ids_vec); + } + let author_id = edit_author_params.author_id; + let detail_event = ReadarrEvent::GetAuthorDetails(author_id); + let event = ReadarrEvent::EditAuthor(EditAuthorParams::default()); + info!("Fetching author details for author with ID: {author_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{author_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_author_body, _| { + response = detailed_author_body.to_string() + }) + .await?; + + info!("Constructing edit author body"); + + let mut detailed_author_body: Value = serde_json::from_str(&response)?; + let ( + monitored, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tags, + ) = { + let monitored = edit_author_params.monitored.unwrap_or( + detailed_author_body["monitored"] + .as_bool() + .expect("Unable to deserialize 'monitored'"), + ); + let monitor_new_items = edit_author_params.monitor_new_items.unwrap_or_else(|| { + serde_json::from_value(detailed_author_body["monitorNewItems"].clone()) + .expect("Unable to deserialize 'monitorNewItems'") + }); + let quality_profile_id = edit_author_params.quality_profile_id.unwrap_or_else(|| { + detailed_author_body["qualityProfileId"] + .as_i64() + .expect("Unable to deserialize 'qualityProfileId'") + }); + let metadata_profile_id = edit_author_params.metadata_profile_id.unwrap_or_else(|| { + detailed_author_body["metadataProfileId"] + .as_i64() + .expect("Unable to deserialize 'metadataProfileId'") + }); + let root_folder_path = edit_author_params.root_folder_path.unwrap_or_else(|| { + detailed_author_body["path"] + .as_str() + .expect("Unable to deserialize 'path'") + .to_owned() + }); + let tags = if edit_author_params.clear_tags { + vec![] + } else { + edit_author_params.tags.unwrap_or( + detailed_author_body["tags"] + .as_array() + .expect("Unable to deserialize 'tags'") + .iter() + .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) + .collect(), + ) + }; + + ( + monitored, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tags, + ) + }; + + *detailed_author_body.get_mut("monitored").unwrap() = json!(monitored); + *detailed_author_body.get_mut("monitorNewItems").unwrap() = json!(monitor_new_items); + *detailed_author_body.get_mut("qualityProfileId").unwrap() = json!(quality_profile_id); + *detailed_author_body.get_mut("metadataProfileId").unwrap() = json!(metadata_profile_id); + *detailed_author_body.get_mut("path").unwrap() = json!(root_folder_path); + *detailed_author_body.get_mut("tags").unwrap() = json!(tags); + + debug!("Edit author body: {detailed_author_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_author_body), + Some(format!("/{author_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn get_author_details( + &mut self, + author_id: i64, + ) -> Result { + info!("Fetching details for Readarr author with ID: {author_id}"); + let event = ReadarrEvent::GetAuthorDetails(author_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + Some(format!("/{author_id}")), + None, + ) + .await; + + self + .handle_request::<(), Author>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn get_author_releases( + &mut self, + author_id: i64, + ) -> Result> { + info!("Fetching releases for Readarr author with ID: {author_id}"); + let event = ReadarrEvent::GetAuthorReleases(author_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("authorId={author_id}")), + ) + .await; + + self + .handle_request::<(), ReadarrReleaseResponse>(request_props, |response, mut app| { + app + .data + .readarr_data + .author_releases + .set_items(response.into_releases()); + }) + .await + .map(ReadarrReleaseResponse::into_releases) + } + + pub(in crate::network::readarr_network) async fn get_readarr_author_history( + &mut self, + author_id: i64, + ) -> Result> { + info!("Fetching Readarr author history for author with ID: {author_id}"); + let event = ReadarrEvent::GetAuthorHistory(author_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("authorId={author_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut history_vec, mut app| { + let is_sorting = matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::AuthorHistorySortPrompt, _) + ); + + let author_history = &mut app.data.readarr_data.author_history; + + if !is_sorting { + history_vec.sort_by_key(|a| a.id); + author_history.set_items(history_vec); + author_history.apply_sorting_toggle(false); + } + }) + .await + } + + pub(in crate::network::readarr_network) async fn list_authors(&mut self) -> Result> { + info!("Fetching Readarr authors"); + let event = ReadarrEvent::ListAuthors; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut authors_vec, mut app| { + if !matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::AuthorsSortPrompt, _) + ) { + authors_vec.sort_by_key(|a| a.id); + app.data.readarr_data.authors.set_items(authors_vec); + app.data.readarr_data.authors.apply_sorting_toggle(false); + } + }) + .await + } + + pub(in crate::network::readarr_network) async fn search_author( + &mut self, + query: String, + ) -> Result> { + info!("Searching for author: {query}"); + let event = ReadarrEvent::SearchNewAuthor(String::new()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("term={}", encode(&query))), + ) + .await; + + let result = self + .handle_request::<(), Vec>(request_props, |author_vec, mut app| { + if author_vec.is_empty() { + app.pop_and_push_navigation_stack(ActiveReadarrBlock::AddAuthorEmptySearchResults.into()); + } else if let Some(add_searched_authors) = + app.data.readarr_data.add_searched_authors.as_mut() + { + add_searched_authors.set_items(author_vec); + } else { + let mut add_searched_authors = StatefulTable::default(); + add_searched_authors.set_items(author_vec); + app.data.readarr_data.add_searched_authors = Some(add_searched_authors); + } + }) + .await; + + if result.is_err() { + self.app.lock().await.data.readarr_data.add_searched_authors = Some(StatefulTable::default()); + } + + result + } + + pub(in crate::network::readarr_network) async fn toggle_author_monitoring( + &mut self, + author_id: i64, + ) -> Result<()> { + let event = ReadarrEvent::ToggleAuthorMonitoring(author_id); + + let detail_event = ReadarrEvent::GetAuthorDetails(author_id); + info!("Toggling author monitoring for author with ID: {author_id}"); + info!("Fetching author details for author with ID: {author_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{author_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_author_body, _| { + response = detailed_author_body.to_string() + }) + .await?; + + info!("Constructing toggle author monitoring body"); + + match serde_json::from_str::(&response) { + Ok(mut detailed_author_body) => { + let monitored = detailed_author_body + .get("monitored") + .unwrap() + .as_bool() + .unwrap(); + + *detailed_author_body.get_mut("monitored").unwrap() = json!(!monitored); + + debug!("Toggle author monitoring body: {detailed_author_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_author_body), + Some(format!("/{author_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + Err(_) => { + warn!("Request for detailed author body was interrupted"); + Ok(()) + } + } + } + + pub(in crate::network::readarr_network) async fn trigger_automatic_author_search( + &mut self, + author_id: i64, + ) -> Result { + let event = ReadarrEvent::TriggerAutomaticAuthorSearch(author_id); + info!("Searching indexers for author with ID: {author_id}"); + let body = ReadarrCommandBody { + name: "AuthorSearch".to_owned(), + author_id: Some(author_id), + ..ReadarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn update_all_authors(&mut self) -> Result { + info!("Updating all authors"); + let event = ReadarrEvent::UpdateAllAuthors; + let body = ReadarrCommandBody { + name: "RefreshAuthor".to_owned(), + ..ReadarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn update_and_scan_author( + &mut self, + author_id: i64, + ) -> Result { + let event = ReadarrEvent::UpdateAndScanAuthor(author_id); + info!("Updating and scanning author with ID: {author_id}"); + let body = ReadarrCommandBody { + name: "RefreshAuthor".to_owned(), + author_id: Some(author_id), + ..ReadarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } +} diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs new file mode 100644 index 00000000..39bd0bf0 --- /dev/null +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -0,0 +1,1261 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{ + AddAuthorBody, AddAuthorSearchResult, Author, DeleteParams, EditAuthorParams, + NewItemMonitorType, ReadarrHistoryItem, ReadarrSerdeable, + }; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::{SortOption, StatefulTable}; + use crate::network::NetworkResource; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, RELEASE_JSON, add_author_body, + readarr_history_item, stale_add_author_search_result, stale_author, stale_readarr_history_item, + stale_release, torrent_release, usenet_release, + }; + use bimap::BiMap; + use mockito::Matcher; + use pretty_assertions::assert_eq; + use rstest::rstest; + use serde_json::{Value, json}; + + #[tokio::test] + async fn test_handle_get_author_details_event() { + let expected_author: Author = serde_json::from_str(AUTHOR_JSON).unwrap(); + let stale_authors = vec![stale_author()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .authors + .set_items(stale_authors.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorDetails(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Author(author) = result.unwrap() else { + panic!("Expected Author") + }; + + assert_eq!(author, expected_author); + assert_eq!( + app.lock().await.data.readarr_data.authors.items, + stale_authors + ); + } + + #[tokio::test] + async fn test_handle_get_author_details_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .status(500) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorDetails(1)) + .await; + + mock.assert_async().await; + + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_get_author_releases_event() { + let expected_releases = vec![torrent_release(), usenet_release()]; + let releases_json = json!([ + serde_json::from_str::(RELEASE_JSON).unwrap(), + { + "guid": "test-usenet-release-guid", + "protocol": "usenet", + "age": 4492, + "title": "Test Author - Test Book [AZW3]", + "authorName": "Test Author", + "bookTitle": "Test Book", + "indexer": "DrunkenSlug", + "indexerId": 4, + "size": 313924185, + "rejected": true, + "rejections": ["Unknown quality profile", "Release is already mapped"], + "quality": { + "quality": { "id": 12, "name": "AZW3" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + } + } + ]); + let (mock, app, _server) = MockServarrApi::get() + .returns(releases_json) + .query("authorId=3") + .build_for(ReadarrEvent::GetAuthorReleases(3)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .author_releases + .set_items(vec![stale_release()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorReleases(3)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Releases(releases) = result.unwrap() else { + panic!("Expected Releases") + }; + + assert_eq!(releases, expected_releases); + assert_eq!( + app.lock().await.data.readarr_data.author_releases.items, + expected_releases + ); + } + + #[tokio::test] + async fn test_handle_get_author_releases_event_unwraps_the_release_envelope() { + let expected_releases = vec![torrent_release()]; + let releases_json = json!({ + "releases": [serde_json::from_str::(RELEASE_JSON).unwrap()], + "hiddenReleases": [], + "filterSummary": { "totalResults": 1, "displayedCount": 1 }, + "siblingToggleEnabled": false + }); + let (mock, app, _server) = MockServarrApi::get() + .returns(releases_json) + .query("authorId=3") + .build_for(ReadarrEvent::GetAuthorReleases(3)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .author_releases + .set_items(vec![stale_release()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorReleases(3)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Releases(releases) = result.unwrap() else { + panic!("Expected Releases") + }; + + assert_eq!(releases, expected_releases); + assert_eq!( + app.lock().await.data.readarr_data.author_releases.items, + expected_releases + ); + } + + #[tokio::test] + async fn test_handle_get_author_releases_event_failure() { + let stale_releases = vec![stale_release()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!( + [serde_json::from_str::(RELEASE_JSON).unwrap()] + )) + .status(500) + .query("authorId=3") + .build_for(ReadarrEvent::GetAuthorReleases(3)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .author_releases + .set_items(stale_releases.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorReleases(3)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.author_releases.items, + stale_releases + ); + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_readarr_author_history_event( + #[values(true, false)] use_custom_sorting: bool, + ) { + let history_json = json!([{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let mut expected_history_items = vec![ + ReadarrHistoryItem { + id: 123, + author_id: 1007, + book_id: 1007, + source_title: "z book".into(), + ..readarr_history_item() + }, + ReadarrHistoryItem { + id: 456, + author_id: 2001, + book_id: 2001, + source_title: "An Anthology".into(), + ..readarr_history_item() + }, + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("authorId=1") + .build_for(ReadarrEvent::GetAuthorHistory(1)) + .await; + if use_custom_sorting { + let cmp_fn = |a: &ReadarrHistoryItem, b: &ReadarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + expected_history_items.sort_by(cmp_fn); + + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .readarr_data + .author_history + .sorting(vec![history_sort_option]); + } + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.author_history.sort_asc = true; + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorHistory(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryItems(history_items) = result.unwrap() else { + panic!("Expected ReadarrHistoryItems") + }; + + assert_eq!(history_items, response); + let app = app.lock().await; + assert_eq!( + app.data.readarr_data.author_history.items, + expected_history_items + ); + assert!(app.data.readarr_data.author_history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_author_history_event_no_op_when_user_is_selecting_sort_options() + { + let history_json = json!([{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("authorId=1") + .build_for(ReadarrEvent::GetAuthorHistory(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.author_history.sort_asc = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorHistorySortPrompt.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorHistory(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryItems(history_items) = result.unwrap() else { + panic!("Expected ReadarrHistoryItems") + }; + + assert_eq!(history_items, response); + let app = app.lock().await; + assert_is_empty!(app.data.readarr_data.author_history); + assert!(app.data.readarr_data.author_history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_author_history_event_empty_response() { + let stale_history_items = vec![stale_readarr_history_item()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .query("authorId=1") + .build_for(ReadarrEvent::GetAuthorHistory(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .author_history + .set_items(stale_history_items.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorHistory(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryItems(history_items) = result.unwrap() else { + panic!("Expected ReadarrHistoryItems") + }; + + assert_is_empty!(history_items); + assert_is_empty!(app.lock().await.data.readarr_data.author_history); + } + + #[tokio::test] + async fn test_handle_get_readarr_author_history_event_failure() { + let history_json = json!([{ + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let stale_history_items = vec![stale_readarr_history_item()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .status(500) + .query("authorId=1") + .build_for(ReadarrEvent::GetAuthorHistory(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .author_history + .set_items(stale_history_items.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetAuthorHistory(1)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.author_history.items, + stale_history_items + ); + } + + #[tokio::test] + async fn test_handle_list_authors_event() { + let authors_json = json!([ + { + "id": 2, + "authorName": "Zeta Author", + "foreignAuthorId": "foreign-author-2", + "status": "continuing", + "overview": "An overview of Zeta Author", + "path": "/nfs/books/Zeta Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["Fantasy"], + "tags": [1] + }, + { + "id": 1, + "authorName": "Alpha Author", + "foreignAuthorId": "foreign-author-1", + "status": "continuing", + "overview": "An overview of Alpha Author", + "path": "/nfs/books/Alpha Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["Fantasy"], + "tags": [1] + } + ]); + let response: Vec = serde_json::from_value(authors_json.clone()).unwrap(); + let mut sorted_authors = response.clone(); + sorted_authors.sort_by_key(|author| author.id); + let (mock, app, _server) = MockServarrApi::get() + .returns(authors_json) + .build_for(ReadarrEvent::ListAuthors) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ListAuthors) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Authors(authors) = result.unwrap() else { + panic!("Expected Authors") + }; + + assert_eq!(authors, response); + assert_eq!( + app.lock().await.data.readarr_data.authors.items, + sorted_authors + ); + } + + #[tokio::test] + async fn test_handle_list_authors_event_no_op_when_user_is_selecting_sort_options() { + let authors_json = json!([serde_json::from_str::(AUTHOR_JSON).unwrap()]); + let (mock, app, _server) = MockServarrApi::get() + .returns(authors_json) + .build_for(ReadarrEvent::ListAuthors) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.push_navigation_stack(ActiveReadarrBlock::AuthorsSortPrompt.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ListAuthors) + .await; + + mock.assert_async().await; + + assert_ok!(result); + assert_is_empty!(app.lock().await.data.readarr_data.authors); + } + + #[tokio::test] + async fn test_handle_list_authors_event_failure() { + let stale_authors = vec![stale_author()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::ListAuthors) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .authors + .set_items(stale_authors.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ListAuthors) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.authors.items, + stale_authors + ); + } + + #[tokio::test] + async fn test_handle_search_new_author_event() { + let search_results_json = + json!([serde_json::from_str::(ADD_AUTHOR_SEARCH_RESULT_JSON).unwrap()]); + let expected_results: Vec = + serde_json::from_value(search_results_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(search_results_json) + .query("term=test%20%26%20author") + .build_for(ReadarrEvent::SearchNewAuthor("test & author".to_owned())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::SearchNewAuthor("test & author".to_owned())) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::AddAuthorSearchResults(search_results) = result.unwrap() else { + panic!("Expected AddAuthorSearchResults") + }; + + assert_eq!(search_results, expected_results); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .add_searched_authors + .as_ref() + .unwrap() + .items, + expected_results + ); + } + + #[tokio::test] + async fn test_handle_search_new_author_event_navigates_to_empty_results_when_empty() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .query("term=nonexistent") + .build_for(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned())) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned())) + .await; + + mock.assert_async().await; + + assert_ok!(result); + let app = app.lock().await; + assert_none!(&app.data.readarr_data.add_searched_authors); + assert_eq!( + app.get_current_route(), + ActiveReadarrBlock::AddAuthorEmptySearchResults.into() + ); + } + + #[tokio::test] + async fn test_handle_search_new_author_event_sets_empty_table_on_api_error() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([serde_json::from_str::( + ADD_AUTHOR_SEARCH_RESULT_JSON + ) + .unwrap()])) + .status(500) + .query("term=nonexistent") + .build_for(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned())) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut stale_table = StatefulTable::default(); + stale_table.set_items(vec![stale_add_author_search_result()]); + app.data.readarr_data.add_searched_authors = Some(stale_table); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::SearchNewAuthor("nonexistent".to_owned())) + .await; + + mock.assert_async().await; + + assert_err!(result); + let app = app.lock().await; + assert_some!(&app.data.readarr_data.add_searched_authors); + assert_is_empty!(app.data.readarr_data.add_searched_authors.as_ref().unwrap()); + } + + #[tokio::test] + async fn test_handle_add_author_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "foreignAuthorId": "test-foreign-id", + "authorName": "Test Author", + "monitored": true, + "rootFolderPath": "/nfs/books", + "qualityProfileId": 1, + "metadataProfileId": 1, + "tags": [1, 2], + "images": [], + "addOptions": { + "monitor": "all", + "monitorNewItems": "all", + "searchForMissingBooks": true + } + })) + .returns(json!({"id": 1})) + .build_for(ReadarrEvent::AddAuthor(AddAuthorBody::default())) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddAuthor(add_author_body())) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_add_author_event_does_not_overwrite_tags_vec_when_tag_input_string_is_none() + { + let add_author_body = AddAuthorBody { + tags: vec![1, 2], + tag_input_string: None, + ..add_author_body() + }; + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "foreignAuthorId": "test-foreign-id", + "authorName": "Test Author", + "monitored": true, + "rootFolderPath": "/nfs/books", + "qualityProfileId": 1, + "metadataProfileId": 1, + "tags": [1, 2], + "images": [], + "addOptions": { + "monitor": "all", + "monitorNewItems": "all", + "searchForMissingBooks": true + } + })) + .returns(json!({"id": 1})) + .build_for(ReadarrEvent::AddAuthor(add_author_body.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddAuthor(add_author_body)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_add_author_event_failure() { + let add_author_body = AddAuthorBody { + tag_input_string: None, + ..add_author_body() + }; + let (mock, app, _server) = MockServarrApi::post() + .returns(json!({"id": 1})) + .status(400) + .build_for(ReadarrEvent::AddAuthor(add_author_body.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddAuthor(add_author_body)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_edit_author_event() { + let mut expected_body: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("monitorNewItems").unwrap() = json!("none"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("metadataProfileId").unwrap() = json!(2222); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1111), + metadata_profile_id: Some(2222), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + ..EditAuthorParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app + .data + .readarr_data + .authors + .set_items(vec![stale_author()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + let app = app.lock().await; + assert_eq!(app.data.readarr_data.authors.items, vec![stale_author()]); + } + + #[tokio::test] + async fn test_handle_edit_author_event_does_not_overwrite_tags_vec_when_tag_input_string_is_none() + { + let mut expected_body: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("monitorNewItems").unwrap() = json!("none"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("metadataProfileId").unwrap() = json!(2222); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1111), + metadata_profile_id: Some(2222), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tags: Some(vec![1, 2]), + ..EditAuthorParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_author_event_defaults_to_previous_values() { + let edit_author_params = EditAuthorParams { + author_id: 1, + ..EditAuthorParams::default() + }; + let expected_body: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_author_event_returns_empty_tags_vec_when_clear_tags_is_true() { + let mut expected_body: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([]); + let edit_author_params = EditAuthorParams { + author_id: 1, + clear_tags: true, + ..EditAuthorParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_author_event_failure() { + let edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(false), + ..EditAuthorParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .status(404) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .expect(0) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_err!(result); + } + + #[rstest] + #[case(true, false, "deleteFiles=true&addImportListExclusion=false")] + #[case(false, true, "deleteFiles=false&addImportListExclusion=true")] + #[tokio::test] + async fn test_handle_delete_author_event( + #[case] delete_files: bool, + #[case] add_import_list_exclusion: bool, + #[case] expected_query: &str, + ) { + let delete_author_params = DeleteParams { + id: 1, + delete_files, + add_import_list_exclusion, + }; + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .query(expected_query) + .build_for(ReadarrEvent::DeleteAuthor(delete_author_params.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteAuthor(delete_author_params)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_author_event_failure() { + let delete_author_params = DeleteParams { + id: 1, + delete_files: true, + add_import_list_exclusion: false, + }; + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .query("deleteFiles=true&addImportListExclusion=false") + .status(500) + .build_for(ReadarrEvent::DeleteAuthor(delete_author_params.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteAuthor(delete_author_params)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[rstest] + #[case(true, false)] + #[case(false, true)] + #[tokio::test] + async fn test_handle_toggle_author_monitoring_event( + #[case] initial_monitored: bool, + #[case] expected_monitored: bool, + ) { + let mut author_json: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + *author_json.get_mut("monitored").unwrap() = json!(initial_monitored); + let mut expected_body = author_json.clone(); + *expected_body.get_mut("monitored").unwrap() = json!(expected_monitored); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(author_json) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::ToggleAuthorMonitoring(1).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ToggleAuthorMonitoring(1)) + .await; + + async_details_server.assert_async().await; + async_toggle_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_toggle_author_monitoring_event_failure() { + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .status(404) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::ToggleAuthorMonitoring(1).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .expect(0) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ToggleAuthorMonitoring(1)) + .await; + + async_details_server.assert_async().await; + async_toggle_server.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_trigger_automatic_author_search_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "AuthorSearch", + "authorId": 1 + })) + .returns(json!({})) + .build_for(ReadarrEvent::TriggerAutomaticAuthorSearch(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TriggerAutomaticAuthorSearch(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_trigger_automatic_author_search_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "AuthorSearch", + "authorId": 1 + })) + .returns(json!({"name": "AuthorSearch", "status": "queued"})) + .status(500) + .build_for(ReadarrEvent::TriggerAutomaticAuthorSearch(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TriggerAutomaticAuthorSearch(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_update_and_scan_author_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "RefreshAuthor", + "authorId": 1 + })) + .returns(json!({})) + .build_for(ReadarrEvent::UpdateAndScanAuthor(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateAndScanAuthor(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_update_and_scan_author_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "RefreshAuthor", + "authorId": 1 + })) + .returns(json!({"name": "RefreshAuthor", "status": "queued"})) + .status(500) + .build_for(ReadarrEvent::UpdateAndScanAuthor(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateAndScanAuthor(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_update_all_authors_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "RefreshAuthor" + })) + .returns(json!({})) + .build_for(ReadarrEvent::UpdateAllAuthors) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateAllAuthors) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_update_all_authors_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "RefreshAuthor" + })) + .returns(json!({"name": "RefreshAuthor", "status": "queued"})) + .status(500) + .build_for(ReadarrEvent::UpdateAllAuthors) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::UpdateAllAuthors) + .await; + + mock.assert_async().await; + assert_err!(result); + } +} diff --git a/src/network/readarr_network/library/books/mod.rs b/src/network/readarr_network/library/books/mod.rs new file mode 100644 index 00000000..98d2859f --- /dev/null +++ b/src/network/readarr_network/library/books/mod.rs @@ -0,0 +1,365 @@ +use anyhow::Result; +use log::{debug, info, warn}; +use serde_json::{Value, json}; + +use crate::models::Route; +use crate::models::readarr_models::{ + Book, BookFile, DeleteParams, Edition, ReadarrCommandBody, ReadarrHistoryItem, ReadarrRelease, + ReadarrReleaseResponse, +}; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; + +#[cfg(test)] +#[path = "readarr_books_network_tests.rs"] +mod readarr_books_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn delete_book( + &mut self, + delete_book_params: DeleteParams, + ) -> Result<()> { + let event = ReadarrEvent::DeleteBook(DeleteParams::default()); + let DeleteParams { + id, + delete_files, + add_import_list_exclusion, + } = delete_book_params; + + info!( + "Deleting Readarr book with ID: {id} with deleteFiles={delete_files} and addImportListExclusion={add_import_list_exclusion}" + ); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + Some(format!( + "deleteFiles={delete_files}&addImportListExclusion={add_import_list_exclusion}" + )), + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn delete_readarr_book_file( + &mut self, + book_file_id: i64, + ) -> Result<()> { + info!("Deleting Readarr book file with ID: {book_file_id}"); + let event = ReadarrEvent::DeleteBookFile(book_file_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{book_file_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn get_book_details( + &mut self, + book_id: i64, + ) -> Result { + info!("Fetching details for Readarr book with ID: {book_id}"); + let event = ReadarrEvent::GetBookDetails(book_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + Some(format!("/{book_id}")), + None, + ) + .await; + + self + .handle_request::<(), Book>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn get_book_editions( + &mut self, + book_id: i64, + ) -> Result> { + info!("Fetching editions for Readarr book with ID: {book_id}"); + let event = ReadarrEvent::GetBookEditions(book_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("bookId={book_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut editions_vec, mut app| { + editions_vec.sort_by_key(|edition| edition.id); + let book_details_modal = app + .data + .readarr_data + .book_details_modal + .get_or_insert_default(); + + book_details_modal.editions.set_items(editions_vec); + }) + .await + } + + pub(in crate::network::readarr_network) async fn get_book_files( + &mut self, + book_id: i64, + ) -> Result> { + info!("Fetching book files for Readarr book with ID: {book_id}"); + let event = ReadarrEvent::GetBookFiles(book_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("bookId={book_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |book_files_vec, mut app| { + let book_details_modal = app + .data + .readarr_data + .book_details_modal + .get_or_insert_default(); + + book_details_modal.book_files.set_items(book_files_vec); + }) + .await + } + + pub(in crate::network::readarr_network) async fn get_book_releases( + &mut self, + book_id: i64, + ) -> Result> { + info!("Fetching releases for Readarr book with ID: {book_id}"); + let event = ReadarrEvent::GetBookReleases(book_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("bookId={book_id}")), + ) + .await; + + self + .handle_request::<(), ReadarrReleaseResponse>(request_props, |response, mut app| { + let book_details_modal = app + .data + .readarr_data + .book_details_modal + .get_or_insert_default(); + + book_details_modal + .book_releases + .set_items(response.into_releases()); + }) + .await + .map(ReadarrReleaseResponse::into_releases) + } + + pub(in crate::network::readarr_network) async fn get_books( + &mut self, + author_id: i64, + ) -> Result> { + info!("Fetching books for Readarr author with ID: {author_id}"); + let event = ReadarrEvent::GetBooks(author_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("authorId={author_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut books_vec, mut app| { + books_vec.sort_by_key(|a| a.id); + app.data.readarr_data.books.set_items(books_vec); + }) + .await + } + + pub(in crate::network::readarr_network) async fn get_readarr_book_history( + &mut self, + author_id: i64, + book_id: i64, + ) -> Result> { + info!("Fetching Readarr book history for book with ID: {book_id}"); + let event = ReadarrEvent::GetBookHistory(author_id, book_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("authorId={author_id}&bookId={book_id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut history_vec, mut app| { + let is_sorting = matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::BookHistorySortPrompt, _) + ); + + let book_details_modal = app + .data + .readarr_data + .book_details_modal + .get_or_insert_default(); + + if !is_sorting { + history_vec.sort_by_key(|item| item.id); + book_details_modal.book_history.set_items(history_vec); + book_details_modal.book_history.apply_sorting_toggle(false); + } + }) + .await + } + + pub(in crate::network::readarr_network) async fn toggle_book_monitoring( + &mut self, + book_id: i64, + ) -> Result<()> { + let event = ReadarrEvent::ToggleBookMonitoring(book_id); + + let detail_event = ReadarrEvent::GetBookDetails(book_id); + info!("Toggling book monitoring for book with ID: {book_id}"); + info!("Fetching book details for book with ID: {book_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{book_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_book_body, _| { + response = detailed_book_body.to_string() + }) + .await?; + + info!("Fetching book editions for book with ID: {book_id}"); + + let editions_event = ReadarrEvent::GetBookEditions(book_id); + let request_props = self + .request_props_from( + editions_event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("bookId={book_id}")), + ) + .await; + + let mut editions_response = String::new(); + + self + .handle_request::<(), Value>(request_props, |editions_body, _| { + editions_response = editions_body.to_string() + }) + .await?; + + info!("Constructing toggle book monitoring body"); + + match ( + serde_json::from_str::(&response), + serde_json::from_str::(&editions_response), + ) { + (Ok(mut detailed_book_body), Ok(editions)) => { + let monitored = detailed_book_body + .get("monitored") + .unwrap() + .as_bool() + .unwrap(); + + *detailed_book_body.get_mut("monitored").unwrap() = json!(!monitored); + detailed_book_body + .as_object_mut() + .unwrap() + .insert("editions".to_owned(), editions); + + debug!("Toggle book monitoring body: {detailed_book_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_book_body), + Some(format!("/{book_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + _ => { + warn!("Request for detailed book body or book editions was interrupted"); + Ok(()) + } + } + } + + pub(in crate::network::readarr_network) async fn trigger_automatic_book_search( + &mut self, + book_id: i64, + ) -> Result { + let event = ReadarrEvent::TriggerAutomaticBookSearch(book_id); + info!("Searching indexers for book with ID: {book_id}"); + let body = ReadarrCommandBody { + name: "BookSearch".to_owned(), + book_ids: Some(vec![book_id]), + ..ReadarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } +} diff --git a/src/network/readarr_network/library/books/readarr_books_network_tests.rs b/src/network/readarr_network/library/books/readarr_books_network_tests.rs new file mode 100644 index 00000000..a7aab4d2 --- /dev/null +++ b/src/network/readarr_network/library/books/readarr_books_network_tests.rs @@ -0,0 +1,1288 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{ + Book, BookFile, DeleteParams, Edition, ReadarrHistoryItem, ReadarrSerdeable, + }; + use crate::models::servarr_data::readarr::modals::BookDetailsModal; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::models::stateful_table::SortOption; + use crate::network::NetworkResource; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + BOOK_FILE_JSON, BOOK_JSON, EDITION_JSON, RELEASE_JSON, readarr_history_item, stale_book, + stale_book_file, stale_edition, stale_readarr_history_item, stale_release, torrent_release, + usenet_release, + }; + use mockito::Matcher; + use pretty_assertions::assert_eq; + use rstest::rstest; + use serde_json::{Value, json}; + + #[rstest] + #[case(true, false, "deleteFiles=true&addImportListExclusion=false")] + #[case(false, true, "deleteFiles=false&addImportListExclusion=true")] + #[tokio::test] + async fn test_handle_delete_book_event( + #[case] delete_files: bool, + #[case] add_import_list_exclusion: bool, + #[case] expected_query: &str, + ) { + let delete_book_params = DeleteParams { + id: 1, + delete_files, + add_import_list_exclusion, + }; + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .query(expected_query) + .build_for(ReadarrEvent::DeleteBook(delete_book_params.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteBook(delete_book_params)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_book_event_failure() { + let delete_book_params = DeleteParams { + id: 1, + delete_files: true, + add_import_list_exclusion: false, + }; + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .query("deleteFiles=true&addImportListExclusion=false") + .status(500) + .build_for(ReadarrEvent::DeleteBook(delete_book_params.clone())) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteBook(delete_book_params)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_book_file_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .build_for(ReadarrEvent::DeleteBookFile(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteBookFile(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_book_file_event_failure() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .status(500) + .build_for(ReadarrEvent::DeleteBookFile(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteBookFile(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_get_book_details_event() { + let expected_book: Book = serde_json::from_str(BOOK_JSON).unwrap(); + let stale_books = vec![stale_book()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(BOOK_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetBookDetails(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.books.set_items(stale_books.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookDetails(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Book(book) = result.unwrap() else { + panic!("Expected Book") + }; + + assert_eq!(book, expected_book); + assert_eq!(app.lock().await.data.readarr_data.books.items, stale_books); + } + + #[tokio::test] + async fn test_handle_get_book_details_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(BOOK_JSON).unwrap()) + .status(500) + .path("/1") + .build_for(ReadarrEvent::GetBookDetails(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookDetails(1)) + .await; + + mock.assert_async().await; + + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_get_book_editions_event() { + let editions_json = json!([ + { + "id": 3, + "bookId": 1, + "foreignEditionId": "test-foreign-edition-id-3", + "monitored": false, + "isEbook": true, + "title": "Third Edition", + "language": "eng", + "overview": "the third edition", + "format": "Kindle Edition", + "publisher": "Third Publisher", + "pageCount": 320, + "releaseDate": "2023-03-01T00:00:00Z", + "isbn13": "9780000000003", + "asin": "B000000003", + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } + }, + { + "id": 1, + "bookId": 1, + "foreignEditionId": "test-foreign-edition-id-1", + "monitored": true, + "isEbook": false, + "title": "First Edition", + "language": "eng", + "overview": "the first edition", + "format": "Hardcover", + "publisher": "First Publisher", + "pageCount": 662, + "releaseDate": "2023-01-01T00:00:00Z", + "isbn13": "9780000000001", + "asin": "B000000001", + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } + }, + { + "id": 2, + "bookId": 1, + "foreignEditionId": "test-foreign-edition-id-2", + "monitored": true, + "isEbook": false, + "title": "Second Edition", + "language": "eng", + "overview": "the second edition", + "format": "Paperback", + "publisher": "Second Publisher", + "pageCount": 128, + "releaseDate": "2023-02-01T00:00:00Z", + "isbn13": "9780000000002", + "asin": "B000000002", + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } + } + ]); + let response: Vec = serde_json::from_value(editions_json.clone()).unwrap(); + let mut sorted_editions = response.clone(); + sorted_editions.sort_by_key(|edition| edition.id); + let (mock, app, _server) = MockServarrApi::get() + .returns(editions_json) + .query("bookId=1") + .build_for(ReadarrEvent::GetBookEditions(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.book_details_modal = Some(BookDetailsModal::default()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookEditions(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Editions(editions) = result.unwrap() else { + panic!("Expected Editions") + }; + + assert_eq!(editions, response); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .editions + .items, + sorted_editions + ); + } + + #[tokio::test] + async fn test_handle_get_book_editions_event_empty_book_details_modal() { + let expected_editions: Vec = vec![serde_json::from_str(EDITION_JSON).unwrap()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!( + [serde_json::from_str::(EDITION_JSON).unwrap()] + )) + .query("bookId=1") + .build_for(ReadarrEvent::GetBookEditions(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookEditions(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Editions(editions) = result.unwrap() else { + panic!("Expected Editions") + }; + + assert_eq!(editions, expected_editions); + + let app = app.lock().await; + + assert_some!(&app.data.readarr_data.book_details_modal); + assert_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .editions + .items, + expected_editions + ); + } + + #[tokio::test] + async fn test_handle_get_book_editions_event_failure() { + let stale_editions = vec![stale_edition()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!( + [serde_json::from_str::(EDITION_JSON).unwrap()] + )) + .status(500) + .query("bookId=1") + .build_for(ReadarrEvent::GetBookEditions(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .editions + .set_items(stale_editions.clone()); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookEditions(1)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .editions + .items, + stale_editions + ); + } + + #[tokio::test] + async fn test_handle_get_book_files_event() { + let expected_book_files: Vec = vec![serde_json::from_str(BOOK_FILE_JSON).unwrap()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([ + serde_json::from_str::(BOOK_FILE_JSON).unwrap() + ])) + .query("bookId=1") + .build_for(ReadarrEvent::GetBookFiles(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.book_details_modal = Some(BookDetailsModal::default()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookFiles(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::BookFiles(book_files) = result.unwrap() else { + panic!("Expected BookFiles") + }; + + assert_eq!(book_files, expected_book_files); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_files + .items, + expected_book_files + ); + } + + #[tokio::test] + async fn test_handle_get_book_files_event_empty_book_details_modal() { + let expected_book_files: Vec = vec![serde_json::from_str(BOOK_FILE_JSON).unwrap()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([ + serde_json::from_str::(BOOK_FILE_JSON).unwrap() + ])) + .query("bookId=1") + .build_for(ReadarrEvent::GetBookFiles(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookFiles(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::BookFiles(book_files) = result.unwrap() else { + panic!("Expected BookFiles") + }; + + assert_eq!(book_files, expected_book_files); + + let app = app.lock().await; + + assert_some!(&app.data.readarr_data.book_details_modal); + assert_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_files + .items, + expected_book_files + ); + } + + #[tokio::test] + async fn test_handle_get_book_files_event_failure() { + let stale_book_files = vec![stale_book_file()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([ + serde_json::from_str::(BOOK_FILE_JSON).unwrap() + ])) + .status(500) + .query("bookId=1") + .build_for(ReadarrEvent::GetBookFiles(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .book_files + .set_items(stale_book_files.clone()); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookFiles(1)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_files + .items, + stale_book_files + ); + } + + #[tokio::test] + async fn test_handle_get_book_releases_event() { + let expected_releases = vec![torrent_release(), usenet_release()]; + let releases_json = json!([ + serde_json::from_str::(RELEASE_JSON).unwrap(), + { + "guid": "test-usenet-release-guid", + "protocol": "usenet", + "age": 4492, + "title": "Test Author - Test Book [AZW3]", + "authorName": "Test Author", + "bookTitle": "Test Book", + "indexer": "DrunkenSlug", + "indexerId": 4, + "size": 313924185, + "rejected": true, + "rejections": ["Unknown quality profile", "Release is already mapped"], + "quality": { + "quality": { "id": 12, "name": "AZW3" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + } + } + ]); + let (mock, app, _server) = MockServarrApi::get() + .returns(releases_json) + .query("bookId=7") + .build_for(ReadarrEvent::GetBookReleases(7)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .book_releases + .set_items(vec![stale_release()]); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookReleases(7)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Releases(releases) = result.unwrap() else { + panic!("Expected Releases") + }; + + assert_eq!(releases, expected_releases); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_releases + .items, + expected_releases + ); + } + + #[tokio::test] + async fn test_handle_get_book_releases_event_unwraps_the_release_envelope() { + let expected_releases = vec![torrent_release()]; + let releases_json = json!({ + "releases": [serde_json::from_str::(RELEASE_JSON).unwrap()], + "hiddenReleases": [], + "filterSummary": { "totalResults": 1, "displayedCount": 1 }, + "siblingToggleEnabled": false + }); + let (mock, app, _server) = MockServarrApi::get() + .returns(releases_json) + .query("bookId=7") + .build_for(ReadarrEvent::GetBookReleases(7)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .book_releases + .set_items(vec![stale_release()]); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookReleases(7)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Releases(releases) = result.unwrap() else { + panic!("Expected Releases") + }; + + assert_eq!(releases, expected_releases); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_releases + .items, + expected_releases + ); + } + + #[tokio::test] + async fn test_handle_get_book_releases_event_empty_book_details_modal() { + let expected_releases = vec![torrent_release()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!( + [serde_json::from_str::(RELEASE_JSON).unwrap()] + )) + .query("bookId=7") + .build_for(ReadarrEvent::GetBookReleases(7)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookReleases(7)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Releases(releases) = result.unwrap() else { + panic!("Expected Releases") + }; + + assert_eq!(releases, expected_releases); + + let app = app.lock().await; + + assert_some!(&app.data.readarr_data.book_details_modal); + assert_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_releases + .items, + expected_releases + ); + } + + #[tokio::test] + async fn test_handle_get_book_releases_event_failure() { + let stale_releases = vec![stale_release()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!( + [serde_json::from_str::(RELEASE_JSON).unwrap()] + )) + .status(500) + .query("bookId=7") + .build_for(ReadarrEvent::GetBookReleases(7)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .book_releases + .set_items(stale_releases.clone()); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookReleases(7)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_releases + .items, + stale_releases + ); + } + + #[tokio::test] + async fn test_handle_get_books_event() { + let books_json = json!([ + { + "id": 3, + "title": "Third Book", + "authorId": 1, + "foreignBookId": "test-foreign-book-id-3", + "monitored": true, + "anyEditionOk": true, + "pageCount": 320, + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "releaseDate": "2023-03-01T00:00:00Z", + "statistics": { + "bookFileCount": 2, + "totalBookCount": 3, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + }, + "grabbed": false + }, + { + "id": 1, + "title": "First Book", + "authorId": 1, + "foreignBookId": "test-foreign-book-id-1", + "monitored": true, + "anyEditionOk": true, + "pageCount": 662, + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "releaseDate": "2023-01-01T00:00:00Z", + "statistics": { + "bookFileCount": 2, + "totalBookCount": 3, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + }, + "grabbed": false + }, + { + "id": 2, + "title": "Second Book", + "authorId": 1, + "foreignBookId": "test-foreign-book-id-2", + "monitored": false, + "anyEditionOk": true, + "pageCount": 128, + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "releaseDate": "2023-02-01T00:00:00Z", + "statistics": { + "bookFileCount": 2, + "totalBookCount": 3, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + }, + "grabbed": false + } + ]); + let response: Vec = serde_json::from_value(books_json.clone()).unwrap(); + let mut sorted_books = response.clone(); + sorted_books.sort_by_key(|book| book.id); + let (mock, app, _server) = MockServarrApi::get() + .returns(books_json) + .query("authorId=1") + .build_for(ReadarrEvent::GetBooks(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBooks(1)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Books(books) = result.unwrap() else { + panic!("Expected Books") + }; + + assert_eq!(books, response); + assert_eq!(app.lock().await.data.readarr_data.books.items, sorted_books); + } + + #[tokio::test] + async fn test_handle_get_books_event_failure() { + let stale_books = vec![stale_book()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([serde_json::from_str::(BOOK_JSON).unwrap()])) + .status(500) + .query("authorId=1") + .build_for(ReadarrEvent::GetBooks(1)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.books.set_items(stale_books.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBooks(1)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!(app.lock().await.data.readarr_data.books.items, stale_books); + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_readarr_book_history_event( + #[values(true, false)] use_custom_sorting: bool, + ) { + let history_json = json!([{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let mut expected_history_items = vec![ + ReadarrHistoryItem { + id: 123, + author_id: 1007, + book_id: 1007, + source_title: "z book".into(), + ..readarr_history_item() + }, + ReadarrHistoryItem { + id: 456, + author_id: 2001, + book_id: 2001, + source_title: "An Anthology".into(), + ..readarr_history_item() + }, + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("authorId=1&bookId=2") + .build_for(ReadarrEvent::GetBookHistory(1, 2)) + .await; + app.lock().await.data.readarr_data.book_details_modal = Some(BookDetailsModal::default()); + if use_custom_sorting { + let cmp_fn = |a: &ReadarrHistoryItem, b: &ReadarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + expected_history_items.sort_by(cmp_fn); + + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .book_history + .sorting(vec![history_sort_option]); + } + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .book_history + .sort_asc = true; + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookHistory(1, 2)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryItems(history_items) = result.unwrap() else { + panic!("Expected ReadarrHistoryItems") + }; + + assert_eq!(history_items, response); + let app = app.lock().await; + let book_details_modal = app.data.readarr_data.book_details_modal.as_ref().unwrap(); + assert_eq!( + book_details_modal.book_history.items, + expected_history_items + ); + assert!(book_details_modal.book_history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_book_history_event_no_op_when_user_is_selecting_sort_options() { + let history_json = json!([{ + "id": 456, + "authorId": 2001, + "bookId": 2001, + "sourceTitle": "An Anthology", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }, + { + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("authorId=1&bookId=2") + .build_for(ReadarrEvent::GetBookHistory(1, 2)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal.book_history.sort_asc = true; + app.data.readarr_data.book_details_modal = Some(book_details_modal); + app.push_navigation_stack(ActiveReadarrBlock::BookHistorySortPrompt.into()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookHistory(1, 2)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryItems(history_items) = result.unwrap() else { + panic!("Expected ReadarrHistoryItems") + }; + + assert_eq!(history_items, response); + let app = app.lock().await; + let book_details_modal = app.data.readarr_data.book_details_modal.as_ref().unwrap(); + assert_is_empty!(book_details_modal.book_history); + assert!(book_details_modal.book_history.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_readarr_book_history_event_empty_book_details_modal() { + let history_json = json!([{ + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let expected_history_items = vec![ReadarrHistoryItem { + id: 123, + author_id: 1007, + book_id: 1007, + source_title: "z book".into(), + ..readarr_history_item() + }]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .query("authorId=1&bookId=2") + .build_for(ReadarrEvent::GetBookHistory(1, 2)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookHistory(1, 2)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::ReadarrHistoryItems(history_items) = result.unwrap() else { + panic!("Expected ReadarrHistoryItems") + }; + + assert_eq!(history_items, expected_history_items); + + let app = app.lock().await; + + assert_some!(&app.data.readarr_data.book_details_modal); + assert_eq!( + app + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_history + .items, + expected_history_items + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_book_history_event_failure() { + let history_json = json!([{ + "id": 123, + "authorId": 1007, + "bookId": 1007, + "sourceTitle": "z book", + "quality": { "quality": { "name": "AZW3" } }, + "date": "2023-01-01T00:00:00Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/books/Something/cool.azw3", + "importedPath": "/nfs/books/Test Author/Book 1/Cool.azw3" + } + }]); + let stale_history_items = vec![stale_readarr_history_item()]; + let (mock, app, _server) = MockServarrApi::get() + .returns(history_json) + .status(500) + .query("authorId=1&bookId=2") + .build_for(ReadarrEvent::GetBookHistory(1, 2)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + let mut book_details_modal = BookDetailsModal::default(); + book_details_modal + .book_history + .set_items(stale_history_items.clone()); + app.data.readarr_data.book_details_modal = Some(book_details_modal); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetBookHistory(1, 2)) + .await; + + mock.assert_async().await; + + assert_err!(result); + assert_eq!( + app + .lock() + .await + .data + .readarr_data + .book_details_modal + .as_ref() + .unwrap() + .book_history + .items, + stale_history_items + ); + } + + #[rstest] + #[case(true, false)] + #[case(false, true)] + #[tokio::test] + async fn test_handle_toggle_book_monitoring_event( + #[case] initial_monitored: bool, + #[case] expected_monitored: bool, + ) { + let editions_json = json!([ + { + "id": 2, + "bookId": 1, + "foreignEditionId": "test-foreign-edition-id-2", + "monitored": false, + "isEbook": false, + "title": "Second Edition", + "language": "eng", + "overview": "the second edition", + "format": "Hardcover", + "publisher": "Second Publisher", + "pageCount": 128, + "releaseDate": "2023-02-01T00:00:00Z", + "isbn13": "9780000000002", + "asin": "B000000002", + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } + }, + serde_json::from_str::(EDITION_JSON).unwrap() + ]); + let mut book_json: Value = serde_json::from_str(BOOK_JSON).unwrap(); + *book_json.get_mut("monitored").unwrap() = json!(initial_monitored); + let mut expected_body = book_json.clone(); + *expected_body.get_mut("monitored").unwrap() = json!(expected_monitored); + expected_body + .as_object_mut() + .unwrap() + .insert("editions".to_owned(), editions_json.clone()); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(book_json) + .path("/1") + .build_for(ReadarrEvent::GetBookDetails(1)) + .await; + let async_editions_server = server + .mock( + "GET", + format!( + "/api/v1{}?bookId=1", + ReadarrEvent::GetBookEditions(1).resource() + ) + .as_str(), + ) + .with_status(200) + .match_header("X-Api-Key", "test1234") + .with_body(editions_json.to_string()) + .create_async() + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::ToggleBookMonitoring(1).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ToggleBookMonitoring(1)) + .await; + + async_details_server.assert_async().await; + async_editions_server.assert_async().await; + async_toggle_server.assert_async().await; + assert_ok!(result); + assert_none!(app.lock().await.data.readarr_data.book_details_modal); + } + + #[tokio::test] + async fn test_handle_toggle_book_monitoring_event_failure() { + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(BOOK_JSON).unwrap()) + .status(404) + .path("/1") + .build_for(ReadarrEvent::GetBookDetails(1)) + .await; + let async_editions_server = server + .mock( + "GET", + format!( + "/api/v1{}?bookId=1", + ReadarrEvent::GetBookEditions(1).resource() + ) + .as_str(), + ) + .with_status(200) + .match_header("X-Api-Key", "test1234") + .expect(0) + .create_async() + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::ToggleBookMonitoring(1).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .expect(0) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ToggleBookMonitoring(1)) + .await; + + async_details_server.assert_async().await; + async_editions_server.assert_async().await; + async_toggle_server.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_toggle_book_monitoring_event_editions_failure() { + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(BOOK_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetBookDetails(1)) + .await; + let async_editions_server = server + .mock( + "GET", + format!( + "/api/v1{}?bookId=1", + ReadarrEvent::GetBookEditions(1).resource() + ) + .as_str(), + ) + .with_status(404) + .match_header("X-Api-Key", "test1234") + .with_body(json!([serde_json::from_str::(EDITION_JSON).unwrap()]).to_string()) + .create_async() + .await; + let async_toggle_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::ToggleBookMonitoring(1).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .expect(0) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::ToggleBookMonitoring(1)) + .await; + + async_details_server.assert_async().await; + async_editions_server.assert_async().await; + async_toggle_server.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_trigger_automatic_book_search_event() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "BookSearch", + "bookIds": [1] + })) + .returns(json!({})) + .build_for(ReadarrEvent::TriggerAutomaticBookSearch(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TriggerAutomaticBookSearch(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_trigger_automatic_book_search_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "BookSearch", + "bookIds": [1] + })) + .returns(json!({"name": "BookSearch", "status": "queued"})) + .status(500) + .build_for(ReadarrEvent::TriggerAutomaticBookSearch(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TriggerAutomaticBookSearch(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } +} diff --git a/src/network/readarr_network/library/mod.rs b/src/network/readarr_network/library/mod.rs new file mode 100644 index 00000000..bf8c3725 --- /dev/null +++ b/src/network/readarr_network/library/mod.rs @@ -0,0 +1,56 @@ +use crate::models::servarr_data::Notification; +use crate::models::servarr_models::ReleaseDownloadBody; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::info; +use serde_json::Value; + +mod authors; +mod books; + +#[cfg(test)] +#[path = "readarr_library_network_tests.rs"] +mod readarr_library_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn download_readarr_release( + &mut self, + release_download_body: ReleaseDownloadBody, + ) -> Result { + let event = ReadarrEvent::DownloadRelease(ReleaseDownloadBody::default()); + info!("Downloading Readarr release with params: {release_download_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(release_download_body), + None, + None, + ) + .await; + + let result = self + .handle_request::(request_props, |_, mut app| { + app.notification = Some(Notification::new( + "Download Result".to_owned(), + "Download request sent successfully".to_owned(), + true, + )); + }) + .await; + + if result.is_err() { + let mut app = self.app.lock().await; + std::mem::take(&mut app.error.text); + app.notification = Some(Notification::new( + "Download Failed".to_owned(), + "Download request failed. Check the logs for more details.".to_owned(), + false, + )); + } + + result + } +} diff --git a/src/network/readarr_network/library/readarr_library_network_tests.rs b/src/network/readarr_network/library/readarr_library_network_tests.rs new file mode 100644 index 00000000..7268e40c --- /dev/null +++ b/src/network/readarr_network/library/readarr_library_network_tests.rs @@ -0,0 +1,82 @@ +#[cfg(test)] +mod tests { + use crate::models::servarr_data::Notification; + use crate::models::servarr_models::ReleaseDownloadBody; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[tokio::test] + async fn test_handle_download_readarr_release_event_uses_provided_params() { + let params = ReleaseDownloadBody { + guid: "test-release-guid".to_owned(), + indexer_id: 6, + }; + + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "guid": "test-release-guid", + "indexerId": 6, + })) + .returns(json!({ "id": 3 })) + .build_for(ReadarrEvent::DownloadRelease(params.clone())) + .await; + + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DownloadRelease(params)) + .await; + + mock.assert_async().await; + assert_ok!(result); + assert_eq!( + app.lock().await.notification, + Some(Notification::new( + "Download Result".to_owned(), + "Download request sent successfully".to_owned(), + true, + )) + ); + } + + #[tokio::test] + async fn test_handle_download_readarr_release_event_sets_failure_notification_on_error() { + let params = ReleaseDownloadBody { + guid: "test-release-guid".to_owned(), + indexer_id: 6, + }; + + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "guid": "test-release-guid", + "indexerId": 6, + })) + .returns(json!({ "id": 3 })) + .status(500) + .build_for(ReadarrEvent::DownloadRelease(params.clone())) + .await; + + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DownloadRelease(params)) + .await; + + mock.assert_async().await; + assert_err!(result); + let app = app.lock().await; + assert_is_empty!(app.error.text); + assert_some_eq_x!( + &app.notification, + &Notification::new( + "Download Failed".to_owned(), + "Download request failed. Check the logs for more details.".to_owned(), + false, + ) + ); + } +} diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs new file mode 100644 index 00000000..48d1a6f5 --- /dev/null +++ b/src/network/readarr_network/mod.rs @@ -0,0 +1,506 @@ +use anyhow::Result; +use log::info; +use serde_json::{Value, json}; + +use super::{NetworkEvent, NetworkResource}; +use crate::models::readarr_models::{ + AddAuthorBody, AddReadarrRootFolderBody, DeleteParams, EditAuthorParams, ReadarrSerdeable, + ReadarrTaskName, +}; +use crate::models::servarr_models::{ + EditIndexerParams, IndexerSettings, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, +}; +use crate::network::{Network, RequestMethod}; + +mod blocklist; +mod downloads; +mod history; +mod indexers; +mod library; +mod root_folders; +mod system; + +#[cfg(test)] +#[path = "readarr_network_tests.rs"] +mod readarr_network_tests; + +#[cfg(test)] +#[path = "readarr_network_test_utils.rs"] +pub mod readarr_network_test_utils; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum ReadarrEvent { + AddAuthor(AddAuthorBody), + AddRootFolder(AddReadarrRootFolderBody), + AddTag(String), + ClearBlocklist, + DeleteAuthor(DeleteParams), + DeleteBlocklistItem(i64), + DeleteBook(DeleteParams), + DeleteBookFile(i64), + DeleteDownload(i64), + DeleteIndexer(i64), + DeleteRootFolder(i64), + DeleteTag(i64), + DownloadRelease(ReleaseDownloadBody), + EditAllIndexerSettings(IndexerSettings), + EditAuthor(EditAuthorParams), + EditIndexer(EditIndexerParams), + GetAllIndexerSettings, + GetAuthorDetails(i64), + GetAuthorHistory(i64), + GetAuthorReleases(i64), + GetBlocklist, + GetBookDetails(i64), + GetBookEditions(i64), + GetBookFiles(i64), + GetBookHistory(i64, i64), + GetBookReleases(i64), + GetBooks(i64), + GetDiskSpace, + GetDownloads(u64), + GetHistory(u64), + GetHostConfig, + GetIndexers, + GetLogs(u64), + GetMetadataProfiles, + GetQualityProfiles, + GetQueuedEvents, + GetRootFolders, + GetSecurityConfig, + GetStatus, + GetTags, + GetTasks, + GetUpdates, + HealthCheck, + ListAuthors, + MarkHistoryItemAsFailed(i64), + SearchNewAuthor(String), + StartTask(ReadarrTaskName), + TestAllIndexers, + TestIndexer(i64), + ToggleAuthorMonitoring(i64), + ToggleBookMonitoring(i64), + TriggerAutomaticAuthorSearch(i64), + TriggerAutomaticBookSearch(i64), + UpdateAllAuthors, + UpdateAndScanAuthor(i64), + UpdateDownloads, +} + +impl NetworkResource for ReadarrEvent { + fn resource(&self) -> &'static str { + match &self { + ReadarrEvent::AddAuthor(_) + | ReadarrEvent::DeleteAuthor(_) + | ReadarrEvent::EditAuthor(_) + | ReadarrEvent::GetAuthorDetails(_) + | ReadarrEvent::ListAuthors + | ReadarrEvent::ToggleAuthorMonitoring(_) => "/author", + ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", + ReadarrEvent::DeleteBlocklistItem(_) => "/blocklist", + ReadarrEvent::ClearBlocklist => "/blocklist/bulk", + ReadarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", + ReadarrEvent::DeleteBook(_) + | ReadarrEvent::GetBookDetails(_) + | ReadarrEvent::GetBooks(_) + | ReadarrEvent::ToggleBookMonitoring(_) => "/book", + ReadarrEvent::DeleteBookFile(_) | ReadarrEvent::GetBookFiles(_) => "/bookfile", + ReadarrEvent::GetQueuedEvents + | ReadarrEvent::StartTask(_) + | ReadarrEvent::TriggerAutomaticAuthorSearch(_) + | ReadarrEvent::TriggerAutomaticBookSearch(_) + | ReadarrEvent::UpdateAllAuthors + | ReadarrEvent::UpdateAndScanAuthor(_) + | ReadarrEvent::UpdateDownloads => "/command", + ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", + ReadarrEvent::GetAllIndexerSettings | ReadarrEvent::EditAllIndexerSettings(_) => { + "/config/indexer" + } + ReadarrEvent::GetDiskSpace => "/diskspace", + ReadarrEvent::GetBookEditions(_) => "/edition", + ReadarrEvent::HealthCheck => "/health", + ReadarrEvent::GetHistory(_) => "/history", + ReadarrEvent::GetAuthorHistory(_) | ReadarrEvent::GetBookHistory(_, _) => "/history/author", + ReadarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", + ReadarrEvent::DeleteIndexer(_) | ReadarrEvent::EditIndexer(_) | ReadarrEvent::GetIndexers => { + "/indexer" + } + ReadarrEvent::TestIndexer(_) => "/indexer/test", + ReadarrEvent::TestAllIndexers => "/indexer/testall", + ReadarrEvent::GetLogs(_) => "/log", + ReadarrEvent::GetMetadataProfiles => "/metadataprofile", + ReadarrEvent::GetQualityProfiles => "/qualityprofile", + ReadarrEvent::DeleteDownload(_) | ReadarrEvent::GetDownloads(_) => "/queue", + ReadarrEvent::DownloadRelease(_) + | ReadarrEvent::GetAuthorReleases(_) + | ReadarrEvent::GetBookReleases(_) => "/release", + ReadarrEvent::AddRootFolder(_) + | ReadarrEvent::DeleteRootFolder(_) + | ReadarrEvent::GetRootFolders => "/rootfolder", + ReadarrEvent::GetStatus => "/system/status", + ReadarrEvent::GetTasks => "/system/task", + ReadarrEvent::AddTag(_) | ReadarrEvent::DeleteTag(_) | ReadarrEvent::GetTags => "/tag", + ReadarrEvent::GetUpdates => "/update", + } + } +} + +impl From for NetworkEvent { + fn from(readarr_event: ReadarrEvent) -> Self { + NetworkEvent::Readarr(readarr_event) + } +} + +impl Network<'_, '_> { + pub async fn handle_readarr_event( + &mut self, + readarr_event: ReadarrEvent, + ) -> Result { + match readarr_event { + ReadarrEvent::AddAuthor(body) => self.add_author(body).await.map(ReadarrSerdeable::from), + ReadarrEvent::AddRootFolder(add_root_folder_body) => self + .add_readarr_root_folder(add_root_folder_body) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::AddTag(tag) => self.add_readarr_tag(tag).await.map(ReadarrSerdeable::from), + ReadarrEvent::ClearBlocklist => self + .clear_readarr_blocklist() + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::DeleteAuthor(delete_author_params) => self + .delete_author(delete_author_params) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::DeleteBlocklistItem(blocklist_item_id) => self + .delete_readarr_blocklist_item(blocklist_item_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::DeleteBook(delete_book_params) => self + .delete_book(delete_book_params) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::DeleteBookFile(book_file_id) => self + .delete_readarr_book_file(book_file_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::DeleteDownload(download_id) => self + .delete_readarr_download(download_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::DeleteIndexer(indexer_id) => self + .delete_readarr_indexer(indexer_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::DeleteRootFolder(root_folder_id) => self + .delete_readarr_root_folder(root_folder_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::DeleteTag(tag_id) => self + .delete_readarr_tag(tag_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::DownloadRelease(release_download_body) => self + .download_readarr_release(release_download_body) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::EditAllIndexerSettings(params) => self + .edit_all_readarr_indexer_settings(params) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::EditAuthor(edit_author_params) => self + .edit_author(edit_author_params) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::EditIndexer(edit_indexer_params) => self + .edit_readarr_indexer(edit_indexer_params) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetAllIndexerSettings => self + .get_all_readarr_indexer_settings() + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetAuthorDetails(author_id) => self + .get_author_details(author_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetAuthorHistory(author_id) => self + .get_readarr_author_history(author_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetAuthorReleases(author_id) => self + .get_author_releases(author_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetBlocklist => self + .get_readarr_blocklist() + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetBookDetails(book_id) => self + .get_book_details(book_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetBookEditions(book_id) => self + .get_book_editions(book_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetBookFiles(book_id) => self + .get_book_files(book_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetBookHistory(author_id, book_id) => self + .get_readarr_book_history(author_id, book_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetBookReleases(book_id) => self + .get_book_releases(book_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetBooks(author_id) => { + self.get_books(author_id).await.map(ReadarrSerdeable::from) + } + ReadarrEvent::GetDiskSpace => self + .get_readarr_diskspace() + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetDownloads(count) => self + .get_readarr_downloads(count) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetHistory(events) => self + .get_readarr_history(events) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetHostConfig => self + .get_readarr_host_config() + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetIndexers => self + .get_readarr_indexers() + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetLogs(events) => self + .get_readarr_logs(events) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetMetadataProfiles => self + .get_readarr_metadata_profiles() + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetQualityProfiles => self + .get_readarr_quality_profiles() + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetQueuedEvents => self + .get_queued_readarr_events() + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetRootFolders => self + .get_readarr_root_folders() + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetSecurityConfig => self + .get_readarr_security_config() + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::GetStatus => self.get_readarr_status().await.map(ReadarrSerdeable::from), + ReadarrEvent::GetTags => self.get_readarr_tags().await.map(ReadarrSerdeable::from), + ReadarrEvent::GetTasks => self.get_readarr_tasks().await.map(ReadarrSerdeable::from), + ReadarrEvent::GetUpdates => self.get_readarr_updates().await.map(ReadarrSerdeable::from), + ReadarrEvent::HealthCheck => self + .get_readarr_healthcheck() + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::ListAuthors => self.list_authors().await.map(ReadarrSerdeable::from), + ReadarrEvent::MarkHistoryItemAsFailed(history_item_id) => self + .mark_readarr_history_item_as_failed(history_item_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::SearchNewAuthor(query) => { + self.search_author(query).await.map(ReadarrSerdeable::from) + } + ReadarrEvent::StartTask(task_name) => self + .start_readarr_task(task_name) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::TestAllIndexers => self + .test_all_readarr_indexers() + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::TestIndexer(indexer_id) => self + .test_readarr_indexer(indexer_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::ToggleAuthorMonitoring(author_id) => self + .toggle_author_monitoring(author_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::ToggleBookMonitoring(book_id) => self + .toggle_book_monitoring(book_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::TriggerAutomaticAuthorSearch(author_id) => self + .trigger_automatic_author_search(author_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::TriggerAutomaticBookSearch(book_id) => self + .trigger_automatic_book_search(book_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::UpdateAllAuthors => self.update_all_authors().await.map(ReadarrSerdeable::from), + ReadarrEvent::UpdateAndScanAuthor(author_id) => self + .update_and_scan_author(author_id) + .await + .map(ReadarrSerdeable::from), + ReadarrEvent::UpdateDownloads => self + .update_readarr_downloads() + .await + .map(ReadarrSerdeable::from), + } + } + + pub(in crate::network::readarr_network) async fn get_readarr_healthcheck( + &mut self, + ) -> Result<()> { + info!("Performing Readarr health check"); + let event = ReadarrEvent::HealthCheck; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn get_readarr_metadata_profiles(&mut self) -> Result> { + info!("Fetching Readarr metadata profiles"); + let event = ReadarrEvent::GetMetadataProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |metadata_profiles, mut app| { + app.data.readarr_data.metadata_profile_map = metadata_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } + + async fn get_readarr_quality_profiles(&mut self) -> Result> { + info!("Fetching Readarr quality profiles"); + let event = ReadarrEvent::GetQualityProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |quality_profiles, mut app| { + app.data.readarr_data.quality_profile_map = quality_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } + + async fn get_readarr_tags(&mut self) -> Result> { + info!("Fetching Readarr tags"); + let event = ReadarrEvent::GetTags; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |tags_vec, mut app| { + app.data.readarr_data.tags_map = tags_vec + .into_iter() + .map(|tag| (tag.id, tag.label)) + .collect(); + }) + .await + } + + async fn add_readarr_tag(&mut self, tag: String) -> Result { + info!("Adding a new Readarr tag"); + let event = ReadarrEvent::AddTag(String::new()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(json!({ "label": tag })), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |tag, mut app| { + app.data.readarr_data.tags_map.insert(tag.id, tag.label); + }) + .await + } + + async fn delete_readarr_tag(&mut self, id: i64) -> Result<()> { + info!("Deleting Readarr tag with ID: {id}"); + let event = ReadarrEvent::DeleteTag(id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn extract_and_add_readarr_tag_ids_vec( + &mut self, + edit_tags: &str, + ) -> Vec { + let missing_tags_vec = { + let tags_map = &self.app.lock().await.data.readarr_data.tags_map; + edit_tags + .split(',') + .filter(|&tag| { + !tag.is_empty() && tags_map.get_by_right(tag.to_lowercase().trim()).is_none() + }) + .collect::>() + }; + + for tag in missing_tags_vec { + self + .add_readarr_tag(tag.trim().to_owned()) + .await + .expect("Unable to add tag"); + } + + let app = self.app.lock().await; + edit_tags + .split(',') + .filter(|tag| !tag.is_empty()) + .map(|tag| { + *app + .data + .readarr_data + .tags_map + .get_by_right(tag.to_lowercase().trim()) + .unwrap() + }) + .collect() + } +} diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs new file mode 100644 index 00000000..6afc5b28 --- /dev/null +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -0,0 +1,554 @@ +#[cfg(test)] +#[allow(dead_code)] +pub mod test_utils { + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{ + AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, BlocklistItem, + Book, BookFile, DownloadRecord, DownloadsResponse, Edition, MonitorType, NewItemMonitorType, + ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrRelease, + }; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; + use crate::models::servarr_models::{ + DownloadStatus, Indexer, IndexerField, IndexerSettings, Quality, QualityWrapper, RootFolder, + }; + use chrono::DateTime; + use serde_json::{Number, json}; + + pub const AUTHOR_JSON: &str = r#"{ + "id": 1, + "authorName": "Test Author", + "foreignAuthorId": "test-foreign-id", + "status": "continuing", + "overview": "some interesting description of the author", + "authorType": "Person", + "disambiguation": "American novelist", + "path": "/nfs/books/Test Author", + "rootFolderPath": "/nfs/books/", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["science fiction"], + "tags": [1], + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "statistics": { + "bookCount": 3, + "bookFileCount": 2, + "totalBookCount": 3, + "availableBookCount": 2, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + } + }"#; + + pub const ADD_AUTHOR_SEARCH_RESULT_JSON: &str = r#"{ + "foreignAuthorId": "test-foreign-id", + "authorName": "Test Author", + "status": "continuing", + "ended": false, + "overview": "some interesting description of the author", + "authorType": "Person", + "disambiguation": "American novelist", + "genres": ["science fiction"], + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } + }"#; + + pub const BOOK_JSON: &str = r#"{ + "id": 1, + "title": "Test Book", + "authorTitle": "Test Author Test Book", + "seriesTitle": "Test Series", + "authorId": 1, + "foreignBookId": "test-foreign-book-id", + "monitored": true, + "anyEditionOk": true, + "pageCount": 662, + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 }, + "releaseDate": "2023-01-01T00:00:00Z", + "statistics": { + "bookFileCount": 2, + "totalBookCount": 3, + "sizeOnDisk": 12345, + "percentOfBooks": 66.6 + }, + "lastSearchTime": "2023-01-01T00:00:00Z", + "grabbed": false + }"#; + + pub const BOOK_FILE_JSON: &str = r#"{ + "id": 1, + "authorId": 1, + "bookId": 1, + "path": "/nfs/books/Test Author/Test Book/Test Author - Test Book.azw3", + "size": 1074732, + "dateAdded": "2023-01-01T00:00:00Z", + "quality": { "quality": { "name": "AZW3" } }, + "mediaInfo": { + "audioBitRate": "128 kbps", + "audioChannels": 2, + "audioCodec": "MP3", + "audioBits": "16", + "audioSampleRate": "44.1 kHz" + }, + "qualityCutoffNotMet": false + }"#; + + pub const EDITION_JSON: &str = r#"{ + "id": 1, + "bookId": 1, + "foreignEditionId": "test-foreign-edition-id", + "monitored": true, + "isEbook": true, + "title": "Test Edition", + "language": "eng", + "overview": "some interesting description of the edition", + "format": "Paperback", + "publisher": "Test Publisher", + "pageCount": 662, + "releaseDate": "2023-01-01T00:00:00Z", + "isbn13": "9780000000001", + "asin": "B000000001", + "ratings": { "votes": 15, "value": 8.4, "popularity": 1.2 } + }"#; + + pub const DOWNLOAD_RECORD_JSON: &str = r#"{ + "title": "Test Book Download", + "status": "downloading", + "id": 1, + "bookId": 1, + "authorId": 1, + "size": 1000.0, + "sizeleft": 250.0, + "outputPath": "/nfs/nzbget/completed/books/Test Author - Test Book", + "indexer": "test-indexer", + "downloadClient": "NZBGet" + }"#; + + pub const RELEASE_JSON: &str = r#"{ + "guid": "test-release-guid", + "protocol": "torrent", + "age": 4492, + "title": "Test Author - Test Book [AZW3]", + "authorName": "Test Author", + "bookTitle": "Test Book", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 313924185, + "rejected": true, + "rejections": ["Unknown quality profile", "Release is already mapped"], + "seeders": 2, + "leechers": 1, + "quality": { + "quality": { "id": 12, "name": "AZW3" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + }, + "customFormatScore": 0, + "downloadAllowed": true, + "publishDate": "2014-05-11T14:38:20Z" + }"#; + + pub const INDEXER_JSON: &str = r#"{ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "supportsRss": true, + "supportsSearch": true, + "protocol": "torrent", + "priority": 25, + "downloadClientId": 0, + "name": "Test Indexer", + "implementationName": "Torznab", + "implementation": "Torznab", + "configContract": "TorznabSettings", + "infoLink": "https://wiki.servarr.com/readarr/supported#torznab", + "fields": [ + { + "order": 0, + "name": "baseUrl", + "label": "URL", + "value": "https://test.com", + "type": "textbox", + "advanced": false, + "isFloat": false + }, + { + "order": 2, + "name": "apiKey", + "label": "API Key", + "value": "", + "type": "textbox", + "advanced": false, + "isFloat": false + }, + { + "order": 7, + "name": "seedCriteria.seedRatio", + "label": "Seed Ratio", + "value": "1.2", + "type": "number", + "advanced": false, + "isFloat": true + } + ], + "tags": [1], + "id": 8 + }"#; + + pub fn stale_author() -> Author { + Author { + id: 99, + author_name: "Stale Author".into(), + foreign_author_id: "foreign-author-99".to_owned(), + status: AuthorStatus::Continuing, + path: "/nfs/books/Stale Author".to_owned(), + root_folder_path: Some("/nfs/books/".to_owned()), + quality_profile_id: 1, + metadata_profile_id: 1, + monitored: true, + monitor_new_items: NewItemMonitorType::All, + genres: vec![], + tags: vec![], + ..Author::default() + } + } + + pub fn stale_book() -> Book { + Book { + id: 99, + title: "Stale Book".into(), + author_id: 99, + foreign_book_id: "foreign-book-99".to_owned(), + monitored: true, + ..Book::default() + } + } + + pub fn stale_book_file() -> BookFile { + BookFile { + id: 99, + author_id: 99, + book_id: 99, + path: "/nfs/books/Stale Author/Stale Book.azw3".to_owned(), + ..BookFile::default() + } + } + + pub fn stale_edition() -> Edition { + Edition { + id: 99, + book_id: 99, + foreign_edition_id: "foreign-edition-99".to_owned(), + monitored: true, + is_ebook: true, + title: "Stale Edition".to_owned(), + ..Edition::default() + } + } + + pub fn stale_add_author_search_result() -> AddAuthorSearchResult { + AddAuthorSearchResult { + foreign_author_id: "foreign-author-99".to_owned(), + author_name: "Stale Author".into(), + status: AuthorStatus::Continuing, + ..AddAuthorSearchResult::default() + } + } + + pub fn stale_root_folder() -> RootFolder { + RootFolder { + id: 99, + path: "/stale".to_owned(), + accessible: false, + free_space: 1, + unmapped_folders: None, + } + } + + pub fn stale_readarr_history_item() -> ReadarrHistoryItem { + ReadarrHistoryItem { + id: 99, + source_title: "Stale source title".into(), + ..ReadarrHistoryItem::default() + } + } + + pub fn stale_download_record() -> DownloadRecord { + DownloadRecord { + id: 99, + title: "Stale Download".to_owned(), + status: DownloadStatus::Queued, + ..DownloadRecord::default() + } + } + + pub fn quality_wrapper() -> QualityWrapper { + QualityWrapper { quality: quality() } + } + + pub fn stale_blocklist_item() -> BlocklistItem { + BlocklistItem { + id: 99, + author_id: 99, + source_title: "Stale source title".to_owned(), + ..BlocklistItem::default() + } + } + + pub fn blocklist_item() -> BlocklistItem { + BlocklistItem { + id: 1, + author_id: 1, + book_ids: Some(vec![Number::from(1)]), + source_title: "Test source title".to_owned(), + quality: quality_wrapper(), + date: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), + protocol: "usenet".to_owned(), + indexer: "DrunkenSlug".to_owned(), + message: "test message".to_owned(), + author: serde_json::from_str(AUTHOR_JSON).unwrap(), + } + } + + pub fn stale_release() -> ReadarrRelease { + ReadarrRelease { + guid: "stale-release-guid".to_owned(), + protocol: "usenet".to_owned(), + title: "Stale Release".into(), + indexer: "Stale Indexer".to_owned(), + indexer_id: 99, + ..ReadarrRelease::default() + } + } + + pub fn rejections() -> Vec { + vec![ + "Unknown quality profile".to_owned(), + "Release is already mapped".to_owned(), + ] + } + + pub fn torrent_release() -> ReadarrRelease { + ReadarrRelease { + guid: "test-release-guid".to_owned(), + protocol: "torrent".to_owned(), + age: 4492, + title: "Test Author - Test Book [AZW3]".into(), + author_name: Some("Test Author".to_owned()), + book_title: Some("Test Book".to_owned()), + indexer: "kickass torrents".to_owned(), + indexer_id: 2, + size: 313924185, + rejected: true, + rejections: Some(rejections()), + seeders: Some(Number::from(2)), + leechers: Some(Number::from(1)), + quality: quality_wrapper(), + } + } + + pub fn usenet_release() -> ReadarrRelease { + ReadarrRelease { + guid: "test-usenet-release-guid".to_owned(), + protocol: "usenet".to_owned(), + indexer: "DrunkenSlug".to_owned(), + indexer_id: 4, + seeders: None, + leechers: None, + ..torrent_release() + } + } + + pub fn quality() -> Quality { + Quality { + name: "AZW3".to_string(), + } + } + + pub fn readarr_history_item() -> ReadarrHistoryItem { + ReadarrHistoryItem { + id: 1, + author_id: 1, + book_id: 1, + source_title: "Test source title".into(), + quality: quality_wrapper(), + date: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()), + event_type: ReadarrHistoryEventType::Grabbed, + data: readarr_history_data(), + } + } + + pub fn readarr_history_data() -> ReadarrHistoryData { + ReadarrHistoryData { + dropped_path: Some("/nfs/nzbget/completed/books/Something/cool.azw3".to_owned()), + imported_path: Some("/nfs/books/Test Author/Book 1/Cool.azw3".to_owned()), + ..ReadarrHistoryData::default() + } + } + + pub fn add_author_body() -> AddAuthorBody { + AddAuthorBody { + foreign_author_id: "test-foreign-id".to_owned(), + author_name: "Test Author".to_owned(), + monitored: true, + root_folder_path: "/nfs/books".to_owned(), + quality_profile_id: 1, + metadata_profile_id: 1, + tags: Vec::default(), + images: Vec::new(), + tag_input_string: Some("usenet, testing".to_owned()), + add_options: AddAuthorOptions { + monitor: MonitorType::All, + monitor_new_items: NewItemMonitorType::All, + search_for_missing_books: true, + }, + } + } + + pub fn download_record() -> DownloadRecord { + DownloadRecord { + title: "Test Book Download".to_owned(), + status: DownloadStatus::Downloading, + id: 1, + book_id: Some(Number::from(1)), + author_id: Some(Number::from(1)), + size: 1000.0, + sizeleft: 250.0, + output_path: Some("/nfs/nzbget/completed/books/Test Author - Test Book".into()), + indexer: "test-indexer".to_owned(), + download_client: Some("NZBGet".to_owned()), + } + } + + pub fn downloads_response() -> DownloadsResponse { + DownloadsResponse { + records: vec![download_record()], + } + } + + pub fn stale_indexer() -> Indexer { + Indexer { + id: 99, + name: Some("Stale Indexer".to_owned()), + protocol: "usenet".to_owned(), + priority: 99, + ..Indexer::default() + } + } + + pub fn indexer() -> Indexer { + Indexer { + id: 8, + name: Some("Test Indexer".to_owned()), + implementation: Some("Torznab".to_owned()), + implementation_name: Some("Torznab".to_owned()), + config_contract: Some("TorznabSettings".to_owned()), + supports_rss: true, + supports_search: true, + fields: Some(vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(json!("https://test.com")), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(json!("")), + }, + IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(json!("1.2")), + }, + ]), + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + protocol: "torrent".to_owned(), + priority: 25, + download_client_id: 0, + tags: vec![Number::from(1)], + } + } + + pub const INDEXER_TEST_RESULTS_JSON: &str = r#"[ + { + "id": 8, + "isValid": true, + "validationFailures": [] + }, + { + "id": 14, + "isValid": false, + "validationFailures": [ + { + "propertyName": "baseUrl", + "errorMessage": "Unable to connect to indexer", + "severity": "error" + }, + { + "propertyName": "apiKey", + "errorMessage": "Invalid API key", + "severity": "error" + } + ] + } + ]"#; + + pub fn tested_indexers() -> Vec { + vec![ + Indexer { + id: 8, + name: Some("Test Indexer".to_owned()), + ..Indexer::default() + }, + Indexer { + id: 14, + name: Some("Other Indexer".to_owned()), + ..Indexer::default() + }, + ] + } + + pub fn indexer_test_results() -> Vec { + vec![ + IndexerTestResultModalItem { + name: "Test Indexer".to_owned(), + is_valid: true, + validation_failures: HorizontallyScrollableText::default(), + }, + IndexerTestResultModalItem { + name: "Other Indexer".to_owned(), + is_valid: false, + validation_failures: + "Failure for field 'baseUrl': Unable to connect to indexer, Failure for field 'apiKey': Invalid API key" + .into(), + }, + ] + } + + pub const INDEXER_SETTINGS_JSON: &str = r#"{ + "id": 4, + "minimumAge": 17, + "retention": 43, + "maximumSize": 26500, + "rssSyncInterval": 35 + }"#; + + pub fn indexer_settings() -> IndexerSettings { + IndexerSettings { + id: 4, + minimum_age: 17, + retention: 43, + maximum_size: 26500, + rss_sync_interval: 35, + } + } + + pub fn stale_indexer_settings() -> IndexerSettings { + IndexerSettings { + id: 9, + minimum_age: 22, + retention: 58, + maximum_size: 31200, + rss_sync_interval: 90, + } + } +} diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs new file mode 100644 index 00000000..1c0bf4a3 --- /dev/null +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -0,0 +1,556 @@ +#[cfg(test)] +mod tests { + use crate::app::App; + use crate::models::readarr_models::{ + AddAuthorBody, AddReadarrRootFolderBody, DeleteParams, EditAuthorParams, ReadarrSerdeable, + }; + use crate::models::servarr_models::{ + EditIndexerParams, IndexerSettings, MetadataProfile, QualityProfile, ReleaseDownloadBody, Tag, + }; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use serde_json::json; + use std::sync::Arc; + use tokio::sync::Mutex; + + #[rstest] + #[case(ReadarrEvent::AddAuthor(AddAuthorBody::default()), "/author")] + #[case(ReadarrEvent::DeleteAuthor(DeleteParams::default()), "/author")] + #[case(ReadarrEvent::EditAuthor(EditAuthorParams::default()), "/author")] + #[case(ReadarrEvent::GetAuthorDetails(1), "/author")] + #[case(ReadarrEvent::ListAuthors, "/author")] + #[case(ReadarrEvent::ToggleAuthorMonitoring(1), "/author")] + #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")] + #[case(ReadarrEvent::DeleteBlocklistItem(1), "/blocklist")] + #[case(ReadarrEvent::ClearBlocklist, "/blocklist/bulk")] + #[case(ReadarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] + #[case(ReadarrEvent::DeleteBook(DeleteParams::default()), "/book")] + #[case(ReadarrEvent::GetBookDetails(1), "/book")] + #[case(ReadarrEvent::GetBooks(1), "/book")] + #[case(ReadarrEvent::ToggleBookMonitoring(1), "/book")] + #[case(ReadarrEvent::DeleteBookFile(1), "/bookfile")] + #[case(ReadarrEvent::GetBookFiles(1), "/bookfile")] + #[case(ReadarrEvent::GetQueuedEvents, "/command")] + #[case(ReadarrEvent::StartTask(Default::default()), "/command")] + #[case(ReadarrEvent::TriggerAutomaticAuthorSearch(1), "/command")] + #[case(ReadarrEvent::TriggerAutomaticBookSearch(1), "/command")] + #[case(ReadarrEvent::UpdateAllAuthors, "/command")] + #[case(ReadarrEvent::UpdateAndScanAuthor(1), "/command")] + #[case(ReadarrEvent::UpdateDownloads, "/command")] + #[case(ReadarrEvent::GetHostConfig, "/config/host")] + #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] + #[case(ReadarrEvent::GetAllIndexerSettings, "/config/indexer")] + #[case( + ReadarrEvent::EditAllIndexerSettings(IndexerSettings::default()), + "/config/indexer" + )] + #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] + #[case(ReadarrEvent::GetBookEditions(1), "/edition")] + #[case(ReadarrEvent::HealthCheck, "/health")] + #[case(ReadarrEvent::GetHistory(500), "/history")] + #[case(ReadarrEvent::GetAuthorHistory(1), "/history/author")] + #[case(ReadarrEvent::GetBookHistory(1, 2), "/history/author")] + #[case(ReadarrEvent::MarkHistoryItemAsFailed(1), "/history/failed")] + #[case(ReadarrEvent::DeleteIndexer(1), "/indexer")] + #[case(ReadarrEvent::EditIndexer(EditIndexerParams::default()), "/indexer")] + #[case(ReadarrEvent::GetIndexers, "/indexer")] + #[case(ReadarrEvent::TestIndexer(8), "/indexer/test")] + #[case(ReadarrEvent::TestAllIndexers, "/indexer/testall")] + #[case(ReadarrEvent::GetLogs(500), "/log")] + #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] + #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")] + #[case(ReadarrEvent::DeleteDownload(1), "/queue")] + #[case(ReadarrEvent::GetDownloads(500), "/queue")] + #[case( + ReadarrEvent::DownloadRelease(ReleaseDownloadBody::default()), + "/release" + )] + #[case(ReadarrEvent::GetAuthorReleases(1), "/release")] + #[case(ReadarrEvent::GetBookReleases(1), "/release")] + #[case( + ReadarrEvent::AddRootFolder(AddReadarrRootFolderBody::default()), + "/rootfolder" + )] + #[case(ReadarrEvent::DeleteRootFolder(1), "/rootfolder")] + #[case(ReadarrEvent::GetRootFolders, "/rootfolder")] + #[case(ReadarrEvent::GetStatus, "/system/status")] + #[case(ReadarrEvent::GetTasks, "/system/task")] + #[case(ReadarrEvent::AddTag(String::new()), "/tag")] + #[case(ReadarrEvent::DeleteTag(1), "/tag")] + #[case(ReadarrEvent::GetTags, "/tag")] + #[case(ReadarrEvent::GetUpdates, "/update")] + fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { + assert_str_eq!(event.resource(), expected_uri); + } + + #[test] + fn test_from_readarr_event() { + assert_eq!( + NetworkEvent::Readarr(ReadarrEvent::HealthCheck), + NetworkEvent::from(ReadarrEvent::HealthCheck) + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_healthcheck_event() { + let (mock, app, _server) = MockServarrApi::get() + .build_for(ReadarrEvent::HealthCheck) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let _ = network + .handle_readarr_event(ReadarrEvent::HealthCheck) + .await; + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_handle_get_readarr_healthcheck_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .status(500) + .build_for(ReadarrEvent::HealthCheck) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::HealthCheck) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_get_metadata_profiles_event() { + let metadata_profiles_json = json!([ + { + "id": 1, + "name": "Standard" + }, + { + "id": 2, + "name": "None" + } + ]); + let response: Vec = + serde_json::from_value(metadata_profiles_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(metadata_profiles_json) + .build_for(ReadarrEvent::GetMetadataProfiles) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetMetadataProfiles) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::MetadataProfiles(metadata_profiles) = result.unwrap() else { + panic!("Expected MetadataProfiles"); + }; + + assert_eq!(metadata_profiles, response); + + let app = app.lock().await; + + assert_some_eq_x!( + app.data.readarr_data.metadata_profile_map.get_by_left(&1), + &"Standard".to_owned() + ); + assert_some_eq_x!( + app.data.readarr_data.metadata_profile_map.get_by_left(&2), + &"None".to_owned() + ); + } + + #[tokio::test] + async fn test_handle_get_metadata_profiles_event_empty_response() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .build_for(ReadarrEvent::GetMetadataProfiles) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.metadata_profile_map = + BiMap::from_iter([(99i64, "Stale Profile".to_owned())]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetMetadataProfiles) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::MetadataProfiles(metadata_profiles) = result.unwrap() else { + panic!("Expected MetadataProfiles"); + }; + + assert_is_empty!(metadata_profiles); + assert_is_empty!(app.lock().await.data.readarr_data.metadata_profile_map); + } + + #[tokio::test] + async fn test_handle_get_metadata_profiles_event_failure() { + let seeded_map = BiMap::from_iter([(99i64, "Stale Profile".to_owned())]); + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetMetadataProfiles) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.metadata_profile_map = seeded_map.clone(); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetMetadataProfiles) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.metadata_profile_map, + seeded_map + ); + } + + #[tokio::test] + async fn test_handle_get_quality_profiles_event() { + let quality_profiles_json = json!([ + { + "id": 1, + "name": "eBook" + }, + { + "id": 2, + "name": "Spoken" + } + ]); + let response: Vec = + serde_json::from_value(quality_profiles_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(quality_profiles_json) + .build_for(ReadarrEvent::GetQualityProfiles) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetQualityProfiles) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::QualityProfiles(quality_profiles) = result.unwrap() else { + panic!("Expected QualityProfiles"); + }; + + assert_eq!(quality_profiles, response); + + let app = app.lock().await; + + assert_some_eq_x!( + app.data.readarr_data.quality_profile_map.get_by_left(&1), + &"eBook".to_owned() + ); + assert_some_eq_x!( + app.data.readarr_data.quality_profile_map.get_by_left(&2), + &"Spoken".to_owned() + ); + } + + #[tokio::test] + async fn test_handle_get_quality_profiles_event_empty_response() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .build_for(ReadarrEvent::GetQualityProfiles) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.quality_profile_map = + BiMap::from_iter([(99i64, "Stale Profile".to_owned())]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetQualityProfiles) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::QualityProfiles(quality_profiles) = result.unwrap() else { + panic!("Expected QualityProfiles"); + }; + + assert_is_empty!(quality_profiles); + assert_is_empty!(app.lock().await.data.readarr_data.quality_profile_map); + } + + #[tokio::test] + async fn test_handle_get_quality_profiles_event_failure() { + let seeded_map = BiMap::from_iter([(99i64, "Stale Profile".to_owned())]); + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetQualityProfiles) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.quality_profile_map = seeded_map.clone(); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetQualityProfiles) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.quality_profile_map, + seeded_map + ); + } + + #[tokio::test] + async fn test_handle_get_tags_event() { + let tags_json = json!([ + { + "id": 1, + "label": "huntarr-missing" + } + ]); + let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::get() + .returns(tags_json) + .build_for(ReadarrEvent::GetTags) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetTags).await; + + mock.assert_async().await; + + let ReadarrSerdeable::Tags(tags) = result.unwrap() else { + panic!("Expected Tags"); + }; + + assert_eq!(tags, response); + assert_some_eq_x!( + app.lock().await.data.readarr_data.tags_map.get_by_left(&1), + &"huntarr-missing".to_owned() + ); + } + + #[tokio::test] + async fn test_handle_get_tags_event_empty_response() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .build_for(ReadarrEvent::GetTags) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = BiMap::from_iter([(99i64, "stale-tag".to_owned())]); + } + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetTags).await; + + mock.assert_async().await; + + let ReadarrSerdeable::Tags(tags) = result.unwrap() else { + panic!("Expected Tags"); + }; + + assert_is_empty!(tags); + assert_is_empty!(app.lock().await.data.readarr_data.tags_map); + } + + #[tokio::test] + async fn test_handle_get_tags_event_failure() { + let seeded_map = BiMap::from_iter([(99i64, "stale-tag".to_owned())]); + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetTags) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = seeded_map.clone(); + } + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetTags).await; + + mock.assert_async().await; + assert_err!(result); + assert_eq!(app.lock().await.data.readarr_data.tags_map, seeded_map); + } + + #[tokio::test] + async fn test_handle_add_readarr_tag_event() { + let tag_json = json!({ + "id": 2, + "label": "managarr-verify" + }); + let response: Tag = serde_json::from_value(tag_json.clone()).unwrap(); + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ "label": "managarr-verify" })) + .returns(tag_json) + .build_for(ReadarrEvent::AddTag("managarr-verify".to_owned())) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = BiMap::from_iter([(1i64, "huntarr-missing".to_owned())]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddTag("managarr-verify".to_owned())) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Tag(tag) = result.unwrap() else { + panic!("Expected Tag"); + }; + + assert_eq!(tag, response); + assert_eq!( + app.lock().await.data.readarr_data.tags_map, + BiMap::from_iter([ + (1i64, "huntarr-missing".to_owned()), + (2i64, "managarr-verify".to_owned()) + ]) + ); + } + + #[tokio::test] + async fn test_handle_add_readarr_tag_event_failure() { + let seeded_map = BiMap::from_iter([(1i64, "huntarr-missing".to_owned())]); + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ "label": "managarr-verify" })) + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::AddTag("managarr-verify".to_owned())) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = seeded_map.clone(); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddTag("managarr-verify".to_owned())) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_eq!(app.lock().await.data.readarr_data.tags_map, seeded_map); + } + + #[tokio::test] + async fn test_handle_delete_readarr_tag_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .build_for(ReadarrEvent::DeleteTag(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteTag(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_tag_event_failure() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .status(500) + .build_for(ReadarrEvent::DeleteTag(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteTag(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_extract_and_add_readarr_tag_ids_vec() { + let app_arc = Arc::new(Mutex::new(App::test_default())); + let tags = " test,HI ,, usenet "; + { + let mut app = app_arc.lock().await; + app.data.readarr_data.tags_map = BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "hi".to_owned()), + ]); + } + app_arc.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app_arc); + + assert_eq!( + network.extract_and_add_readarr_tag_ids_vec(tags).await, + vec![2, 3, 1] + ); + } + + #[tokio::test] + async fn test_extract_and_add_readarr_tag_ids_vec_add_missing_tags_first() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ "label": "TESTING" })) + .returns(json!({ "id": 3, "label": "testing" })) + .build_for(ReadarrEvent::GetTags) + .await; + let tags = "usenet, test, TESTING"; + { + let mut app_guard = app.lock().await; + app_guard.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + } + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let tag_ids_vec = network.extract_and_add_readarr_tag_ids_vec(tags).await; + + mock.assert_async().await; + assert_eq!(tag_ids_vec, vec![1, 2, 3]); + assert_eq!( + app.lock().await.data.readarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + } +} diff --git a/src/network/readarr_network/root_folders/mod.rs b/src/network/readarr_network/root_folders/mod.rs new file mode 100644 index 00000000..dca86c96 --- /dev/null +++ b/src/network/readarr_network/root_folders/mod.rs @@ -0,0 +1,82 @@ +use crate::models::readarr_models::AddReadarrRootFolderBody; +use crate::models::servarr_models::RootFolder; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use log::{debug, info}; +use serde_json::Value; + +#[cfg(test)] +#[path = "readarr_root_folders_network_tests.rs"] +mod readarr_root_folders_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn add_readarr_root_folder( + &mut self, + mut add_root_folder_body: AddReadarrRootFolderBody, + ) -> Result { + info!("Adding new root folder to Readarr"); + if let Some(tag_input_str) = add_root_folder_body.tag_input_string.as_ref() { + let tag_ids_vec = self + .extract_and_add_readarr_tag_ids_vec(tag_input_str) + .await; + add_root_folder_body.default_tags = tag_ids_vec; + } + let event = ReadarrEvent::AddRootFolder(AddReadarrRootFolderBody::default()); + + debug!("Add root folder body: {add_root_folder_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(add_root_folder_body), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn delete_readarr_root_folder( + &mut self, + root_folder_id: i64, + ) -> Result<()> { + info!("Deleting Readarr root folder with ID: {root_folder_id}"); + let event = ReadarrEvent::DeleteRootFolder(root_folder_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{root_folder_id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn get_readarr_root_folders( + &mut self, + ) -> Result> { + info!("Fetching Readarr root folders"); + let event = ReadarrEvent::GetRootFolders; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |root_folders, mut app| { + app.data.readarr_data.root_folders.set_items(root_folders); + }) + .await + } +} diff --git a/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs b/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs new file mode 100644 index 00000000..29b3fa93 --- /dev/null +++ b/src/network/readarr_network/root_folders/readarr_root_folders_network_tests.rs @@ -0,0 +1,307 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{ + AddReadarrRootFolderBody, MonitorType, NewItemMonitorType, ReadarrSerdeable, + }; + use crate::models::servarr_models::RootFolder; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::stale_root_folder; + use bimap::BiMap; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[tokio::test] + async fn test_handle_add_readarr_root_folder_event() { + let expected_add_root_folder_body = AddReadarrRootFolderBody { + name: "Books".to_owned(), + path: "/nfs/test".to_owned(), + default_quality_profile_id: 1, + default_metadata_profile_id: 1, + default_monitor_option: MonitorType::All, + default_new_item_monitor_option: NewItemMonitorType::All, + default_tags: vec![], + tag_input_string: Some("usenet, testing".to_owned()), + }; + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "Books", + "path": "/nfs/test", + "defaultQualityProfileId": 1, + "defaultMetadataProfileId": 1, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [1, 2] + })) + .returns(json!({})) + .build_for(ReadarrEvent::AddRootFolder( + expected_add_root_folder_body.clone(), + )) + .await; + app.lock().await.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + assert_ok!( + network + .handle_readarr_event(ReadarrEvent::AddRootFolder(expected_add_root_folder_body)) + .await + ); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_handle_add_readarr_root_folder_event_does_not_overwrite_default_tags_vec_when_tag_input_string_is_none() + { + let expected_add_root_folder_body = AddReadarrRootFolderBody { + name: "Books".to_owned(), + path: "/nfs/test".to_owned(), + default_quality_profile_id: 1, + default_metadata_profile_id: 1, + default_monitor_option: MonitorType::All, + default_new_item_monitor_option: NewItemMonitorType::All, + default_tags: vec![1, 2], + tag_input_string: None, + }; + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "Books", + "path": "/nfs/test", + "defaultQualityProfileId": 1, + "defaultMetadataProfileId": 1, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [1, 2] + })) + .returns(json!({})) + .build_for(ReadarrEvent::AddRootFolder( + expected_add_root_folder_body.clone(), + )) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + assert_ok!( + network + .handle_readarr_event(ReadarrEvent::AddRootFolder(expected_add_root_folder_body)) + .await + ); + + mock.assert_async().await; + } + + #[tokio::test] + async fn test_handle_add_readarr_root_folder_event_failure() { + let expected_add_root_folder_body = AddReadarrRootFolderBody { + name: "Books".to_owned(), + path: "/nfs/test".to_owned(), + default_quality_profile_id: 1, + default_metadata_profile_id: 1, + default_monitor_option: MonitorType::All, + default_new_item_monitor_option: NewItemMonitorType::All, + default_tags: vec![], + tag_input_string: None, + }; + let (mock, app, _server) = MockServarrApi::post() + .returns(json!({})) + .status(400) + .build_for(ReadarrEvent::AddRootFolder( + expected_add_root_folder_body.clone(), + )) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::AddRootFolder(expected_add_root_folder_body)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_root_folder_event() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .build_for(ReadarrEvent::DeleteRootFolder(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteRootFolder(1)) + .await; + + mock.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_delete_readarr_root_folder_event_failure() { + let (mock, app, _server) = MockServarrApi::delete() + .path("/1") + .status(500) + .build_for(ReadarrEvent::DeleteRootFolder(1)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::DeleteRootFolder(1)) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_get_readarr_root_folders_event() { + let root_folders_json = json!([ + { + "name": "Books", + "path": "/nfs/books", + "defaultMetadataProfileId": 1, + "defaultQualityProfileId": 1, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [], + "isCalibreLibrary": false, + "port": 8080, + "outputProfile": "default", + "useSsl": false, + "accessible": true, + "freeSpace": 11039514558464i64, + "totalSpace": 117810875334656i64, + "id": 1 + }, + { + "name": "Audiobooks", + "path": "/nfs/audiobooks", + "defaultMetadataProfileId": 1, + "defaultQualityProfileId": 2, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [], + "isCalibreLibrary": false, + "port": 8080, + "outputProfile": "default", + "useSsl": false, + "accessible": true, + "freeSpace": 11039514558464i64, + "totalSpace": 117810875334656i64, + "id": 2 + } + ]); + let response: Vec = serde_json::from_value(root_folders_json.clone()).unwrap(); + let expected_root_folders = vec![ + RootFolder { + id: 1, + path: "/nfs/books".to_owned(), + accessible: true, + free_space: 11039514558464, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs/audiobooks".to_owned(), + accessible: true, + free_space: 11039514558464, + unmapped_folders: None, + }, + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(root_folders_json) + .build_for(ReadarrEvent::GetRootFolders) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .root_folders + .set_items(vec![stale_root_folder()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetRootFolders) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::RootFolders(root_folders) = result.unwrap() else { + panic!("Expected RootFolders") + }; + + assert_eq!(root_folders, response); + assert_eq!( + app.lock().await.data.readarr_data.root_folders.items, + expected_root_folders + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_root_folders_event_empty_response() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .build_for(ReadarrEvent::GetRootFolders) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .root_folders + .set_items(vec![stale_root_folder()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetRootFolders) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::RootFolders(root_folders) = result.unwrap() else { + panic!("Expected RootFolders") + }; + + assert_is_empty!(root_folders); + assert_is_empty!(app.lock().await.data.readarr_data.root_folders); + } + + #[tokio::test] + async fn test_handle_get_readarr_root_folders_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetRootFolders) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .root_folders + .set_items(vec![stale_root_folder()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetRootFolders) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.root_folders.items, + vec![stale_root_folder()] + ); + } +} diff --git a/src/network/readarr_network/system/mod.rs b/src/network/readarr_network/system/mod.rs new file mode 100644 index 00000000..694e2df4 --- /dev/null +++ b/src/network/readarr_network/system/mod.rs @@ -0,0 +1,275 @@ +use crate::models::readarr_models::{ReadarrTask, ReadarrTaskName}; +use crate::models::servarr_models::{ + CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, +}; +use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; +use crate::network::readarr_network::ReadarrEvent; +use crate::network::{Network, RequestMethod}; +use anyhow::Result; +use indoc::formatdoc; +use log::info; +use serde_json::Value; + +#[cfg(test)] +#[path = "readarr_system_network_tests.rs"] +mod readarr_system_network_tests; + +impl Network<'_, '_> { + pub(in crate::network::readarr_network) async fn get_queued_readarr_events( + &mut self, + ) -> Result> { + info!("Fetching Readarr queued events"); + let event = ReadarrEvent::GetQueuedEvents; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |queued_events_vec, mut app| { + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec); + }) + .await + } + + pub(in crate::network::readarr_network) async fn get_readarr_diskspace( + &mut self, + ) -> Result> { + info!("Fetching Readarr disk space"); + let event = ReadarrEvent::GetDiskSpace; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |disk_space_vec, mut app| { + app.data.readarr_data.disk_space_vec = disk_space_vec; + }) + .await + } + + pub(in crate::network::readarr_network) async fn get_readarr_host_config( + &mut self, + ) -> Result { + info!("Fetching Readarr host config"); + let event = ReadarrEvent::GetHostConfig; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), HostConfig>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn get_readarr_logs( + &mut self, + events: u64, + ) -> Result { + info!("Fetching Readarr logs"); + let event = ReadarrEvent::GetLogs(events); + + let params = format!("pageSize={events}&sortDirection=descending&sortKey=time"); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), LogResponse>(request_props, |log_response, mut app| { + let mut logs = log_response.records; + logs.reverse(); + + let log_lines = logs + .into_iter() + .map(|log| { + if let Some(exception) = log.exception { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log + .logger + .as_ref() + .expect("logger must exist when exception is present"), + log + .exception_type + .as_ref() + .expect("exception_type must exist when exception is present"), + exception + )) + } else { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log.logger.as_ref().expect("logger must exist in log entry"), + log + .message + .as_ref() + .expect("message must exist when exception is not present") + )) + } + }) + .collect(); + + app.data.readarr_data.logs.set_items(log_lines); + app.data.readarr_data.logs.scroll_to_bottom(); + }) + .await + } + + pub(in crate::network::readarr_network) async fn get_readarr_security_config( + &mut self, + ) -> Result { + info!("Fetching Readarr security config"); + let event = ReadarrEvent::GetSecurityConfig; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SecurityConfig>(request_props, |_, _| ()) + .await + } + + pub(in crate::network::readarr_network) async fn get_readarr_status( + &mut self, + ) -> Result { + info!("Fetching Readarr system status"); + let event = ReadarrEvent::GetStatus; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SystemStatus>(request_props, |system_status, mut app| { + app.data.readarr_data.version = system_status.version; + app.data.readarr_data.start_time = system_status.start_time; + }) + .await + } + + pub(in crate::network::readarr_network) async fn get_readarr_tasks( + &mut self, + ) -> Result> { + info!("Fetching Readarr tasks"); + let event = ReadarrEvent::GetTasks; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |tasks_vec, mut app| { + app.data.readarr_data.tasks.set_items(tasks_vec); + }) + .await + } + + pub(in crate::network::readarr_network) async fn get_readarr_updates( + &mut self, + ) -> Result> { + info!("Fetching Readarr updates"); + let event = ReadarrEvent::GetUpdates; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |updates_vec, mut app| { + let latest_installed = if updates_vec + .iter() + .any(|update| update.latest && update.installed_on.is_some()) + { + "already".to_owned() + } else { + "not".to_owned() + }; + let updates = updates_vec + .into_iter() + .map(|update| { + let install_status = if update.installed_on.is_some() { + if update.installed { + " (Currently Installed)".to_owned() + } else { + " (Previously Installed)".to_owned() + } + } else { + String::new() + }; + let vec_to_bullet_points = |vec: Vec| { + vec + .iter() + .map(|change| format!(" * {change}")) + .collect::>() + .join("\n") + }; + + let mut update_info = formatdoc!( + "{} - {}{install_status} + {}", + update.version, + update.release_date, + "-".repeat(200) + ); + + if let Some(new_changes) = update.changes.new { + let changes = vec_to_bullet_points(new_changes); + update_info = formatdoc!( + "{update_info} + New: + {changes}" + ) + } + + if let Some(fixes) = update.changes.fixed { + let fixes = vec_to_bullet_points(fixes); + update_info = formatdoc!( + "{update_info} + Fixed: + {fixes}" + ); + } + + update_info + }) + .reduce(|version_1, version_2| format!("{version_1}\n\n\n{version_2}")) + .unwrap(); + + app.data.readarr_data.updates = ScrollableText::with_string(formatdoc!( + "The latest version of Readarr is {latest_installed} installed + + {updates}" + )); + }) + .await + } + + pub(in crate::network::readarr_network) async fn start_readarr_task( + &mut self, + task: ReadarrTaskName, + ) -> Result { + let event = ReadarrEvent::StartTask(task); + let task_name = task.to_string(); + info!("Starting Readarr task: {task_name}"); + + let body = CommandBody { name: task_name }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } +} diff --git a/src/network/readarr_network/system/readarr_system_network_tests.rs b/src/network/readarr_network/system/readarr_system_network_tests.rs new file mode 100644 index 00000000..1f713b36 --- /dev/null +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -0,0 +1,723 @@ +#[cfg(test)] +mod tests { + use crate::models::readarr_models::{ReadarrSerdeable, ReadarrTask, ReadarrTaskName}; + use crate::models::servarr_models::{ + AuthenticationMethod, AuthenticationRequired, CertificateValidation, DiskSpace, HostConfig, + LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, + }; + use crate::models::{HorizontallyScrollableText, ScrollableText}; + use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; + use crate::network::readarr_network::ReadarrEvent; + use chrono::DateTime; + use indoc::formatdoc; + use pretty_assertions::{assert_eq, assert_str_eq}; + use serde_json::json; + + #[tokio::test] + async fn test_handle_get_queued_readarr_events_event() { + let queued_events_json = json!([{ + "name": "CheckHealth", + "commandName": "Check Health", + "status": "completed", + "queued": "2023-05-20T21:29:16Z", + "started": "2023-05-20T21:29:16Z", + "ended": "2023-05-20T21:29:16Z", + "duration": "00:00:00.3524068", + "trigger": "manual", + }]); + let response: Vec = serde_json::from_value(queued_events_json.clone()).unwrap(); + let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let expected_event = QueueEvent { + name: "CheckHealth".to_owned(), + command_name: "Check Health".to_owned(), + status: "completed".to_owned(), + queued: timestamp, + started: Some(timestamp), + ended: Some(timestamp), + duration: Some("00:00:00.3524068".to_owned()), + trigger: "manual".to_owned(), + }; + let (mock, app, _server) = MockServarrApi::get() + .returns(queued_events_json) + .build_for(ReadarrEvent::GetQueuedEvents) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetQueuedEvents) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::QueueEvents(events) = result.unwrap() else { + panic!("Expected QueueEvents") + }; + + assert_eq!(events, response); + assert_eq!( + app.lock().await.data.readarr_data.queued_events.items, + vec![expected_event] + ); + } + + #[tokio::test] + async fn test_handle_get_queued_readarr_events_event_empty_response() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([])) + .build_for(ReadarrEvent::GetQueuedEvents) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .queued_events + .set_items(vec![QueueEvent { + name: "StaleEvent".to_owned(), + ..QueueEvent::default() + }]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetQueuedEvents) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::QueueEvents(events) = result.unwrap() else { + panic!("Expected QueueEvents") + }; + + assert_is_empty!(events); + assert_is_empty!(app.lock().await.data.readarr_data.queued_events); + } + + #[tokio::test] + async fn test_handle_get_queued_readarr_events_event_failure() { + let seeded_event = QueueEvent { + name: "StaleEvent".to_owned(), + ..QueueEvent::default() + }; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetQueuedEvents) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .queued_events + .set_items(vec![seeded_event.clone()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetQueuedEvents) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.queued_events.items, + vec![seeded_event] + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_diskspace_event() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!([ + { + "path": "/nfs/books/", + "freeSpace": 11039526748160i64, + "totalSpace": 117810875334656i64 + }, + { + "path": "/config", + "label": "", + "freeSpace": 783978655744i64, + "totalSpace": 1004298338304i64 + } + ])) + .build_for(ReadarrEvent::GetDiskSpace) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + let disk_space_vec = vec![ + DiskSpace { + path: Some("/nfs/books/".to_owned()), + free_space: 11039526748160, + total_space: 117810875334656, + }, + DiskSpace { + path: Some("/config".to_owned()), + free_space: 783978655744, + total_space: 1004298338304, + }, + ]; + + let result = network + .handle_readarr_event(ReadarrEvent::GetDiskSpace) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::DiskSpaces(disk_spaces) = result.unwrap() else { + panic!("Expected DiskSpaces") + }; + + assert_eq!(disk_spaces, disk_space_vec); + assert_eq!( + app.lock().await.data.readarr_data.disk_space_vec, + disk_space_vec + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_diskspace_event_failure() { + let seeded_disk_space = vec![DiskSpace { + path: Some("/stale".to_owned()), + free_space: 1, + total_space: 2, + }]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetDiskSpace) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.disk_space_vec = seeded_disk_space.clone(); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetDiskSpace) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.disk_space_vec, + seeded_disk_space + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_host_config_event() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({ + "bindAddress": "*", + "port": 8787, + "urlBase": "", + "instanceName": "Readarr", + "applicationUrl": "", + "enableSsl": false, + "sslPort": 6868, + "sslCertPath": "", + "sslCertPassword": "" + })) + .build_for(ReadarrEvent::GetHostConfig) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHostConfig) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::HostConfig(host_config) = result.unwrap() else { + panic!("Expected HostConfig") + }; + + assert_eq!( + host_config, + HostConfig { + bind_address: "*".into(), + port: 8787, + url_base: Some(HorizontallyScrollableText::default()), + instance_name: Some("Readarr".into()), + application_url: Some(HorizontallyScrollableText::default()), + enable_ssl: false, + ssl_port: 6868, + ssl_cert_path: Some(String::new()), + ssl_cert_password: Some(String::new()), + } + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_host_config_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetHostConfig) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetHostConfig) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_get_readarr_logs_event() { + let logs_response_json = json!({ + "page": 1, + "pageSize": 500, + "sortKey": "time", + "sortDirection": "descending", + "totalRecords": 2, + "records": [ + { + "time": "2023-05-20T21:29:16Z", + "level": "info", + "logger": "TestLogger", + "message": "test message", + "id": 1 + }, + { + "time": "2023-05-20T21:29:16Z", + "level": "fatal", + "logger": "ReadarrError", + "exception": "test exception", + "exceptionType": "Some.Big.Bad.Exception", + "id": 2 + } + ] + }); + let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); + let expected_logs = vec![ + HorizontallyScrollableText::from( + "2023-05-20 21:29:16 UTC|FATAL|ReadarrError|Some.Big.Bad.Exception|test exception", + ), + HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"), + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(logs_response_json) + .query("pageSize=500&sortDirection=descending&sortKey=time") + .build_for(ReadarrEvent::GetLogs(500)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetLogs(500)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::LogResponse(logs) = result.unwrap() else { + panic!("Expected LogResponse") + }; + + assert_eq!(logs, response); + assert_eq!(app.lock().await.data.readarr_data.logs.items, expected_logs); + assert_str_eq!( + app + .lock() + .await + .data + .readarr_data + .logs + .current_selection() + .text, + "2023-05-20 21:29:16 UTC|INFO|TestLogger|test message" + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_logs_event_failure() { + let seeded_logs = vec![HorizontallyScrollableText::from("stale log entry")]; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .query("pageSize=500&sortDirection=descending&sortKey=time") + .build_for(ReadarrEvent::GetLogs(500)) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.logs.set_items(seeded_logs.clone()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetLogs(500)) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_eq!(app.lock().await.data.readarr_data.logs.items, seeded_logs); + } + + #[tokio::test] + async fn test_handle_get_readarr_security_config_event() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({ + "authenticationMethod": "forms", + "authenticationRequired": "enabled", + "username": "test", + "password": "test", + "apiKey": "test1234", + "certificateValidation": "enabled" + })) + .build_for(ReadarrEvent::GetSecurityConfig) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetSecurityConfig) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::SecurityConfig(security_config) = result.unwrap() else { + panic!("Expected SecurityConfig") + }; + + assert_eq!( + security_config, + SecurityConfig { + authentication_method: AuthenticationMethod::Forms, + authentication_required: Some(AuthenticationRequired::Enabled), + username: Some("test".to_owned()), + password: Some("test".to_owned()), + api_key: "test1234".to_owned(), + certificate_validation: CertificateValidation::Enabled, + } + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_security_config_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetSecurityConfig) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetSecurityConfig) + .await; + + mock.assert_async().await; + assert_err!(result); + } + + #[tokio::test] + async fn test_handle_get_readarr_status_event() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({ + "version": "0.4.20.129", + "startTime": "2026-08-17T03:55:02Z" + })) + .build_for(ReadarrEvent::GetStatus) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + let date_time = DateTime::from(DateTime::parse_from_rfc3339("2026-08-17T03:55:02Z").unwrap()); + + let result = network.handle_readarr_event(ReadarrEvent::GetStatus).await; + + mock.assert_async().await; + + let ReadarrSerdeable::SystemStatus(status) = result.unwrap() else { + panic!("Expected SystemStatus") + }; + + assert_eq!( + status, + SystemStatus { + version: "0.4.20.129".to_owned(), + start_time: date_time + } + ); + assert_str_eq!(app.lock().await.data.readarr_data.version, "0.4.20.129"); + assert_eq!(app.lock().await.data.readarr_data.start_time, date_time); + } + + #[tokio::test] + async fn test_handle_get_readarr_status_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetStatus) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.version = "stale version".to_owned(); + } + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetStatus).await; + + mock.assert_async().await; + assert_err!(result); + assert_str_eq!(app.lock().await.data.readarr_data.version, "stale version"); + } + + #[tokio::test] + async fn test_handle_get_readarr_tasks_event() { + let tasks_json = json!([{ + "name": "Application Update Check", + "taskName": "ApplicationUpdateCheck", + "interval": 360, + "lastExecution": "2023-05-20T21:29:16Z", + "lastDuration": "00:00:00.2293467", + "nextExecution": "2023-05-20T21:29:16Z", + }, + { + "name": "Refresh Author", + "taskName": "RefreshAuthor", + "interval": 10080, + "lastExecution": "2023-05-20T21:29:16Z", + "lastDuration": "00:00:03.7211204", + "nextExecution": "2023-05-20T21:29:16Z", + }]); + let response: Vec = serde_json::from_value(tasks_json.clone()).unwrap(); + let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let expected_tasks = vec![ + ReadarrTask { + name: "Application Update Check".to_owned(), + task_name: ReadarrTaskName::ApplicationUpdateCheck, + interval: 360, + last_execution: timestamp, + last_duration: "00:00:00.2293467".to_owned(), + next_execution: timestamp, + }, + ReadarrTask { + name: "Refresh Author".to_owned(), + task_name: ReadarrTaskName::RefreshAuthor, + interval: 10080, + last_execution: timestamp, + last_duration: "00:00:03.7211204".to_owned(), + next_execution: timestamp, + }, + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(tasks_json) + .build_for(ReadarrEvent::GetTasks) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetTasks).await; + + mock.assert_async().await; + + let ReadarrSerdeable::Tasks(tasks) = result.unwrap() else { + panic!("Expected Tasks") + }; + + assert_eq!(tasks, response); + assert_eq!( + app.lock().await.data.readarr_data.tasks.items, + expected_tasks + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_tasks_event_failure() { + let seeded_task = ReadarrTask { + name: "Stale Task".to_owned(), + ..ReadarrTask::default() + }; + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetTasks) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app + .data + .readarr_data + .tasks + .set_items(vec![seeded_task.clone()]); + } + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetTasks).await; + + mock.assert_async().await; + assert_err!(result); + assert_eq!( + app.lock().await.data.readarr_data.tasks.items, + vec![seeded_task] + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_updates_event() { + let updates_json = json!([{ + "version": "4.3.2.1", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": true, + "installedOn": "2023-04-15T02:02:53Z", + "latest": true, + "changes": { "new": ["Cool new thing"], "fixed": ["Some bugs killed"] }, + }, + { + "version": "3.2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "installedOn": "2023-04-15T02:02:53Z", + "latest": false, + "changes": { "new": ["Cool new thing (old)", "Other cool new thing (old)"] }, + }, + { + "version": "2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "latest": false, + "changes": { "fixed": ["Killed bug 1", "Fixed bug 2"] }, + }, + { + "version": "1.0.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "latest": false, + }]); + let response: Vec = serde_json::from_value(updates_json.clone()).unwrap(); + let line_break = "-".repeat(200); + let expected_text = formatdoc!( + "The latest version of Readarr is already installed + + 4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed) + {line_break} + New: + * Cool new thing + Fixed: + * Some bugs killed + + + 3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed) + {line_break} + New: + * Cool new thing (old) + * Other cool new thing (old) + + + 2.1.0 - 2023-04-15 02:02:53 UTC + {line_break} + Fixed: + * Killed bug 1 + * Fixed bug 2 + + + 1.0.0 - 2023-04-15 02:02:53 UTC + {line_break}" + ); + let (mock, app, _server) = MockServarrApi::get() + .returns(updates_json) + .build_for(ReadarrEvent::GetUpdates) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetUpdates).await; + + mock.assert_async().await; + + let ReadarrSerdeable::Updates(updates) = result.unwrap() else { + panic!("Expected Updates") + }; + + assert_eq!(updates, response); + + assert_str_eq!( + app.lock().await.data.readarr_data.updates.get_text(), + expected_text + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_updates_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::GetUpdates) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.updates = ScrollableText::with_string("stale updates".to_owned()); + } + let mut network = test_network(&app); + + let result = network.handle_readarr_event(ReadarrEvent::GetUpdates).await; + + mock.assert_async().await; + assert_err!(result); + assert_str_eq!( + app.lock().await.data.readarr_data.updates.get_text(), + "stale updates" + ); + } + + #[tokio::test] + async fn test_handle_start_readarr_task_event() { + let response = json!({ "test": "test" }); + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "CheckHealth" + })) + .returns(response.clone()) + .build_for(ReadarrEvent::StartTask(ReadarrTaskName::CheckHealth)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::StartTask(ReadarrTaskName::CheckHealth)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::Value(value) = result.unwrap() else { + panic!("Expected Value") + }; + + assert_eq!(value, response); + } + + #[tokio::test] + async fn test_handle_start_readarr_task_event_failure() { + let (mock, app, _server) = MockServarrApi::post() + .with_request_body(json!({ + "name": "CheckHealth" + })) + .returns(json!({})) + .status(500) + .build_for(ReadarrEvent::StartTask(ReadarrTaskName::CheckHealth)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::StartTask(ReadarrTaskName::CheckHealth)) + .await; + + mock.assert_async().await; + assert_err!(result); + } +} diff --git a/src/network/servarr_test_utils.rs b/src/network/servarr_test_utils.rs index 23c0872c..d81d96dd 100644 --- a/src/network/servarr_test_utils.rs +++ b/src/network/servarr_test_utils.rs @@ -12,9 +12,9 @@ pub fn diskspace() -> DiskSpace { pub fn indexer_settings() -> IndexerSettings { IndexerSettings { - id: 1, - minimum_age: 1, - retention: 1, + id: 7, + minimum_age: 13, + retention: 21, maximum_size: 12345, rss_sync_interval: 60, } diff --git a/src/network/sonarr_network/downloads/sonarr_downloads_network_tests.rs b/src/network/sonarr_network/downloads/sonarr_downloads_network_tests.rs index 3a74f396..1e0e2c0e 100644 --- a/src/network/sonarr_network/downloads/sonarr_downloads_network_tests.rs +++ b/src/network/sonarr_network/downloads/sonarr_downloads_network_tests.rs @@ -41,8 +41,8 @@ mod tests { "records": [{ "title": "Test Download Title", "status": "downloading", - "id": 1, - "episodeId": 1, + "id": 9, + "episodeId": 11, "size": 3543348019f64, "sizeleft": 1771674009f64, "outputPath": "/nfs/tv/Test show/season 1/", diff --git a/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs b/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs index 2eeaa8a6..5769e4cf 100644 --- a/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs +++ b/src/network/sonarr_network/indexers/sonarr_indexers_network_tests.rs @@ -42,10 +42,10 @@ mod tests { #[tokio::test] async fn test_handle_edit_all_indexer_settings_event() { let indexer_settings_json = json!({ - "id": 1, - "minimumAge": 1, + "id": 7, + "minimumAge": 13, "maximumSize": 12345, - "retention": 1, + "retention": 21, "rssSyncInterval": 60 }); let (mock, app, _server) = MockServarrApi::put() diff --git a/src/network/sonarr_network/library/episodes/mod.rs b/src/network/sonarr_network/library/episodes/mod.rs index 0eec86db..25810653 100644 --- a/src/network/sonarr_network/library/episodes/mod.rs +++ b/src/network/sonarr_network/library/episodes/mod.rs @@ -1,14 +1,14 @@ use crate::models::servarr_data::sonarr::modals::SeasonDetailsModal; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; -use crate::models::servarr_models::Language; +use crate::models::servarr_models::{DownloadStatus, Language}; use crate::models::sonarr_models::{ - DownloadRecord, DownloadStatus, Episode, EpisodeFile, MonitorEpisodeBody, SonarrCommandBody, + DownloadRecord, Episode, EpisodeFile, MonitorEpisodeBody, SonarrCommandBody, SonarrHistoryWrapper, SonarrRelease, }; use crate::models::{Route, ScrollableText}; use crate::network::sonarr_network::SonarrEvent; use crate::network::{Network, RequestMethod}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use anyhow::Result; use indoc::formatdoc; use log::info; @@ -222,12 +222,12 @@ impl Network<'_, '_> { overview.unwrap_or_default(), )); if let Some(file) = episode_file { - let size = convert_to_gb(file.size); + let size = format_size(file.size, 2); episode_details_modal.file_details = formatdoc!( " Relative Path: {} Absolute Path: {} - Size: {size:.2} GB + Size: {size} Language: {} Date Added: {}", file.relative_path, diff --git a/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs b/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs index 62b2530d..ebaf8e6d 100644 --- a/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs +++ b/src/network/sonarr_network/library/episodes/sonarr_episodes_network_tests.rs @@ -2,8 +2,9 @@ mod tests { use crate::models::servarr_data::sonarr::modals::{EpisodeDetailsModal, SeasonDetailsModal}; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::servarr_models::DownloadStatus; use crate::models::sonarr_models::{ - DownloadRecord, DownloadStatus, Episode, MonitorEpisodeBody, Season, Series, SonarrHistoryItem, + DownloadRecord, Episode, MonitorEpisodeBody, Season, Series, SonarrHistoryItem, SonarrHistoryWrapper, SonarrRelease, SonarrSerdeable, }; use crate::models::stateful_table::SortOption; @@ -69,7 +70,7 @@ mod tests { ..episode() }; let expected_episodes = vec![episode_1.clone(), episode_2.clone(), episode_3.clone()]; - let mut expected_sorted_episodes = vec![episode_1.clone(), episode_3.clone()]; + let mut expected_sorted_episodes = vec![episode_3.clone(), episode_1.clone()]; let (mock, app, _server) = MockServarrApi::get() .query("seriesId=1") .returns(json!([episode_1, episode_2, episode_3])) @@ -175,6 +176,7 @@ mod tests { ..episode() }; let expected_episodes = vec![episode_1.clone(), episode_2.clone(), episode_3.clone()]; + let expected_sorted_episodes = vec![episode_2.clone(), episode_3.clone(), episode_1.clone()]; let (mock, app, _server) = MockServarrApi::get() .query("seriesId=1") .returns(json!([episode_1, episode_2, episode_3])) @@ -216,7 +218,7 @@ mod tests { .unwrap() .episodes .items, - expected_episodes + expected_sorted_episodes ); assert!( app @@ -294,10 +296,10 @@ mod tests { "monitored": true }, { - "id": 1, + "id": 11, "seriesId": 1, "tvdbId": 1234, - "episodeFileId": 1, + "episodeFileId": 7, "seasonNumber": 1, "episodeNumber": 1, "title": "Something cool", diff --git a/src/network/sonarr_network/sonarr_network_test_utils.rs b/src/network/sonarr_network/sonarr_network_test_utils.rs index 0a9fab3d..ff651cc5 100644 --- a/src/network/sonarr_network/sonarr_network_test_utils.rs +++ b/src/network/sonarr_network/sonarr_network_test_utils.rs @@ -1,13 +1,13 @@ #[cfg(test)] pub mod test_utils { use crate::models::servarr_models::{ - Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, + DownloadStatus, Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder, }; use crate::models::sonarr_models::{ AddSeriesSearchResult, AddSeriesSearchResultStatistics, BlocklistItem, DownloadRecord, - DownloadStatus, DownloadsResponse, Episode, EpisodeFile, MediaInfo, Rating, Season, - SeasonStatistics, Series, SeriesStatistics, SeriesStatus, SeriesType, SonarrHistoryData, - SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, SonarrTask, SonarrTaskName, + DownloadsResponse, Episode, EpisodeFile, MediaInfo, Rating, Season, SeasonStatistics, Series, + SeriesStatistics, SeriesStatus, SeriesType, SonarrHistoryData, SonarrHistoryEventType, + SonarrHistoryItem, SonarrRelease, SonarrTask, SonarrTaskName, }; use crate::models::{HorizontallyScrollableText, ScrollableText}; use bimap::BiMap; @@ -19,7 +19,7 @@ pub mod test_utils { "title": "Test", "status": "continuing", "ended": false, - "overview": "Blah blah blah", + "overview": "Blah blah blah\r\n\r\n\tIt was filmed in Madison, Wisconsin: a city the crew never really left. \r\n\r\nThe first season took seven years to finish.\r\n", "network": "HBO", "seasons": [ { @@ -28,7 +28,7 @@ pub mod test_utils { "monitored": true, "statistics": { "previousAiring": "2022-10-24T01:00:00Z", - "episodeFileCount": 10, + "episodeFileCount": 7, "episodeCount": 10, "totalEpisodeCount": 10, "sizeOnDisk": 36708563419, @@ -64,14 +64,14 @@ pub mod test_utils { pub const EPISODE_JSON: &str = r#"{ "seriesId": 1, "tvdbId": 1234, - "episodeFileId": 1, + "episodeFileId": 7, "seasonNumber": 1, "episodeNumber": 1, "title": "Something cool", "airDateUtc": "2024-02-10T07:28:45Z", "overview": "Okay so this one time at band camp...", "episodeFile": { - "id": 1, + "id": 7, "relativePath": "/season 1/episode 1.mkv", "path": "/nfs/tv/series/season 1/episode 1.mkv", "size": 3543348019, @@ -96,7 +96,7 @@ pub mod test_utils { }, "hasFile": true, "monitored": true, - "id": 1 + "id": 11 }"#; pub fn add_series_search_result() -> AddSeriesSearchResult { @@ -139,8 +139,8 @@ pub mod test_utils { DownloadRecord { title: "Test Download Title".to_owned(), status: DownloadStatus::Downloading, - id: 1, - episode_id: Some(Number::from(1i64)), + id: 9, + episode_id: Some(Number::from(11i64)), size: 3543348019f64, sizeleft: 1771674009f64, output_path: Some(HorizontallyScrollableText::from( @@ -159,10 +159,10 @@ pub mod test_utils { pub fn episode() -> Episode { Episode { - id: 1, + id: 11, series_id: 1, tvdb_id: 1234, - episode_file_id: 1, + episode_file_id: 7, season_number: 1, episode_number: 1, title: "Something cool".to_owned(), @@ -178,7 +178,7 @@ pub mod test_utils { pub fn episode_file() -> EpisodeFile { EpisodeFile { - id: 1, + id: 7, relative_path: "/season 1/episode 1.mkv".to_owned(), path: "/nfs/tv/series/season 1/episode 1.mkv".to_owned(), size: 3543348019, @@ -306,7 +306,7 @@ pub mod test_utils { DateTime::parse_from_rfc3339("2022-10-24T01:00:00Z").unwrap(), )), next_airing: None, - episode_file_count: 10, + episode_file_count: 7, episode_count: 10, total_episode_count: 10, size_on_disk: 36708563419, @@ -319,7 +319,14 @@ pub mod test_utils { title: "Test".to_owned().into(), status: SeriesStatus::Continuing, ended: false, - overview: Some("Blah blah blah".to_owned()), + overview: Some( + "Blah blah blah\r\n\ + \r\n\ + \tIt was filmed in Madison, Wisconsin: a city the crew never really left. \r\n\ + \r\n\ + The first season took seven years to finish.\r\n" + .to_owned(), + ), network: Some("HBO".to_owned()), seasons: Some(vec![season()]), year: 2022, diff --git a/src/network/sonarr_network/system/mod.rs b/src/network/sonarr_network/system/mod.rs index c32b75b4..f9745b8d 100644 --- a/src/network/sonarr_network/system/mod.rs +++ b/src/network/sonarr_network/system/mod.rs @@ -1,7 +1,7 @@ use crate::models::servarr_models::{ - CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + CommandBody, DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; -use crate::models::sonarr_models::{SonarrTask, SonarrTaskName, SystemStatus}; +use crate::models::sonarr_models::{SonarrTask, SonarrTaskName}; use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; use crate::network::sonarr_network::SonarrEvent; use crate::network::{Network, RequestMethod}; diff --git a/src/network/sonarr_network/system/sonarr_system_network_tests.rs b/src/network/sonarr_network/system/sonarr_system_network_tests.rs index 5742f224..1d0cf842 100644 --- a/src/network/sonarr_network/system/sonarr_system_network_tests.rs +++ b/src/network/sonarr_network/system/sonarr_system_network_tests.rs @@ -2,9 +2,9 @@ mod tests { use crate::models::HorizontallyScrollableText; use crate::models::servarr_models::{ - DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, Update, + DiskSpace, HostConfig, LogResponse, QueueEvent, SecurityConfig, SystemStatus, Update, }; - use crate::models::sonarr_models::{SonarrSerdeable, SonarrTask, SonarrTaskName, SystemStatus}; + use crate::models::sonarr_models::{SonarrSerdeable, SonarrTask, SonarrTaskName}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::sonarr_network::SonarrEvent; use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::updates; diff --git a/src/ui/lidarr_ui/downloads/mod.rs b/src/ui/lidarr_ui/downloads/mod.rs index 004606da..0de74e66 100644 --- a/src/ui/lidarr_ui/downloads/mod.rs +++ b/src/ui/lidarr_ui/downloads/mod.rs @@ -12,7 +12,7 @@ use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::{Popup, Size}; -use crate::utils::convert_f64_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "downloads_ui_tests.rs"] @@ -97,12 +97,12 @@ fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } else { 1f64 - (*sizeleft / *size) }; - let file_size: f64 = convert_f64_to_gb(*size); + let file_size = format_size(*size as i64, 2); Row::new(vec![ Cell::from(title.to_owned()), Cell::from(format!("{:.0}%", percent * 100.0)), - Cell::from(format!("{file_size:.2} GB")), + Cell::from(file_size), Cell::from( output_path .as_ref() diff --git a/src/ui/lidarr_ui/indexers/indexers_ui_tests.rs b/src/ui/lidarr_ui/indexers/indexers_ui_tests.rs index 80191880..f064db82 100644 --- a/src/ui/lidarr_ui/indexers/indexers_ui_tests.rs +++ b/src/ui/lidarr_ui/indexers/indexers_ui_tests.rs @@ -32,6 +32,7 @@ mod tests { use crate::models::BlockSelectionState; use crate::models::servarr_data::lidarr::lidarr_data::{ EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + INDEXER_SETTINGS_SELECTION_BLOCKS, }; use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::indexer; use crate::ui::ui_test_utils::test_utils::TerminalSize; @@ -152,5 +153,19 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_indexers_ui_renders_indexer_settings_over_indexers() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::AllIndexerSettingsPrompt.into()); + app.data.lidarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap b/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap index 7f375933..f43387ba 100644 --- a/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap +++ b/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap @@ -21,10 +21,10 @@ expression: output │ │ │ │ │ ╭────────────────────────────────╮ │ - │ Minimum Age (minutes) ▴▾: │1 │ │ + │ Minimum Age (minutes) ▴▾: │11 │ │ │ ╰────────────────────────────────╯ │ │ ╭────────────────────────────────╮ │ - │ Retention (days) ▴▾: │1 │ │ + │ Retention (days) ▴▾: │29 │ │ │ ╰────────────────────────────────╯ │ │ ╭────────────────────────────────╮ │ │ Maximum Size (MB) ▴▾: │12345 │ │ diff --git a/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap b/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap new file mode 100644 index 00000000..d6df46e4 --- /dev/null +++ b/src/ui/lidarr_ui/indexers/snapshots/managarr__ui__lidarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/lidarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Test Indexer Enabled Enabled Enabled 25 alex + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │11 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │29 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │12345 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │60 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/album_details_ui.rs b/src/ui/lidarr_ui/library/album_details_ui.rs index 86e7899b..46624d97 100644 --- a/src/ui/lidarr_ui/library/album_details_ui.rs +++ b/src/ui/lidarr_ui/library/album_details_ui.rs @@ -1,7 +1,8 @@ use crate::app::App; use crate::models::Route; -use crate::models::lidarr_models::{LidarrHistoryItem, LidarrRelease, Track}; +use crate::models::lidarr_models::{DownloadRecord, LidarrHistoryItem, LidarrRelease, Track}; use crate::models::servarr_data::lidarr::lidarr_data::{ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock}; +use crate::models::servarr_models::DownloadStatus; use crate::ui::lidarr_ui::library::track_details_ui::TrackDetailsUi; use crate::ui::lidarr_ui::lidarr_ui_utils::create_history_event_details; use crate::ui::styles::{ManagarrStyle, secondary_style}; @@ -14,7 +15,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Rect}; use ratatui::prelude::{Line, Stylize, Text}; @@ -150,6 +151,7 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .expect("Album details modal is unpopulated") .tracks, ); + let downloads_vec = &app.data.lidarr_data.downloads.items; let track_row_mapping = |track: &Track| { let Track { @@ -157,7 +159,6 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { title, duration, track_file_id, - has_file, .. } = track; @@ -187,19 +188,17 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let secs = duration_secs % 60; let duration_str = format!("{mins}:{secs:02}"); - let row = Row::new(vec![ - Cell::from(track_number.clone()), - Cell::from(title.clone()), - Cell::from(duration_str), - Cell::from(audio_info), - Cell::from(quality), - ]); - - if *has_file { - row.downloaded() - } else { - row.missing() - } + decorate_track_row_with_style( + downloads_vec, + track, + Row::new(vec![ + Cell::from(track_number.clone()), + Cell::from(title.clone()), + Cell::from(duration_str), + Cell::from(audio_info), + Cell::from(quality), + ]), + ) }; let is_searching = active_lidarr_block == ActiveLidarrBlock::SearchTracks; @@ -225,6 +224,37 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } } +fn decorate_track_row_with_style<'a>( + downloads_vec: &[DownloadRecord], + track: &Track, + row: Row<'a>, +) -> Row<'a> { + if !track.has_file { + let default_album_id = Number::from(-1i64); + if let Some(download) = downloads_vec.iter().find(|&download| { + download + .album_id + .as_ref() + .unwrap_or(&default_album_id) + .as_i64() + .unwrap() + == track.album_id + }) { + if download.status == DownloadStatus::Downloading { + return row.downloading(); + } + + if download.status == DownloadStatus::Completed { + return row.awaiting_import(); + } + } + + return row.missing(); + } + + row.downloaded() +} + fn draw_album_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { match app.data.lidarr_data.album_details_modal.as_ref() { Some(album_details_modal) if !app.is_loading => { @@ -347,7 +377,7 @@ fn draw_album_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { && active_lidarr_block != ActiveLidarrBlock::ManualAlbumSearchConfirmPrompt, app.should_text_scroll, ); - let size = convert_to_gb(*size); + let size = format_size(*size, 1); let rejected_str = if *rejected { "⛔" } else { "" }; let peers = if seeders.is_none() || leechers.is_none() { Text::from("") @@ -378,7 +408,7 @@ fn draw_album_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(rejected_str), Cell::from(title.to_string()), Cell::from(indexer.clone()), - Cell::from(format!("{size:.1} GB")), + Cell::from(size), Cell::from(peers), Cell::from(quality_name), ]) @@ -440,12 +470,12 @@ fn draw_manual_album_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) let prompt = if current_selection.rejected { format!( "Do you really want to download the rejected release: {}?", - ¤t_selection.title.text + current_selection.title.text ) } else { format!( "Do you want to download the release: {}?", - ¤t_selection.title.text + current_selection.title.text ) }; diff --git a/src/ui/lidarr_ui/library/album_details_ui_tests.rs b/src/ui/lidarr_ui/library/album_details_ui_tests.rs index 874ba338..3ae7b035 100644 --- a/src/ui/lidarr_ui/library/album_details_ui_tests.rs +++ b/src/ui/lidarr_ui/library/album_details_ui_tests.rs @@ -1,14 +1,23 @@ #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; + use serde_json::Number; use strum::IntoEnumIterator; use crate::app::App; + use crate::models::lidarr_models::{DownloadRecord, Track}; use crate::models::servarr_data::lidarr::lidarr_data::{ ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock, TRACK_DETAILS_BLOCKS, }; + use crate::models::servarr_models::DownloadStatus; use crate::models::stateful_table::StatefulTable; + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::download_record; use crate::ui::DrawUi; - use crate::ui::lidarr_ui::library::album_details_ui::AlbumDetailsUi; + use crate::ui::lidarr_ui::library::album_details_ui::{ + AlbumDetailsUi, decorate_track_row_with_style, + }; + use crate::ui::styles::ManagarrStyle; use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; #[test] @@ -25,6 +34,184 @@ mod tests { }); } + #[test] + fn test_decorate_track_row_with_style_downloaded_when_track_has_file() { + let track = Track { + has_file: true, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&[], &track, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_track_row_with_style_missing_when_track_has_no_file() { + let track = Track { + has_file: false, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&[], &track, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_track_row_with_style_downloading_when_track_has_no_file_and_album_is_downloading() + { + let downloads_vec = vec![album_download(7, DownloadStatus::Downloading)]; + let track = Track { + has_file: false, + album_id: 7, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone()); + + assert_eq!(style, row.downloading()); + } + + #[test] + fn test_decorate_track_row_with_style_awaiting_import_when_track_has_no_file_and_album_download_is_completed() + { + let downloads_vec = vec![album_download(7, DownloadStatus::Completed)]; + let track = Track { + has_file: false, + album_id: 7, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone()); + + assert_eq!(style, row.awaiting_import()); + } + + #[test] + fn test_decorate_track_row_with_style_missing_when_album_download_is_neither_downloading_nor_completed() + { + let downloads_vec = vec![album_download(7, DownloadStatus::Queued)]; + let track = Track { + has_file: false, + album_id: 7, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_track_row_with_style_missing_when_only_a_different_album_is_downloading() { + let downloads_vec = vec![album_download(8, DownloadStatus::Downloading)]; + let track = Track { + has_file: false, + album_id: 7, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_track_row_with_style_downloaded_when_track_has_file_and_album_is_downloading() { + let downloads_vec = vec![album_download(7, DownloadStatus::Downloading)]; + let track = Track { + has_file: true, + album_id: 7, + ..Track::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + mod test_track_row_styling { + use pretty_assertions::assert_eq; + use ratatui::style::Style; + + use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::track; + use crate::ui::styles::{downloading_style, missing_style}; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, create_test_terminal}; + + use super::*; + + #[test] + fn test_album_details_ui_renders_downloading_track_with_downloading_style() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::AlbumDetails.into()); + set_tracks_with_unselected_probe(&mut app); + + let style = rendered_row_style(&mut app, "Unselected track"); + + assert_eq!(style.fg, downloading_style().fg); + } + + #[test] + fn test_album_details_ui_renders_missing_track_with_missing_style_when_queue_is_empty() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::AlbumDetails.into()); + app.data.lidarr_data.downloads.set_items(vec![]); + set_tracks_with_unselected_probe(&mut app); + + let style = rendered_row_style(&mut app, "Unselected track"); + + assert_eq!(style.fg, missing_style().fg); + } + + fn set_tracks_with_unselected_probe(app: &mut App<'_>) { + let album_details_modal = app.data.lidarr_data.album_details_modal.as_mut().unwrap(); + album_details_modal.album_details_tabs.set_index(0); + album_details_modal.tracks.set_items(vec![ + track(), + Track { + has_file: false, + title: "Unselected track".to_owned(), + ..track() + }, + ]); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + AlbumDetailsUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + } + mod snapshot_tests { use crate::ui::ui_test_utils::test_utils::TerminalSize; use rstest::rstest; @@ -145,4 +332,13 @@ mod tests { insta::assert_snapshot!(output); } } + + fn album_download(album_id: i64, status: DownloadStatus) -> DownloadRecord { + DownloadRecord { + status, + album_id: Some(Number::from(album_id)), + artist_id: Some(Number::from(3i64)), + ..download_record() + } + } } diff --git a/src/ui/lidarr_ui/library/artist_details_ui.rs b/src/ui/lidarr_ui/library/artist_details_ui.rs index 952bfc1b..9f86c456 100644 --- a/src/ui/lidarr_ui/library/artist_details_ui.rs +++ b/src/ui/lidarr_ui/library/artist_details_ui.rs @@ -5,19 +5,20 @@ use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; -use regex::Regex; use crate::app::App; use crate::models::Route; use crate::models::lidarr_models::{Album, LidarrHistoryItem, LidarrRelease}; use crate::models::servarr_data::lidarr::lidarr_data::{ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock}; use crate::ui::lidarr_ui::library::album_details_ui::AlbumDetailsUi; +use crate::ui::lidarr_ui::library::artist_overview_ui::ArtistOverviewUi; use crate::ui::lidarr_ui::library::delete_album_ui::DeleteAlbumUi; use crate::ui::lidarr_ui::lidarr_ui_utils::create_history_event_details; use crate::ui::styles::{ManagarrStyle, secondary_style}; use crate::ui::utils::decorate_peer_style; use crate::ui::utils::{ - borderless_block, get_width_from_percentage, layout_block_top_border, title_block, + borderless_block, collapse_whitespace, get_width_from_percentage, layout_block_top_border, + title_block, }; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::loading_block::LoadingBlock; @@ -25,7 +26,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use ratatui::layout::Alignment; use ratatui::text::Text; use serde_json::Number; @@ -43,6 +44,7 @@ impl DrawUi for ArtistDetailsUi { }; AlbumDetailsUi::accepts(route) || DeleteAlbumUi::accepts(route) + || ArtistOverviewUi::accepts(route) || ARTIST_DETAILS_BLOCKS.contains(&active_lidarr_block) } @@ -122,6 +124,10 @@ impl DrawUi for ArtistDetailsUi { if AlbumDetailsUi::accepts(route) { AlbumDetailsUi::draw(f, app, area); } + + if ArtistOverviewUi::accepts(route) { + ArtistOverviewUi::draw(f, app, area); + } } } } @@ -147,18 +153,12 @@ fn draw_artist_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .get_by_left(¤t_selection.metadata_profile_id) .cloned() .unwrap_or_default(); - let overview = Regex::new(r"[\r\n\t]") - .unwrap() - .replace_all( - &deunicode( - current_selection - .overview - .as_ref() - .unwrap_or(&String::new()), - ), - "", - ) - .to_string(); + let overview = collapse_whitespace(&deunicode( + current_selection + .overview + .as_ref() + .unwrap_or(&String::new()), + )); let mut artist_description = vec![ Line::from(vec![ @@ -223,7 +223,7 @@ fn draw_artist_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { ]; if let Some(stats) = current_selection.statistics.as_ref() { - let size = convert_to_gb(stats.size_on_disk); + let size = format_size(stats.size_on_disk, 2); artist_description.extend(vec![ Line::from(vec![ "Albums: ".primary().bold(), @@ -235,7 +235,7 @@ fn draw_artist_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { ]), Line::from(vec![ "Size on Disk: ".primary().bold(), - format!("{size:.2} GB").default_color(), + size.default_color(), ]), ]); } @@ -279,42 +279,27 @@ fn draw_albums_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .release_date .map_or_else(|| "N/A".to_owned(), |d| d.format("%Y-%m-%d").to_string()); let track_count = album.statistics.as_ref().map_or_else( - || "0/0".to_owned(), + || "N/A".to_owned(), |s| format!("{}/{}", s.track_file_count, s.total_track_count), ); let size = album .statistics .as_ref() - .map_or(0f64, |s| convert_to_gb(s.size_on_disk)); + .map_or_else(|| "N/A".to_owned(), |s| format_size(s.size_on_disk, 2)); let duration_mins = album.duration / 60000; - let row = Row::new(vec![ - Cell::from(monitored.to_owned()), - Cell::from(album.title.to_string()), - Cell::from(album_type), - Cell::from(track_count), - Cell::from(format!("{duration_mins} min")), - Cell::from(release_date), - Cell::from(format!("{size:.2} GB")), - ]); - - if !album.monitored { - row.unmonitored() - } else if let Some(stats) = album.statistics.as_ref() { - if stats.track_file_count == stats.total_track_count && stats.total_track_count > 0 { - row.downloaded() - } else if let Some(release_date) = album.release_date.as_ref() { - if release_date > &Utc::now() { - row.unreleased() - } else { - row.missing() - } - } else { - row.missing() - } - } else { - row.indeterminate() - } + decorate_album_row_with_style( + album, + Row::new(vec![ + Cell::from(monitored.to_owned()), + Cell::from(album.title.to_string()), + Cell::from(album_type), + Cell::from(track_count), + Cell::from(format!("{duration_mins} min")), + Cell::from(release_date), + Cell::from(size), + ]), + ) }; let is_searching = active_lidarr_block == ActiveLidarrBlock::SearchAlbums; @@ -350,6 +335,26 @@ fn draw_albums_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } } +fn decorate_album_row_with_style<'a>(album: &Album, row: Row<'a>) -> Row<'a> { + if !album.monitored { + row.unmonitored() + } else if let Some(stats) = album.statistics.as_ref() { + if stats.track_file_count == stats.total_track_count && stats.total_track_count > 0 { + row.downloaded() + } else if let Some(release_date) = album.release_date.as_ref() { + if release_date > &Utc::now() { + row.unreleased() + } else { + row.missing() + } + } else { + row.missing() + } + } else { + row.indeterminate() + } +} + fn draw_artist_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if !app.is_loading { let current_selection = if app.data.lidarr_data.artist_history.is_empty() { @@ -492,7 +497,7 @@ fn draw_artist_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { && active_lidarr_block != ActiveLidarrBlock::ManualArtistSearchConfirmPrompt, app.should_text_scroll, ); - let size = convert_to_gb(*size); + let size = format_size(*size, 1); let rejected_str = if *rejected { "⛔" } else { "" }; let peers = if seeders.is_none() || leechers.is_none() { Text::from("") @@ -523,7 +528,7 @@ fn draw_artist_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(rejected_str), Cell::from(title.to_string()), Cell::from(indexer.clone()), - Cell::from(format!("{size:.1} GB")), + Cell::from(size), Cell::from(peers), Cell::from(quality_name), ]) @@ -566,12 +571,12 @@ fn draw_manual_artist_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_> let prompt = if current_selection.rejected { format!( "Do you really want to download the rejected release: {}?", - ¤t_selection.title.text + current_selection.title.text ) } else { format!( "Do you want to download the release: {}?", - ¤t_selection.title.text + current_selection.title.text ) }; diff --git a/src/ui/lidarr_ui/library/artist_details_ui_tests.rs b/src/ui/lidarr_ui/library/artist_details_ui_tests.rs index 0ce22c5a..1652cea3 100644 --- a/src/ui/lidarr_ui/library/artist_details_ui_tests.rs +++ b/src/ui/lidarr_ui/library/artist_details_ui_tests.rs @@ -1,13 +1,20 @@ #[cfg(test)] mod tests { + use chrono::{Duration, Utc}; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; use strum::IntoEnumIterator; + use crate::models::lidarr_models::{Album, AlbumStatistics}; use crate::models::servarr_data::lidarr::lidarr_data::{ - ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, DELETE_ALBUM_BLOCKS, - TRACK_DETAILS_BLOCKS, + ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock, + DELETE_ALBUM_BLOCKS, TRACK_DETAILS_BLOCKS, }; use crate::ui::DrawUi; - use crate::ui::lidarr_ui::library::artist_details_ui::ArtistDetailsUi; + use crate::ui::lidarr_ui::library::artist_details_ui::{ + ArtistDetailsUi, decorate_album_row_with_style, + }; + use crate::ui::styles::ManagarrStyle; #[test] fn test_artist_details_ui_accepts() { @@ -15,6 +22,7 @@ mod tests { blocks.extend(DELETE_ALBUM_BLOCKS); blocks.extend(ALBUM_DETAILS_BLOCKS); blocks.extend(TRACK_DETAILS_BLOCKS); + blocks.extend(ARTIST_OVERVIEW_BLOCKS); ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { if blocks.contains(&active_lidarr_block) { @@ -25,7 +33,124 @@ mod tests { }); } + #[test] + fn test_decorate_album_row_with_style_unmonitored() { + let album = Album::default(); + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + + #[test] + fn test_decorate_album_row_with_style_downloaded_when_all_tracks_present() { + let album = Album { + monitored: true, + statistics: Some(AlbumStatistics { + track_file_count: 3, + total_track_count: 3, + ..AlbumStatistics::default() + }), + ..Album::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_album_row_with_style_unreleased_when_tracks_are_missing_and_release_date_is_future() + { + let album = Album { + monitored: true, + release_date: Some(Utc::now() + Duration::days(1)), + statistics: Some(AlbumStatistics { + track_file_count: 0, + total_track_count: 3, + ..AlbumStatistics::default() + }), + ..Album::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.unreleased()); + } + + #[test] + fn test_decorate_album_row_with_style_missing_when_tracks_are_missing_and_release_date_has_passed() + { + let album = Album { + monitored: true, + release_date: Some(Utc::now() - Duration::days(1)), + statistics: Some(AlbumStatistics { + track_file_count: 1, + total_track_count: 3, + ..AlbumStatistics::default() + }), + ..Album::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_album_row_with_style_missing_when_tracks_are_missing_and_no_release_date() { + let album = Album { + monitored: true, + release_date: None, + statistics: Some(AlbumStatistics { + track_file_count: 1, + total_track_count: 3, + ..AlbumStatistics::default() + }), + ..Album::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_album_row_with_style_missing_when_total_track_count_is_zero() { + let album = Album { + monitored: true, + release_date: Some(Utc::now() - Duration::days(1)), + statistics: Some(AlbumStatistics::default()), + ..Album::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_album_row_with_style_indeterminate_when_no_statistics() { + let album = Album { + monitored: true, + statistics: None, + ..Album::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_album_row_with_style(&album, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + mod snapshot_tests { + use pretty_assertions::assert_str_eq; use rstest::rstest; use crate::app::App; @@ -124,6 +249,19 @@ mod tests { ); } + #[test] + fn test_artist_details_ui_renders_album_without_statistics() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.albums.items[0].statistics = None; + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[test] fn test_artist_details_ui_renders_delete_album_prompt_over_artist_details() { let mut app = App::test_default_fully_populated(); @@ -138,6 +276,46 @@ mod tests { insta::assert_snapshot!(output); } + #[test] + fn test_artist_details_ui_renders_artist_overview_over_artist_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_scrolling_the_artist_overview_does_not_scroll_the_artist_details_background() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into()); + let before = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistDetailsUi::draw(f, app, f.area()); + }); + + app + .data + .lidarr_data + .artist_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 3; + + let after = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistDetailsUi::draw(f, app, f.area()); + }); + assert_str_eq!(before, after); + assert_contains!( + before, + "Overview: some interesting description of the artist" + ); + } + #[test] fn test_artist_details_ui_renders_update_and_scan_prompt_over_artist_details() { let mut app = App::test_default_fully_populated(); diff --git a/src/ui/lidarr_ui/library/artist_overview_ui.rs b/src/ui/lidarr_ui/library/artist_overview_ui.rs new file mode 100644 index 00000000..dbe96f87 --- /dev/null +++ b/src/ui/lidarr_ui/library/artist_overview_ui.rs @@ -0,0 +1,87 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::lidarr_models::Artist; +use crate::models::servarr_data::lidarr::lidarr_data::ARTIST_OVERVIEW_BLOCKS; +use crate::ui::styles::{primary_style, unmonitored_style}; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{DrawUi, draw_popup}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Paragraph, Wrap}; + +#[cfg(test)] +#[path = "artist_overview_ui_tests.rs"] +mod artist_overview_ui_tests; + +pub(super) struct ArtistOverviewUi; + +impl DrawUi for ArtistOverviewUi { + fn accepts(route: Route) -> bool { + let Route::Lidarr(active_lidarr_block, _) = route else { + return false; + }; + ARTIST_OVERVIEW_BLOCKS.contains(&active_lidarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if app.data.lidarr_data.artist_overview_modal.is_some() { + let draw_artist_overview_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + draw_artist_overview(f, app, popup_area); + }; + + draw_popup(f, app, draw_artist_overview_popup, Size::Large); + } + } +} + +fn draw_artist_overview(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block_centered("Overview"); + + match app.data.lidarr_data.artist_overview_modal.as_ref() { + Some(artist_overview_modal) if !app.is_loading => { + let style = if app.data.lidarr_data.artists.is_empty() { + primary_style() + } else { + style_from_artist(app.data.lidarr_data.artists.current_selection()) + }; + let overview = &artist_overview_modal.overview; + let text = Text::from( + overview + .items + .iter() + .map(|line| overview_line(line, style)) + .collect::>>(), + ); + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((overview.offset, 0)); + + f.render_widget(paragraph, area); + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.lidarr_data.artist_overview_modal.is_none(), + block, + ), + area, + ), + } +} + +fn style_from_artist(artist: &Artist) -> Style { + if !artist.monitored { + return unmonitored_style(); + } + + primary_style() +} + +fn overview_line(line: &str, style: Style) -> Line<'static> { + Line::from(Span::styled(line.trim_end().to_owned(), style)) +} diff --git a/src/ui/lidarr_ui/library/artist_overview_ui_tests.rs b/src/ui/lidarr_ui/library/artist_overview_ui_tests.rs new file mode 100644 index 00000000..bd30c691 --- /dev/null +++ b/src/ui/lidarr_ui/library/artist_overview_ui_tests.rs @@ -0,0 +1,205 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use ratatui::style::Modifier; + use ratatui::text::Line; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::lidarr_models::Artist; + use crate::models::servarr_data::lidarr::lidarr_data::{ + ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock, + }; + use crate::ui::DrawUi; + use crate::ui::lidarr_ui::library::artist_overview_ui::{ + ArtistOverviewUi, overview_line, style_from_artist, + }; + use crate::ui::styles::{primary_style, unmonitored_style}; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_artist_overview_ui_accepts() { + ActiveLidarrBlock::iter().for_each(|active_lidarr_block| { + if ARTIST_OVERVIEW_BLOCKS.contains(&active_lidarr_block) { + assert!(ArtistOverviewUi::accepts(active_lidarr_block.into())); + } else { + assert!(!ArtistOverviewUi::accepts(active_lidarr_block.into())); + } + }); + } + + #[test] + fn test_style_from_artist_monitored() { + let artist = Artist { + monitored: true, + ..Artist::default() + }; + + let style = style_from_artist(&artist); + + assert_eq!(style, primary_style()); + } + + #[test] + fn test_style_from_artist_unmonitored() { + let artist = Artist { + monitored: false, + ..Artist::default() + }; + + let style = style_from_artist(&artist); + + assert_eq!(style, unmonitored_style()); + } + + #[test] + fn test_overview_line_does_not_split_a_label_from_a_colon_in_the_prose() { + let line = overview_line( + "She was born in Madison, Wisconsin: a city she has never really left.", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["She was born in Madison, Wisconsin: a city she has never really left."] + ); + } + + #[test] + fn test_overview_line_renders_the_prose_without_bolding_it() { + let line = overview_line("Overview: a prose line", primary_style()); + + assert_eq!(line.spans.len(), 1); + assert!(!line.spans[0].style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_overview_line_applies_the_given_style() { + let line = overview_line("an unmonitored artist", unmonitored_style()); + + assert_eq!(line.spans[0].style.fg, unmonitored_style().fg); + } + + #[test] + fn test_overview_line_trims_a_trailing_carriage_return() { + let line = overview_line( + "Her first album took her seven years to finish.\r", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["Her first album took her seven years to finish."] + ); + } + + #[test] + fn test_overview_line_renders_a_carriage_return_only_line_as_blank() { + let line = overview_line("\r", primary_style()); + + assert_eq!(span_contents(&line), vec![""]); + } + + fn span_contents(line: &Line<'_>) -> Vec { + line + .spans + .iter() + .map(|span| span.content.to_string()) + .collect() + } + + mod snapshot_tests { + use crate::models::ScrollableText; + use crate::models::servarr_data::lidarr::modals::ArtistOverviewModal; + use crate::models::stateful_table::StatefulTable; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_artist_overview_ui_renders() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_artist_overview_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_artist_overview_ui_renders_scrolled() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + app + .data + .lidarr_data + .artist_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 2; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_artist_overview_ui_renders_an_artist_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + app.data.lidarr_data.artist_overview_modal = Some(ArtistOverviewModal { + overview: ScrollableText::with_string(String::new()), + }); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_artist_overview_ui_renders_nothing_when_the_artist_overview_modal_is_undefined() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + app.data.lidarr_data.artist_overview_modal = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_artist_overview_ui_renders_with_zero_artists() { + let mut app = App::test_default_fully_populated(); + app.data.lidarr_data.artists = StatefulTable::default(); + app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ArtistOverviewUi::draw(f, app, f.area()); + }); + + assert_contains!(output, "some interesting description of the artist"); + } + } +} diff --git a/src/ui/lidarr_ui/library/library_ui_tests.rs b/src/ui/lidarr_ui/library/library_ui_tests.rs index c2505e50..579a66e9 100644 --- a/src/ui/lidarr_ui/library/library_ui_tests.rs +++ b/src/ui/lidarr_ui/library/library_ui_tests.rs @@ -4,9 +4,9 @@ mod tests { use crate::models::lidarr_models::{Artist, ArtistStatistics, ArtistStatus}; use crate::models::servarr_data::lidarr::lidarr_data::{ - ADD_ARTIST_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, ActiveLidarrBlock, - DELETE_ALBUM_BLOCKS, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, LIBRARY_BLOCKS, - TRACK_DETAILS_BLOCKS, + ADD_ARTIST_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS, ARTIST_OVERVIEW_BLOCKS, + ActiveLidarrBlock, DELETE_ALBUM_BLOCKS, DELETE_ARTIST_BLOCKS, EDIT_ARTIST_BLOCKS, + LIBRARY_BLOCKS, TRACK_DETAILS_BLOCKS, }; use crate::ui::DrawUi; use crate::ui::lidarr_ui::library::{LibraryUi, decorate_artist_row_with_style}; @@ -23,6 +23,7 @@ mod tests { library_ui_blocks.extend(EDIT_ARTIST_BLOCKS); library_ui_blocks.extend(ADD_ARTIST_BLOCKS); library_ui_blocks.extend(ARTIST_DETAILS_BLOCKS); + library_ui_blocks.extend(ARTIST_OVERVIEW_BLOCKS); library_ui_blocks.extend(ALBUM_DETAILS_BLOCKS); library_ui_blocks.extend(TRACK_DETAILS_BLOCKS); diff --git a/src/ui/lidarr_ui/library/mod.rs b/src/ui/lidarr_ui/library/mod.rs index 4f981aa8..ed6584f1 100644 --- a/src/ui/lidarr_ui/library/mod.rs +++ b/src/ui/lidarr_ui/library/mod.rs @@ -13,7 +13,7 @@ use crate::ui::widgets::{ confirmation_prompt::ConfirmationPrompt, popup::{Popup, Size}, }; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use crate::{ app::App, models::{ @@ -31,6 +31,7 @@ use crate::{ mod add_artist_ui; mod album_details_ui; mod artist_details_ui; +mod artist_overview_ui; mod delete_album_ui; mod delete_artist_ui; mod edit_artist_ui; @@ -103,7 +104,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let size = artist .statistics .as_ref() - .map_or(0f64, |stats| convert_to_gb(stats.size_on_disk)); + .map_or(0, |stats| stats.size_on_disk); let quality_profile = quality_profile_map .get_by_left(&artist.quality_profile_id) .cloned() @@ -139,7 +140,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(metadata_profile), Cell::from(albums.to_string()), Cell::from(tracks), - Cell::from(format!("{size:.2} GB")), + Cell::from(format_size(size, 2)), Cell::from(monitored.to_owned()), Cell::from(tags), ]), diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_AutomaticallySearchAlbumPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_AutomaticallySearchAlbumPrompt_2.snap index 70a533ed..bd30a682 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_AutomaticallySearchAlbumPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_AutomaticallySearchAlbumPrompt_2.snap @@ -10,8 +10,8 @@ expression: output │ Tracks │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless │ │ │ │ │ │ │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchConfirmPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchConfirmPrompt_2.snap index 6791f5c7..c6e6e8a3 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchConfirmPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchConfirmPrompt_2.snap @@ -10,8 +10,8 @@ expression: output │ Tracks │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless │ │ │ │ │ │ │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchSortPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchSortPrompt_2.snap index 9bfe80d5..43271405 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchSortPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearchSortPrompt_2.snap @@ -10,8 +10,8 @@ expression: output │ Tracks │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source Age ⛔ Title Indexer Size Peers Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless │ │ │ │ │ │ │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearch_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearch_2.snap index 9c738089..5406c4be 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearch_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__album_details_ui__album_details_ui_tests__tests__snapshot_tests__album_details_renders_ManualAlbumSearch_2.snap @@ -10,8 +10,8 @@ expression: output │ Tracks │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless │ │ │ │ │ │ │ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap index b94d5baf..1c6b39d2 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistDetails_0.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,13 +19,12 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Type Tracks Duration Release Date Size ││ - ││=> 🏷 Test Album Album 10/10 0 min 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 302.47 MB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap index 77e46b0f..cae8d5c4 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistoryDetails_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭─────────────────────────────────── Details ───────────────────────────────────╮ │ - │Size on Disk: 0.00 GB │Source Title: Test source title │ │ + │Albums: 1 ╭─────────────────────────────────── Details ───────────────────────────────────╮ │ + │Tracks: 12/15 │Source Title: Test source title │ │ │╭ Artist Details ─────────────│Event Type: grabbed │───────────────────────────────╮│ ││ Albums │ History │ Manual Sear│Quality: Lossless │ ││ ││───────────────────────────────│Date: 2023-01-01 00:00:00 UTC │───────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap index 6dc4f79a..223822d6 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistorySortPrompt_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,8 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap index b9881f68..ad92d6bf 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ArtistHistory_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,8 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap index e6361697..96959850 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_0.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,14 +18,13 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────── Automatic Artist Search ────────────────╮ │ - │Size on Disk: 0.00 GB │Do you want to trigger an automatic search of your indexers│ │ + │Albums: 1 ╭──────────────── Automatic Artist Search ────────────────╮ │ + │Tracks: 12/15 │Do you want to trigger an automatic search of your indexers│ │ │╭ Artist Details ───────────────────────│ for all monitored album(s) for the artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Monitored Title │ │ Release Date Size ││ - ││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album │ │ 2023-01-01 302.47 MB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap index 773f42d5..cef0f722 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────── Automatic Artist Search ────────────────╮ │ - │Size on Disk: 0.00 GB │Do you want to trigger an automatic search of your indexers│ │ + │Albums: 1 ╭──────────────── Automatic Artist Search ────────────────╮ │ + │Tracks: 12/15 │Do you want to trigger an automatic search of your indexers│ │ │╭ Artist Details ───────────────────────│ for all monitored album(s) for the artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap index 3dc5c230..2115191a 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_AutomaticallySearchArtistPrompt_2.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,15 +18,14 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────── Automatic Artist Search ────────────────╮ │ - │Size on Disk: 0.00 GB │Do you want to trigger an automatic search of your indexers│ │ + │Albums: 1 ╭──────────────── Automatic Artist Search ────────────────╮ │ + │Tracks: 12/15 │Do you want to trigger an automatic search of your indexers│ │ │╭ Artist Details ───────────────────────│ for all monitored album(s) for the artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Source ▼ Age ⛔ Title │ │ Size Peers Quality ││ - ││=> torrent 1 days ⛔ Test Releas│ │ 0.0 GB 2 / 1 Lossless ││ - ││ usenet 1 days ⛔ Test Releas│ │ 0.0 GB Lossless ││ + ││=> torrent 1 days ⛔ Test Releas│ │ 0.0 MB 2 / 1 Lossless ││ + ││ usenet 1 days ⛔ Test Releas│ │ 0.0 MB Lossless ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap index e384a505..d7e5d59d 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistoryError_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,8 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap index b3354746..66b9ef80 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_FilterArtistHistory_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,8 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap index 4d2c9e62..983b1e5e 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchConfirmPrompt_2.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -16,16 +17,15 @@ expression: output │Path: /nfs/music/test-artist │ │Quality Profile: Lossless │ │Metadata Profile: Standard │ - │Monitored: Yes │ - │Albums: 1 ╭───────────────── Download Rejected Release ──────────────────╮ │ - │Tracks: 15/15 │ Do you really want to download the rejected release: Test │ │ - │Size on Disk: 0.00 GB │ Release? │ │ + │Monitored: Yes ╭───────────────── Download Rejected Release ──────────────────╮ │ + │Albums: 1 │ Do you really want to download the rejected release: Test │ │ + │Tracks: 12/15 │ Release? │ │ │╭ Artist Details ──────────────────────│ │───────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││────────────────────────────────────────│Rejection reasons: │───────────────────────────────────────││ ││ Source ▼ Age ⛔ Title │• Unknown quality profile │e Peers Quality ││ - ││=> torrent 1 days ⛔ Test Relea│• Release is already mapped │ GB 2 / 1 Lossless ││ - ││ usenet 1 days ⛔ Test Relea│ │ GB Lossless ││ + ││=> torrent 1 days ⛔ Test Relea│• Release is already mapped │ MB 2 / 1 Lossless ││ + ││ usenet 1 days ⛔ Test Relea│ │ MB Lossless ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap index 07ceddf4..8beb877d 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearchSortPrompt_2.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,14 +19,13 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Source Age ⛔ Title Indexer Size Peers Quality ││ - ││=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless ││ - ││ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless ││ + ││=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless ││ + ││ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap index 684e4f66..46a9082a 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ManualArtistSearch_2.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,14 +19,13 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Source ▼ Age ⛔ Title Indexer Size Peers Quality ││ - ││=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless ││ - ││ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless ││ + ││=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless ││ + ││ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap index 93b238ec..c65c57da 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbumsError_0.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,13 +19,12 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Type Tracks Duration Release Date Size ││ - ││=> 🏷 Test Album Album 10/10 0 min 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 302.47 MB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap index a940bc30..be5daf72 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchAlbums_0.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,13 +19,12 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Type Tracks Duration Release Date Size ││ - ││=> 🏷 Test Album Album 10/10 0 min 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 302.47 MB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap index 4c2d8f7d..e8ae9c74 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistoryError_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,8 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap index 42e9e6a9..338d1818 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_SearchArtistHistory_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,8 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap index 929cfa02..5b615091 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_0.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,14 +18,13 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────────── Update and Scan ────────────────────╮ │ - │Size on Disk: 0.00 GB │ Do you want to trigger an update and disk scan for the │ │ + │Albums: 1 ╭──────────────────── Update and Scan ────────────────────╮ │ + │Tracks: 12/15 │ Do you want to trigger an update and disk scan for the │ │ │╭ Artist Details ───────────────────────│ artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Monitored Title │ │ Release Date Size ││ - ││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album │ │ 2023-01-01 302.47 MB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap index 2876cdfa..24279b7d 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_1.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────────── Update and Scan ────────────────────╮ │ - │Size on Disk: 0.00 GB │ Do you want to trigger an update and disk scan for the │ │ + │Albums: 1 ╭──────────────────── Update and Scan ────────────────────╮ │ + │Tracks: 12/15 │ Do you want to trigger an update and disk scan for the │ │ │╭ Artist Details ───────────────────────│ artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap index b717f47f..96b7f3da 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_UpdateAndScanArtistPrompt_2.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,15 +18,14 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────────── Update and Scan ────────────────────╮ │ - │Size on Disk: 0.00 GB │ Do you want to trigger an update and disk scan for the │ │ + │Albums: 1 ╭──────────────────── Update and Scan ────────────────────╮ │ + │Tracks: 12/15 │ Do you want to trigger an update and disk scan for the │ │ │╭ Artist Details ───────────────────────│ artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Source ▼ Age ⛔ Title │ │ Size Peers Quality ││ - ││=> torrent 1 days ⛔ Test Releas│ │ 0.0 GB 2 / 1 Lossless ││ - ││ usenet 1 days ⛔ Test Releas│ │ 0.0 GB Lossless ││ + ││=> torrent 1 days ⛔ Test Releas│ │ 0.0 MB 2 / 1 Lossless ││ + ││ usenet 1 days ⛔ Test Releas│ │ 0.0 MB Lossless ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_album_without_statistics.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_album_without_statistics.snap new file mode 100644 index 00000000..9be4b0f3 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_album_without_statistics.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs +expression: output +--- + + + + ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Artist: Alex │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ + │Disambiguation: American pianist │ + │Type: Person │ + │Status: Continuing │ + │Genres: soundtrack │ + │Rating: 84% │ + │Path: /nfs/music/test-artist │ + │Quality Profile: Lossless │ + │Metadata Profile: Standard │ + │Monitored: Yes │ + │Albums: 1 │ + │Tracks: 12/15 │ + │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Albums │ History │ Manual Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Type Tracks Duration Release Date Size ││ + ││=> 🏷 Test Album Album N/A 0 min 2023-01-01 N/A ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap new file mode 100644 index 00000000..d1661061 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_artist_overview_over_artist_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs +expression: output +--- + + + + ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Artist: Alex │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ │ + │Disambiguat│some interesting description of the artist │ │ + │Type: Perso│ │ │ + │Status: Con│She was born in Madison, Wisconsin: a city she has never really left. │ │ + │Genres: sou│ │ │ + │Rating: 84%│Her first album took her seven years to finish. │ │ + │Path: /nfs/│ │ │ + │Quality Pro│ │ │ + │Metadata Pr│ │ │ + │Monitored: │ │ │ + │Albums: 1 │ │ │ + │Tracks: 12/│ │ │ + │╭ Artist D│ │───────────╮│ + ││ Albums │ │ │ ││ + ││──────────│ │───────────││ + ││ Monitor│ │ ││ + ││=> 🏷 │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap index e6361697..96959850 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_automatic_search_prompt_over_artist_details.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,14 +18,13 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────── Automatic Artist Search ────────────────╮ │ - │Size on Disk: 0.00 GB │Do you want to trigger an automatic search of your indexers│ │ + │Albums: 1 ╭──────────────── Automatic Artist Search ────────────────╮ │ + │Tracks: 12/15 │Do you want to trigger an automatic search of your indexers│ │ │╭ Artist Details ───────────────────────│ for all monitored album(s) for the artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Monitored Title │ │ Release Date Size ││ - ││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album │ │ 2023-01-01 302.47 MB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap index 6d7f6e9f..f6ba7021 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_delete_album_prompt_over_artist_details.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,14 +18,13 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭───────────────────── Delete Album ──────────────────────╮ │ - │Size on Disk: 0.00 GB │ Do you really want to delete the album: │ │ + │Albums: 1 ╭───────────────────── Delete Album ──────────────────────╮ │ + │Tracks: 12/15 │ Do you really want to delete the album: │ │ │╭ Artist Details ───────────────────────│ Test Album? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Monitored Title │ ╭───╮ │ Release Date Size ││ - ││=> 🏷 Test Album │ Delete Album Files: │ ✔ │ │ 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album │ Delete Album Files: │ ✔ │ │ 2023-01-01 302.47 MB ││ ││ │ ╰───╯ │ ││ ││ │ ╭───╮ │ ││ ││ │ Add List Exclusion: │ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap index 929cfa02..5b615091 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__artist_details_ui_renders_update_and_scan_prompt_over_artist_details.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,14 +18,13 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭──────────────────── Update and Scan ────────────────────╮ │ - │Size on Disk: 0.00 GB │ Do you want to trigger an update and disk scan for the │ │ + │Albums: 1 ╭──────────────────── Update and Scan ────────────────────╮ │ + │Tracks: 12/15 │ Do you want to trigger an update and disk scan for the │ │ │╭ Artist Details ───────────────────────│ artist: Alex? │───────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search │ │ ││ ││─────────────────────────────────────────│ │───────────────────────────────────────────││ ││ Monitored Title │ │ Release Date Size ││ - ││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album │ │ 2023-01-01 302.47 MB ││ ││ │ │ ││ ││ │ │ ││ ││ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap index 89ee9563..98c8f630 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistDetails.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,8 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap index b9881f68..ad92d6bf 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistory.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,8 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap index 77e46b0f..cae8d5c4 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ArtistHistoryDetails.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -17,9 +18,8 @@ expression: output │Quality Profile: Lossless │ │Metadata Profile: Standard │ │Monitored: Yes │ - │Albums: 1 │ - │Tracks: 15/15 ╭─────────────────────────────────── Details ───────────────────────────────────╮ │ - │Size on Disk: 0.00 GB │Source Title: Test source title │ │ + │Albums: 1 ╭─────────────────────────────────── Details ───────────────────────────────────╮ │ + │Tracks: 12/15 │Source Title: Test source title │ │ │╭ Artist Details ─────────────│Event Type: grabbed │───────────────────────────────╮│ ││ Albums │ History │ Manual Sear│Quality: Lossless │ ││ ││───────────────────────────────│Date: 2023-01-01 00:00:00 UTC │───────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap index 896b7068..e131b101 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__empty_artist_details_ManualArtistSearch.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,8 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap index 896b7068..e131b101 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistDetails.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,8 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap index 896b7068..e131b101 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ArtistHistory.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,8 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap index 896b7068..e131b101 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_details_ui__artist_details_ui_tests__tests__snapshot_tests__loading_artist_details_ManualArtistSearch.snap @@ -7,7 +7,8 @@ expression: output ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,8 +19,7 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders.snap new file mode 100644 index 00000000..8ae8b8fe --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/library/artist_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │some interesting description of the artist │ + │ │ + │She was born in Madison, Wisconsin: a city she has never really left. │ + │ │ + │Her first album took her seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_an_artist_with_no_overview.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_an_artist_with_no_overview.snap new file mode 100644 index 00000000..de5b377b --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_an_artist_with_no_overview.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/library/artist_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_loading.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_loading.snap new file mode 100644 index 00000000..837c92bb --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_loading.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/library/artist_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_nothing_when_the_artist_overview_modal_is_undefined.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_nothing_when_the_artist_overview_modal_is_undefined.snap new file mode 100644 index 00000000..767e0ef9 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_nothing_when_the_artist_overview_modal_is_undefined.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/lidarr_ui/library/artist_overview_ui_tests.rs +expression: output +--- + diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_scrolled.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_scrolled.snap new file mode 100644 index 00000000..f5193931 --- /dev/null +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__artist_overview_ui__artist_overview_ui_tests__tests__snapshot_tests__artist_overview_ui_renders_scrolled.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/library/artist_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │She was born in Madison, Wisconsin: a city she has never really left. │ + │ │ + │Her first album took her seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap index 1da6ab43..a1f208d5 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistDetails.snap @@ -4,11 +4,12 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ - │Disambiguation: Ame╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to fini╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ + │Disambiguation: Ame│ │ │ │Type: Person │ │ │ │Status: Continuing │ │ │ │Genres: soundtrack │ │ │ @@ -16,15 +17,14 @@ expression: output │Path: /nfs/music/te│ │ │ │Quality Profile: Lo│ │ │ │Metadata Profile: S│ │ │ - │Monitored: Yes │ │ │ - │Albums: 1 │ ╭───╮ │ │ - │Tracks: 15/15 │ Monitored: │ ✔ │ │ │ - │Size on Disk: 0.00 │ ╰───╯ │ │ + │Monitored: Yes │ ╭───╮ │ │ + │Albums: 1 │ Monitored: │ ✔ │ │ │ + │Tracks: 12/15 │ ╰───╯ │ │ │╭ Artist Details │ ╭─────────────────────────────────────────────────╮ │────────────────────╮│ ││ Albums │ History │ Monitor New Albums: │All Albums ▼ │ │ ││ ││──────────────────│ ╰─────────────────────────────────────────────────╯ │────────────────────││ ││ Monitored Titl│ ╭─────────────────────────────────────────────────╮ │ize ││ - ││=> 🏷 Test│ Quality Profile: │Lossless ▼ │ │.00 GB ││ + ││=> 🏷 Test│ Quality Profile: │Lossless ▼ │ │02.47 MB ││ ││ │ ╰─────────────────────────────────────────────────╯ │ ││ ││ │ ╭─────────────────────────────────────────────────╮ │ ││ ││ │ Metadata Profile: │Standard ▼ │ │ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap index f27ce3cf..87198105 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ArtistHistory.snap @@ -4,11 +4,12 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ - │Disambiguation: Ame╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to fini╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ + │Disambiguation: Ame│ │ │ │Type: Person │ │ │ │Status: Continuing │ │ │ │Genres: soundtrack │ │ │ @@ -16,10 +17,9 @@ expression: output │Path: /nfs/music/te│ │ │ │Quality Profile: Lo│ │ │ │Metadata Profile: S│ │ │ - │Monitored: Yes │ │ │ - │Albums: 1 │ ╭───╮ │ │ - │Tracks: 15/15 │ Monitored: │ ✔ │ │ │ - │Size on Disk: 0.00 │ ╰───╯ │ │ + │Monitored: Yes │ ╭───╮ │ │ + │Albums: 1 │ Monitored: │ ✔ │ │ │ + │Tracks: 12/15 │ ╰───╯ │ │ │╭ Artist Details │ ╭─────────────────────────────────────────────────╮ │────────────────────╮│ ││ Albums │ History │ Monitor New Albums: │All Albums ▼ │ │ ││ ││──────────────────│ ╰─────────────────────────────────────────────────╯ │────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap index b510a18b..18e0d3ab 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__edit_artist_renders_over_ManualArtistSearch.snap @@ -4,11 +4,12 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ - │Disambiguation: Ame╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to fini╭─────────────────────────────────── Edit - Alex (American pianist) ────────────────────────────────────╮ │ + │Disambiguation: Ame│ │ │ │Type: Person │ │ │ │Status: Continuing │ │ │ │Genres: soundtrack │ │ │ @@ -16,10 +17,9 @@ expression: output │Path: /nfs/music/te│ │ │ │Quality Profile: Lo│ │ │ │Metadata Profile: S│ │ │ - │Monitored: Yes │ │ │ - │Albums: 1 │ ╭───╮ │ │ - │Tracks: 15/15 │ Monitored: │ ✔ │ │ │ - │Size on Disk: 0.00 │ ╰───╯ │ │ + │Monitored: Yes │ ╭───╮ │ │ + │Albums: 1 │ Monitored: │ ✔ │ │ │ + │Tracks: 12/15 │ ╰───╯ │ │ │╭ Artist Details │ ╭─────────────────────────────────────────────────╮ │────────────────────╮│ ││ Albums │ History │ Monitor New Albums: │All Albums ▼ │ │ ││ ││──────────────────│ ╰─────────────────────────────────────────────────╯ │────────────────────││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap index b60cb0f9..a36b629b 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_artist_details_over_library.snap @@ -4,10 +4,11 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex ╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Artist: Alex │ - │Overview: some interesting description of the artist │ + │Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│ + │seven years to finish. │ │Disambiguation: American pianist │ │Type: Person │ │Status: Continuing │ @@ -18,13 +19,12 @@ expression: output │Metadata Profile: Standard │ │Monitored: Yes │ │Albums: 1 │ - │Tracks: 15/15 │ - │Size on Disk: 0.00 GB │ + │Tracks: 12/15 │ │╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ ││ Albums │ History │ Manual Search ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Title Type Tracks Duration Release Date Size ││ - ││=> 🏷 Test Album Album 10/10 0 min 2023-01-01 0.00 GB ││ + ││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 302.47 MB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap index c4602f8d..ca9ffab9 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_artist_over_library.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_dropdown_over_edit_artist_over_artist_details.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_dropdown_over_edit_artist_over_artist_details.snap index 311a0530..1a02d863 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_dropdown_over_edit_artist_over_artist_details.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_dropdown_over_edit_artist_over_artist_details.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_artists_prompt.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_artists_prompt.snap index d20a2fa1..6c895a09 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_artists_prompt.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_artists_prompt.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap index 1443a4fb..6c07d7e7 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_Artists.snap @@ -4,4 +4,4 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap index c2d2087b..bac43a58 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_ArtistsSortPrompt.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap index 24b5f600..6230c0e2 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtists.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap index 2b5b359b..0b6f6a3c 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_FilterArtistsError.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap index 1c21242e..16a1fd84 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtists.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap index 6af1685a..06e0c83a 100644 --- a/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap +++ b/src/ui/lidarr_ui/library/snapshots/managarr__ui__lidarr_ui__library__library_ui_tests__tests__snapshot_tests__lidarr_library_SearchArtistsError.snap @@ -4,7 +4,7 @@ expression: output --- ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags -=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex +=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex diff --git a/src/ui/lidarr_ui/library/track_details_ui.rs b/src/ui/lidarr_ui/library/track_details_ui.rs index f9c3d72a..9dac1205 100644 --- a/src/ui/lidarr_ui/library/track_details_ui.rs +++ b/src/ui/lidarr_ui/library/track_details_ui.rs @@ -13,7 +13,7 @@ use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Rect}; -use ratatui::style::{Style, Stylize}; +use ratatui::style::Style; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; @@ -97,7 +97,7 @@ fn draw_track_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let style = style_from_status(&track); Line::from(vec![ - title.bold().style(style), + Span::styled(title, style.bold()), Span::styled(split[1..].join(":"), style), ]) }) diff --git a/src/ui/lidarr_ui/library/track_details_ui_tests.rs b/src/ui/lidarr_ui/library/track_details_ui_tests.rs index cacbb4bd..0651ac24 100644 --- a/src/ui/lidarr_ui/library/track_details_ui_tests.rs +++ b/src/ui/lidarr_ui/library/track_details_ui_tests.rs @@ -1,11 +1,14 @@ #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; use strum::IntoEnumIterator; use crate::app::App; + use crate::models::lidarr_models::Track; use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, TRACK_DETAILS_BLOCKS}; use crate::ui::DrawUi; - use crate::ui::lidarr_ui::library::track_details_ui::TrackDetailsUi; + use crate::ui::lidarr_ui::library::track_details_ui::{TrackDetailsUi, style_from_status}; + use crate::ui::styles::{downloaded_style, missing_style}; use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; #[test] @@ -19,6 +22,30 @@ mod tests { }); } + #[test] + fn test_style_from_status_downloaded_when_track_has_file() { + let track = Track { + has_file: true, + ..Track::default() + }; + + let style = style_from_status(&track); + + assert_eq!(style, downloaded_style()); + } + + #[test] + fn test_style_from_status_missing_when_track_has_no_file() { + let track = Track { + has_file: false, + ..Track::default() + }; + + let style = style_from_status(&track); + + assert_eq!(style, missing_style()); + } + mod snapshot_tests { use crate::ui::ui_test_utils::test_utils::TerminalSize; use rstest::rstest; diff --git a/src/ui/lidarr_ui/mod.rs b/src/ui/lidarr_ui/mod.rs index dbf277d4..a8fe835b 100644 --- a/src/ui/lidarr_ui/mod.rs +++ b/src/ui/lidarr_ui/mod.rs @@ -37,7 +37,7 @@ use crate::{ servarr_data::lidarr::lidarr_data::LidarrData, servarr_models::{DiskSpace, RootFolder}, }, - utils::convert_to_gb, + utils::format_size, }; mod blocklist; @@ -176,8 +176,8 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let RootFolder { path, free_space, .. } = &monitored_root_folders[i]; - let space: f64 = convert_to_gb(*free_space); - let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) + let space = format_size(*free_space, 2); + let root_folder_space = Paragraph::new(format!("{path}: {space} free")) .block(borderless_block()) .default_color(); diff --git a/src/ui/lidarr_ui/root_folders/mod.rs b/src/ui/lidarr_ui/root_folders/mod.rs index ac2afa10..61603a0e 100644 --- a/src/ui/lidarr_ui/root_folders/mod.rs +++ b/src/ui/lidarr_ui/root_folders/mod.rs @@ -15,7 +15,7 @@ use crate::ui::utils::layout_block_top_border; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::{Popup, Size}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; mod add_root_folder_ui; @@ -69,11 +69,11 @@ fn draw_root_folders(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .. } = root_folders; - let space: f64 = convert_to_gb(*free_space); + let space = format_size(*free_space, 2); Row::new(vec![ Cell::from(path.to_owned()), - Cell::from(format!("{space:.2} GB")), + Cell::from(space), Cell::from( unmapped_folders .as_ref() diff --git a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap index 9ca7db1a..1140ba12 100644 --- a/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap +++ b/src/ui/lidarr_ui/snapshots/managarr__ui__lidarr_ui__lidarr_ui_tests__tests__snapshot_tests__lidarr_tabs_Artists.snap @@ -6,7 +6,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ -│=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │ +│=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/lidarr_ui/system/mod.rs b/src/ui/lidarr_ui/system/mod.rs index e34362d9..56da00c9 100644 --- a/src/ui/lidarr_ui/system/mod.rs +++ b/src/ui/lidarr_ui/system/mod.rs @@ -165,7 +165,7 @@ fn draw_logs(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let logs_box = SelectableList::new(&mut app.data.lidarr_data.logs, |log| { let log_line = log.to_string(); - let level = log_line.split('|').collect::>()[1].to_string(); + let level = log.text.split('|').collect::>()[1].to_string(); style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) }) diff --git a/src/ui/lidarr_ui/system/snapshots/managarr__ui__lidarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap b/src/ui/lidarr_ui/system/snapshots/managarr__ui__lidarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap new file mode 100644 index 00000000..e51c3939 --- /dev/null +++ b/src/ui/lidarr_ui/system/snapshots/managarr__ui__lidarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/lidarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/lidarr_ui/system/system_details_ui_tests.rs b/src/ui/lidarr_ui/system/system_details_ui_tests.rs index a3fafc1b..8af08067 100644 --- a/src/ui/lidarr_ui/system/system_details_ui_tests.rs +++ b/src/ui/lidarr_ui/system/system_details_ui_tests.rs @@ -70,6 +70,21 @@ mod tests { insta::assert_snapshot!(format!("loading_{active_lidarr_block}"), output); } + #[test] + fn test_system_details_ui_updates_popup_is_loading_when_updates_are_present() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveLidarrBlock::SystemUpdates.into()); + + assert!(!app.data.lidarr_data.updates.get_text().is_empty()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[test] fn test_system_details_ui_updates_popup_loading_when_empty() { let mut app = App::test_default_fully_populated(); diff --git a/src/ui/lidarr_ui/system/system_ui_tests.rs b/src/ui/lidarr_ui/system/system_ui_tests.rs index 58d9fcf7..b6fd236d 100644 --- a/src/ui/lidarr_ui/system/system_ui_tests.rs +++ b/src/ui/lidarr_ui/system/system_ui_tests.rs @@ -25,6 +25,83 @@ mod tests { }); } + mod test_log_level_styling { + use std::sync::atomic::Ordering; + + use pretty_assertions::assert_eq; + use ratatui::style::{Modifier, Style}; + + use crate::models::HorizontallyScrollableText; + use crate::ui::styles::failure_style; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, create_test_terminal}; + + use super::*; + + #[test] + fn test_system_ui_styles_a_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::System.into()); + app.data.lidarr_data.logs.set_items(log_lines_vec()); + app.data.lidarr_data.logs.items[1] + .offset + .store(30, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "CommandExecutor|log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_system_ui_styles_a_fully_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveLidarrBlock::System.into()); + app.data.lidarr_data.logs.set_items(log_lines_vec()); + app.data.lidarr_data.logs.items[1] + .offset + .store(46, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + SystemUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + + fn log_lines_vec() -> Vec { + vec![ + "2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha".into(), + "2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma".into(), + ] + } + } + mod snapshot_tests { use crate::models::stateful_list::StatefulList; use crate::ui::ui_test_utils::test_utils::TerminalSize; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 17bc5a77..db2c3a93 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -10,6 +10,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Tabs; use ratatui::widgets::Wrap; use ratatui::widgets::{Clear, Row}; +use readarr_ui::ReadarrUi; use sonarr_ui::SonarrUi; use utils::layout_block; @@ -32,6 +33,7 @@ use crate::ui::widgets::popup::{Popup, Size}; mod builtin_themes; mod lidarr_ui; mod radarr_ui; +mod readarr_ui; mod sonarr_ui; mod styles; pub mod theme; @@ -94,6 +96,10 @@ pub fn ui(f: &mut Frame<'_>, app: &mut App<'_>) { LidarrUi::draw_context_row(f, app, context_area); LidarrUi::draw(f, app, table_area); } + route if ReadarrUi::accepts(route) => { + ReadarrUi::draw_context_row(f, app, context_area); + ReadarrUi::draw(f, app, table_area); + } _ => (), } diff --git a/src/ui/radarr_ui/collections/collection_details_ui.rs b/src/ui/radarr_ui/collections/collection_details_ui.rs index f0bf0d42..eda54eaf 100644 --- a/src/ui/radarr_ui/collections/collection_details_ui.rs +++ b/src/ui/radarr_ui/collections/collection_details_ui.rs @@ -1,7 +1,7 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::style::Stylize; -use ratatui::text::{Line, Text}; +use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use crate::app::App; @@ -10,11 +10,12 @@ use crate::models::radarr_models::CollectionMovie; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, }; -use crate::ui::styles::ManagarrStyle; +use crate::ui::styles::{ManagarrStyle, default_style}; use crate::ui::utils::{ borderless_block, get_width_from_percentage, layout_block_top_border_with_title, title_block, title_style, }; +use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::Size; use crate::ui::{DrawUi, draw_popup}; @@ -45,7 +46,7 @@ impl DrawUi for CollectionDetailsUi { draw_popup(f, app, draw_collection_details, Size::Large); if context_option.unwrap_or(active_radarr_block) == ActiveRadarrBlock::ViewMovieOverview { - draw_popup(f, app, draw_movie_overview, Size::Small); + draw_popup(f, app, draw_movie_overview, Size::Medium); } } } @@ -219,20 +220,35 @@ fn draw_movie_overview(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .flex(Flex::SpaceBetween) .margin(1) .areas(area); - let overview = Text::from( - app - .data - .radarr_data - .collection_movies - .current_selection() - .clone() - .overview, - ) - .default_color(); - let paragraph = Paragraph::new(overview) - .block(borderless_block()) - .wrap(Wrap { trim: false }); + match app.data.radarr_data.movie_overview_modal.as_ref() { + Some(movie_overview_modal) if !app.is_loading => { + let overview = &movie_overview_modal.overview; + let text = Text::from( + overview + .items + .iter() + .map(|line| overview_line(line)) + .collect::>>(), + ); - f.render_widget(paragraph, paragraph_area); + let paragraph = Paragraph::new(text) + .block(borderless_block()) + .wrap(Wrap { trim: false }) + .scroll((overview.offset, 0)); + + f.render_widget(paragraph, paragraph_area); + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.radarr_data.movie_overview_modal.is_none(), + borderless_block(), + ), + paragraph_area, + ), + } +} + +fn overview_line(line: &str) -> Line<'static> { + Line::from(Span::styled(line.trim_end().to_owned(), default_style())) } diff --git a/src/ui/radarr_ui/collections/collection_details_ui_tests.rs b/src/ui/radarr_ui/collections/collection_details_ui_tests.rs index 9a78c2fd..b727a4a2 100644 --- a/src/ui/radarr_ui/collections/collection_details_ui_tests.rs +++ b/src/ui/radarr_ui/collections/collection_details_ui_tests.rs @@ -1,5 +1,6 @@ #[cfg(test)] mod tests { + use pretty_assertions::assert_str_eq; use strum::IntoEnumIterator; use crate::app::App; @@ -7,9 +8,39 @@ mod tests { ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, }; use crate::ui::DrawUi; - use crate::ui::radarr_ui::collections::collection_details_ui::CollectionDetailsUi; + use crate::ui::radarr_ui::collections::collection_details_ui::{ + CollectionDetailsUi, overview_line, + }; use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + #[test] + fn test_overview_line_trims_a_trailing_carriage_return() { + let line = overview_line("The sequel took seven years to finish.\r"); + + assert_str_eq!( + line.spans[0].content, + "The sequel took seven years to finish." + ); + } + + #[test] + fn test_overview_line_renders_a_carriage_return_only_line_as_blank() { + let line = overview_line("\r"); + + assert_str_eq!(line.spans[0].content, ""); + } + + #[test] + fn test_overview_line_does_not_split_a_label_from_a_colon_in_the_prose() { + let line = overview_line("It was shot in Madison, Wisconsin: a city the cast never left."); + + assert_eq!(line.spans.len(), 1); + assert_str_eq!( + line.spans[0].content, + "It was shot in Madison, Wisconsin: a city the cast never left." + ); + } + #[test] fn test_collection_details_ui_accepts() { ActiveRadarrBlock::iter().for_each(|active_radarr_block| { @@ -36,6 +67,32 @@ mod tests { )); } + #[test] + fn test_scrolling_the_movie_overview_does_not_scroll_the_collection_details_behind_it() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into()); + + let before = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + CollectionDetailsUi::draw(f, app, f.area()); + }); + + app + .data + .radarr_data + .movie_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 3; + + let after = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + CollectionDetailsUi::draw(f, app, f.area()); + }); + + assert_contains!(before, "Overview: Collection blah blah blah"); + assert_str_eq!(before, after); + } + mod snapshot_tests { use super::*; use crate::models::stateful_table::StatefulTable; @@ -59,6 +116,52 @@ mod tests { insta::assert_snapshot!(active_radarr_block.to_string(), output); } + #[test] + fn test_collection_details_ui_renders_scrolled_movie_overview() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()); + app + .data + .radarr_data + .movie_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 2; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + CollectionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_collection_details_ui_renders_movie_overview_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + CollectionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_collection_details_ui_renders_movie_overview_when_the_modal_is_absent() { + let mut app = App::test_default_fully_populated(); + app.data.radarr_data.movie_overview_modal = None; + app.push_navigation_stack(ActiveRadarrBlock::ViewMovieOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + CollectionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[test] fn test_collection_details_ui_renders_collection_details_empty() { let mut app = App::test_default_fully_populated(); diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap index f1294684..fb30709a 100644 --- a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__ViewMovieOverview.snap @@ -12,36 +12,36 @@ expression: output │Overview: Collection blah blah blah │ │Root Folder Path: /nfs/movies │ │Quality Profile: HD - 1080p │ - │Minimum Availability: Released │ - │Monitored: Yes │ - │Search on Add: Yes │ - │ │ - │ │ - │ ╭ Overview ────────────────────────────────────────────────────╮ │ - │ Movies ───────────────────│Collection blah blah blah │───────────────────────────│ - │ ✔ Title │ │ │ - │=> ✔ Test │ │family, fun │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - │ ╰────────────────────────────────────────────────────────────────╯ │ - │ │ - │ │ - │ │ - │ │ - │ │ + │Minimum Avai╭ Overview ─────────────────────────────────────────────────────────────────────────────────────╮ │ + │Monitored: Y│Collection movie blah blah blah │ │ + │Search on Ad│ │ │ + │ │It was shot in Madison, Wisconsin: a city the cast never really left. │ │ + │ │ │ │ + │ │The sequel took seven years to finish. │ │ + │ Movies ──│ │───────────│ + │ ✔ Title │ │ │ + │=> ✔ Test │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap new file mode 100644 index 00000000..dada3b57 --- /dev/null +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_loading.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/radarr_ui/collections/collection_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Overview: Collection blah blah blah │ + │Root Folder Path: /nfs/movies │ + │Quality Profile: HD - 1080p │ + │Minimum Avai╭ Overview ─────────────────────────────────────────────────────────────────────────────────────╮ │ + │Monitored: Y│ │ │ + │Search on Ad│ │ │ + │ │ Loading ... │ │ + │ │ │ │ + │ │ │ │ + │ Movies ──│ │───────────│ + │ │ │ │ + │ │ │ │ + │ Loading ...│ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap new file mode 100644 index 00000000..07ec0757 --- /dev/null +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_movie_overview_when_the_modal_is_absent.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/radarr_ui/collections/collection_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Overview: Collection blah blah blah │ + │Root Folder Path: /nfs/movies │ + │Quality Profile: HD - 1080p │ + │Minimum Avai╭ Overview ─────────────────────────────────────────────────────────────────────────────────────╮ │ + │Monitored: Y│ │ │ + │Search on Ad│ │ │ + │ │ Loading ... │ │ + │ │ │ │ + │ │ │ │ + │ Movies ──│ │───────────│ + │ ✔ Title │ │ │ + │=> ✔ Test │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap new file mode 100644 index 00000000..c3b95b16 --- /dev/null +++ b/src/ui/radarr_ui/collections/snapshots/managarr__ui__radarr_ui__collections__collection_details_ui__collection_details_ui_tests__tests__snapshot_tests__collection_details_ui_renders_scrolled_movie_overview.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/radarr_ui/collections/collection_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test Collection ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Overview: Collection blah blah blah │ + │Root Folder Path: /nfs/movies │ + │Quality Profile: HD - 1080p │ + │Minimum Avai╭ Overview ─────────────────────────────────────────────────────────────────────────────────────╮ │ + │Monitored: Y│It was shot in Madison, Wisconsin: a city the cast never really left. │ │ + │Search on Ad│ │ │ + │ │The sequel took seven years to finish. │ │ + │ │ │ │ + │ │ │ │ + │ Movies ──│ │───────────│ + │ ✔ Title │ │ │ + │=> ✔ Test │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/radarr_ui/downloads/mod.rs b/src/ui/radarr_ui/downloads/mod.rs index af147896..bdaf2615 100644 --- a/src/ui/radarr_ui/downloads/mod.rs +++ b/src/ui/radarr_ui/downloads/mod.rs @@ -12,7 +12,7 @@ use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::{Popup, Size}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "downloads_ui_tests.rs"] @@ -96,12 +96,12 @@ fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } else { 1f64 - (*sizeleft as f64 / *size as f64) }; - let file_size: f64 = convert_to_gb(*size); + let file_size = format_size(*size, 2); Row::new(vec![ Cell::from(title.to_owned()), Cell::from(format!("{:.0}%", percent * 100.0)), - Cell::from(format!("{file_size:.2} GB")), + Cell::from(file_size), Cell::from( output_path .as_ref() diff --git a/src/ui/radarr_ui/indexers/indexers_ui_tests.rs b/src/ui/radarr_ui/indexers/indexers_ui_tests.rs index 19193d3b..553d4095 100644 --- a/src/ui/radarr_ui/indexers/indexers_ui_tests.rs +++ b/src/ui/radarr_ui/indexers/indexers_ui_tests.rs @@ -29,6 +29,9 @@ mod tests { } mod snapshot_tests { + use crate::models::BlockSelectionState; + use crate::models::servarr_data::radarr::radarr_data::INDEXER_SETTINGS_SELECTION_BLOCKS; + use super::*; #[test] @@ -120,5 +123,19 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_indexers_ui_renders_indexer_settings_over_indexers() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveRadarrBlock::AllIndexerSettingsPrompt.into()); + app.data.radarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/radarr_ui/indexers/snapshots/managarr__ui__radarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap b/src/ui/radarr_ui/indexers/snapshots/managarr__ui__radarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap new file mode 100644 index 00000000..796f4fd5 --- /dev/null +++ b/src/ui/radarr_ui/indexers/snapshots/managarr__ui__radarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/radarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer ▼ RSS Automatic Search Interactive Search Priority Tags +=> Test Indexer Enabled Enabled Enabled 25 alex + + + + + + + + + + + ╭─────────────────────────────────────── Configure All Indexer Settings ────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │12 │ Availability Delay (days) │0 │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Retention (days) ▴▾: │30 │ RSS Sync Interval (minutes│60 │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │1234 │ Whitelisted Subtitle Tags:│eng │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭───╮ │ + │ Prefer Indexer Flags: │ ✔ │ Allow Hardcoded Subs: │ ✔ │ │ + │ ╰───╯ ╰───╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/radarr_ui/library/library_ui_tests.rs b/src/ui/radarr_ui/library/library_ui_tests.rs index 629c9555..5b7753cc 100644 --- a/src/ui/radarr_ui/library/library_ui_tests.rs +++ b/src/ui/radarr_ui/library/library_ui_tests.rs @@ -29,6 +29,80 @@ mod tests { }); } + mod test_movie_row_styling { + use pretty_assertions::assert_eq; + use ratatui::style::Style; + + use crate::models::radarr_models::Movie; + use crate::network::radarr_network::radarr_network_test_utils::test_utils::movie; + use crate::ui::styles::{downloading_style, missing_style}; + use crate::ui::ui_test_utils::test_utils::create_test_terminal; + + use super::*; + + #[test] + fn test_library_ui_renders_downloading_movie_with_downloading_style() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + set_movies_with_unselected_probe(&mut app); + + let style = rendered_row_style(&mut app, "Unselected movie"); + + assert_eq!(style.fg, downloading_style().fg); + } + + #[test] + fn test_library_ui_renders_missing_movie_with_missing_style_when_queue_is_empty() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveRadarrBlock::Movies.into()); + app.data.radarr_data.downloads.set_items(vec![]); + set_movies_with_unselected_probe(&mut app); + + let style = rendered_row_style(&mut app, "Unselected movie"); + + assert_eq!(style.fg, missing_style().fg); + } + + fn set_movies_with_unselected_probe(app: &mut App<'_>) { + app.data.radarr_data.movies.set_items(vec![ + movie(), + Movie { + has_file: false, + monitored: true, + title: "Unselected movie".into(), + ..movie() + }, + ]); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + LibraryUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + } + mod snapshot_tests { use super::*; use crate::models::BlockSelectionState; diff --git a/src/ui/radarr_ui/library/mod.rs b/src/ui/radarr_ui/library/mod.rs index 0b1af8d7..58669ce0 100644 --- a/src/ui/radarr_ui/library/mod.rs +++ b/src/ui/radarr_ui/library/mod.rs @@ -16,7 +16,7 @@ use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::{Popup, Size}; -use crate::utils::{convert_runtime, convert_to_gb}; +use crate::utils::{convert_runtime, format_size}; mod add_movie_ui; mod delete_movie_ui; @@ -95,7 +95,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let monitored = if movie.monitored { "🏷" } else { "" }; let studio = movie.studio.clone().unwrap_or_default(); let (hours, minutes) = convert_runtime(movie.runtime); - let file_size: f64 = convert_to_gb(movie.size_on_disk); + let file_size = format_size(movie.size_on_disk, 2); let certification = movie.certification.clone().unwrap_or_default(); let quality_profile = quality_profile_map .get_by_left(&movie.quality_profile_id) @@ -121,7 +121,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(format!("{hours}h {minutes}m")), Cell::from(certification), Cell::from(movie.original_language.name.to_owned()), - Cell::from(format!("{file_size:.2} GB")), + Cell::from(file_size), Cell::from(quality_profile), Cell::from(monitored.to_owned()), Cell::from(tags), diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index bd2c41f2..ee38b7dd 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -13,7 +13,7 @@ use serde_json::Number; use crate::app::App; use crate::models::Route; -use crate::models::radarr_models::{Credit, MovieHistoryItem, RadarrRelease}; +use crate::models::radarr_models::{Credit, MovieHistoryItem, MovieStatus, RadarrRelease}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; use crate::ui::styles::ManagarrStyle; @@ -26,7 +26,7 @@ use crate::ui::widgets::loading_block::LoadingBlock; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "movie_details_ui_tests.rs"] @@ -179,13 +179,7 @@ fn draw_movie_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { match app.data.radarr_data.movie_details_modal.as_ref() { Some(movie_details_modal) if !app.is_loading => { let is_monitored = app.data.radarr_data.movies.current_selection().monitored; - let status = app - .data - .radarr_data - .movies - .current_selection() - .status - .clone(); + let status = app.data.radarr_data.movies.current_selection().status; let movie_details = &movie_details_modal.movie_details; let download_status = movie_details .items @@ -201,10 +195,10 @@ fn draw_movie_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { .map(|line| { let split = line.split(':').collect::>(); let title = format!("{}:", split[0]); - let style = style_from_download_status(download_status, is_monitored, status.clone()); + let style = style_from_download_status(download_status, is_monitored, status); Line::from(vec![ - title.bold().style(style), + Span::styled(title, style.bold()), Span::styled(split[1..].join(":"), style), ]) }) @@ -404,7 +398,7 @@ fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { && current_route != ActiveRadarrBlock::ManualSearchConfirmPrompt.into(), app.should_text_scroll, ); - let size = convert_to_gb(*size); + let size = format_size(*size, 1); let rejected_str = if *rejected { "⛔" } else { "" }; let peers = if seeders.is_none() || leechers.is_none() { Text::from("") @@ -440,7 +434,7 @@ fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(rejected_str), Cell::from(title.to_string()), Cell::from(indexer.clone()), - Cell::from(format!("{size:.1} GB")), + Cell::from(size), Cell::from(peers), Cell::from(language), Cell::from(quality), @@ -487,12 +481,12 @@ fn draw_manual_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { let prompt = if current_selection.rejected { format!( "Do you really want to download the rejected release: {}?", - ¤t_selection.title.text + current_selection.title.text ) } else { format!( "Do you want to download the release: {}?", - ¤t_selection.title.text + current_selection.title.text ) }; @@ -531,13 +525,17 @@ fn draw_manual_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { } } -fn style_from_download_status(download_status: &str, is_monitored: bool, status: String) -> Style { +fn style_from_download_status( + download_status: &str, + is_monitored: bool, + status: MovieStatus, +) -> Style { match download_status { "Downloaded" => downloaded_style(), "Awaiting Import" => awaiting_import_style(), "Downloading" => downloading_style(), _ if !is_monitored && download_status == "Missing" => unmonitored_missing_style(), - _ if status != "released" && download_status == "Missing" => unreleased_style(), + _ if status != MovieStatus::Released && download_status == "Missing" => unreleased_style(), "Missing" => missing_style(), _ => downloaded_style(), } diff --git a/src/ui/radarr_ui/library/movie_details_ui_tests.rs b/src/ui/radarr_ui/library/movie_details_ui_tests.rs index 551297dc..cc5bf18e 100644 --- a/src/ui/radarr_ui/library/movie_details_ui_tests.rs +++ b/src/ui/radarr_ui/library/movie_details_ui_tests.rs @@ -6,6 +6,7 @@ mod tests { use strum::IntoEnumIterator; use crate::app::App; + use crate::models::radarr_models::MovieStatus; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; use crate::ui::DrawUi; use crate::ui::radarr_ui::library::movie_details_ui::{ @@ -13,7 +14,7 @@ mod tests { }; use crate::ui::styles::{ awaiting_import_style, downloaded_style, downloading_style, missing_style, - unmonitored_missing_style, + unmonitored_missing_style, unreleased_style, }; use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; @@ -29,21 +30,26 @@ mod tests { } #[rstest] - #[case("Downloading", true, "", downloading_style())] - #[case("Downloaded", true, "", downloaded_style())] - #[case("Awaiting Import", true, "", awaiting_import_style())] - #[case("Missing", false, "", unmonitored_missing_style())] - #[case("Missing", false, "", unmonitored_missing_style())] - #[case("Missing", true, "released", missing_style())] - #[case("", true, "", downloaded_style())] + #[case("Downloading", true, MovieStatus::Released, downloading_style())] + #[case("Downloaded", true, MovieStatus::Released, downloaded_style())] + #[case( + "Awaiting Import", + true, + MovieStatus::Released, + awaiting_import_style() + )] + #[case("Missing", false, MovieStatus::InCinemas, unmonitored_missing_style())] + #[case("Missing", true, MovieStatus::InCinemas, unreleased_style())] + #[case("Missing", true, MovieStatus::Released, missing_style())] + #[case("", true, MovieStatus::Released, downloaded_style())] fn test_style_from_download_status( #[case] download_status: &str, #[case] is_monitored: bool, - #[case] movie_status: &str, + #[case] movie_status: MovieStatus, #[case] expected_style: Style, ) { assert_eq!( - style_from_download_status(download_status, is_monitored, movie_status.to_owned()), + style_from_download_status(download_status, is_monitored, movie_status), expected_style ); } diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMoviePrompt_CollectionDetails.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMoviePrompt_CollectionDetails.snap index ae1d2432..5c152515 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMoviePrompt_CollectionDetails.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMoviePrompt_CollectionDetails.snap @@ -13,11 +13,11 @@ expression: output │Root Folder Path: /nfs/movies │ │Quality Profile: HD - 1080p │ │Minimum Avai╭────────────────────────────────────── Add Movie - Test ───────────────────────────────────────╮ │ - │Monitored: Y│ Collection blah blah blah │ │ + │Monitored: Y│ Collection movie blah blah blah │ │ │Search on Ad│ │ │ + │ │ It was shot in Madison, Wisconsin: a city the cast never really left. │ │ │ │ │ │ - │ │ │ │ - │ │ │ │ + │ │ The sequel took seven years to finish. │ │ │ Movies ──│ │───────────│ │ ✔ Title │ ╭────────────────────────────────────────────╮ │ │ │=> ✔ Test │ Root Folder: │/nfs ▼ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMinimumAvailability_CollectionDetails.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMinimumAvailability_CollectionDetails.snap index dfb9281c..dbef8d7d 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMinimumAvailability_CollectionDetails.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMinimumAvailability_CollectionDetails.snap @@ -13,11 +13,11 @@ expression: output │Root Folder Path: /nfs/movies │ │Quality Profile: HD - 1080p │ │Minimum Avai╭────────────────────────────────────── Add Movie - Test ───────────────────────────────────────╮ │ - │Monitored: Y│ Collection blah blah blah │ │ + │Monitored: Y│ Collection movie blah blah blah │ │ │Search on Ad│ │ │ + │ │ It was shot in Madison, Wisconsin: a city the cast never really left. │ │ │ │ │ │ - │ │ │ │ - │ │ │ │ + │ │ The sequel took seven years to finish. │ │ │ Movies ──│ │───────────│ │ ✔ Title │ ╭────────────────────────────────────────────╮ │ │ │=> ✔ Test │ ╭───────────────────────────────╮ ▼ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMonitor_CollectionDetails.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMonitor_CollectionDetails.snap index 50f15fed..309deaa2 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMonitor_CollectionDetails.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectMonitor_CollectionDetails.snap @@ -13,11 +13,11 @@ expression: output │Root Folder Path: /nfs/movies │ │Quality Profile: HD - 1080p │ │Minimum Avai╭────────────────────────────────────── Add Movie - Test ───────────────────────────────────────╮ │ - │Monitored: Y│ Collection blah blah blah │ │ + │Monitored: Y│ Collection movie blah blah blah │ │ │Search on Ad│ │ │ + │ │ It was shot in Madison, Wisconsin: a city the cast never really left. │ │ │ │ │ │ - │ │ │ │ - │ │ │ │ + │ │ The sequel took seven years to finish. │ │ │ Movies ──│ │───────────│ │ ✔ Title │ ╭────────────────────────────────────────────╮ │ │ │=> ✔ Test │ ╭───────────────────────────────╮ ▼ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectQualityProfile_CollectionDetails.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectQualityProfile_CollectionDetails.snap index 4c3ee663..e373d8f7 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectQualityProfile_CollectionDetails.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectQualityProfile_CollectionDetails.snap @@ -13,11 +13,11 @@ expression: output │Root Folder Path: /nfs/movies │ │Quality Profile: HD - 1080p │ │Minimum Avai╭────────────────────────────────────── Add Movie - Test ───────────────────────────────────────╮ │ - │Monitored: Y│ Collection blah blah blah │ │ + │Monitored: Y│ Collection movie blah blah blah │ │ │Search on Ad│ │ │ + │ │ It was shot in Madison, Wisconsin: a city the cast never really left. │ │ │ │ │ │ - │ │ │ │ - │ │ │ │ + │ │ The sequel took seven years to finish. │ │ │ Movies ──│ │───────────│ │ ✔ Title │ ╭────────────────────────────────────────────╮ │ │ │=> ✔ Test │ ╭───────────────────────────────╮ ▼ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectRootFolder_CollectionDetails.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectRootFolder_CollectionDetails.snap index 23dfbaaf..91b19825 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectRootFolder_CollectionDetails.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieSelectRootFolder_CollectionDetails.snap @@ -13,11 +13,11 @@ expression: output │Root Folder Path: /nfs/movies │ │Quality Profile: HD - 1080p │ │Minimum Avai╭────────────────────────────────────── Add Movie - Test ───────────────────────────────────────╮ │ - │Monitored: Y│ Collection blah blah blah │ │ + │Monitored: Y│ Collection movie blah blah blah │ │ │Search on Ad│ │ │ + │ │ It was shot in Madison, Wisconsin: a city the cast never really left. │ │ │ │ │ │ - │ │ │ │ - │ │ │ │ + │ │ The sequel took seven years to finish. │ │ │ Movies ──│ │───────────│ │ ✔ Title │ ╭────────────────────────────────────────────╮ │ │ │=> ✔ Test │ ╭───────────────────────────────╮ ▼ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieTagsInput_CollectionDetails.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieTagsInput_CollectionDetails.snap index ae1d2432..5c152515 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieTagsInput_CollectionDetails.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__add_movie_ui__add_movie_ui_tests__tests__snapshot_tests__AddMovieTagsInput_CollectionDetails.snap @@ -13,11 +13,11 @@ expression: output │Root Folder Path: /nfs/movies │ │Quality Profile: HD - 1080p │ │Minimum Avai╭────────────────────────────────────── Add Movie - Test ───────────────────────────────────────╮ │ - │Monitored: Y│ Collection blah blah blah │ │ + │Monitored: Y│ Collection movie blah blah blah │ │ │Search on Ad│ │ │ + │ │ It was shot in Madison, Wisconsin: a city the cast never really left. │ │ │ │ │ │ - │ │ │ │ - │ │ │ │ + │ │ The sequel took seven years to finish. │ │ │ Movies ──│ │───────────│ │ ✔ Title │ ╭────────────────────────────────────────────╮ │ │ │=> ✔ Test │ Root Folder: │/nfs ▼ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch.snap index b66682e8..e9b04342 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Cast │ Crew │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 GB 25 / 3 English HD - 1080p │ - │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 GB English HD - 1080p │ + │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 MB 25 / 3 English HD - 1080p │ + │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 MB English HD - 1080p │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchConfirmPrompt.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchConfirmPrompt.snap index e94e5ddc..dff44a15 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchConfirmPrompt.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchConfirmPrompt.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Cast │ Crew │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 GB 25 / 3 English HD - 1080p │ - │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 GB English HD - 1080p │ + │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 MB 25 / 3 English HD - 1080p │ + │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 MB English HD - 1080p │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchSortPrompt.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchSortPrompt.snap index faf0a485..2f9e989d 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchSortPrompt.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearchSortPrompt.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Cast │ Crew │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 GB 25 / 3 English HD - 1080p │ - │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 GB English HD - 1080p │ + │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 MB 25 / 3 English HD - 1080p │ + │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 MB English HD - 1080p │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_AutomaticallySearchMoviePrompt.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_AutomaticallySearchMoviePrompt.snap index 9d72a3fc..812ce100 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_AutomaticallySearchMoviePrompt.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_AutomaticallySearchMoviePrompt.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Cast │ Crew │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 GB 25 / 3 English HD - 1080p │ - │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 GB English HD - 1080p │ + │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 MB 25 / 3 English HD - 1080p │ + │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 MB English HD - 1080p │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_UpdateAndScanPrompt.snap b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_UpdateAndScanPrompt.snap index 8be43539..d4f855aa 100644 --- a/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_UpdateAndScanPrompt.snap +++ b/src/ui/radarr_ui/library/snapshots/managarr__ui__radarr_ui__library__movie_details_ui__movie_details_ui_tests__tests__snapshot_tests__movie_details_render_ManualSearch_UpdateAndScanPrompt.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Cast │ Crew │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 GB 25 / 3 English HD - 1080p │ - │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 GB English HD - 1080p │ + │=> torrent 12 days ⛔ Some movie release The Pirate Bay 0.0 MB 25 / 3 English HD - 1080p │ + │ usenet 22 days ⛔ Some Other movie rele The Pirate Bay 0.0 MB English HD - 1080p │ │ │ │ │ │ │ diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index 79561e95..7e2c9cc1 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -1,9 +1,9 @@ use crate::app::App; use crate::logos::RADARR_LOGO; use crate::models::Route; -use crate::models::radarr_models::{DownloadRecord, Movie}; +use crate::models::radarr_models::{DownloadRecord, Movie, MovieStatus}; use crate::models::servarr_data::radarr::radarr_data::RadarrData; -use crate::models::servarr_models::{DiskSpace, RootFolder}; +use crate::models::servarr_models::{DiskSpace, DownloadStatus, RootFolder}; use crate::ui::DrawUi; use crate::ui::draw_tabs; use crate::ui::radarr_ui::blocklist::BlocklistUi; @@ -22,7 +22,7 @@ use crate::ui::utils::{ line_gauge_with_label, line_gauge_with_title, title_block, }; use crate::ui::widgets::loading_block::LoadingBlock; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use chrono::Duration; #[cfg(not(test))] use chrono::Utc; @@ -170,8 +170,8 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let RootFolder { path, free_space, .. } = &monitored_root_folders[i]; - let space: f64 = convert_to_gb(*free_space); - let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) + let space = format_size(*free_space, 2); + let root_folder_space = Paragraph::new(format!("{path}: {space} free")) .block(borderless_block()) .default_color(); @@ -230,11 +230,11 @@ fn decorate_with_row_style<'a>( .iter() .find(|&download| download.movie_id == movie.id) { - if download.status == "downloading" { + if download.status == DownloadStatus::Downloading { return row.downloading(); } - if download.status == "completed" { + if download.status == DownloadStatus::Completed { return row.awaiting_import(); } } @@ -243,7 +243,7 @@ fn decorate_with_row_style<'a>( return row.unmonitored_missing(); } - if movie.status != "released" { + if movie.status != MovieStatus::Released { return row.unreleased(); } diff --git a/src/ui/radarr_ui/radarr_ui_tests.rs b/src/ui/radarr_ui/radarr_ui_tests.rs index 4c0838f3..b90e53f5 100644 --- a/src/ui/radarr_ui/radarr_ui_tests.rs +++ b/src/ui/radarr_ui/radarr_ui_tests.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod tests { - use crate::models::radarr_models::{DownloadRecord, Movie}; + use crate::models::radarr_models::{DownloadRecord, Movie, MovieStatus}; + use crate::models::servarr_models::DownloadStatus; use pretty_assertions::assert_eq; use ratatui::widgets::{Cell, Row}; use rstest::rstest; @@ -50,24 +51,42 @@ mod tests { } #[rstest] - #[case(false, Some("downloading"), false, "", RowStyle::Downloading)] - #[case(false, Some("completed"), false, "", RowStyle::AwaitingImport)] - #[case(false, None, false, "", RowStyle::UnmonitoredMissing)] - #[case(false, None, true, "", RowStyle::Unreleased)] - #[case(false, None, true, "released", RowStyle::Missing)] - #[case(true, None, false, "", RowStyle::Unmonitored)] - #[case(true, None, true, "", RowStyle::Downloaded)] + #[case( + false, + Some(DownloadStatus::Downloading), + false, + MovieStatus::Released, + RowStyle::Downloading + )] + #[case( + false, + Some(DownloadStatus::Completed), + false, + MovieStatus::Released, + RowStyle::AwaitingImport + )] + #[case( + false, + None, + false, + MovieStatus::Released, + RowStyle::UnmonitoredMissing + )] + #[case(false, None, true, MovieStatus::InCinemas, RowStyle::Unreleased)] + #[case(false, None, true, MovieStatus::Released, RowStyle::Missing)] + #[case(true, None, false, MovieStatus::Released, RowStyle::Unmonitored)] + #[case(true, None, true, MovieStatus::Released, RowStyle::Downloaded)] fn test_decorate_with_row_style( #[case] has_file: bool, - #[case] download_status: Option<&str>, + #[case] download_status: Option, #[case] is_monitored: bool, - #[case] movie_status: String, + #[case] movie_status: MovieStatus, #[case] expected_style: RowStyle, ) { let downloads_vec = if let Some(download_status) = download_status { vec![DownloadRecord { movie_id: 1, - status: download_status.to_owned(), + status: download_status, ..DownloadRecord::default() }] } else { diff --git a/src/ui/radarr_ui/root_folders/mod.rs b/src/ui/radarr_ui/root_folders/mod.rs index 9c5ecfea..3fcadec9 100644 --- a/src/ui/radarr_ui/root_folders/mod.rs +++ b/src/ui/radarr_ui/root_folders/mod.rs @@ -12,7 +12,7 @@ use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_input_box_popup, draw_popup}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "root_folders_ui_tests.rs"] @@ -66,11 +66,11 @@ fn draw_root_folders(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .. } = root_folders; - let space: f64 = convert_to_gb(*free_space); + let space = format_size(*free_space, 2); Row::new(vec![ Cell::from(path.to_owned()), - Cell::from(format!("{space:.2} GB")), + Cell::from(space), Cell::from( unmapped_folders .as_ref() diff --git a/src/ui/radarr_ui/system/mod.rs b/src/ui/radarr_ui/system/mod.rs index 8bec01e2..270286fa 100644 --- a/src/ui/radarr_ui/system/mod.rs +++ b/src/ui/radarr_ui/system/mod.rs @@ -172,7 +172,7 @@ fn draw_logs(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let logs_box = SelectableList::new(&mut app.data.radarr_data.logs, |log| { let log_line = log.to_string(); - let level = log_line.split('|').collect::>()[1].to_string(); + let level = log.text.split('|').collect::>()[1].to_string(); style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) }) diff --git a/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_renders_updates_loading_when_empty.snap b/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_renders_updates_loading_when_empty.snap new file mode 100644 index 00000000..c8e5896f --- /dev/null +++ b/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_renders_updates_loading_when_empty.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/radarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_renders_updates_loading_when_updates_are_present.snap b/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_renders_updates_loading_when_updates_are_present.snap new file mode 100644 index 00000000..c8e5896f --- /dev/null +++ b/src/ui/radarr_ui/system/snapshots/managarr__ui__radarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_renders_updates_loading_when_updates_are_present.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/radarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/radarr_ui/system/system_details_ui.rs b/src/ui/radarr_ui/system/system_details_ui.rs index bc8db27f..465677b7 100644 --- a/src/ui/radarr_ui/system/system_details_ui.rs +++ b/src/ui/radarr_ui/system/system_details_ui.rs @@ -126,7 +126,7 @@ fn draw_updates_popup(f: &mut Frame<'_>, app: &mut App<'_>) { let updates = app.data.radarr_data.updates.get_text(); let block = title_block("Updates"); - if !updates.is_empty() { + if !updates.is_empty() && !app.is_loading { let updates_paragraph = Paragraph::new(Text::from(updates)) .block(borderless_block()) .scroll((app.data.radarr_data.updates.offset, 0)); diff --git a/src/ui/radarr_ui/system/system_details_ui_tests.rs b/src/ui/radarr_ui/system/system_details_ui_tests.rs index 2277f3ad..94891b8c 100644 --- a/src/ui/radarr_ui/system/system_details_ui_tests.rs +++ b/src/ui/radarr_ui/system/system_details_ui_tests.rs @@ -24,6 +24,7 @@ mod tests { mod snapshot_tests { use super::*; + use crate::models::ScrollableText; use rstest::rstest; #[rstest] @@ -85,5 +86,34 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_system_details_ui_renders_updates_loading_when_updates_are_present() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveRadarrBlock::SystemUpdates.into()); + + assert!(!app.data.radarr_data.updates.get_text().is_empty()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_system_details_ui_renders_updates_loading_when_empty() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveRadarrBlock::SystemUpdates.into()); + app.data.radarr_data.updates = ScrollableText::with_string("".to_string()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/radarr_ui/system/system_ui_tests.rs b/src/ui/radarr_ui/system/system_ui_tests.rs index 31bd1610..fec72e7a 100644 --- a/src/ui/radarr_ui/system/system_ui_tests.rs +++ b/src/ui/radarr_ui/system/system_ui_tests.rs @@ -25,6 +25,83 @@ mod tests { }); } + mod test_log_level_styling { + use std::sync::atomic::Ordering; + + use pretty_assertions::assert_eq; + use ratatui::style::{Modifier, Style}; + + use crate::models::HorizontallyScrollableText; + use crate::ui::styles::failure_style; + use crate::ui::ui_test_utils::test_utils::create_test_terminal; + + use super::*; + + #[test] + fn test_system_ui_styles_a_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::System.into()); + app.data.radarr_data.logs.set_items(log_lines_vec()); + app.data.radarr_data.logs.items[1] + .offset + .store(30, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "CommandExecutor|log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_system_ui_styles_a_fully_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveRadarrBlock::System.into()); + app.data.radarr_data.logs.set_items(log_lines_vec()); + app.data.radarr_data.logs.items[1] + .offset + .store(46, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + SystemUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + + fn log_lines_vec() -> Vec { + vec![ + "2025-12-15 16:14:45 UTC|INFO|DownloadDecisionMaker|log line alpha".into(), + "2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma".into(), + ] + } + } + mod snapshot_tests { use super::*; use crate::models::stateful_list::StatefulList; diff --git a/src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs b/src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs new file mode 100644 index 00000000..b05240fb --- /dev/null +++ b/src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs @@ -0,0 +1,147 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BLOCKLIST_BLOCKS}; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::blocklist::BlocklistUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_blocklist_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if BLOCKLIST_BLOCKS.contains(&active_readarr_block) { + assert!(BlocklistUi::accepts(active_readarr_block.into())); + } else { + assert!(!BlocklistUi::accepts(active_readarr_block.into())); + } + }); + } + + mod snapshot_tests { + use chrono::DateTime; + use rstest::rstest; + + use crate::models::readarr_models::{Author, BlocklistItem}; + use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_blocklist_ui_renders_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BlocklistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_blocklist_ui_renders_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Blocklist.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BlocklistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_blocklist_ui_renders( + #[values( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::BlocklistItemDetails, + ActiveReadarrBlock::DeleteBlocklistItemPrompt, + ActiveReadarrBlock::BlocklistClearAllItemsPrompt, + ActiveReadarrBlock::BlocklistSortPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BlocklistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("blocklist_tab_{active_readarr_block}"), output); + } + + #[rstest] + fn test_blocklist_ui_renders_second_item_selected( + #[values( + ActiveReadarrBlock::Blocklist, + ActiveReadarrBlock::BlocklistItemDetails, + ActiveReadarrBlock::DeleteBlocklistItemPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.blocklist.set_items(blocklist_vec()); + app.data.readarr_data.blocklist.select_index(Some(1)); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BlocklistUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("blocklist_second_item_selected_{active_readarr_block}"), + output + ); + } + + fn blocklist_vec() -> Vec { + vec![ + BlocklistItem { + id: 1, + author_id: 1, + source_title: "First Source Title".to_owned(), + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()), + protocol: "usenet".to_owned(), + indexer: "DrunkenSlug".to_owned(), + message: "first blocklist message".to_owned(), + author: Author { + id: 1, + author_name: "First Author".into(), + ..Author::default() + }, + ..BlocklistItem::default() + }, + BlocklistItem { + id: 2, + author_id: 2, + source_title: "Second Source Title".to_owned(), + quality: QualityWrapper { + quality: Quality { + name: "MOBI".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-08-14T09:03:47Z").unwrap()), + protocol: "torrent".to_owned(), + indexer: "Nyaa".to_owned(), + message: "second blocklist message".to_owned(), + author: Author { + id: 2, + author_name: "Second Author".into(), + ..Author::default() + }, + ..BlocklistItem::default() + }, + ] + } + } +} diff --git a/src/ui/readarr_ui/blocklist/mod.rs b/src/ui/readarr_ui/blocklist/mod.rs new file mode 100644 index 00000000..f706424f --- /dev/null +++ b/src/ui/readarr_ui/blocklist/mod.rs @@ -0,0 +1,156 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::BlocklistItem; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BLOCKLIST_BLOCKS}; +use crate::ui::DrawUi; +use crate::ui::styles::{ManagarrStyle, secondary_style}; +use crate::ui::utils::layout_block_top_border; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Rect}; +use ratatui::style::Stylize; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Cell, Row}; + +#[cfg(test)] +#[path = "blocklist_ui_tests.rs"] +mod blocklist_ui_tests; + +pub(super) struct BlocklistUi; + +impl DrawUi for BlocklistUi { + fn accepts(route: Route) -> bool { + if let Route::Readarr(active_readarr_block, _) = route { + return BLOCKLIST_BLOCKS.contains(&active_readarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + draw_blocklist_table(f, app, area); + + match active_readarr_block { + ActiveReadarrBlock::BlocklistItemDetails => { + draw_blocklist_item_details_popup(f, app); + } + ActiveReadarrBlock::DeleteBlocklistItemPrompt => { + let prompt = format!( + "Do you want to remove this item from your blocklist: \n{}?", + app + .data + .readarr_data + .blocklist + .current_selection() + .source_title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Remove Item from Blocklist") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveReadarrBlock::BlocklistClearAllItemsPrompt => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Clear Blocklist") + .prompt("Do you want to clear your blocklist?") + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::SmallPrompt), + f.area(), + ); + } + _ => (), + } + } + } +} + +fn draw_blocklist_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let blocklist_row_mapping = |blocklist_item: &BlocklistItem| { + let BlocklistItem { + source_title, + author, + quality, + date, + .. + } = blocklist_item; + + let title = author.author_name.text.to_owned(); + + Row::new(vec![ + Cell::from(title), + Cell::from(source_title.to_owned()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let blocklist_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.blocklist), + blocklist_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_readarr_block == ActiveReadarrBlock::BlocklistSortPrompt) + .headers(["Author Name", "Source Title", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(27), + Constraint::Percentage(43), + Constraint::Percentage(13), + Constraint::Percentage(17), + ]); + + f.render_widget(blocklist_table, area); + } +} + +fn draw_blocklist_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = if app.data.readarr_data.blocklist.items.is_empty() { + BlocklistItem::default() + } else { + app.data.readarr_data.blocklist.current_selection().clone() + }; + let BlocklistItem { + source_title, + protocol, + indexer, + message, + .. + } = current_selection; + let text = Text::from(vec![ + Line::from(vec![ + "Name: ".bold().secondary(), + source_title.to_owned().secondary(), + ]), + Line::from(vec![ + "Protocol: ".bold().secondary(), + protocol.to_owned().secondary(), + ]), + Line::from(vec![ + "Indexer: ".bold().secondary(), + indexer.to_owned().secondary(), + ]), + Line::from(vec![ + "Message: ".bold().secondary(), + message.to_owned().secondary(), + ]), + ]); + + let message = Message::new(text) + .title("Details") + .style(secondary_style()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowMessage), f.area()); +} diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_Blocklist.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_Blocklist.snap new file mode 100644 index 00000000..a372efdd --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_Blocklist.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name Source Title Quality Date + First Author First Source Title EPUB 2023-05-20 21:29:16 UTC +=> Second Author Second Source Title MOBI 2024-08-14 09:03:47 UTC diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_BlocklistItemDetails.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_BlocklistItemDetails.snap new file mode 100644 index 00000000..f600120f --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_BlocklistItemDetails.snap @@ -0,0 +1,34 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name Source Title Quality Date + First Author First Source Title EPUB 2023-05-20 21:29:16 UTC +=> Second Author Second Source Title MOBI 2024-08-14 09:03:47 UTC + + + + + + + + + + + + + + + + + ╭─────────────────────────────────── Details ───────────────────────────────────╮ + │Name: Second Source Title │ + │Protocol: torrent │ + │Indexer: Nyaa │ + │Message: second blocklist message │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_DeleteBlocklistItemPrompt.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_DeleteBlocklistItemPrompt.snap new file mode 100644 index 00000000..afe15f3d --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_second_item_selected_DeleteBlocklistItemPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name Source Title Quality Date + First Author First Source Title EPUB 2023-05-20 21:29:16 UTC +=> Second Author Second Source Title MOBI 2024-08-14 09:03:47 UTC + + + + + + + + + + + + + ╭────────────── Remove Item from Blocklist ───────────────╮ + │ Do you want to remove this item from your blocklist: │ + │ Second Source Title? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_Blocklist.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_Blocklist.snap new file mode 100644 index 00000000..af56634e --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_Blocklist.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name ▼ Source Title Quality Date +=> Test Author Test Source Title EPUB 2023-05-20 21:29:16 UTC diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistClearAllItemsPrompt.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistClearAllItemsPrompt.snap new file mode 100644 index 00000000..b8f767b5 --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistClearAllItemsPrompt.snap @@ -0,0 +1,34 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name ▼ Source Title Quality Date +=> Test Author Test Source Title EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + ╭────── Clear Blocklist ──────╮ + │ Do you want to clear your │ + │ blocklist? │ + │ │ + │ │ + │ │ + │╭──────────────╮╭─────────────╮│ + ││ Yes ││ No ││ + │╰──────────────╯╰─────────────╯│ + ╰───────────────────────────────╯ diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistItemDetails.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistItemDetails.snap new file mode 100644 index 00000000..c8183d3d --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistItemDetails.snap @@ -0,0 +1,34 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name ▼ Source Title Quality Date +=> Test Author Test Source Title EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + ╭─────────────────────────────────── Details ───────────────────────────────────╮ + │Name: Test Source Title │ + │Protocol: usenet │ + │Indexer: DrunkenSlug │ + │Message: test message │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistSortPrompt.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistSortPrompt.snap new file mode 100644 index 00000000..7dab724f --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_BlocklistSortPrompt.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name Source Title Quality Date +=> Test Author Test Source Title EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + ╭───────────────────────────────╮ + │Something │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────╯ diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_DeleteBlocklistItemPrompt.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_DeleteBlocklistItemPrompt.snap new file mode 100644 index 00000000..3ae052bc --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_tab_DeleteBlocklistItemPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Author Name ▼ Source Title Quality Date +=> Test Author Test Source Title EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + ╭────────────── Remove Item from Blocklist ───────────────╮ + │ Do you want to remove this item from your blocklist: │ + │ Test Source Title? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_empty.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_empty.snap new file mode 100644 index 00000000..4971b8c6 --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_empty.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_loading.snap b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_loading.snap new file mode 100644 index 00000000..30938176 --- /dev/null +++ b/src/ui/readarr_ui/blocklist/snapshots/managarr__ui__readarr_ui__blocklist__blocklist_ui_tests__tests__snapshot_tests__blocklist_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/blocklist/blocklist_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/readarr_ui/downloads/downloads_ui_tests.rs b/src/ui/readarr_ui/downloads/downloads_ui_tests.rs new file mode 100644 index 00000000..17bddeb3 --- /dev/null +++ b/src/ui/readarr_ui/downloads/downloads_ui_tests.rs @@ -0,0 +1,128 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DOWNLOADS_BLOCKS}; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::downloads::DownloadsUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_downloads_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if DOWNLOADS_BLOCKS.contains(&active_readarr_block) { + assert!(DownloadsUi::accepts(active_readarr_block.into())); + } else { + assert!(!DownloadsUi::accepts(active_readarr_block.into())); + } + }); + } + + mod snapshot_tests { + use rstest::rstest; + use serde_json::Number; + + use crate::models::readarr_models::DownloadRecord; + use crate::models::servarr_models::DownloadStatus; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_downloads_ui_renders_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DownloadsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_downloads_ui_renders_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Downloads.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DownloadsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_downloads_ui_renders( + #[values( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt, + ActiveReadarrBlock::UpdateDownloadsPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DownloadsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("downloads_ui_{active_readarr_block}"), output); + } + + #[rstest] + fn test_downloads_ui_renders_second_item_selected( + #[values( + ActiveReadarrBlock::Downloads, + ActiveReadarrBlock::DeleteDownloadPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.downloads.set_items(downloads_vec()); + app.data.readarr_data.downloads.select_index(Some(1)); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DownloadsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("downloads_second_item_selected_{active_readarr_block}"), + output + ); + } + + fn downloads_vec() -> Vec { + vec![ + DownloadRecord { + id: 1, + title: "First Download Title".to_owned(), + status: DownloadStatus::Downloading, + book_id: Some(Number::from(1)), + author_id: Some(Number::from(1)), + size: 3543348019f64, + sizeleft: 1771674009f64, + output_path: Some("/nfs/books/First Author".into()), + indexer: "DrunkenSlug".to_owned(), + download_client: Some("sabnzbd".to_owned()), + }, + DownloadRecord { + id: 2, + title: "Second Download Title".to_owned(), + status: DownloadStatus::Paused, + book_id: Some(Number::from(2)), + author_id: Some(Number::from(2)), + size: 1073741824f64, + sizeleft: 268435456f64, + output_path: Some("/nfs/books/Second Author".into()), + indexer: "Nyaa".to_owned(), + download_client: Some("transmission".to_owned()), + }, + ] + } + } +} diff --git a/src/ui/readarr_ui/downloads/mod.rs b/src/ui/readarr_ui/downloads/mod.rs new file mode 100644 index 00000000..60863a51 --- /dev/null +++ b/src/ui/readarr_ui/downloads/mod.rs @@ -0,0 +1,146 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::widgets::{Cell, Row}; + +use crate::app::App; +use crate::models::readarr_models::DownloadRecord; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DOWNLOADS_BLOCKS}; +use crate::models::{HorizontallyScrollableText, Route}; +use crate::ui::DrawUi; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::utils::format_size; + +#[cfg(test)] +#[path = "downloads_ui_tests.rs"] +mod downloads_ui_tests; + +pub(super) struct DownloadsUi; + +impl DrawUi for DownloadsUi { + fn accepts(route: Route) -> bool { + if let Route::Readarr(active_readarr_block, _) = route { + return DOWNLOADS_BLOCKS.contains(&active_readarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + draw_downloads(f, app, area); + + match active_readarr_block { + ActiveReadarrBlock::DeleteDownloadPrompt => { + let prompt = format!( + "Do you really want to delete this download: \n{}?", + app.data.readarr_data.downloads.current_selection().title + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Cancel Download") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveReadarrBlock::UpdateDownloadsPrompt => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update Downloads") + .prompt("Do you want to update your downloads?") + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + _ => (), + } + } + } +} + +fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = if app.data.readarr_data.downloads.items.is_empty() { + DownloadRecord::default() + } else { + app.data.readarr_data.downloads.current_selection().clone() + }; + + let downloads_row_mapping = |download_record: &DownloadRecord| { + let DownloadRecord { + title, + size, + sizeleft, + download_client, + indexer, + output_path, + .. + } = download_record; + + if output_path.is_some() { + output_path.as_ref().unwrap().scroll_left_or_reset( + get_width_from_percentage(area, 18), + current_selection == *download_record, + app.should_text_scroll, + ); + } + + let percent = if *size == 0.0 { + 0.0 + } else { + 1f64 - (*sizeleft / *size) + }; + let file_size = format_size(*size as i64, 2); + + Row::new(vec![ + Cell::from(title.to_owned()), + Cell::from(format!("{:.0}%", percent * 100.0)), + Cell::from(file_size), + Cell::from( + output_path + .as_ref() + .unwrap_or(&HorizontallyScrollableText::default()) + .to_string(), + ), + Cell::from(indexer.to_owned()), + Cell::from( + download_client + .as_ref() + .unwrap_or(&String::new()) + .to_owned(), + ), + ]) + .primary() + }; + let downloads_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.downloads), + downloads_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .headers([ + "Title", + "Percent Complete", + "Size", + "Output Path", + "Indexer", + "Download Client", + ]) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(11), + Constraint::Percentage(11), + Constraint::Percentage(18), + Constraint::Percentage(17), + Constraint::Percentage(13), + ]); + + f.render_widget(downloads_table, area); +} diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_DeleteDownloadPrompt.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_DeleteDownloadPrompt.snap new file mode 100644 index 00000000..f0c11f76 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_DeleteDownloadPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client + First Download Title 50% 3.30 GB /nfs/books/First Author DrunkenSlug sabnzbd +=> Second Download Title 75% 1.00 GB /nfs/books/Second Author Nyaa transmission + + + + + + + + + + + + + ╭──────────────────── Cancel Download ────────────────────╮ + │ Do you really want to delete this download: │ + │ Second Download Title? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_Downloads.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_Downloads.snap new file mode 100644 index 00000000..4c7f4950 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_second_item_selected_Downloads.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client + First Download Title 50% 3.30 GB /nfs/books/First Author DrunkenSlug sabnzbd +=> Second Download Title 75% 1.00 GB /nfs/books/Second Author Nyaa transmission diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_DeleteDownloadPrompt.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_DeleteDownloadPrompt.snap new file mode 100644 index 00000000..697b474e --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_DeleteDownloadPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client +=> Test Download Title 50% 3.30 GB /nfs/books/Test Author kickass torrents transmission + + + + + + + + + + + + + + ╭──────────────────── Cancel Download ────────────────────╮ + │ Do you really want to delete this download: │ + │ Test Download Title? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_Downloads.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_Downloads.snap new file mode 100644 index 00000000..fb117252 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_Downloads.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client +=> Test Download Title 50% 3.30 GB /nfs/books/Test Author kickass torrents transmission diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_UpdateDownloadsPrompt.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_UpdateDownloadsPrompt.snap new file mode 100644 index 00000000..7c0ecc17 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_UpdateDownloadsPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Title Percent Complete Size Output Path Indexer Download Client +=> Test Download Title 50% 3.30 GB /nfs/books/Test Author kickass torrents transmission + + + + + + + + + + + + + + ╭─────────────────── Update Downloads ────────────────────╮ + │ Do you want to update your downloads? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_empty.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_empty.snap new file mode 100644 index 00000000..982f0817 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_empty.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_loading.snap b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_loading.snap new file mode 100644 index 00000000..c74ac942 --- /dev/null +++ b/src/ui/readarr_ui/downloads/snapshots/managarr__ui__readarr_ui__downloads__downloads_ui_tests__tests__snapshot_tests__downloads_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/downloads/downloads_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/readarr_ui/history/history_ui_tests.rs b/src/ui/readarr_ui/history/history_ui_tests.rs new file mode 100644 index 00000000..77615ae5 --- /dev/null +++ b/src/ui/readarr_ui/history/history_ui_tests.rs @@ -0,0 +1,154 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, HISTORY_BLOCKS}; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::history::HistoryUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_history_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if HISTORY_BLOCKS.contains(&active_readarr_block) { + assert!(HistoryUi::accepts(active_readarr_block.into())); + } else { + assert!(!HistoryUi::accepts(active_readarr_block.into())); + } + }); + } + + mod snapshot_tests { + use chrono::DateTime; + use rstest::rstest; + + use crate::models::readarr_models::{ + ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, + }; + use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_history_ui_renders_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::History.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + HistoryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_history_ui_renders_empty( + #[values(ActiveReadarrBlock::History, ActiveReadarrBlock::HistoryItemDetails)] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + HistoryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("empty_history_tab_{active_readarr_block}"), output); + } + + #[rstest] + fn test_history_ui_renders( + #[values( + ActiveReadarrBlock::History, + ActiveReadarrBlock::HistoryItemDetails, + ActiveReadarrBlock::HistorySortPrompt, + ActiveReadarrBlock::FilterHistory, + ActiveReadarrBlock::FilterHistoryError, + ActiveReadarrBlock::SearchHistory, + ActiveReadarrBlock::SearchHistoryError + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + HistoryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("history_tab_{active_readarr_block}"), output); + } + + #[rstest] + fn test_history_ui_renders_second_item_selected( + #[values(ActiveReadarrBlock::History, ActiveReadarrBlock::HistoryItemDetails)] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.data.readarr_data.history.set_items(history_vec()); + app.data.readarr_data.history.select_index(Some(1)); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + HistoryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("history_second_item_selected_{active_readarr_block}"), + output + ); + } + + fn history_vec() -> Vec { + vec![ + ReadarrHistoryItem { + id: 1, + author_id: 1, + book_id: 1, + source_title: "First Source Title".into(), + quality: QualityWrapper { + quality: Quality { + name: "EPUB".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()), + event_type: ReadarrHistoryEventType::Grabbed, + data: ReadarrHistoryData { + indexer: Some("DrunkenSlug".to_owned()), + nzb_info_url: Some("https://drunkenslug.com/details/first".to_owned()), + release_group: Some("FirstReleaseGroup".to_owned()), + age: Some("3".to_owned()), + published_date: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-18T08:15:00Z").unwrap(), + )), + download_client_name: Some("nzbget".to_owned()), + ..ReadarrHistoryData::default() + }, + }, + ReadarrHistoryItem { + id: 2, + author_id: 2, + book_id: 2, + source_title: "Second Source Title".into(), + quality: QualityWrapper { + quality: Quality { + name: "MOBI".to_owned(), + }, + }, + date: DateTime::from(DateTime::parse_from_rfc3339("2024-08-14T09:03:47Z").unwrap()), + event_type: ReadarrHistoryEventType::DownloadFailed, + data: ReadarrHistoryData { + download_client_name: Some("sabnzbd".to_owned()), + message: Some("The download client reported a failure".to_owned()), + release_group: Some("SecondReleaseGroup".to_owned()), + indexer: Some("Nyaa".to_owned()), + ..ReadarrHistoryData::default() + }, + }, + ] + } + } +} diff --git a/src/ui/readarr_ui/history/mod.rs b/src/ui/readarr_ui/history/mod.rs new file mode 100644 index 00000000..eddaef27 --- /dev/null +++ b/src/ui/readarr_ui/history/mod.rs @@ -0,0 +1,122 @@ +use super::readarr_ui_utils::create_history_event_details; +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::ReadarrHistoryItem; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, HISTORY_BLOCKS}; +use crate::ui::DrawUi; +use crate::ui::styles::{ManagarrStyle, secondary_style}; +use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Rect}; +use ratatui::prelude::Constraint; +use ratatui::text::Text; +use ratatui::widgets::{Cell, Row}; + +#[cfg(test)] +#[path = "history_ui_tests.rs"] +mod history_ui_tests; + +pub(super) struct HistoryUi; + +impl DrawUi for HistoryUi { + fn accepts(route: Route) -> bool { + if let Route::Readarr(active_readarr_block, _) = route { + return HISTORY_BLOCKS.contains(&active_readarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + draw_history_table(f, app, area); + + if active_readarr_block == ActiveReadarrBlock::HistoryItemDetails { + draw_history_item_details_popup(f, app); + } + } + } +} + +fn draw_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = if app.data.readarr_data.history.items.is_empty() { + ReadarrHistoryItem::default() + } else { + app.data.readarr_data.history.current_selection().clone() + }; + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let history_row_mapping = |history_item: &ReadarrHistoryItem| { + let ReadarrHistoryItem { + source_title, + quality, + event_type, + date, + .. + } = history_item; + + source_title.scroll_left_or_reset( + get_width_from_percentage(area, 50), + current_selection == *history_item, + app.should_text_scroll, + ); + + Row::new(vec![ + Cell::from(source_title.to_string()), + Cell::from(event_type.to_string()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let history_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.history), + history_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_readarr_block == ActiveReadarrBlock::HistorySortPrompt) + .searching(active_readarr_block == ActiveReadarrBlock::SearchHistory) + .search_produced_empty_results(active_readarr_block == ActiveReadarrBlock::SearchHistoryError) + .filtering(active_readarr_block == ActiveReadarrBlock::FilterHistory) + .filter_produced_empty_results(active_readarr_block == ActiveReadarrBlock::FilterHistoryError) + .headers(["Source Title", "Event Type", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(50), + Constraint::Percentage(18), + Constraint::Percentage(12), + Constraint::Percentage(20), + ]); + + if [ + ActiveReadarrBlock::SearchHistory, + ActiveReadarrBlock::FilterHistory, + ] + .contains(&active_readarr_block) + { + history_table.show_cursor(f, area); + } + + f.render_widget(history_table, area); + } +} + +fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = if app.data.readarr_data.history.items.is_empty() { + ReadarrHistoryItem::default() + } else { + app.data.readarr_data.history.current_selection().clone() + }; + + let line_vec = create_history_event_details(current_selection); + let text = Text::from(line_vec); + + let message = Message::new(text) + .title("Details") + .style(secondary_style()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowLongMessage), f.area()); +} diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_History.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_History.snap new file mode 100644 index 00000000..f7b5e42a --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_History.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_HistoryItemDetails.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_HistoryItemDetails.snap new file mode 100644 index 00000000..bc14d79e --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__empty_history_tab_HistoryItemDetails.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + + + + + + + + + + + + + + ╭─────────────────────────────────── Details ───────────────────────────────────╮ + │Source Title: │ + │Event Type: unknown │ + │Quality: │ + │Date: 1970-01-01 00:00:00 UTC │ + │No additional details available. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_History.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_History.snap new file mode 100644 index 00000000..26950843 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_History.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title Event Type Quality Date + First Source Title grabbed EPUB 2023-05-20 21:29:16 UTC +=> Second Source Title downloadFailed MOBI 2024-08-14 09:03:47 UTC diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_HistoryItemDetails.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_HistoryItemDetails.snap new file mode 100644 index 00000000..2324e2f0 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_second_item_selected_HistoryItemDetails.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title Event Type Quality Date + First Source Title grabbed EPUB 2023-05-20 21:29:16 UTC +=> Second Source Title downloadFailed MOBI 2024-08-14 09:03:47 UTC + + + + + + + + + + + + + ╭─────────────────────────────────── Details ───────────────────────────────────╮ + │Source Title: Second Source Title │ + │Event Type: downloadFailed │ + │Quality: MOBI │ + │Date: 2024-08-14 09:03:47 UTC │ + │Download Client: sabnzbd │ + │Message: The download client reported a failure │ + │Release Group: SecondReleaseGroup │ + │Indexer: Nyaa │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistory.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistory.snap new file mode 100644 index 00000000..b0b4c285 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistory.snap @@ -0,0 +1,28 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + + ╭───────────────── Filter ──────────────────╮ + │test filter │ + ╰─────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistoryError.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistoryError.snap new file mode 100644 index 00000000..1f909902 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_FilterHistoryError.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │The given filter produced empty results│ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_History.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_History.snap new file mode 100644 index 00000000..e3a516b0 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_History.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistoryItemDetails.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistoryItemDetails.snap new file mode 100644 index 00000000..79747de7 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistoryItemDetails.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + ╭─────────────────────────────────── Details ───────────────────────────────────╮ + │Source Title: Test Source Title │ + │Event Type: grabbed │ + │Quality: EPUB │ + │Date: 2023-05-20 21:29:16 UTC │ + │Indexer: │ + │NZB Info URL: │ + │Release Group: │ + │Age: 0 days │ + │Published Date: 1970-01-01 00:00:00 UTC │ + │Download Client: │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistorySortPrompt.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistorySortPrompt.snap new file mode 100644 index 00000000..c6d2c733 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_HistorySortPrompt.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + ╭───────────────────────────────╮ + │Date │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistory.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistory.snap new file mode 100644 index 00000000..ce8e938c --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistory.snap @@ -0,0 +1,28 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + + ╭───────────────── Search ──────────────────╮ + │test search │ + ╰─────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistoryError.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistoryError.snap new file mode 100644 index 00000000..4d4ea130 --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_tab_SearchHistoryError.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Source Title ▼ Event Type Quality Date +=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC + + + + + + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │ No items found matching search │ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_ui_renders_loading.snap b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_ui_renders_loading.snap new file mode 100644 index 00000000..bfafe99c --- /dev/null +++ b/src/ui/readarr_ui/history/snapshots/managarr__ui__readarr_ui__history__history_ui_tests__tests__snapshot_tests__history_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/history/history_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/readarr_ui/indexers/edit_indexer_ui.rs b/src/ui/readarr_ui/indexers/edit_indexer_ui.rs new file mode 100644 index 00000000..353adf31 --- /dev/null +++ b/src/ui/readarr_ui/indexers/edit_indexer_ui.rs @@ -0,0 +1,202 @@ +use std::sync::atomic::Ordering; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, EDIT_INDEXER_BLOCKS}; +use crate::render_selectable_input_box; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::button::Button; +use crate::ui::widgets::checkbox::Checkbox; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{DrawUi, draw_popup}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; + +#[cfg(test)] +#[path = "edit_indexer_ui_tests.rs"] +mod edit_indexer_ui_tests; + +pub(super) struct EditIndexerUi; + +impl DrawUi for EditIndexerUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + EDIT_INDEXER_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup(f, app, draw_edit_indexer_prompt, Size::WideLargePrompt); + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct EditIndexerPromptHighlights { + name: bool, + url: bool, + api_key: bool, + seed_ratio: bool, + tags: bool, + priority: bool, + rss: bool, + automatic_search: bool, + interactive_search: bool, + confirm: bool, +} + +fn edit_indexer_prompt_highlights( + selected_block: ActiveReadarrBlock, +) -> EditIndexerPromptHighlights { + EditIndexerPromptHighlights { + name: selected_block == ActiveReadarrBlock::EditIndexerNameInput, + url: selected_block == ActiveReadarrBlock::EditIndexerUrlInput, + api_key: selected_block == ActiveReadarrBlock::EditIndexerApiKeyInput, + seed_ratio: selected_block == ActiveReadarrBlock::EditIndexerSeedRatioInput, + tags: selected_block == ActiveReadarrBlock::EditIndexerTagsInput, + priority: selected_block == ActiveReadarrBlock::EditIndexerPriorityInput, + rss: selected_block == ActiveReadarrBlock::EditIndexerToggleEnableRss, + automatic_search: selected_block == ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + interactive_search: selected_block + == ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + confirm: selected_block == ActiveReadarrBlock::EditIndexerConfirmPrompt, + } +} + +fn draw_edit_indexer_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let block = title_block_centered("Edit Indexer"); + let yes_no_value = app.data.readarr_data.prompt_confirm; + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = edit_indexer_prompt_highlights(selected_block); + let edit_indexer_modal_option = &app.data.readarr_data.edit_indexer_modal; + let protocol = &app.data.readarr_data.indexers.current_selection().protocol; + + if edit_indexer_modal_option.is_some() { + f.render_widget(block, area); + let edit_indexer_modal = edit_indexer_modal_option.as_ref().unwrap(); + + let [settings_area, buttons_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]) + .margin(1) + .areas(area); + let [left_side_area, right_side_area] = + Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) + .margin(1) + .areas(settings_area); + let [ + name_area, + rss_area, + auto_search_area, + interactive_search_area, + priority_area, + ] = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + ]) + .areas(left_side_area); + let [url_area, api_key_area, seed_ratio_area, tags_area] = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + ]) + .areas(right_side_area); + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let priority = edit_indexer_modal.priority.to_string(); + let name_input_box = InputBox::new(&edit_indexer_modal.name.text) + .offset(edit_indexer_modal.name.offset.load(Ordering::SeqCst)) + .label("Name") + .highlighted(highlights.name) + .selected(active_readarr_block == ActiveReadarrBlock::EditIndexerNameInput); + let url_input_box = InputBox::new(&edit_indexer_modal.url.text) + .offset(edit_indexer_modal.url.offset.load(Ordering::SeqCst)) + .label("URL") + .highlighted(highlights.url) + .selected(active_readarr_block == ActiveReadarrBlock::EditIndexerUrlInput); + let api_key_input_box = InputBox::new(&edit_indexer_modal.api_key.text) + .offset(edit_indexer_modal.api_key.offset.load(Ordering::SeqCst)) + .label("API Key") + .highlighted(highlights.api_key) + .selected(active_readarr_block == ActiveReadarrBlock::EditIndexerApiKeyInput); + let tags_input_box = InputBox::new(&edit_indexer_modal.tags.text) + .offset(edit_indexer_modal.tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(highlights.tags) + .selected(active_readarr_block == ActiveReadarrBlock::EditIndexerTagsInput); + let priority_input_box = InputBox::new(&priority) + .cursor_after_string(false) + .label("Indexer Priority ▴▾") + .highlighted(highlights.priority) + .selected(active_readarr_block == ActiveReadarrBlock::EditIndexerPriorityInput); + + render_selectable_input_box!(name_input_box, f, name_area); + render_selectable_input_box!(url_input_box, f, url_area); + render_selectable_input_box!(api_key_input_box, f, api_key_area); + + if protocol == "torrent" { + let seed_ratio_input_box = InputBox::new(&edit_indexer_modal.seed_ratio.text) + .offset(edit_indexer_modal.seed_ratio.offset.load(Ordering::SeqCst)) + .label("Seed Ratio") + .highlighted(highlights.seed_ratio) + .selected(active_readarr_block == ActiveReadarrBlock::EditIndexerSeedRatioInput); + let tags_input_box = InputBox::new(&edit_indexer_modal.tags.text) + .offset(edit_indexer_modal.tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(highlights.tags) + .selected(active_readarr_block == ActiveReadarrBlock::EditIndexerTagsInput); + + render_selectable_input_box!(seed_ratio_input_box, f, seed_ratio_area); + render_selectable_input_box!(tags_input_box, f, tags_area); + render_selectable_input_box!(priority_input_box, f, priority_area); + } else { + render_selectable_input_box!(tags_input_box, f, seed_ratio_area); + render_selectable_input_box!(priority_input_box, f, tags_area); + } + + let rss_checkbox = Checkbox::new("Enable RSS") + .checked(edit_indexer_modal.enable_rss.unwrap_or_default()) + .highlighted(highlights.rss); + let auto_search_checkbox = Checkbox::new("Enable Automatic Search") + .checked( + edit_indexer_modal + .enable_automatic_search + .unwrap_or_default(), + ) + .highlighted(highlights.automatic_search); + let interactive_search_checkbox = Checkbox::new("Enable Interactive Search") + .checked( + edit_indexer_modal + .enable_interactive_search + .unwrap_or_default(), + ) + .highlighted(highlights.interactive_search); + + let [save_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(25)]) + .flex(Flex::Center) + .areas(buttons_area); + + let save_button = Button::default() + .title("Save") + .selected(yes_no_value && highlights.confirm); + let cancel_button = Button::default() + .title("Cancel") + .selected(!yes_no_value && highlights.confirm); + + f.render_widget(rss_checkbox, rss_area); + f.render_widget(auto_search_checkbox, auto_search_area); + f.render_widget(interactive_search_checkbox, interactive_search_area); + f.render_widget(save_button, save_area); + f.render_widget(cancel_button, cancel_area); + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} diff --git a/src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs b/src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs new file mode 100644 index 00000000..c2b300ef --- /dev/null +++ b/src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs @@ -0,0 +1,305 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_INDEXER_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::indexers::edit_indexer_ui::{ + EditIndexerPromptHighlights, EditIndexerUi, edit_indexer_prompt_highlights, + }; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_edit_indexer_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if EDIT_INDEXER_BLOCKS.contains(&active_readarr_block) { + assert!(EditIndexerUi::accepts(active_readarr_block.into())); + } else { + assert!(!EditIndexerUi::accepts(active_readarr_block.into())); + } + }); + } + + #[rstest] + #[case( + ActiveReadarrBlock::EditIndexerNameInput, + EditIndexerPromptHighlights { + name: true, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerUrlInput, + EditIndexerPromptHighlights { + name: false, + url: true, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerApiKeyInput, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: true, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerSeedRatioInput, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: true, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerTagsInput, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: true, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerPriorityInput, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: true, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerToggleEnableRss, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: true, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: true, + interactive_search: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerConfirmPrompt, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: true, + } + )] + #[case( + ActiveReadarrBlock::EditIndexerPrompt, + EditIndexerPromptHighlights { + name: false, + url: false, + api_key: false, + seed_ratio: false, + tags: false, + priority: false, + rss: false, + automatic_search: false, + interactive_search: false, + confirm: false, + } + )] + fn test_edit_indexer_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: EditIndexerPromptHighlights, + ) { + let highlights = edit_indexer_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + mod snapshot_tests { + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::{ + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + }; + use crate::models::servarr_models::Indexer; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + fn seed_asymmetric_enable_flags( + app: &mut App<'_>, + enable_rss: bool, + enable_automatic_search: bool, + enable_interactive_search: bool, + ) { + let edit_indexer_modal = app + .data + .readarr_data + .edit_indexer_modal + .as_mut() + .expect("edit_indexer_modal must exist in this context"); + edit_indexer_modal.enable_rss = Some(enable_rss); + edit_indexer_modal.enable_automatic_search = Some(enable_automatic_search); + edit_indexer_modal.enable_interactive_search = Some(enable_interactive_search); + } + + fn seed_indexer_protocol(app: &mut App<'_>, protocol: &str) { + app.data.readarr_data.indexers.set_items(vec![Indexer { + protocol: protocol.to_owned(), + ..indexer() + }]); + } + + #[test] + fn test_edit_indexer_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.data.readarr_data.edit_indexer_modal = None; + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditIndexerUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + #[case(ActiveReadarrBlock::EditIndexerPrompt, 0, 0)] + #[case(ActiveReadarrBlock::EditIndexerNameInput, 0, 0)] + #[case(ActiveReadarrBlock::EditIndexerUrlInput, 1, 0)] + #[case(ActiveReadarrBlock::EditIndexerToggleEnableRss, 0, 1)] + #[case(ActiveReadarrBlock::EditIndexerApiKeyInput, 1, 1)] + #[case(ActiveReadarrBlock::EditIndexerToggleEnableAutomaticSearch, 0, 2)] + #[case(ActiveReadarrBlock::EditIndexerSeedRatioInput, 1, 2)] + #[case(ActiveReadarrBlock::EditIndexerToggleEnableInteractiveSearch, 0, 3)] + #[case(ActiveReadarrBlock::EditIndexerTagsInput, 1, 3)] + #[case(ActiveReadarrBlock::EditIndexerPriorityInput, 0, 4)] + #[case(ActiveReadarrBlock::EditIndexerConfirmPrompt, 1, 4)] + fn test_edit_indexer_ui_renders_torrent_grid( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] x: usize, + #[case] y: usize, + ) { + let mut app = App::test_default_fully_populated(); + seed_asymmetric_enable_flags(&mut app, true, false, true); + seed_indexer_protocol(&mut app, "torrent"); + let mut selected_block = BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + selected_block.set_index(x, y); + app.data.readarr_data.selected_block = selected_block; + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditIndexerUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("edit_indexer_torrent_{active_readarr_block}"), + output + ); + } + + #[test] + fn test_edit_indexer_ui_renders_usenet_grid() { + let mut app = App::test_default_fully_populated(); + seed_asymmetric_enable_flags(&mut app, false, true, true); + seed_indexer_protocol(&mut app, "usenet"); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditIndexerUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/indexers/indexer_settings_ui.rs b/src/ui/readarr_ui/indexers/indexer_settings_ui.rs new file mode 100644 index 00000000..3eca2f83 --- /dev/null +++ b/src/ui/readarr_ui/indexers/indexer_settings_ui.rs @@ -0,0 +1,139 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, INDEXER_SETTINGS_BLOCKS, +}; +use crate::render_selectable_input_box; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::button::Button; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{DrawUi, draw_popup}; + +#[cfg(test)] +#[path = "indexer_settings_ui_tests.rs"] +mod indexer_settings_ui_tests; + +pub(super) struct IndexerSettingsUi; + +impl DrawUi for IndexerSettingsUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + INDEXER_SETTINGS_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup(f, app, draw_edit_indexer_settings_prompt, Size::LargePrompt); + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct IndexerSettingsPromptHighlights { + minimum_age: bool, + retention: bool, + maximum_size: bool, + rss_sync_interval: bool, + confirm: bool, +} + +fn indexer_settings_prompt_highlights( + selected_block: ActiveReadarrBlock, +) -> IndexerSettingsPromptHighlights { + IndexerSettingsPromptHighlights { + minimum_age: selected_block == ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + retention: selected_block == ActiveReadarrBlock::IndexerSettingsRetentionInput, + maximum_size: selected_block == ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + rss_sync_interval: selected_block == ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput, + confirm: selected_block == ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + } +} + +fn draw_edit_indexer_settings_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let block = title_block_centered("Configure All Indexer Settings"); + let yes_no_value = app.data.readarr_data.prompt_confirm; + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = indexer_settings_prompt_highlights(selected_block); + let indexer_settings_option = &app.data.readarr_data.indexer_settings; + + if indexer_settings_option.is_some() { + f.render_widget(block, area); + let indexer_settings = indexer_settings_option.as_ref().unwrap(); + + let [ + _, + min_age_area, + retention_area, + max_size_area, + rss_sync_area, + _, + buttons_area, + ] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(3), + ]) + .margin(1) + .areas(area); + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let min_age = indexer_settings.minimum_age.to_string(); + let retention = indexer_settings.retention.to_string(); + let max_size = indexer_settings.maximum_size.to_string(); + let rss_sync_interval = indexer_settings.rss_sync_interval.to_string(); + + let min_age_text_box = InputBox::new(&min_age) + .cursor_after_string(false) + .label("Minimum Age (minutes) ▴▾") + .highlighted(highlights.minimum_age) + .selected(active_readarr_block == ActiveReadarrBlock::IndexerSettingsMinimumAgeInput); + let retention_input_box = InputBox::new(&retention) + .cursor_after_string(false) + .label("Retention (days) ▴▾") + .highlighted(highlights.retention) + .selected(active_readarr_block == ActiveReadarrBlock::IndexerSettingsRetentionInput); + let max_size_input_box = InputBox::new(&max_size) + .cursor_after_string(false) + .label("Maximum Size (MB) ▴▾") + .highlighted(highlights.maximum_size) + .selected(active_readarr_block == ActiveReadarrBlock::IndexerSettingsMaximumSizeInput); + let rss_sync_interval_input_box = InputBox::new(&rss_sync_interval) + .cursor_after_string(false) + .label("RSS Sync Interval (minutes) ▴▾") + .highlighted(highlights.rss_sync_interval) + .selected(active_readarr_block == ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput); + + render_selectable_input_box!(min_age_text_box, f, min_age_area); + render_selectable_input_box!(retention_input_box, f, retention_area); + render_selectable_input_box!(max_size_input_box, f, max_size_area); + render_selectable_input_box!(rss_sync_interval_input_box, f, rss_sync_area); + } + + let [save_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(25)]) + .flex(Flex::Center) + .areas(buttons_area); + + let save_button = Button::default() + .title("Save") + .selected(yes_no_value && highlights.confirm); + let cancel_button = Button::default() + .title("Cancel") + .selected(!yes_no_value && highlights.confirm); + + f.render_widget(save_button, save_area); + f.render_widget(cancel_button, cancel_area); + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} diff --git a/src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs b/src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs new file mode 100644 index 00000000..992d26f1 --- /dev/null +++ b/src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs @@ -0,0 +1,162 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, INDEXER_SETTINGS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::indexers::indexer_settings_ui::{ + IndexerSettingsPromptHighlights, IndexerSettingsUi, indexer_settings_prompt_highlights, + }; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_indexer_settings_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if INDEXER_SETTINGS_BLOCKS.contains(&active_readarr_block) { + assert!(IndexerSettingsUi::accepts(active_readarr_block.into())); + } else { + assert!(!IndexerSettingsUi::accepts(active_readarr_block.into())); + } + }); + } + + #[rstest] + #[case( + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + IndexerSettingsPromptHighlights { + minimum_age: true, + retention: false, + maximum_size: false, + rss_sync_interval: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::IndexerSettingsRetentionInput, + IndexerSettingsPromptHighlights { + minimum_age: false, + retention: true, + maximum_size: false, + rss_sync_interval: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + IndexerSettingsPromptHighlights { + minimum_age: false, + retention: false, + maximum_size: true, + rss_sync_interval: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput, + IndexerSettingsPromptHighlights { + minimum_age: false, + retention: false, + maximum_size: false, + rss_sync_interval: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + IndexerSettingsPromptHighlights { + minimum_age: false, + retention: false, + maximum_size: false, + rss_sync_interval: false, + confirm: true, + } + )] + #[case( + ActiveReadarrBlock::AllIndexerSettingsPrompt, + IndexerSettingsPromptHighlights { + minimum_age: false, + retention: false, + maximum_size: false, + rss_sync_interval: false, + confirm: false, + } + )] + fn test_indexer_settings_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: IndexerSettingsPromptHighlights, + ) { + let highlights = indexer_settings_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + #[test] + fn test_indexer_settings_prompt_highlights_covers_every_selection_step() { + for step in INDEXER_SETTINGS_SELECTION_BLOCKS { + let highlights = indexer_settings_prompt_highlights(step[0]); + + assert_ne!( + highlights, + indexer_settings_prompt_highlights(ActiveReadarrBlock::AllIndexerSettingsPrompt) + ); + } + } + + mod snapshot_tests { + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer_settings; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + fn seed_distinct_indexer_settings(app: &mut App<'_>) { + app.data.readarr_data.indexer_settings = Some(indexer_settings()); + } + + #[test] + fn test_indexer_settings_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.data.readarr_data.indexer_settings = None; + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexerSettingsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_indexer_settings_ui_renders( + #[values( + ActiveReadarrBlock::AllIndexerSettingsPrompt, + ActiveReadarrBlock::IndexerSettingsConfirmPrompt, + ActiveReadarrBlock::IndexerSettingsMaximumSizeInput, + ActiveReadarrBlock::IndexerSettingsMinimumAgeInput, + ActiveReadarrBlock::IndexerSettingsRetentionInput, + ActiveReadarrBlock::IndexerSettingsRssSyncIntervalInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexer_settings(&mut app); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexerSettingsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("indexer_settings_{active_readarr_block}"), output); + } + } +} diff --git a/src/ui/readarr_ui/indexers/indexers_ui_tests.rs b/src/ui/readarr_ui/indexers/indexers_ui_tests.rs new file mode 100644 index 00000000..802337a0 --- /dev/null +++ b/src/ui/readarr_ui/indexers/indexers_ui_tests.rs @@ -0,0 +1,266 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, INDEXERS_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::indexers::IndexersUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_indexers_ui_accepts() { + let mut indexers_blocks = Vec::new(); + indexers_blocks.extend(INDEXERS_BLOCKS); + indexers_blocks.extend(INDEXER_SETTINGS_BLOCKS); + indexers_blocks.extend(EDIT_INDEXER_BLOCKS); + indexers_blocks.push(ActiveReadarrBlock::TestAllIndexers); + + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if indexers_blocks.contains(&active_readarr_block) { + assert!(IndexersUi::accepts(active_readarr_block.into())); + } else { + assert!(!IndexersUi::accepts(active_readarr_block.into())); + } + }); + } + + mod test_indexer_flag_text { + use pretty_assertions::assert_eq; + use ratatui::text::Text; + + use crate::ui::readarr_ui::indexers::indexer_flag_text; + use crate::ui::styles::ManagarrStyle; + + #[test] + fn test_indexer_flag_text_when_the_flag_is_enabled() { + let flag_text = indexer_flag_text(true); + + assert_eq!(flag_text, Text::from("Enabled").success()); + } + + #[test] + fn test_indexer_flag_text_when_the_flag_is_disabled() { + let flag_text = indexer_flag_text(false); + + assert_eq!(flag_text, Text::from("Disabled").failure()); + } + + #[test] + fn test_indexer_flag_text_styles_the_two_branches_differently() { + let enabled_flag_text = indexer_flag_text(true); + + assert_ne!(enabled_flag_text, Text::from("Enabled").failure()); + } + } + + mod snapshot_tests { + use crate::models::BlockSelectionState; + use crate::models::Scrollable; + use crate::models::servarr_data::readarr::readarr_data::{ + EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + INDEXER_SETTINGS_SELECTION_BLOCKS, + }; + use crate::models::servarr_models::Indexer; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ + indexer, indexer_settings, + }; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + use rstest::rstest; + use serde_json::Number; + + use super::*; + + fn seed_distinct_indexers(app: &mut App<'_>) { + app + .data + .readarr_data + .tags_map + .insert(2, "usenet-only".to_owned()); + app.data.readarr_data.indexers.set_items(vec![ + Indexer { + id: 3, + name: Some("Decoy Usenet Indexer".to_owned()), + protocol: "usenet".to_owned(), + enable_rss: false, + enable_automatic_search: true, + enable_interactive_search: true, + priority: 7, + tags: vec![Number::from(2)], + ..indexer() + }, + Indexer { + id: 8, + name: Some("Test Torrent Indexer".to_owned()), + protocol: "torrent".to_owned(), + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: false, + priority: 42, + tags: vec![Number::from(1)], + ..indexer() + }, + ]); + } + + #[test] + fn test_indexers_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_empty_indexers() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Indexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_loading_test_results() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::TestIndexer.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_loading_test_results_when_indexer_test_errors_is_none() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::TestIndexer.into()); + app.data.readarr_data.indexer_test_errors = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_test_indexer_success_when_there_are_no_errors() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::TestIndexer.into()); + app.data.readarr_data.indexer_test_errors = Some(String::new()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_indexers_ui_renders( + #[values( + ActiveReadarrBlock::Indexers, + ActiveReadarrBlock::DeleteIndexerPrompt, + ActiveReadarrBlock::TestIndexer + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexers(&mut app); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("indexers_ui_{active_readarr_block}"), output); + } + + #[test] + fn test_indexers_ui_renders_delete_prompt_for_non_default_selection() { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexers(&mut app); + app.data.readarr_data.indexers.scroll_down(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteIndexerPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_edit_usenet_indexer_over_indexers() { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexers(&mut app); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_NZB_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_edit_torrent_indexer_over_indexers() { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexers(&mut app); + app.data.readarr_data.indexers.scroll_down(); + app.data.readarr_data.selected_block = + BlockSelectionState::new(EDIT_INDEXER_TORRENT_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::EditIndexerPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_indexer_settings_over_indexers() { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexers(&mut app); + app.data.readarr_data.indexer_settings = Some(indexer_settings()); + app.data.readarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::AllIndexerSettingsPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_indexers_ui_renders_test_all_indexers_over_indexers() { + let mut app = App::test_default_fully_populated(); + seed_distinct_indexers(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/indexers/mod.rs b/src/ui/readarr_ui/indexers/mod.rs new file mode 100644 index 00000000..e358e943 --- /dev/null +++ b/src/ui/readarr_ui/indexers/mod.rs @@ -0,0 +1,182 @@ +use crate::ui::styles::success_style; +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::text::Text; +use ratatui::widgets::{Cell, Row}; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, INDEXERS_BLOCKS}; +use crate::models::servarr_models::Indexer; +use crate::ui::DrawUi; +use crate::ui::readarr_ui::indexers::edit_indexer_ui::EditIndexerUi; +use crate::ui::readarr_ui::indexers::indexer_settings_ui::IndexerSettingsUi; +use crate::ui::readarr_ui::indexers::test_all_indexers_ui::TestAllIndexersUi; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{layout_block_top_border, title_block}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; + +mod edit_indexer_ui; +mod indexer_settings_ui; +mod test_all_indexers_ui; + +#[cfg(test)] +#[path = "indexers_ui_tests.rs"] +mod indexers_ui_tests; + +pub(super) struct IndexersUi; + +impl DrawUi for IndexersUi { + fn accepts(route: Route) -> bool { + if let Route::Readarr(active_readarr_block, _) = route { + return EditIndexerUi::accepts(route) + || IndexerSettingsUi::accepts(route) + || TestAllIndexersUi::accepts(route) + || INDEXERS_BLOCKS.contains(&active_readarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let route = app.get_current_route(); + draw_indexers(f, app, area); + + match route { + _ if EditIndexerUi::accepts(route) => EditIndexerUi::draw(f, app, area), + _ if IndexerSettingsUi::accepts(route) => IndexerSettingsUi::draw(f, app, area), + _ if TestAllIndexersUi::accepts(route) => TestAllIndexersUi::draw(f, app, area), + Route::Readarr(active_readarr_block, _) => match active_readarr_block { + ActiveReadarrBlock::TestIndexer => { + if let Some(result) = app + .data + .readarr_data + .indexer_test_errors + .as_ref() + .filter(|_| !app.is_loading) + { + let popup = if !result.is_empty() { + Popup::new(Message::new(result.clone())).size(Size::LargeMessage) + } else { + let message = Message::new("Indexer test succeeded!") + .title("Success") + .style(success_style().bold()); + Popup::new(message).size(Size::Message) + }; + + f.render_widget(popup, f.area()); + } else { + let loading_popup = Popup::new(LoadingBlock::new( + app.is_loading || app.data.readarr_data.indexer_test_errors.is_none(), + title_block("Testing Indexer"), + )) + .size(Size::LargeMessage); + f.render_widget(loading_popup, f.area()); + } + } + ActiveReadarrBlock::DeleteIndexerPrompt => { + let prompt = format!( + "Do you really want to delete this indexer: \n{}?", + app + .data + .readarr_data + .indexers + .current_selection() + .name + .clone() + .unwrap_or_default() + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Indexer") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + _ => (), + }, + _ => (), + } + } +} + +fn indexer_flag_text(flag: bool) -> Text<'static> { + if flag { + return Text::from("Enabled").success(); + } + + Text::from("Disabled").failure() +} + +fn draw_indexers(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let indexers_row_mapping = |indexer: &'_ Indexer| { + let Indexer { + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + priority, + tags, + .. + } = indexer; + + let rss = indexer_flag_text(*enable_rss); + let automatic_search = indexer_flag_text(*enable_automatic_search); + let interactive_search = indexer_flag_text(*enable_interactive_search); + let empty_tag = String::new(); + let tags: String = tags + .iter() + .map(|tag_id| { + app + .data + .readarr_data + .tags_map + .get_by_left(&tag_id.as_i64().unwrap()) + .unwrap_or(&empty_tag) + .clone() + }) + .collect::>() + .join(", "); + + Row::new(vec![ + Cell::from(name.clone().unwrap_or_default()), + Cell::from(rss), + Cell::from(automatic_search), + Cell::from(interactive_search), + Cell::from(priority.to_string()), + Cell::from(tags), + ]) + .primary() + }; + let indexers_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.indexers), + indexers_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .headers([ + "Indexer", + "RSS", + "Automatic Search", + "Interactive Search", + "Priority", + "Tags", + ]) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(23), + ]); + + f.render_widget(indexers_table, area); +} diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerApiKeyInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerApiKeyInput.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerApiKeyInput.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerConfirmPrompt.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerConfirmPrompt.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerConfirmPrompt.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerNameInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerNameInput.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerNameInput.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPriorityInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPriorityInput.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPriorityInput.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPrompt.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPrompt.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerPrompt.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerSeedRatioInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerSeedRatioInput.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerSeedRatioInput.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerTagsInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerTagsInput.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerTagsInput.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableAutomaticSearch.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableAutomaticSearch.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableAutomaticSearch.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableInteractiveSearch.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableInteractiveSearch.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableInteractiveSearch.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableRss.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableRss.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerToggleEnableRss.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerUrlInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerUrlInput.snap new file mode 100644 index 00000000..953efc1d --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_torrent_EditIndexerUrlInput.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_loading.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_loading.snap new file mode 100644 index 00000000..75906c5e --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_loading.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_usenet_grid.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_usenet_grid.snap new file mode 100644 index 00000000..a0cbe6ca --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__edit_indexer_ui__edit_indexer_ui_tests__tests__snapshot_tests__edit_indexer_ui_renders_usenet_grid.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/edit_indexer_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Indexer Priority ▴▾: │1 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_AllIndexerSettingsPrompt.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_AllIndexerSettingsPrompt.snap new file mode 100644 index 00000000..b82e8c33 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_AllIndexerSettingsPrompt.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsConfirmPrompt.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsConfirmPrompt.snap new file mode 100644 index 00000000..b82e8c33 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsConfirmPrompt.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMaximumSizeInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMaximumSizeInput.snap new file mode 100644 index 00000000..b82e8c33 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMaximumSizeInput.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMinimumAgeInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMinimumAgeInput.snap new file mode 100644 index 00000000..b82e8c33 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsMinimumAgeInput.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRetentionInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRetentionInput.snap new file mode 100644 index 00000000..b82e8c33 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRetentionInput.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRssSyncIntervalInput.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRssSyncIntervalInput.snap new file mode 100644 index 00000000..b82e8c33 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_IndexerSettingsRssSyncIntervalInput.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_loading.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_loading.snap new file mode 100644 index 00000000..321e6a35 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_loading.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexer_settings_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_DeleteIndexerPrompt.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_DeleteIndexerPrompt.snap new file mode 100644 index 00000000..98cd8000 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_DeleteIndexerPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only + Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + + + + + + + + + + + ╭──────────────────── Delete Indexer ─────────────────────╮ + │ Do you really want to delete this indexer: │ + │ Decoy Usenet Indexer? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_Indexers.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_Indexers.snap new file mode 100644 index 00000000..70e39648 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_Indexers.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only + Test Torrent Indexer Enabled Enabled Disabled 42 alex diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_TestIndexer.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_TestIndexer.snap new file mode 100644 index 00000000..165a3765 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_TestIndexer.snap @@ -0,0 +1,35 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only + Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │ error │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_delete_prompt_for_non_default_selection.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_delete_prompt_for_non_default_selection.snap new file mode 100644 index 00000000..62f2d669 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_delete_prompt_for_non_default_selection.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags + Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only +=> Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + + + + + + + + + + + ╭──────────────────── Delete Indexer ─────────────────────╮ + │ Do you really want to delete this indexer: │ + │ Test Torrent Indexer? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_torrent_indexer_over_indexers.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_torrent_indexer_over_indexers.snap new file mode 100644 index 00000000..b2d2bedf --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_torrent_indexer_over_indexers.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags + Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only +=> Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ ✔ │ Seed Ratio: │ratio │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭─────────────────────────╮ │ + │ Indexer Priority ▴▾: │1 │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_usenet_indexer_over_indexers.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_usenet_indexer_over_indexers.snap new file mode 100644 index 00000000..18e13888 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_edit_usenet_indexer_over_indexers.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only + Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + + + + + + + + ╭──────────────────────────────────────────────── Edit Indexer ─────────────────────────────────────────────────╮ + │ │ + │ ╭─────────────────────────╮ ╭─────────────────────────╮ │ + │ Name: │DrunkenSlug │ URL: │http://127.0.0.1:9696/1/ │ │ + │ ╰─────────────────────────╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable RSS: │ ✔ │ API Key: │someApiKey │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Automatic Search: │ ✔ │ Tags: │25 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ ╭───╮ ╭─────────────────────────╮ │ + │ Enable Interactive Search: │ ✔ │ Indexer Priority ▴▾: │1 │ │ + │ ╰───╯ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────────────────╮╭──────────────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰───────────────────────────╯╰──────────────────────────╯ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_empty_indexers.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_empty_indexers.snap new file mode 100644 index 00000000..20e686c5 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_empty_indexers.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap new file mode 100644 index 00000000..ff32ac41 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only + Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │17 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │43 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │26500 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │35 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading.snap new file mode 100644 index 00000000..1c8ab1cd --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results.snap new file mode 100644 index 00000000..9bad23a3 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results.snap @@ -0,0 +1,35 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... + + + + + + + + + + + + + + + + ╭ Testing Indexer ────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results_when_indexer_test_errors_is_none.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results_when_indexer_test_errors_is_none.snap new file mode 100644 index 00000000..0d598c66 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_loading_test_results_when_indexer_test_errors_is_none.snap @@ -0,0 +1,35 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Test Indexer Enabled Enabled Enabled 25 alex + + + + + + + + + + + + + + + + + ╭ Testing Indexer ────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_all_indexers_over_indexers.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_all_indexers_over_indexers.snap new file mode 100644 index 00000000..bacce06a --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_all_indexers_over_indexers.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Decoy Usenet Indexer Disabled Enabled Enabled 7 usenet-only + Test Torrent Indexer Enabled Enabled Disabled 42 alex + + + ╭ Test All Indexers ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Indexer Pass/Fail Failure Messages │ + │=> DrunkenSlug x Some failure │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_indexer_success_when_there_are_no_errors.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_indexer_success_when_there_are_no_errors.snap new file mode 100644 index 00000000..4999ebfe --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_test_indexer_success_when_there_are_no_errors.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/readarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Test Indexer Enabled Enabled Enabled 25 alex + + + + + + + + + + + + + + + + + + + + + ╭────────────── Success ──────────────╮ + │ Indexer test succeeded! │ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders.snap new file mode 100644 index 00000000..f1693108 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test All Indexers ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Indexer Pass/Fail Failure Messages │ + │=> Test Indexer + │ + │ Other Indexer x Failure for field 'baseUrl': Unable to connect to indexer, Failure for field 'apiKe│ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_empty_results.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_empty_results.snap new file mode 100644 index 00000000..a719b4db --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_empty_results.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test All Indexers ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_for_non_default_selection.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_for_non_default_selection.snap new file mode 100644 index 00000000..d29cf306 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_for_non_default_selection.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test All Indexers ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Indexer Pass/Fail Failure Messages │ + │ Test Indexer + │ + │=> Other Indexer x Failure for field 'baseUrl': Unable to connect to indexer, Failure for field 'apiKe│ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading.snap new file mode 100644 index 00000000..2f9c8657 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test All Indexers ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading_when_results_are_none.snap b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading_when_results_are_none.snap new file mode 100644 index 00000000..2f9c8657 --- /dev/null +++ b/src/ui/readarr_ui/indexers/snapshots/managarr__ui__readarr_ui__indexers__test_all_indexers_ui__test_all_indexers_ui_tests__tests__snapshot_tests__test_all_indexers_ui_renders_loading_when_results_are_none.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs +expression: output +--- + + + + + + + ╭ Test All Indexers ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/indexers/test_all_indexers_ui.rs b/src/ui/readarr_ui/indexers/test_all_indexers_ui.rs new file mode 100644 index 00000000..d25919f2 --- /dev/null +++ b/src/ui/readarr_ui/indexers/test_all_indexers_ui.rs @@ -0,0 +1,86 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::modals::IndexerTestResultModalItem; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{get_width_from_percentage, title_block}; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::popup::Size; +use crate::ui::{DrawUi, draw_popup}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::widgets::{Cell, Row}; + +#[cfg(test)] +#[path = "test_all_indexers_ui_tests.rs"] +mod test_all_indexers_ui_tests; + +pub(super) struct TestAllIndexersUi; + +impl DrawUi for TestAllIndexersUi { + fn accepts(route: Route) -> bool { + if let Route::Readarr(active_readarr_block, _) = route { + return active_readarr_block == ActiveReadarrBlock::TestAllIndexers; + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + draw_popup(f, app, draw_test_all_indexers_test_results, Size::Large); + } +} + +fn decorate_test_result_row_with_style<'a>( + result: &IndexerTestResultModalItem, + row: Row<'a>, +) -> Row<'a> { + if result.is_valid { + row.success() + } else { + row.failure() + } +} + +fn draw_test_all_indexers_test_results(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let is_loading = app.is_loading || app.data.readarr_data.indexer_test_all_results.is_none(); + let current_selection = if let Some(test_all_results) = + app.data.readarr_data.indexer_test_all_results.as_ref() + && !test_all_results.is_empty() + { + test_all_results.current_selection().clone() + } else { + IndexerTestResultModalItem::default() + }; + f.render_widget(title_block("Test All Indexers"), area); + let test_results_row_mapping = |result: &IndexerTestResultModalItem| { + result.validation_failures.scroll_left_or_reset( + get_width_from_percentage(area, 86), + *result == current_selection, + app.should_text_scroll, + ); + let pass_fail = if result.is_valid { "+" } else { "x" }; + let row = Row::new(vec![ + Cell::from(result.name.to_owned()), + Cell::from(pass_fail.to_owned()), + Cell::from(result.validation_failures.to_string()), + ]); + + decorate_test_result_row_with_style(result, row) + }; + + let indexers_test_results_table = ManagarrTable::new( + app.data.readarr_data.indexer_test_all_results.as_mut(), + test_results_row_mapping, + ) + .loading(is_loading) + .margin(1) + .headers(["Indexer", "Pass/Fail", "Failure Messages"]) + .constraints([ + Constraint::Percentage(20), + Constraint::Percentage(10), + Constraint::Percentage(70), + ]); + + f.render_widget(indexers_test_results_table, area); +} diff --git a/src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs b/src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs new file mode 100644 index 00000000..512bb2d8 --- /dev/null +++ b/src/ui/readarr_ui/indexers/test_all_indexers_ui_tests.rs @@ -0,0 +1,156 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::indexers::test_all_indexers_ui::TestAllIndexersUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_test_all_indexers_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if active_readarr_block == ActiveReadarrBlock::TestAllIndexers { + assert!(TestAllIndexersUi::accepts(active_readarr_block.into())); + } else { + assert!(!TestAllIndexersUi::accepts(active_readarr_block.into())); + } + }); + } + + mod test_decorate_test_result_row_with_style { + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; + + use crate::models::servarr_data::modals::IndexerTestResultModalItem; + use crate::ui::readarr_ui::indexers::test_all_indexers_ui::decorate_test_result_row_with_style; + use crate::ui::styles::ManagarrStyle; + + fn indexer_test_result(is_valid: bool) -> IndexerTestResultModalItem { + IndexerTestResultModalItem { + name: "Test Indexer".to_owned(), + is_valid, + validation_failures: "Unable to connect to indexer".into(), + } + } + + #[test] + fn test_decorate_test_result_row_with_style_when_the_result_is_valid() { + let row = Row::new(vec![Cell::from("Test Indexer")]); + + let decorated_row = + decorate_test_result_row_with_style(&indexer_test_result(true), row.clone()); + + assert_eq!(decorated_row, row.success()); + } + + #[test] + fn test_decorate_test_result_row_with_style_when_the_result_is_invalid() { + let row = Row::new(vec![Cell::from("Test Indexer")]); + + let decorated_row = + decorate_test_result_row_with_style(&indexer_test_result(false), row.clone()); + + assert_eq!(decorated_row, row.failure()); + } + + #[test] + fn test_decorate_test_result_row_with_style_styles_the_two_branches_differently() { + let row = Row::new(vec![Cell::from("Test Indexer")]); + + let decorated_row = + decorate_test_result_row_with_style(&indexer_test_result(true), row.clone()); + + assert_ne!(decorated_row, row.failure()); + } + } + + mod snapshot_tests { + use crate::models::Scrollable; + use crate::models::stateful_table::StatefulTable; + use crate::network::readarr_network::readarr_network_test_utils::test_utils::indexer_test_results; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + fn seed_distinct_test_results(app: &mut App<'_>) { + let mut indexer_test_all_results = StatefulTable::default(); + indexer_test_all_results.set_items(indexer_test_results()); + app.data.readarr_data.indexer_test_all_results = Some(indexer_test_all_results); + } + + #[test] + fn test_test_all_indexers_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + seed_distinct_test_results(&mut app); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + TestAllIndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_test_all_indexers_ui_renders_loading_when_results_are_none() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.indexer_test_all_results = None; + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + TestAllIndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_test_all_indexers_ui_renders_empty_results() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.indexer_test_all_results = Some(StatefulTable::default()); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + TestAllIndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_test_all_indexers_ui_renders() { + let mut app = App::test_default_fully_populated(); + seed_distinct_test_results(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + TestAllIndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_test_all_indexers_ui_renders_for_non_default_selection() { + let mut app = App::test_default_fully_populated(); + seed_distinct_test_results(&mut app); + app + .data + .readarr_data + .indexer_test_all_results + .as_mut() + .expect("indexer_test_all_results must exist in this context") + .scroll_down(); + app.push_navigation_stack(ActiveReadarrBlock::TestAllIndexers.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + TestAllIndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/library/add_author_ui.rs b/src/ui/readarr_ui/library/add_author_ui.rs new file mode 100644 index 00000000..6e1ce4f9 --- /dev/null +++ b/src/ui/readarr_ui/library/add_author_ui.rs @@ -0,0 +1,452 @@ +use std::sync::atomic::Ordering; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::widgets::{Cell, ListItem, Row}; + +use crate::App; +use crate::models::Route; +use crate::models::readarr_models::AddAuthorSearchResult; +use crate::models::servarr_data::readarr::modals::AddAuthorModal; +use crate::models::servarr_data::readarr::readarr_data::{ADD_AUTHOR_BLOCKS, ActiveReadarrBlock}; +use crate::render_selectable_input_box; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{ + get_width_from_percentage, layout_block, layout_paragraph_borderless, title_block_centered, +}; +use crate::ui::widgets::button::Button; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::widgets::selectable_list::SelectableList; +use crate::ui::{DrawUi, draw_popup}; + +#[cfg(test)] +#[path = "add_author_ui_tests.rs"] +mod add_author_ui_tests; + +pub(super) struct AddAuthorUi; + +impl DrawUi for AddAuthorUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + ADD_AUTHOR_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + draw_popup(f, app, draw_add_author_search, Size::Large); + + match active_readarr_block { + ActiveReadarrBlock::AddAuthorPrompt + | ActiveReadarrBlock::AddAuthorSelectMonitor + | ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + | ActiveReadarrBlock::AddAuthorSelectQualityProfile + | ActiveReadarrBlock::AddAuthorSelectMetadataProfile + | ActiveReadarrBlock::AddAuthorSelectRootFolder + | ActiveReadarrBlock::AddAuthorTagsInput => { + draw_popup(f, app, draw_confirmation_popup, Size::Long); + } + ActiveReadarrBlock::AddAuthorAlreadyInLibrary => { + f.render_widget( + Popup::new(Message::new("This author is already in your library")).size(Size::Message), + f.area(), + ); + } + _ => (), + } + } + } +} + +fn build_add_author_prompt_title(author_name: &str, disambiguation: &str) -> String { + if disambiguation.is_empty() { + format!("Add - {author_name}") + } else { + format!("Add - {author_name} ({disambiguation})") + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct AddAuthorPromptHighlights { + root_folder: bool, + monitor: bool, + monitor_new_items: bool, + quality_profile: bool, + metadata_profile: bool, + tags: bool, + confirm: bool, +} + +fn add_author_prompt_highlights(selected_block: ActiveReadarrBlock) -> AddAuthorPromptHighlights { + AddAuthorPromptHighlights { + root_folder: selected_block == ActiveReadarrBlock::AddAuthorSelectRootFolder, + monitor: selected_block == ActiveReadarrBlock::AddAuthorSelectMonitor, + monitor_new_items: selected_block == ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + quality_profile: selected_block == ActiveReadarrBlock::AddAuthorSelectQualityProfile, + metadata_profile: selected_block == ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + tags: selected_block == ActiveReadarrBlock::AddAuthorTagsInput, + confirm: selected_block == ActiveReadarrBlock::AddAuthorConfirmPrompt, + } +} + +fn already_in_library_marker(is_in_library: bool) -> &'static str { + if is_in_library { "✔" } else { "" } +} + +fn draw_add_author_search(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let is_loading = app.is_loading || app.data.readarr_data.add_searched_authors.is_none(); + let current_selection = if let Some(add_searched_authors) = + app.data.readarr_data.add_searched_authors.as_ref() + && !add_searched_authors.is_empty() + { + add_searched_authors.current_selection().clone() + } else { + AddAuthorSearchResult::default() + }; + + let [search_box_area, results_area] = + Layout::vertical([Constraint::Length(3), Constraint::Fill(0)]) + .margin(1) + .areas(area); + let block_content = &app + .data + .readarr_data + .add_author_search + .as_ref() + .expect("add_author_search must be populated") + .text; + let offset = app + .data + .readarr_data + .add_author_search + .as_ref() + .expect("add_author_search must be populated") + .offset + .load(Ordering::SeqCst); + + let search_results_row_mapping = |author: &AddAuthorSearchResult| { + let rating = author + .ratings + .as_ref() + .map_or(String::new(), |r| format!("{:.1}", r.value)); + let in_library = already_in_library_marker( + app + .data + .readarr_data + .authors + .items + .iter() + .any(|a| a.foreign_author_id == author.foreign_author_id), + ); + + author.author_name.scroll_left_or_reset( + get_width_from_percentage(area, 27), + *author == current_selection, + app.should_text_scroll, + ); + + Row::new(vec![ + Cell::from(in_library), + Cell::from(author.author_name.to_string()), + Cell::from(author.author_type.clone().unwrap_or_default()), + Cell::from(author.status.to_display_str()), + Cell::from(rating), + Cell::from(author.genres.join(", ")), + ]) + .primary() + }; + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + match active_readarr_block { + ActiveReadarrBlock::AddAuthorSearchInput => { + let search_box = InputBox::new(block_content) + .offset(offset) + .block(title_block_centered("Add Author")); + + search_box.show_cursor(f, search_box_area); + f.render_widget(layout_block().default_color(), results_area); + f.render_widget(search_box, search_box_area); + } + ActiveReadarrBlock::AddAuthorEmptySearchResults => { + let error_message = Message::new("No authors found matching your query!"); + let error_message_popup = Popup::new(error_message).size(Size::Message); + + f.render_widget(layout_block().default_color(), results_area); + f.render_widget(error_message_popup, f.area()); + } + ActiveReadarrBlock::AddAuthorSearchResults + | ActiveReadarrBlock::AddAuthorPrompt + | ActiveReadarrBlock::AddAuthorSelectMonitor + | ActiveReadarrBlock::AddAuthorSelectMonitorNewItems + | ActiveReadarrBlock::AddAuthorSelectQualityProfile + | ActiveReadarrBlock::AddAuthorSelectMetadataProfile + | ActiveReadarrBlock::AddAuthorSelectRootFolder + | ActiveReadarrBlock::AddAuthorAlreadyInLibrary + | ActiveReadarrBlock::AddAuthorTagsInput => { + let search_results_table = ManagarrTable::new( + app.data.readarr_data.add_searched_authors.as_mut(), + search_results_row_mapping, + ) + .loading(is_loading) + .block(layout_block().default_color()) + .headers(["✔", "Name", "Type", "Status", "Rating", "Genres"]) + .constraints([ + Constraint::Percentage(3), + Constraint::Percentage(27), + Constraint::Percentage(12), + Constraint::Percentage(12), + Constraint::Percentage(8), + Constraint::Percentage(38), + ]); + + f.render_widget(search_results_table, results_area); + } + _ => (), + } + } + + f.render_widget( + InputBox::new(block_content) + .offset(offset) + .block(title_block_centered("Add Author")), + search_box_area, + ); +} + +fn draw_confirmation_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + match active_readarr_block { + ActiveReadarrBlock::AddAuthorSelectMonitor => { + draw_confirmation_prompt(f, app, area); + draw_add_author_select_monitor_popup(f, app); + } + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems => { + draw_confirmation_prompt(f, app, area); + draw_add_author_select_monitor_new_items_popup(f, app); + } + ActiveReadarrBlock::AddAuthorSelectQualityProfile => { + draw_confirmation_prompt(f, app, area); + draw_add_author_select_quality_profile_popup(f, app); + } + ActiveReadarrBlock::AddAuthorSelectMetadataProfile => { + draw_confirmation_prompt(f, app, area); + draw_add_author_select_metadata_profile_popup(f, app); + } + ActiveReadarrBlock::AddAuthorSelectRootFolder => { + draw_confirmation_prompt(f, app, area); + draw_add_author_select_root_folder_popup(f, app); + } + ActiveReadarrBlock::AddAuthorPrompt | ActiveReadarrBlock::AddAuthorTagsInput => { + draw_confirmation_prompt(f, app, area) + } + _ => (), + } + } +} + +fn draw_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let searched_author = app + .data + .readarr_data + .add_searched_authors + .as_ref() + .expect("add_searched_authors must be populated") + .current_selection(); + let author_name = &searched_author.author_name.text; + let author_disambiguation = searched_author.disambiguation.clone().unwrap_or_default(); + + let title = build_add_author_prompt_title(author_name, &author_disambiguation); + let prompt = searched_author.overview.clone().unwrap_or_default(); + let yes_no_value = app.data.readarr_data.prompt_confirm; + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = add_author_prompt_highlights(selected_block); + let AddAuthorModal { + monitor_list, + monitor_new_items_list, + quality_profile_list, + metadata_profile_list, + root_folder_list, + tags, + .. + } = app + .data + .readarr_data + .add_author_modal + .as_ref() + .expect("add_author_modal must exist in this context"); + + let selected_monitor = monitor_list.current_selection(); + let selected_monitor_new_items = monitor_new_items_list.current_selection(); + let selected_quality_profile = quality_profile_list.current_selection(); + let selected_metadata_profile = metadata_profile_list.current_selection(); + let selected_root_folder = root_folder_list.current_selection(); + + f.render_widget(title_block_centered(&title), area); + + let [ + paragraph_area, + root_folder_area, + monitor_area, + monitor_new_items_area, + quality_profile_area, + metadata_profile_area, + tags_area, + _, + buttons_area, + ] = Layout::vertical([ + Constraint::Length(6), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(3), + ]) + .margin(1) + .areas(area); + + let prompt_paragraph = layout_paragraph_borderless(&prompt); + f.render_widget(prompt_paragraph, paragraph_area); + + let [add_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(buttons_area); + + let root_folder_drop_down_button = Button::default() + .title(&selected_root_folder.path) + .label("Root Folder") + .icon("▼") + .selected(highlights.root_folder); + let monitor_drop_down_button = Button::default() + .title(selected_monitor.to_display_str()) + .label("Monitor") + .icon("▼") + .selected(highlights.monitor); + let monitor_new_items_drop_down_button = Button::default() + .title(selected_monitor_new_items.to_display_str()) + .label("Monitor New Items") + .icon("▼") + .selected(highlights.monitor_new_items); + let quality_profile_drop_down_button = Button::default() + .title(selected_quality_profile) + .label("Quality Profile") + .icon("▼") + .selected(highlights.quality_profile); + let metadata_profile_drop_down_button = Button::default() + .title(selected_metadata_profile) + .label("Metadata Profile") + .icon("▼") + .selected(highlights.metadata_profile); + + f.render_widget(root_folder_drop_down_button, root_folder_area); + f.render_widget(monitor_drop_down_button, monitor_area); + f.render_widget(monitor_new_items_drop_down_button, monitor_new_items_area); + f.render_widget(quality_profile_drop_down_button, quality_profile_area); + f.render_widget(metadata_profile_drop_down_button, metadata_profile_area); + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let tags_input_box = InputBox::new(&tags.text) + .offset(tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(highlights.tags) + .selected(active_readarr_block == ActiveReadarrBlock::AddAuthorTagsInput); + render_selectable_input_box!(tags_input_box, f, tags_area); + } + + let add_button = Button::default() + .title("Add") + .selected(yes_no_value && highlights.confirm); + let cancel_button = Button::default() + .title("Cancel") + .selected(!yes_no_value && highlights.confirm); + + f.render_widget(add_button, add_area); + f.render_widget(cancel_button, cancel_area); +} + +fn draw_add_author_select_monitor_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_author_modal + .as_mut() + .expect("add_author_modal must exist in this context") + .monitor_list, + |monitor| ListItem::new(monitor.to_display_str().to_owned()), + ); + let popup = Popup::new(monitor_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_author_select_monitor_new_items_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_new_items_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_author_modal + .as_mut() + .expect("add_author_modal must exist in this context") + .monitor_new_items_list, + |monitor_new_items| ListItem::new(monitor_new_items.to_display_str().to_owned()), + ); + let popup = Popup::new(monitor_new_items_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_author_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let quality_profile_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_author_modal + .as_mut() + .expect("add_author_modal must exist in this context") + .quality_profile_list, + |quality_profile| ListItem::new(quality_profile.clone()), + ); + let popup = Popup::new(quality_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_author_select_metadata_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let metadata_profile_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_author_modal + .as_mut() + .expect("add_author_modal must exist in this context") + .metadata_profile_list, + |metadata_profile| ListItem::new(metadata_profile.clone()), + ); + let popup = Popup::new(metadata_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_author_select_root_folder_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let root_folder_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_author_modal + .as_mut() + .expect("add_author_modal must exist in this context") + .root_folder_list, + |root_folder| ListItem::new(root_folder.path.to_owned()), + ); + let popup = Popup::new(root_folder_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} diff --git a/src/ui/readarr_ui/library/add_author_ui_tests.rs b/src/ui/readarr_ui/library/add_author_ui_tests.rs new file mode 100644 index 00000000..248dccbe --- /dev/null +++ b/src/ui/readarr_ui/library/add_author_ui_tests.rs @@ -0,0 +1,328 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use pretty_assertions::assert_str_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_AUTHOR_BLOCKS, ADD_AUTHOR_SELECTION_BLOCKS, ActiveReadarrBlock, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::add_author_ui::{ + AddAuthorPromptHighlights, AddAuthorUi, add_author_prompt_highlights, + already_in_library_marker, build_add_author_prompt_title, + }; + + #[test] + fn test_add_author_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if ADD_AUTHOR_BLOCKS.contains(&active_readarr_block) { + assert!(AddAuthorUi::accepts(active_readarr_block.into())); + } else { + assert!(!AddAuthorUi::accepts(active_readarr_block.into())); + } + }); + } + + #[test] + fn test_build_add_author_prompt_title_without_disambiguation() { + let title = build_add_author_prompt_title("Test Author", ""); + + assert_str_eq!(title, "Add - Test Author"); + } + + #[test] + fn test_build_add_author_prompt_title_with_disambiguation() { + let title = build_add_author_prompt_title("Test Author", "American novelist"); + + assert_str_eq!(title, "Add - Test Author (American novelist)"); + } + + #[test] + fn test_already_in_library_marker_when_in_library() { + let marker = already_in_library_marker(true); + + assert_str_eq!(marker, "✔"); + } + + #[test] + fn test_already_in_library_marker_when_not_in_library() { + let marker = already_in_library_marker(false); + + assert_str_eq!(marker, ""); + } + + #[rstest] + #[case( + ActiveReadarrBlock::AddAuthorSelectRootFolder, + AddAuthorPromptHighlights { + root_folder: true, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddAuthorSelectMonitor, + AddAuthorPromptHighlights { + root_folder: false, + monitor: true, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddAuthorSelectMonitorNewItems, + AddAuthorPromptHighlights { + root_folder: false, + monitor: false, + monitor_new_items: true, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddAuthorSelectQualityProfile, + AddAuthorPromptHighlights { + root_folder: false, + monitor: false, + monitor_new_items: false, + quality_profile: true, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddAuthorSelectMetadataProfile, + AddAuthorPromptHighlights { + root_folder: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: true, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddAuthorTagsInput, + AddAuthorPromptHighlights { + root_folder: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddAuthorConfirmPrompt, + AddAuthorPromptHighlights { + root_folder: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: true, + } + )] + fn test_add_author_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: AddAuthorPromptHighlights, + ) { + let highlights = add_author_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + #[test] + fn test_add_author_prompt_highlights_covers_every_selection_step() { + for step in ADD_AUTHOR_SELECTION_BLOCKS { + let highlights = add_author_prompt_highlights(step[0]); + + assert_ne!( + highlights, + add_author_prompt_highlights(ActiveReadarrBlock::Authors) + ); + } + } + + mod snapshot_tests { + use rstest::rstest; + + use super::*; + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::models::HorizontallyScrollableText; + use crate::models::servarr_data::readarr::readarr_data::ADD_AUTHOR_SELECTION_BLOCKS; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + fn seed_distinct_profile_lists(app: &mut App<'_>) { + let add_author_modal = app + .data + .readarr_data + .add_author_modal + .as_mut() + .expect("add_author_modal must exist in this context"); + add_author_modal + .quality_profile_list + .set_items(vec!["eBook".to_owned()]); + add_author_modal + .metadata_profile_list + .set_items(vec!["Author Only".to_owned()]); + } + + #[test] + fn test_add_author_ui_renders_loading_for_search() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.add_author_search = Some(HorizontallyScrollableText::default()); + app.data.readarr_data.add_searched_authors = None; + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_add_author_ui_renders( + #[values( + ActiveReadarrBlock::AddAuthorSearchInput, + ActiveReadarrBlock::AddAuthorSearchResults, + ActiveReadarrBlock::AddAuthorEmptySearchResults + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("add_author_ui_{active_readarr_block}"), output); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::AddAuthorSelectRootFolder)] + #[case(1, ActiveReadarrBlock::AddAuthorSelectMonitor)] + #[case(2, ActiveReadarrBlock::AddAuthorSelectMonitorNewItems)] + #[case(3, ActiveReadarrBlock::AddAuthorSelectQualityProfile)] + #[case(4, ActiveReadarrBlock::AddAuthorSelectMetadataProfile)] + #[case(5, ActiveReadarrBlock::AddAuthorTagsInput)] + #[case(6, ActiveReadarrBlock::AddAuthorConfirmPrompt)] + fn test_add_author_modal_ui_renders_step( + #[case] step: usize, + #[case] selected_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + seed_distinct_profile_lists(&mut app); + let mut selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("add_author_modal_step_{step}_{selected_readarr_block}"), + output + ); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::AddAuthorSelectRootFolder)] + #[case(1, ActiveReadarrBlock::AddAuthorSelectMonitor)] + #[case(2, ActiveReadarrBlock::AddAuthorSelectMonitorNewItems)] + #[case(3, ActiveReadarrBlock::AddAuthorSelectQualityProfile)] + #[case(4, ActiveReadarrBlock::AddAuthorSelectMetadataProfile)] + #[case(5, ActiveReadarrBlock::AddAuthorTagsInput)] + fn test_add_author_modal_ui_renders_active_block( + #[case] step: usize, + #[case] active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + seed_distinct_profile_lists(&mut app); + let mut selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("add_author_modal_active_{active_readarr_block}"), + output + ); + } + + #[test] + fn test_add_author_modal_ui_renders_the_selected_author_overview() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + seed_distinct_profile_lists(&mut app); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + assert_contains!(output, "Test Author is an American novelist"); + assert_not_contains!(output, "announced for next year"); + insta::assert_snapshot!(output); + } + + #[test] + fn test_add_author_modal_ui_renders_an_author_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorPrompt.into()); + seed_distinct_profile_lists(&mut app); + app.data.readarr_data.selected_block = BlockSelectionState::new(ADD_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .add_searched_authors + .as_mut() + .expect("add_searched_authors must be populated") + .items[0] + .overview = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + assert_not_contains!(output, "Test Author is an American novelist"); + insta::assert_snapshot!(output); + } + + #[test] + fn test_add_author_already_in_library_ui_renders() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorAlreadyInLibrary.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/library/author_details_ui.rs b/src/ui/readarr_ui/library/author_details_ui.rs new file mode 100644 index 00000000..1b49197b --- /dev/null +++ b/src/ui/readarr_ui/library/author_details_ui.rs @@ -0,0 +1,597 @@ +use chrono::Utc; +use deunicode::deunicode; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; + +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::{Book, ReadarrHistoryItem, ReadarrRelease}; +use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, +}; +use crate::ui::readarr_ui::library::author_overview_ui::AuthorOverviewUi; +use crate::ui::readarr_ui::library::book_details_ui::BookDetailsUi; +use crate::ui::readarr_ui::library::delete_book_ui::DeleteBookUi; +use crate::ui::readarr_ui::readarr_ui_utils::create_history_event_details; +use crate::ui::styles::{ManagarrStyle, secondary_style}; +use crate::ui::utils::decorate_peer_style; +use crate::ui::utils::{ + borderless_block, collapse_whitespace, get_width_from_percentage, layout_block_top_border, + title_block, +}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::{DrawUi, draw_popup, draw_tabs}; +use crate::utils::format_size; +use ratatui::layout::Alignment; +use ratatui::text::Text; +use serde_json::Number; + +#[cfg(test)] +#[path = "author_details_ui_tests.rs"] +mod author_details_ui_tests; + +pub(super) struct AuthorDetailsUi; + +impl DrawUi for AuthorDetailsUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + BookDetailsUi::accepts(route) + || DeleteBookUi::accepts(route) + || AuthorOverviewUi::accepts(route) + || AUTHOR_DETAILS_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + let route = app.get_current_route(); + if let Route::Readarr(active_readarr_block, _) = route { + let draw_author_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + f.render_widget( + title_block( + &app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text, + ), + popup_area, + ); + let [description_area, detail_area] = + Layout::vertical([Constraint::Length(12), Constraint::Fill(0)]) + .margin(1) + .areas(popup_area); + draw_author_description(f, app, description_area); + let content_area = draw_tabs( + f, + detail_area, + "Author Details", + &app.data.readarr_data.author_info_tabs, + ); + draw_author_details(f, app, content_area); + + match active_readarr_block { + _ if DeleteBookUi::accepts(route) => DeleteBookUi::draw(f, app, _area), + ActiveReadarrBlock::AuthorHistoryDetails => { + draw_author_history_item_details_popup(f, app); + } + ActiveReadarrBlock::AutomaticallySearchAuthorPrompt => { + let prompt = format!( + "Do you want to trigger an automatic search of your indexers for all monitored book(s) for the author: {}?", + app + .data + .readarr_data + .authors + .current_selection() + .author_name + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Automatic Author Search") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveReadarrBlock::UpdateAndScanAuthorPrompt => { + let prompt = format!( + "Do you want to trigger an update and disk scan for the author: {}?", + app + .data + .readarr_data + .authors + .current_selection() + .author_name + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update and Scan") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt => { + draw_manual_author_search_confirm_prompt(f, app); + } + _ => (), + } + }; + + draw_popup(f, app, draw_author_details_popup, Size::XXLarge); + + if BookDetailsUi::accepts(route) { + BookDetailsUi::draw(f, app, _area); + } + + if AuthorOverviewUi::accepts(route) { + AuthorOverviewUi::draw(f, app, _area); + } + } + } +} + +fn draw_author_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let current_selection = app.data.readarr_data.authors.current_selection(); + let monitored = if current_selection.monitored { + "Yes" + } else { + "No" + }; + let quality_profile = app + .data + .readarr_data + .quality_profile_map + .get_by_left(¤t_selection.quality_profile_id) + .cloned() + .unwrap_or_default(); + let metadata_profile = app + .data + .readarr_data + .metadata_profile_map + .get_by_left(¤t_selection.metadata_profile_id) + .cloned() + .unwrap_or_default(); + let overview = collapse_whitespace(&deunicode( + current_selection + .overview + .as_ref() + .unwrap_or(&String::new()), + )); + + let mut author_description = vec![ + Line::from(vec![ + "Author: ".primary().bold(), + current_selection.author_name.text.clone().primary().bold(), + ]), + Line::from(vec![ + "Status: ".primary().bold(), + current_selection.status.to_display_str().default_color(), + ]), + Line::from(vec![ + "Genres: ".primary().bold(), + current_selection.genres.join(", ").default_color(), + ]), + Line::from(vec![ + "Rating: ".primary().bold(), + current_selection + .ratings + .as_ref() + .map_or_else( + || "N/A".to_owned(), + |r| format!("{}%", (r.value * 10.0) as i32), + ) + .default_color(), + ]), + Line::from(vec![ + "Path: ".primary().bold(), + current_selection.path.clone().default_color(), + ]), + Line::from(vec![ + "Quality Profile: ".primary().bold(), + quality_profile.default_color(), + ]), + Line::from(vec![ + "Metadata Profile: ".primary().bold(), + metadata_profile.default_color(), + ]), + Line::from(vec![ + "Monitored: ".primary().bold(), + monitored.default_color(), + ]), + ]; + + if let Some(stats) = current_selection.statistics.as_ref() { + let size = format_size(stats.size_on_disk, 2); + author_description.extend(vec![ + Line::from(vec![ + "Books: ".primary().bold(), + stats.book_count.to_string().default_color(), + ]), + Line::from(vec![ + "Files: ".primary().bold(), + format!("{}/{}", stats.book_file_count, stats.total_book_count).default_color(), + ]), + Line::from(vec![ + "Size on Disk: ".primary().bold(), + size.default_color(), + ]), + ]); + } + + author_description.push(Line::from(vec![ + "Overview: ".primary().bold(), + overview.default_color(), + ])); + + let description_paragraph = Paragraph::new(author_description) + .block(borderless_block()) + .wrap(Wrap { trim: true }); + f.render_widget(description_paragraph, area); +} + +fn draw_author_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = + app.data.readarr_data.author_info_tabs.get_active_route() + { + match active_readarr_block { + ActiveReadarrBlock::AuthorDetails => draw_books_table(f, app, area), + ActiveReadarrBlock::AuthorHistory => draw_author_history_table(f, app, area), + ActiveReadarrBlock::ManualAuthorSearch => draw_author_releases(f, app, area), + _ => (), + } + } +} + +fn draw_books_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let current_selection = if app.data.readarr_data.books.is_empty() { + Book::default() + } else { + app.data.readarr_data.books.current_selection().clone() + }; + let content = Some(&mut app.data.readarr_data.books); + let book_row_mapping = |book: &Book| { + book.title.scroll_left_or_reset( + get_width_from_percentage(area, 38), + *book == current_selection, + app.should_text_scroll, + ); + let monitored = if book.monitored { "🏷" } else { "" }; + let release_date = book + .release_date + .map_or_else(|| "N/A".to_owned(), |d| d.format("%Y-%m-%d").to_string()); + let book_file_count = book.statistics.as_ref().map_or_else( + || "N/A".to_owned(), + |s| format!("{}/{}", s.book_file_count, s.total_book_count), + ); + let size = book + .statistics + .as_ref() + .map_or_else(|| "N/A".to_owned(), |s| format_size(s.size_on_disk, 2)); + + decorate_book_row_with_style( + book, + Row::new(vec![ + Cell::from(monitored.to_owned()), + Cell::from(book.title.to_string()), + Cell::from(book_file_count), + Cell::from(release_date), + Cell::from(size), + ]), + ) + }; + + let is_searching = active_readarr_block == ActiveReadarrBlock::SearchBooks; + let book_table = ManagarrTable::new(content, book_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .searching(is_searching) + .search_produced_empty_results(active_readarr_block == ActiveReadarrBlock::SearchBooksError) + .headers(["Monitored", "Title", "Files", "Release Date", "Size"]) + .constraints([ + Constraint::Percentage(10), + Constraint::Percentage(40), + Constraint::Percentage(15), + Constraint::Percentage(17), + Constraint::Percentage(18), + ]); + + if is_searching { + book_table.show_cursor(f, area); + } + + f.render_widget(book_table, area); + } +} + +fn decorate_book_row_with_style<'a>(book: &Book, row: Row<'a>) -> Row<'a> { + if !book.monitored { + row.unmonitored() + } else if let Some(stats) = book.statistics.as_ref() { + if stats.book_file_count == stats.total_book_count && stats.total_book_count > 0 { + row.downloaded() + } else if let Some(release_date) = book.release_date.as_ref() { + if release_date > &Utc::now() { + row.unreleased() + } else { + row.missing() + } + } else { + row.missing() + } + } else { + row.indeterminate() + } +} + +fn draw_author_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if !app.is_loading { + let current_selection = if app.data.readarr_data.author_history.is_empty() { + ReadarrHistoryItem::default() + } else { + app + .data + .readarr_data + .author_history + .current_selection() + .clone() + }; + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let history_row_mapping = |history_item: &ReadarrHistoryItem| { + let ReadarrHistoryItem { + source_title, + quality, + event_type, + date, + .. + } = history_item; + + source_title.scroll_left_or_reset( + get_width_from_percentage(area, 40), + current_selection == *history_item, + app.should_text_scroll, + ); + + Row::new(vec![ + Cell::from(source_title.to_string()), + Cell::from(event_type.to_string()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let history_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.author_history), + history_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_readarr_block == ActiveReadarrBlock::AuthorHistorySortPrompt) + .searching(active_readarr_block == ActiveReadarrBlock::SearchAuthorHistory) + .search_produced_empty_results( + active_readarr_block == ActiveReadarrBlock::SearchAuthorHistoryError, + ) + .filtering(active_readarr_block == ActiveReadarrBlock::FilterAuthorHistory) + .filter_produced_empty_results( + active_readarr_block == ActiveReadarrBlock::FilterAuthorHistoryError, + ) + .headers(["Source Title", "Event Type", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(20), + Constraint::Percentage(15), + Constraint::Percentage(25), + ]); + + if [ + ActiveReadarrBlock::SearchAuthorHistory, + ActiveReadarrBlock::FilterAuthorHistory, + ] + .contains(&active_readarr_block) + { + history_table.show_cursor(f, area); + } + + f.render_widget(history_table, area); + } + } else { + f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.readarr_data.books.is_empty(), + layout_block_top_border(), + ), + area, + ); + } +} + +fn draw_author_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = if app.data.readarr_data.author_history.is_empty() { + ReadarrHistoryItem::default() + } else { + app + .data + .readarr_data + .author_history + .current_selection() + .clone() + }; + + let line_vec = create_history_event_details(current_selection); + let text = Text::from(line_vec); + + let message = Message::new(text) + .title("Details") + .style(secondary_style()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowLongMessage), f.area()); +} + +fn draw_author_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let (current_selection, is_empty) = if app.data.readarr_data.author_releases.is_empty() { + (ReadarrRelease::default(), true) + } else { + ( + app + .data + .readarr_data + .author_releases + .current_selection() + .clone(), + app.data.readarr_data.author_releases.is_empty(), + ) + }; + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let release_row_mapping = |release: &ReadarrRelease| { + let ReadarrRelease { + protocol, + age, + title, + indexer, + size, + rejected, + seeders, + leechers, + quality, + .. + } = release; + + let age = format!("{age} days"); + title.scroll_left_or_reset( + get_width_from_percentage(area, 35), + current_selection == *release + && active_readarr_block != ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, + app.should_text_scroll, + ); + let size = format_size(*size, 1); + let rejected_str = if *rejected { "⛔" } else { "" }; + let peers = if seeders.is_none() || leechers.is_none() { + Text::from("") + } else { + let seeders = seeders + .clone() + .unwrap_or(Number::from(0u64)) + .as_u64() + .unwrap_or_default(); + let leechers = leechers + .clone() + .unwrap_or(Number::from(0u64)) + .as_u64() + .unwrap_or_default(); + + decorate_peer_style( + seeders, + leechers, + Text::from(format!("{seeders} / {leechers}")), + ) + }; + + let quality_name = quality.quality.name.clone(); + + Row::new(vec![ + Cell::from(protocol.clone()), + Cell::from(age), + Cell::from(rejected_str), + Cell::from(title.to_string()), + Cell::from(indexer.clone()), + Cell::from(size), + Cell::from(peers), + Cell::from(quality_name), + ]) + .primary() + }; + let author_release_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.author_releases), + release_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading || is_empty) + .sorting(active_readarr_block == ActiveReadarrBlock::ManualAuthorSearchSortPrompt) + .headers([ + "Source", "Age", "⛔", "Title", "Indexer", "Size", "Peers", "Quality", + ]) + .constraints([ + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(5), + Constraint::Percentage(35), + Constraint::Percentage(15), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Percentage(10), + ]); + + f.render_widget(author_release_table, area); + } +} + +fn draw_manual_author_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = app.data.readarr_data.author_releases.current_selection(); + let title = if current_selection.rejected { + "Download Rejected Release" + } else { + "Download Release" + }; + let prompt = if current_selection.rejected { + format!( + "Do you really want to download the rejected release: {}?", + current_selection.title.text + ) + } else { + format!( + "Do you want to download the release: {}?", + current_selection.title.text + ) + }; + + if current_selection.rejected { + let mut lines_vec = vec![Line::from("Rejection reasons: ".primary().bold())]; + let mut rejections_spans = current_selection + .rejections + .clone() + .unwrap_or_default() + .iter() + .map(|item| Line::from(format!("• {item}").primary().bold())) + .collect::>>(); + lines_vec.append(&mut rejections_spans); + + let content_paragraph = Paragraph::new(lines_vec) + .block(borderless_block()) + .wrap(Wrap { trim: false }) + .left_aligned(); + let confirmation_prompt = ConfirmationPrompt::new() + .title(title) + .prompt(&prompt) + .content(content_paragraph) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget(Popup::new(confirmation_prompt).size(Size::Small), f.area()); + } else { + let confirmation_prompt = ConfirmationPrompt::new() + .title(title) + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } +} diff --git a/src/ui/readarr_ui/library/author_details_ui_tests.rs b/src/ui/readarr_ui/library/author_details_ui_tests.rs new file mode 100644 index 00000000..5ef55151 --- /dev/null +++ b/src/ui/readarr_ui/library/author_details_ui_tests.rs @@ -0,0 +1,411 @@ +#[cfg(test)] +mod tests { + use chrono::{Duration, Utc}; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; + use strum::IntoEnumIterator; + + use crate::models::readarr_models::{Book, BookStatistics}; + use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, + DELETE_BOOK_BLOCKS, EDITION_DETAILS_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::author_details_ui::{ + AuthorDetailsUi, decorate_book_row_with_style, + }; + use crate::ui::styles::ManagarrStyle; + + #[test] + fn test_author_details_ui_accepts() { + let mut author_details_blocks = AUTHOR_DETAILS_BLOCKS.to_vec(); + author_details_blocks.extend(BOOK_DETAILS_BLOCKS); + author_details_blocks.extend(EDITION_DETAILS_BLOCKS); + author_details_blocks.extend(DELETE_BOOK_BLOCKS); + author_details_blocks.extend(AUTHOR_OVERVIEW_BLOCKS); + + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if author_details_blocks.contains(&active_readarr_block) { + assert!(AuthorDetailsUi::accepts(active_readarr_block.into())); + } else { + assert!(!AuthorDetailsUi::accepts(active_readarr_block.into())); + } + }); + } + + #[test] + fn test_decorate_book_row_with_style_unmonitored() { + let book = Book::default(); + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + + #[test] + fn test_decorate_book_row_with_style_downloaded_when_all_files_present() { + let book = Book { + monitored: true, + statistics: Some(BookStatistics { + book_file_count: 3, + total_book_count: 3, + ..BookStatistics::default() + }), + ..Book::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_book_row_with_style_unreleased_when_files_are_missing_and_release_date_is_future() + { + let book = Book { + monitored: true, + release_date: Some(Utc::now() + Duration::days(1)), + statistics: Some(BookStatistics { + book_file_count: 0, + total_book_count: 3, + ..BookStatistics::default() + }), + ..Book::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.unreleased()); + } + + #[test] + fn test_decorate_book_row_with_style_missing_when_files_are_missing_and_release_date_has_passed() + { + let book = Book { + monitored: true, + release_date: Some(Utc::now() - Duration::days(1)), + statistics: Some(BookStatistics { + book_file_count: 1, + total_book_count: 3, + ..BookStatistics::default() + }), + ..Book::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_book_row_with_style_missing_when_files_are_missing_and_no_release_date() { + let book = Book { + monitored: true, + release_date: None, + statistics: Some(BookStatistics { + book_file_count: 1, + total_book_count: 3, + ..BookStatistics::default() + }), + ..Book::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_book_row_with_style_missing_when_total_book_count_is_zero() { + let book = Book { + monitored: true, + release_date: Some(Utc::now() - Duration::days(1)), + statistics: Some(BookStatistics::default()), + ..Book::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_book_row_with_style_indeterminate_when_no_statistics() { + let book = Book { + monitored: true, + statistics: None, + ..Book::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_book_row_with_style(&book, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + + mod snapshot_tests { + use pretty_assertions::assert_str_eq; + use rstest::rstest; + + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::models::readarr_models::{AuthorStatistics, BookStatistics}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_BOOK_SELECTION_BLOCKS, + }; + use crate::models::stateful_table::StatefulTable; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::author_details_ui::AuthorDetailsUi; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + #[rstest] + #[case(ActiveReadarrBlock::AuthorDetails, 0)] + #[case(ActiveReadarrBlock::AuthorHistory, 1)] + #[case(ActiveReadarrBlock::ManualAuthorSearch, 2)] + #[case(ActiveReadarrBlock::SearchBooks, 0)] + #[case(ActiveReadarrBlock::SearchBooksError, 0)] + #[case(ActiveReadarrBlock::UpdateAndScanAuthorPrompt, 0)] + #[case(ActiveReadarrBlock::UpdateAndScanAuthorPrompt, 1)] + #[case(ActiveReadarrBlock::UpdateAndScanAuthorPrompt, 2)] + #[case(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, 0)] + #[case(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, 1)] + #[case(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt, 2)] + #[case(ActiveReadarrBlock::SearchAuthorHistory, 1)] + #[case(ActiveReadarrBlock::SearchAuthorHistoryError, 1)] + #[case(ActiveReadarrBlock::FilterAuthorHistory, 1)] + #[case(ActiveReadarrBlock::FilterAuthorHistoryError, 1)] + #[case(ActiveReadarrBlock::AuthorHistorySortPrompt, 1)] + #[case(ActiveReadarrBlock::AuthorHistoryDetails, 1)] + #[case(ActiveReadarrBlock::ManualAuthorSearchConfirmPrompt, 2)] + #[case(ActiveReadarrBlock::ManualAuthorSearchSortPrompt, 2)] + fn test_author_details_ui_renders( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.author_info_tabs.set_index(index); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("author_details_{active_readarr_block}_{index}"), + output + ); + } + + #[test] + fn test_author_details_ui_renders_every_field_despite_a_long_overview() { + let mut app = App::test_default_fully_populated(); + let mut author = app.data.readarr_data.authors.current_selection().clone(); + author.overview = Some("Carl Edward Sagan was an American astronomer, planetary scientist, cosmologist, astrophysicist, astrobiologist, author, and science communicator. His best known scientific contribution is his research on the possibility of extraterrestrial life, including experimental demonstration of the production of amino acids from basic chemicals by radiation. He assembled the first physical messages sent into space, the Pioneer plaque and the Voyager Golden Record, universal messages that could potentially be understood by any extraterrestrial intelligence that might find them. He argued in favor of the hypothesis, which has become accepted, that the high surface temperatures of Venus are the result of the greenhouse effect.".to_owned()); + app.data.readarr_data.authors.set_items(vec![author]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + assert_contains!(output, "Status:"); + assert_contains!(output, "Genres:"); + assert_contains!(output, "Rating:"); + assert_contains!(output, "Path:"); + assert_contains!(output, "Monitored:"); + assert_contains!(output, "Overview:"); + } + + #[rstest] + #[case(ActiveReadarrBlock::AuthorDetails, 0)] + #[case(ActiveReadarrBlock::AuthorHistory, 1)] + #[case(ActiveReadarrBlock::ManualAuthorSearch, 2)] + fn test_author_details_ui_renders_author_details_loading( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.author_info_tabs.set_index(index); + app.is_loading = true; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("loading_author_details_{active_readarr_block}"), + output + ); + } + + #[rstest] + #[case(ActiveReadarrBlock::AuthorDetails, 0)] + #[case(ActiveReadarrBlock::AuthorHistory, 1)] + #[case(ActiveReadarrBlock::AuthorHistoryDetails, 1)] + #[case(ActiveReadarrBlock::ManualAuthorSearch, 2)] + fn test_author_details_ui_renders_author_details_empty( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.books = StatefulTable::default(); + app.data.readarr_data.author_releases = StatefulTable::default(); + app.data.readarr_data.author_history = StatefulTable::default(); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.author_info_tabs.set_index(index); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("empty_author_details_{active_readarr_block}"), + output + ); + } + + #[test] + fn test_author_details_ui_renders_book_without_statistics() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.books.items[0].statistics = None; + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_details_ui_renders_update_and_scan_prompt_over_author_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAndScanAuthorPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_details_ui_renders_automatic_search_prompt_over_author_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AutomaticallySearchAuthorPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_details_ui_renders_statistics() { + let mut app = App::test_default_fully_populated(); + let mut author = app.data.readarr_data.authors.current_selection().clone(); + author.statistics = Some(AuthorStatistics { + book_count: 3, + book_file_count: 2, + total_book_count: 3, + available_book_count: 2, + size_on_disk: 3543348019, + percent_of_books: 66.6, + }); + app.data.readarr_data.authors.set_items(vec![author]); + let mut book = app.data.readarr_data.books.current_selection().clone(); + book.statistics = Some(BookStatistics { + book_file_count: 1, + total_book_count: 1, + size_on_disk: 1771674009, + percent_of_books: 100.0, + }); + app.data.readarr_data.books.set_items(vec![book]); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_details_ui_renders_book_details_over_author_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_details_ui_renders_author_overview_over_author_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_scrolling_the_author_overview_does_not_scroll_the_author_details_background() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + let before = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + app + .data + .readarr_data + .author_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 3; + + let after = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + assert_str_eq!(before, after); + assert_contains!( + before, + "Overview: some interesting description of the author" + ); + } + + #[test] + fn test_author_details_ui_renders_delete_book_over_author_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/library/author_overview_ui.rs b/src/ui/readarr_ui/library/author_overview_ui.rs new file mode 100644 index 00000000..bafb3c9a --- /dev/null +++ b/src/ui/readarr_ui/library/author_overview_ui.rs @@ -0,0 +1,87 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::Author; +use crate::models::servarr_data::readarr::readarr_data::AUTHOR_OVERVIEW_BLOCKS; +use crate::ui::styles::{primary_style, unmonitored_style}; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{DrawUi, draw_popup}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Paragraph, Wrap}; + +#[cfg(test)] +#[path = "author_overview_ui_tests.rs"] +mod author_overview_ui_tests; + +pub(super) struct AuthorOverviewUi; + +impl DrawUi for AuthorOverviewUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + AUTHOR_OVERVIEW_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if app.data.readarr_data.author_overview_modal.is_some() { + let draw_author_overview_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + draw_author_overview(f, app, popup_area); + }; + + draw_popup(f, app, draw_author_overview_popup, Size::Large); + } + } +} + +fn draw_author_overview(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block_centered("Overview"); + + match app.data.readarr_data.author_overview_modal.as_ref() { + Some(author_overview_modal) if !app.is_loading => { + let style = if app.data.readarr_data.authors.is_empty() { + primary_style() + } else { + style_from_author(app.data.readarr_data.authors.current_selection()) + }; + let overview = &author_overview_modal.overview; + let text = Text::from( + overview + .items + .iter() + .map(|line| overview_line(line, style)) + .collect::>>(), + ); + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((overview.offset, 0)); + + f.render_widget(paragraph, area); + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.readarr_data.author_overview_modal.is_none(), + block, + ), + area, + ), + } +} + +fn style_from_author(author: &Author) -> Style { + if !author.monitored { + return unmonitored_style(); + } + + primary_style() +} + +fn overview_line(line: &str, style: Style) -> Line<'static> { + Line::from(Span::styled(line.trim_end().to_owned(), style)) +} diff --git a/src/ui/readarr_ui/library/author_overview_ui_tests.rs b/src/ui/readarr_ui/library/author_overview_ui_tests.rs new file mode 100644 index 00000000..c87bd4e2 --- /dev/null +++ b/src/ui/readarr_ui/library/author_overview_ui_tests.rs @@ -0,0 +1,205 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use ratatui::style::Modifier; + use ratatui::text::Line; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::readarr_models::Author; + use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::author_overview_ui::{ + AuthorOverviewUi, overview_line, style_from_author, + }; + use crate::ui::styles::{primary_style, unmonitored_style}; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_author_overview_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if AUTHOR_OVERVIEW_BLOCKS.contains(&active_readarr_block) { + assert!(AuthorOverviewUi::accepts(active_readarr_block.into())); + } else { + assert!(!AuthorOverviewUi::accepts(active_readarr_block.into())); + } + }); + } + + #[test] + fn test_style_from_author_monitored() { + let author = Author { + monitored: true, + ..Author::default() + }; + + let style = style_from_author(&author); + + assert_eq!(style, primary_style()); + } + + #[test] + fn test_style_from_author_unmonitored() { + let author = Author { + monitored: false, + ..Author::default() + }; + + let style = style_from_author(&author); + + assert_eq!(style, unmonitored_style()); + } + + #[test] + fn test_overview_line_does_not_split_a_label_from_a_colon_in_the_prose() { + let line = overview_line( + "He was born in Madison, Wisconsin: a city he has never really left.", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["He was born in Madison, Wisconsin: a city he has never really left."] + ); + } + + #[test] + fn test_overview_line_renders_the_prose_without_bolding_it() { + let line = overview_line("Overview: a prose line", primary_style()); + + assert_eq!(line.spans.len(), 1); + assert!(!line.spans[0].style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_overview_line_applies_the_given_style() { + let line = overview_line("an unmonitored author", unmonitored_style()); + + assert_eq!(line.spans[0].style.fg, unmonitored_style().fg); + } + + #[test] + fn test_overview_line_trims_a_trailing_carriage_return() { + let line = overview_line( + "His first novel took him seven years to finish.\r", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["His first novel took him seven years to finish."] + ); + } + + #[test] + fn test_overview_line_renders_a_carriage_return_only_line_as_blank() { + let line = overview_line("\r", primary_style()); + + assert_eq!(span_contents(&line), vec![""]); + } + + fn span_contents(line: &Line<'_>) -> Vec { + line + .spans + .iter() + .map(|span| span.content.to_string()) + .collect() + } + + mod snapshot_tests { + use crate::models::ScrollableText; + use crate::models::servarr_data::readarr::modals::AuthorOverviewModal; + use crate::models::stateful_table::StatefulTable; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_author_overview_ui_renders() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_overview_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_overview_ui_renders_scrolled() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + app + .data + .readarr_data + .author_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 2; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_overview_ui_renders_an_author_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + app.data.readarr_data.author_overview_modal = Some(AuthorOverviewModal { + overview: ScrollableText::with_string(String::new()), + }); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_overview_ui_renders_nothing_when_the_author_overview_modal_is_undefined() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + app.data.readarr_data.author_overview_modal = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_author_overview_ui_renders_with_zero_authors() { + let mut app = App::test_default_fully_populated(); + app.data.readarr_data.authors = StatefulTable::default(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AuthorOverviewUi::draw(f, app, f.area()); + }); + + assert_contains!(output, "some interesting description of the author"); + } + } +} diff --git a/src/ui/readarr_ui/library/book_details_ui.rs b/src/ui/readarr_ui/library/book_details_ui.rs new file mode 100644 index 00000000..f34a9c2b --- /dev/null +++ b/src/ui/readarr_ui/library/book_details_ui.rs @@ -0,0 +1,556 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::{BookFile, Edition, ReadarrHistoryItem, ReadarrRelease}; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, BOOK_DETAILS_BLOCKS}; +use crate::ui::readarr_ui::library::edition_details_ui::EditionDetailsUi; +use crate::ui::readarr_ui::readarr_ui_utils::create_history_event_details; +use crate::ui::styles::{ManagarrStyle, secondary_style}; +use crate::ui::utils::{ + borderless_block, decorate_peer_style, get_width_from_percentage, layout_block_top_border, +}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::message::Message; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::{DrawUi, draw_popup, draw_tabs}; +use crate::utils::format_size; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Rect}; +use ratatui::prelude::{Line, Stylize, Text}; +use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; +use serde_json::Number; + +#[cfg(test)] +#[path = "book_details_ui_tests.rs"] +mod book_details_ui_tests; + +pub(super) struct BookDetailsUi; + +impl DrawUi for BookDetailsUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + EditionDetailsUi::accepts(route) || BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + let route = app.get_current_route(); + if app.data.readarr_data.book_details_modal.is_some() + && let Route::Readarr(active_readarr_block, _) = app.get_current_route() + { + let draw_book_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + let content_area = draw_tabs( + f, + popup_area, + &format!( + "{} Details", + app.data.readarr_data.books.current_selection().title.text + ), + &app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_details_tabs, + ); + draw_book_details(f, app, content_area); + + match active_readarr_block { + ActiveReadarrBlock::AutomaticallySearchBookPrompt => { + let prompt = format!( + "Do you want to trigger an automatic search of your indexers for the book: {}?", + app.data.readarr_data.books.current_selection().title.text + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Automatic Book Search") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveReadarrBlock::DeleteBookFilePrompt => { + let prompt = format!( + "Do you really want to delete this book file: \n{}?", + app.data.readarr_data.books.current_selection().title.text + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Book File") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + ActiveReadarrBlock::ManualBookSearchConfirmPrompt => { + draw_manual_book_search_confirm_prompt(f, app); + } + ActiveReadarrBlock::BookHistoryDetails => { + draw_history_item_details_popup(f, app); + } + _ => (), + } + }; + + draw_popup(f, app, draw_book_details_popup, Size::XLarge); + + if EditionDetailsUi::accepts(route) { + EditionDetailsUi::draw(f, app, _area); + } + } + } +} + +fn draw_book_details(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Some(book_details_modal) = app.data.readarr_data.book_details_modal.as_ref() + && let Route::Readarr(active_readarr_block, _) = + book_details_modal.book_details_tabs.get_active_route() + { + match active_readarr_block { + ActiveReadarrBlock::BookDetails => draw_editions_table(f, app, area), + ActiveReadarrBlock::BookHistory => draw_book_history_table(f, app, area), + ActiveReadarrBlock::BookFileInfo => draw_book_file_info(f, app, area), + ActiveReadarrBlock::ManualBookSearch => draw_book_releases(f, app, area), + _ => (), + } + } +} + +fn draw_editions_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let content = Some( + &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .editions, + ); + + let edition_row_mapping = |edition: &Edition| { + let Edition { + format, + isbn13, + asin, + publisher, + page_count, + release_date, + monitored, + .. + } = edition; + + let page_count = page_count + .map(|count| count.to_string()) + .unwrap_or_default(); + let release_date = release_date + .map(|date| date.format("%Y-%m-%d").to_string()) + .unwrap_or_default(); + let monitored = if *monitored { "🏷" } else { "" }; + + decorate_edition_row_with_style( + edition, + Row::new(vec![ + Cell::from(format.clone().unwrap_or_default()), + Cell::from(isbn13.clone().unwrap_or_default()), + Cell::from(asin.clone().unwrap_or_default()), + Cell::from(publisher.clone().unwrap_or_default()), + Cell::from(page_count), + Cell::from(release_date), + Cell::from(monitored.to_owned()), + ]), + ) + }; + + let is_searching = active_readarr_block == ActiveReadarrBlock::SearchEditions; + let editions_table = ManagarrTable::new(content, edition_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .searching(is_searching) + .search_produced_empty_results( + active_readarr_block == ActiveReadarrBlock::SearchEditionsError, + ) + .headers([ + "Format", + "ISBN13", + "ASIN", + "Publisher", + "Page Count", + "Release Date", + "Monitored", + ]) + .constraints([ + Constraint::Percentage(14), + Constraint::Percentage(16), + Constraint::Percentage(14), + Constraint::Percentage(21), + Constraint::Percentage(11), + Constraint::Percentage(14), + Constraint::Percentage(10), + ]); + + if is_searching { + editions_table.show_cursor(f, area); + } + + f.render_widget(editions_table, area); + } +} + +fn decorate_edition_row_with_style<'a>(edition: &Edition, row: Row<'a>) -> Row<'a> { + if edition.monitored { + row.primary() + } else { + row.unmonitored() + } +} + +fn draw_book_file_info(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.readarr_data.book_details_modal.as_ref() { + Some(book_details_modal) if !app.is_loading && !book_details_modal.book_files.is_empty() => { + let content = Some( + &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_files, + ); + + let book_file_row_mapping = |book_file: &BookFile| { + let BookFile { + path, + size, + quality, + media_info, + .. + } = book_file; + + let size = format_size(*size, 2); + let audio_info = media_info + .as_ref() + .map(|media_info| { + let codec = media_info.audio_codec.as_deref().unwrap_or(""); + let channels = format!("{}.0", media_info.audio_channels); + let bitrate = media_info.audio_bit_rate.as_deref().unwrap_or(""); + let sample_rate = media_info.audio_sample_rate.as_deref().unwrap_or(""); + let bits = media_info.audio_bits.as_deref().unwrap_or(""); + format!("{codec} - {channels} - {bitrate} - {sample_rate} - {bits}") + }) + .unwrap_or_default(); + + Row::new(vec![ + Cell::from(path.clone()), + Cell::from(size), + Cell::from(quality.quality.name.clone()), + Cell::from(audio_info), + ]) + .primary() + }; + + let book_files_table = ManagarrTable::new(content, book_file_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .headers(["Path", "Size", "Quality", "Media Info"]) + .constraints([ + Constraint::Percentage(45), + Constraint::Percentage(11), + Constraint::Percentage(14), + Constraint::Percentage(30), + ]); + + f.render_widget(book_files_table, area); + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.readarr_data.book_details_modal.is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_book_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.readarr_data.book_details_modal.as_ref() { + Some(book_details_modal) if !app.is_loading => { + let current_selection = if book_details_modal.book_history.is_empty() { + ReadarrHistoryItem::default() + } else { + book_details_modal.book_history.current_selection().clone() + }; + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let history_row_mapping = |history_item: &ReadarrHistoryItem| { + let ReadarrHistoryItem { + source_title, + quality, + event_type, + date, + .. + } = history_item; + + source_title.scroll_left_or_reset( + get_width_from_percentage(area, 40), + current_selection == *history_item, + app.should_text_scroll, + ); + + Row::new(vec![ + Cell::from(source_title.to_string()), + Cell::from(event_type.to_string()), + Cell::from(quality.quality.name.to_owned()), + Cell::from(date.to_string()), + ]) + .primary() + }; + let mut book_history_table = &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_history; + let history_table = ManagarrTable::new(Some(&mut book_history_table), history_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_readarr_block == ActiveReadarrBlock::BookHistorySortPrompt) + .searching(active_readarr_block == ActiveReadarrBlock::SearchBookHistory) + .search_produced_empty_results( + active_readarr_block == ActiveReadarrBlock::SearchBookHistoryError, + ) + .filtering(active_readarr_block == ActiveReadarrBlock::FilterBookHistory) + .filter_produced_empty_results( + active_readarr_block == ActiveReadarrBlock::FilterBookHistoryError, + ) + .headers(["Source Title", "Event Type", "Quality", "Date"]) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(20), + Constraint::Percentage(15), + Constraint::Percentage(25), + ]); + + if [ + ActiveReadarrBlock::SearchBookHistory, + ActiveReadarrBlock::FilterBookHistory, + ] + .contains(&active_readarr_block) + { + history_table.show_cursor(f, area); + } + + f.render_widget(history_table, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.readarr_data.book_details_modal.is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_book_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + match app.data.readarr_data.book_details_modal.as_ref() { + Some(book_details_modal) if !app.is_loading => { + let (current_selection, is_empty) = if book_details_modal.book_releases.is_empty() { + (ReadarrRelease::default(), true) + } else { + ( + book_details_modal.book_releases.current_selection().clone(), + book_details_modal.book_releases.is_empty(), + ) + }; + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let book_release_row_mapping = |release: &ReadarrRelease| { + let ReadarrRelease { + protocol, + age, + title, + indexer, + size, + rejected, + seeders, + leechers, + quality, + .. + } = release; + + let age = format!("{age} days"); + title.scroll_left_or_reset( + get_width_from_percentage(area, 35), + current_selection == *release + && active_readarr_block != ActiveReadarrBlock::ManualBookSearchConfirmPrompt, + app.should_text_scroll, + ); + let size = format_size(*size, 1); + let rejected_str = if *rejected { "⛔" } else { "" }; + let peers = if seeders.is_none() || leechers.is_none() { + Text::from("") + } else { + let seeders = seeders + .clone() + .unwrap_or(Number::from(0u64)) + .as_u64() + .unwrap_or_default(); + let leechers = leechers + .clone() + .unwrap_or(Number::from(0u64)) + .as_u64() + .unwrap_or_default(); + + decorate_peer_style( + seeders, + leechers, + Text::from(format!("{seeders} / {leechers}")), + ) + }; + + let quality_name = quality.quality.name.clone(); + + Row::new(vec![ + Cell::from(protocol.clone()), + Cell::from(age), + Cell::from(rejected_str), + Cell::from(title.to_string()), + Cell::from(indexer.clone()), + Cell::from(size), + Cell::from(peers), + Cell::from(quality_name), + ]) + .primary() + }; + let mut book_release_table = &mut app + .data + .readarr_data + .book_details_modal + .as_mut() + .expect("Book details modal is undefined") + .book_releases; + let release_table = + ManagarrTable::new(Some(&mut book_release_table), book_release_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading || is_empty) + .sorting(active_readarr_block == ActiveReadarrBlock::ManualBookSearchSortPrompt) + .headers([ + "Source", "Age", "⛔", "Title", "Indexer", "Size", "Peers", "Quality", + ]) + .constraints([ + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(5), + Constraint::Percentage(35), + Constraint::Percentage(15), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Percentage(10), + ]); + + f.render_widget(release_table, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.readarr_data.book_details_modal.is_none(), + layout_block_top_border(), + ), + area, + ), + } +} + +fn draw_manual_book_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = app + .data + .readarr_data + .book_details_modal + .as_ref() + .expect("Book details modal is undefined") + .book_releases + .current_selection(); + let title = if current_selection.rejected { + "Download Rejected Release" + } else { + "Download Release" + }; + let prompt = if current_selection.rejected { + format!( + "Do you really want to download the rejected release: {}?", + current_selection.title.text + ) + } else { + format!( + "Do you want to download the release: {}?", + current_selection.title.text + ) + }; + + if current_selection.rejected { + let mut lines_vec = vec![Line::from("Rejection reasons: ".primary().bold())]; + let mut rejections_spans = current_selection + .rejections + .clone() + .unwrap_or_default() + .iter() + .map(|item| Line::from(format!("• {item}").primary().bold())) + .collect::>>(); + lines_vec.append(&mut rejections_spans); + + let content_paragraph = Paragraph::new(lines_vec) + .block(borderless_block()) + .wrap(Wrap { trim: false }) + .left_aligned(); + let confirmation_prompt = ConfirmationPrompt::new() + .title(title) + .prompt(&prompt) + .content(content_paragraph) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget(Popup::new(confirmation_prompt).size(Size::Small), f.area()); + } else { + let confirmation_prompt = ConfirmationPrompt::new() + .title(title) + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } +} + +fn draw_history_item_details_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let current_selection = + if let Some(book_details_modal) = app.data.readarr_data.book_details_modal.as_ref() { + if book_details_modal.book_history.is_empty() { + ReadarrHistoryItem::default() + } else { + book_details_modal.book_history.current_selection().clone() + } + } else { + ReadarrHistoryItem::default() + }; + + let line_vec = create_history_event_details(current_selection); + let text = Text::from(line_vec); + + let message = Message::new(text) + .title("Details") + .style(secondary_style()) + .alignment(Alignment::Left); + + f.render_widget(Popup::new(message).size(Size::NarrowLongMessage), f.area()); +} diff --git a/src/ui/readarr_ui/library/book_details_ui_tests.rs b/src/ui/readarr_ui/library/book_details_ui_tests.rs new file mode 100644 index 00000000..8ed9a024 --- /dev/null +++ b/src/ui/readarr_ui/library/book_details_ui_tests.rs @@ -0,0 +1,257 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::readarr_models::Edition; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, EDITION_DETAILS_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::book_details_ui::{ + BookDetailsUi, decorate_edition_row_with_style, + }; + use crate::ui::styles::ManagarrStyle; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_book_details_ui_accepts() { + let mut book_details_blocks = BOOK_DETAILS_BLOCKS.to_vec(); + book_details_blocks.extend(EDITION_DETAILS_BLOCKS); + + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if book_details_blocks.contains(&active_readarr_block) { + assert!(BookDetailsUi::accepts(active_readarr_block.into())); + } else { + assert!(!BookDetailsUi::accepts(active_readarr_block.into())); + } + }); + } + + #[test] + fn test_decorate_edition_row_with_style_monitored() { + let edition = Edition { + monitored: true, + ..Edition::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_edition_row_with_style(&edition, row.clone()); + + assert_eq!(style, row.primary()); + } + + #[test] + fn test_decorate_edition_row_with_style_unmonitored() { + let edition = Edition { + monitored: false, + ..Edition::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_edition_row_with_style(&edition, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + + mod snapshot_tests { + use crate::models::stateful_table::StatefulTable; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + use rstest::rstest; + + use super::*; + + #[rstest] + #[case(ActiveReadarrBlock::BookDetails, 0)] + #[case(ActiveReadarrBlock::SearchEditions, 0)] + #[case(ActiveReadarrBlock::SearchEditionsError, 0)] + #[case(ActiveReadarrBlock::AutomaticallySearchBookPrompt, 0)] + #[case(ActiveReadarrBlock::AutomaticallySearchBookPrompt, 1)] + #[case(ActiveReadarrBlock::AutomaticallySearchBookPrompt, 2)] + #[case(ActiveReadarrBlock::AutomaticallySearchBookPrompt, 3)] + #[case(ActiveReadarrBlock::DeleteBookFilePrompt, 0)] + #[case(ActiveReadarrBlock::DeleteBookFilePrompt, 2)] + #[case(ActiveReadarrBlock::BookHistory, 1)] + #[case(ActiveReadarrBlock::SearchBookHistory, 1)] + #[case(ActiveReadarrBlock::SearchBookHistoryError, 1)] + #[case(ActiveReadarrBlock::FilterBookHistory, 1)] + #[case(ActiveReadarrBlock::FilterBookHistoryError, 1)] + #[case(ActiveReadarrBlock::BookHistorySortPrompt, 1)] + #[case(ActiveReadarrBlock::BookHistoryDetails, 1)] + #[case(ActiveReadarrBlock::BookFileInfo, 2)] + #[case(ActiveReadarrBlock::ManualBookSearch, 3)] + #[case(ActiveReadarrBlock::ManualBookSearchConfirmPrompt, 3)] + #[case(ActiveReadarrBlock::ManualBookSearchSortPrompt, 3)] + fn test_book_details_ui_renders( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .book_details_tabs + .set_index(index); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("book_details_renders_{active_readarr_block}_{index}"), + output + ); + } + + #[rstest] + #[case(ActiveReadarrBlock::BookDetails, 0)] + #[case(ActiveReadarrBlock::BookHistory, 1)] + #[case(ActiveReadarrBlock::BookFileInfo, 2)] + #[case(ActiveReadarrBlock::ManualBookSearch, 3)] + fn test_book_details_ui_renders_loading( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(active_readarr_block.into()); + { + let book_details_modal = app.data.readarr_data.book_details_modal.as_mut().unwrap(); + book_details_modal.editions = StatefulTable::default(); + book_details_modal.book_files = StatefulTable::default(); + book_details_modal.book_history = StatefulTable::default(); + book_details_modal.book_releases = StatefulTable::default(); + book_details_modal.book_details_tabs.set_index(index); + } + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("loading_book_details_{active_readarr_block}_{index}"), + output + ); + } + + #[rstest] + #[case(ActiveReadarrBlock::BookDetails, 0)] + #[case(ActiveReadarrBlock::BookHistory, 1)] + #[case(ActiveReadarrBlock::BookFileInfo, 2)] + #[case(ActiveReadarrBlock::ManualBookSearch, 3)] + fn test_book_details_ui_renders_empty( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + { + let book_details_modal = app.data.readarr_data.book_details_modal.as_mut().unwrap(); + book_details_modal.editions = StatefulTable::default(); + book_details_modal.book_files = StatefulTable::default(); + book_details_modal.book_history = StatefulTable::default(); + book_details_modal.book_releases = StatefulTable::default(); + book_details_modal.book_details_tabs.set_index(index); + } + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("empty_book_details_{active_readarr_block}_{index}"), + output + ); + } + + #[test] + fn test_book_details_ui_renders_editions_table_with_zero_editions() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .editions = StatefulTable::default(); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_book_details_ui_renders_editions_table_with_a_single_edition() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_book_details_ui_renders_editions_table_with_an_unmonitored_edition() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .editions + .set_items(vec![Edition { + monitored: false, + format: Some("Paperback".to_owned()), + isbn13: Some("9780756404741".to_owned()), + asin: Some("B0043RSJ9S".to_owned()), + publisher: Some("DAW Books".to_owned()), + page_count: Some(662), + ..Edition::default() + }]); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_book_details_ui_renders_nothing_when_the_book_details_modal_is_undefined() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookDetails.into()); + app.data.readarr_data.book_details_modal = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_book_details_ui_renders_edition_details_over_book_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + BookDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/library/delete_author_ui.rs b/src/ui/readarr_ui/library/delete_author_ui.rs new file mode 100644 index 00000000..791ce49d --- /dev/null +++ b/src/ui/readarr_ui/library/delete_author_ui.rs @@ -0,0 +1,85 @@ +use ratatui::Frame; +use ratatui::layout::Rect; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_AUTHOR_BLOCKS, +}; +use crate::ui::DrawUi; +use crate::ui::widgets::checkbox::Checkbox; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::popup::{Popup, Size}; + +#[cfg(test)] +#[path = "delete_author_ui_tests.rs"] +mod delete_author_ui_tests; + +pub(super) struct DeleteAuthorUi; + +impl DrawUi for DeleteAuthorUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + DELETE_AUTHOR_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::DeleteAuthorPrompt, _) + ) { + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = delete_author_prompt_highlights(selected_block); + let prompt = format!( + "Do you really want to delete the author: \n{}?", + app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text + ); + let checkboxes = vec![ + Checkbox::new("Delete Author Files") + .checked(app.data.readarr_data.delete_files) + .highlighted(highlights.delete_files), + Checkbox::new("Add List Exclusion") + .checked(app.data.readarr_data.add_import_list_exclusion) + .highlighted(highlights.add_import_list_exclusion), + ]; + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Author") + .prompt(&prompt) + .checkboxes(checkboxes) + .yes_no_highlighted(highlights.confirm) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct DeleteAuthorPromptHighlights { + delete_files: bool, + add_import_list_exclusion: bool, + confirm: bool, +} + +fn delete_author_prompt_highlights( + selected_block: ActiveReadarrBlock, +) -> DeleteAuthorPromptHighlights { + DeleteAuthorPromptHighlights { + delete_files: selected_block == ActiveReadarrBlock::DeleteAuthorToggleDeleteFile, + add_import_list_exclusion: selected_block + == ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion, + confirm: selected_block == ActiveReadarrBlock::DeleteAuthorConfirmPrompt, + } +} diff --git a/src/ui/readarr_ui/library/delete_author_ui_tests.rs b/src/ui/readarr_ui/library/delete_author_ui_tests.rs new file mode 100644 index 00000000..dbcb682e --- /dev/null +++ b/src/ui/readarr_ui/library/delete_author_ui_tests.rs @@ -0,0 +1,155 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_AUTHOR_BLOCKS, DELETE_AUTHOR_SELECTION_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::delete_author_ui::{ + DeleteAuthorPromptHighlights, DeleteAuthorUi, delete_author_prompt_highlights, + }; + + #[test] + fn test_delete_author_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if DELETE_AUTHOR_BLOCKS.contains(&active_readarr_block) { + assert!(DeleteAuthorUi::accepts(active_readarr_block.into())); + } else { + assert!(!DeleteAuthorUi::accepts(active_readarr_block.into())); + } + }); + } + + #[rstest] + #[case( + ActiveReadarrBlock::DeleteAuthorToggleDeleteFile, + DeleteAuthorPromptHighlights { + delete_files: true, + add_import_list_exclusion: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion, + DeleteAuthorPromptHighlights { + delete_files: false, + add_import_list_exclusion: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::DeleteAuthorConfirmPrompt, + DeleteAuthorPromptHighlights { + delete_files: false, + add_import_list_exclusion: false, + confirm: true, + } + )] + #[case( + ActiveReadarrBlock::Authors, + DeleteAuthorPromptHighlights { + delete_files: false, + add_import_list_exclusion: false, + confirm: false, + } + )] + fn test_delete_author_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: DeleteAuthorPromptHighlights, + ) { + let highlights = delete_author_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + #[test] + fn test_delete_author_prompt_highlights_covers_every_selection_step() { + for step in DELETE_AUTHOR_SELECTION_BLOCKS { + let highlights = delete_author_prompt_highlights(step[0]); + + assert_ne!( + highlights, + delete_author_prompt_highlights(ActiveReadarrBlock::Authors) + ); + } + } + + mod snapshot_tests { + use rstest::rstest; + + use super::*; + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + fn seed_distinct_delete_preferences(app: &mut App<'_>) { + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + } + + #[rstest] + #[case(0, ActiveReadarrBlock::DeleteAuthorToggleDeleteFile)] + #[case(1, ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion)] + #[case(2, ActiveReadarrBlock::DeleteAuthorConfirmPrompt)] + fn test_delete_author_ui_renders_step( + #[case] step: usize, + #[case] selected_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + seed_distinct_delete_preferences(&mut app); + let mut selected_block = BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DeleteAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("delete_author_step_{step}_{selected_readarr_block}"), + output + ); + } + + #[test] + fn test_delete_author_ui_renders_inverted_delete_preferences() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + app.data.readarr_data.delete_files = false; + app.data.readarr_data.add_import_list_exclusion = true; + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DeleteAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + #[case(ActiveReadarrBlock::DeleteAuthorConfirmPrompt)] + #[case(ActiveReadarrBlock::DeleteAuthorToggleDeleteFile)] + #[case(ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion)] + fn test_delete_author_ui_renders_nothing_when_route_is_not_the_prompt( + #[case] active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + seed_distinct_delete_preferences(&mut app); + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DeleteAuthorUi::draw(f, app, f.area()); + }); + + assert!(!output.contains("Delete Author")); + assert!(!output.contains("Add List Exclusion")); + } + } +} diff --git a/src/ui/readarr_ui/library/delete_book_ui.rs b/src/ui/readarr_ui/library/delete_book_ui.rs new file mode 100644 index 00000000..799e1b57 --- /dev/null +++ b/src/ui/readarr_ui/library/delete_book_ui.rs @@ -0,0 +1,75 @@ +use ratatui::Frame; +use ratatui::layout::Rect; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DELETE_BOOK_BLOCKS}; +use crate::ui::DrawUi; +use crate::ui::widgets::checkbox::Checkbox; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::popup::{Popup, Size}; + +#[cfg(test)] +#[path = "delete_book_ui_tests.rs"] +mod delete_book_ui_tests; + +pub(super) struct DeleteBookUi; + +impl DrawUi for DeleteBookUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + DELETE_BOOK_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::DeleteBookPrompt, _) + ) { + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = delete_book_prompt_highlights(selected_block); + let prompt = format!( + "Do you really want to delete the book: \n{}?", + app.data.readarr_data.books.current_selection().title.text + ); + let checkboxes = vec![ + Checkbox::new("Delete Book Files") + .checked(app.data.readarr_data.delete_files) + .highlighted(highlights.delete_files), + Checkbox::new("Add List Exclusion") + .checked(app.data.readarr_data.add_import_list_exclusion) + .highlighted(highlights.add_import_list_exclusion), + ]; + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Book") + .prompt(&prompt) + .checkboxes(checkboxes) + .yes_no_highlighted(highlights.confirm) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct DeleteBookPromptHighlights { + delete_files: bool, + add_import_list_exclusion: bool, + confirm: bool, +} + +fn delete_book_prompt_highlights(selected_block: ActiveReadarrBlock) -> DeleteBookPromptHighlights { + DeleteBookPromptHighlights { + delete_files: selected_block == ActiveReadarrBlock::DeleteBookToggleDeleteFile, + add_import_list_exclusion: selected_block + == ActiveReadarrBlock::DeleteBookToggleAddListExclusion, + confirm: selected_block == ActiveReadarrBlock::DeleteBookConfirmPrompt, + } +} diff --git a/src/ui/readarr_ui/library/delete_book_ui_tests.rs b/src/ui/readarr_ui/library/delete_book_ui_tests.rs new file mode 100644 index 00000000..a1dea0f1 --- /dev/null +++ b/src/ui/readarr_ui/library/delete_book_ui_tests.rs @@ -0,0 +1,153 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_BOOK_BLOCKS, DELETE_BOOK_SELECTION_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::delete_book_ui::{ + DeleteBookPromptHighlights, DeleteBookUi, delete_book_prompt_highlights, + }; + + #[test] + fn test_delete_book_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if DELETE_BOOK_BLOCKS.contains(&active_readarr_block) { + assert!(DeleteBookUi::accepts(active_readarr_block.into())); + } else { + assert!(!DeleteBookUi::accepts(active_readarr_block.into())); + } + }); + } + + #[rstest] + #[case( + ActiveReadarrBlock::DeleteBookToggleDeleteFile, + DeleteBookPromptHighlights { + delete_files: true, + add_import_list_exclusion: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::DeleteBookToggleAddListExclusion, + DeleteBookPromptHighlights { + delete_files: false, + add_import_list_exclusion: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::DeleteBookConfirmPrompt, + DeleteBookPromptHighlights { + delete_files: false, + add_import_list_exclusion: false, + confirm: true, + } + )] + #[case( + ActiveReadarrBlock::BookDetails, + DeleteBookPromptHighlights { + delete_files: false, + add_import_list_exclusion: false, + confirm: false, + } + )] + fn test_delete_book_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: DeleteBookPromptHighlights, + ) { + let highlights = delete_book_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + #[test] + fn test_delete_book_prompt_highlights_covers_every_selection_step() { + for step in DELETE_BOOK_SELECTION_BLOCKS { + let highlights = delete_book_prompt_highlights(step[0]); + + assert_ne!( + highlights, + delete_book_prompt_highlights(ActiveReadarrBlock::BookDetails) + ); + } + } + + mod snapshot_tests { + use rstest::rstest; + + use super::*; + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + fn seed_distinct_delete_preferences(app: &mut App<'_>) { + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + } + + #[rstest] + #[case(0, ActiveReadarrBlock::DeleteBookToggleDeleteFile)] + #[case(1, ActiveReadarrBlock::DeleteBookToggleAddListExclusion)] + #[case(2, ActiveReadarrBlock::DeleteBookConfirmPrompt)] + fn test_delete_book_ui_renders_step( + #[case] step: usize, + #[case] selected_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + seed_distinct_delete_preferences(&mut app); + let mut selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DeleteBookUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("delete_book_step_{step}_{selected_readarr_block}"), + output + ); + } + + #[test] + fn test_delete_book_ui_renders_inverted_delete_preferences() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteBookPrompt.into()); + app.data.readarr_data.delete_files = false; + app.data.readarr_data.add_import_list_exclusion = true; + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DeleteBookUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + #[case(ActiveReadarrBlock::DeleteBookConfirmPrompt)] + #[case(ActiveReadarrBlock::DeleteBookToggleDeleteFile)] + #[case(ActiveReadarrBlock::DeleteBookToggleAddListExclusion)] + fn test_delete_book_ui_renders_nothing_when_route_is_not_the_prompt( + #[case] active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + seed_distinct_delete_preferences(&mut app); + app.data.readarr_data.selected_block = BlockSelectionState::new(DELETE_BOOK_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + DeleteBookUi::draw(f, app, f.area()); + }); + + assert!(!output.contains("Delete Book")); + assert!(!output.contains("Add List Exclusion")); + } + } +} diff --git a/src/ui/readarr_ui/library/edit_author_ui.rs b/src/ui/readarr_ui/library/edit_author_ui.rs new file mode 100644 index 00000000..b26c0031 --- /dev/null +++ b/src/ui/readarr_ui/library/edit_author_ui.rs @@ -0,0 +1,270 @@ +use std::sync::atomic::Ordering; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::prelude::Layout; +use ratatui::widgets::ListItem; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::modals::EditAuthorModal; +use crate::models::servarr_data::readarr::readarr_data::{ + AUTHOR_DETAILS_BLOCKS, ActiveReadarrBlock, EDIT_AUTHOR_BLOCKS, +}; +use crate::render_selectable_input_box; + +use crate::ui::readarr_ui::library::author_details_ui::AuthorDetailsUi; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::button::Button; +use crate::ui::widgets::checkbox::Checkbox; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::widgets::selectable_list::SelectableList; +use crate::ui::{DrawUi, draw_popup}; + +#[cfg(test)] +#[path = "edit_author_ui_tests.rs"] +mod edit_author_ui_tests; + +pub(super) struct EditAuthorUi; + +impl DrawUi for EditAuthorUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + EDIT_AUTHOR_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if let Route::Readarr(active_readarr_block, context_option) = app.get_current_route() { + if let Some(context) = context_option + && AUTHOR_DETAILS_BLOCKS.contains(&context) + { + draw_popup(f, app, AuthorDetailsUi::draw, Size::Large); + } + + let draw_edit_author_prompt = |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| { + draw_edit_author_confirmation_prompt(f, app, prompt_area); + + match active_readarr_block { + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems => { + draw_edit_author_select_monitor_new_items_popup(f, app); + } + ActiveReadarrBlock::EditAuthorSelectQualityProfile => { + draw_edit_author_select_quality_profile_popup(f, app); + } + ActiveReadarrBlock::EditAuthorSelectMetadataProfile => { + draw_edit_author_select_metadata_profile_popup(f, app); + } + _ => (), + } + }; + + draw_popup(f, app, draw_edit_author_prompt, Size::Long); + } + } +} + +fn build_edit_author_prompt_title(author_name: &str, disambiguation: &str) -> String { + if disambiguation.is_empty() { + format!("Edit - {author_name}") + } else { + format!("Edit - {author_name} ({disambiguation})") + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct EditAuthorPromptHighlights { + monitored: bool, + monitor_new_items: bool, + quality_profile: bool, + metadata_profile: bool, + path: bool, + tags: bool, + confirm: bool, +} + +fn edit_author_prompt_highlights(selected_block: ActiveReadarrBlock) -> EditAuthorPromptHighlights { + EditAuthorPromptHighlights { + monitored: selected_block == ActiveReadarrBlock::EditAuthorToggleMonitored, + monitor_new_items: selected_block == ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + quality_profile: selected_block == ActiveReadarrBlock::EditAuthorSelectQualityProfile, + metadata_profile: selected_block == ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + path: selected_block == ActiveReadarrBlock::EditAuthorPathInput, + tags: selected_block == ActiveReadarrBlock::EditAuthorTagsInput, + confirm: selected_block == ActiveReadarrBlock::EditAuthorConfirmPrompt, + } +} + +fn draw_edit_author_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let author_name = app + .data + .readarr_data + .authors + .current_selection() + .author_name + .text + .clone(); + let author_disambiguation = app + .data + .readarr_data + .authors + .current_selection() + .disambiguation + .clone() + .unwrap_or_default(); + let title = build_edit_author_prompt_title(&author_name, &author_disambiguation); + f.render_widget(title_block_centered(&title), area); + + let yes_no_value = app.data.readarr_data.prompt_confirm; + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = edit_author_prompt_highlights(selected_block); + let EditAuthorModal { + monitor_list, + quality_profile_list, + metadata_profile_list, + monitored, + path, + tags, + } = app + .data + .readarr_data + .edit_author_modal + .as_ref() + .expect("edit_author_modal must exist in this context"); + let selected_monitor_new_items = monitor_list.current_selection(); + let selected_quality_profile = quality_profile_list.current_selection(); + let selected_metadata_profile = metadata_profile_list.current_selection(); + + let [ + _, + monitored_area, + monitor_new_items_area, + quality_profile_area, + metadata_profile_area, + path_area, + tags_area, + _, + buttons_area, + ] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(3), + ]) + .margin(1) + .areas(area); + let [save_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(buttons_area); + + let monitored_checkbox = Checkbox::new("Monitored") + .checked(monitored.unwrap_or_default()) + .highlighted(highlights.monitored); + let monitor_new_items_drop_down_button = Button::default() + .title(selected_monitor_new_items.to_display_str()) + .label("Monitor New Books") + .icon("▼") + .selected(highlights.monitor_new_items); + let quality_profile_drop_down_button = Button::default() + .title(selected_quality_profile) + .label("Quality Profile") + .icon("▼") + .selected(highlights.quality_profile); + let metadata_profile_drop_down_button = Button::default() + .title(selected_metadata_profile) + .label("Metadata Profile") + .icon("▼") + .selected(highlights.metadata_profile); + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let path_input_box = InputBox::new(&path.text) + .offset(path.offset.load(Ordering::SeqCst)) + .label("Path") + .highlighted(highlights.path) + .selected(active_readarr_block == ActiveReadarrBlock::EditAuthorPathInput); + let tags_input_box = InputBox::new(&tags.text) + .offset(tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(highlights.tags) + .selected(active_readarr_block == ActiveReadarrBlock::EditAuthorTagsInput); + + match active_readarr_block { + ActiveReadarrBlock::EditAuthorPathInput => path_input_box.show_cursor(f, path_area), + ActiveReadarrBlock::EditAuthorTagsInput => tags_input_box.show_cursor(f, tags_area), + _ => (), + } + + render_selectable_input_box!(path_input_box, f, path_area); + render_selectable_input_box!(tags_input_box, f, tags_area); + } + + let save_button = Button::default() + .title("Save") + .selected(yes_no_value && highlights.confirm); + let cancel_button = Button::default() + .title("Cancel") + .selected(!yes_no_value && highlights.confirm); + + f.render_widget(monitored_checkbox, monitored_area); + f.render_widget(monitor_new_items_drop_down_button, monitor_new_items_area); + f.render_widget(quality_profile_drop_down_button, quality_profile_area); + f.render_widget(metadata_profile_drop_down_button, metadata_profile_area); + f.render_widget(save_button, save_area); + f.render_widget(cancel_button, cancel_area); +} + +fn draw_edit_author_select_monitor_new_items_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_list = SelectableList::new( + &mut app + .data + .readarr_data + .edit_author_modal + .as_mut() + .expect("edit_author_modal must exist in this context") + .monitor_list, + |monitor_type| ListItem::new(monitor_type.to_display_str().to_owned()), + ); + let popup = Popup::new(monitor_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_edit_author_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let quality_profile_list = SelectableList::new( + &mut app + .data + .readarr_data + .edit_author_modal + .as_mut() + .expect("edit_author_modal must exist in this context") + .quality_profile_list, + |quality_profile| ListItem::new(quality_profile.clone()), + ); + let popup = Popup::new(quality_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_edit_author_select_metadata_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let metadata_profile_list = SelectableList::new( + &mut app + .data + .readarr_data + .edit_author_modal + .as_mut() + .expect("edit_author_modal must exist in this context") + .metadata_profile_list, + |metadata_profile| ListItem::new(metadata_profile.clone()), + ); + let popup = Popup::new(metadata_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} diff --git a/src/ui/readarr_ui/library/edit_author_ui_tests.rs b/src/ui/readarr_ui/library/edit_author_ui_tests.rs new file mode 100644 index 00000000..7343d9b2 --- /dev/null +++ b/src/ui/readarr_ui/library/edit_author_ui_tests.rs @@ -0,0 +1,266 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use pretty_assertions::assert_str_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDIT_AUTHOR_BLOCKS, EDIT_AUTHOR_SELECTION_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::edit_author_ui::{ + EditAuthorPromptHighlights, EditAuthorUi, build_edit_author_prompt_title, + edit_author_prompt_highlights, + }; + + #[test] + fn test_edit_author_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if EDIT_AUTHOR_BLOCKS.contains(&active_readarr_block) { + assert!(EditAuthorUi::accepts(active_readarr_block.into())); + } else { + assert!(!EditAuthorUi::accepts(active_readarr_block.into())); + } + }); + } + + #[test] + fn test_build_edit_author_prompt_title_without_disambiguation() { + let title = build_edit_author_prompt_title("Test Author", ""); + + assert_str_eq!(title, "Edit - Test Author"); + } + + #[test] + fn test_build_edit_author_prompt_title_with_disambiguation() { + let title = build_edit_author_prompt_title("Test Author", "American novelist"); + + assert_str_eq!(title, "Edit - Test Author (American novelist)"); + } + + #[rstest] + #[case( + ActiveReadarrBlock::EditAuthorToggleMonitored, + EditAuthorPromptHighlights { + monitored: true, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + path: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditAuthorSelectMonitorNewItems, + EditAuthorPromptHighlights { + monitored: false, + monitor_new_items: true, + quality_profile: false, + metadata_profile: false, + path: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditAuthorSelectQualityProfile, + EditAuthorPromptHighlights { + monitored: false, + monitor_new_items: false, + quality_profile: true, + metadata_profile: false, + path: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditAuthorSelectMetadataProfile, + EditAuthorPromptHighlights { + monitored: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: true, + path: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditAuthorPathInput, + EditAuthorPromptHighlights { + monitored: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + path: true, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditAuthorTagsInput, + EditAuthorPromptHighlights { + monitored: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + path: false, + tags: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::EditAuthorConfirmPrompt, + EditAuthorPromptHighlights { + monitored: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + path: false, + tags: false, + confirm: true, + } + )] + fn test_edit_author_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: EditAuthorPromptHighlights, + ) { + let highlights = edit_author_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + #[test] + fn test_edit_author_prompt_highlights_covers_every_selection_step() { + for step in EDIT_AUTHOR_SELECTION_BLOCKS { + let highlights = edit_author_prompt_highlights(step[0]); + + assert_ne!( + highlights, + edit_author_prompt_highlights(ActiveReadarrBlock::Authors) + ); + } + } + + mod snapshot_tests { + use rstest::rstest; + + use super::*; + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + fn seed_distinct_profile_lists(app: &mut App<'_>) { + let edit_author_modal = app + .data + .readarr_data + .edit_author_modal + .as_mut() + .expect("edit_author_modal must exist in this context"); + edit_author_modal + .quality_profile_list + .set_items(vec!["eBook".to_owned()]); + edit_author_modal + .metadata_profile_list + .set_items(vec!["Author Only".to_owned()]); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::EditAuthorToggleMonitored)] + #[case(1, ActiveReadarrBlock::EditAuthorSelectMonitorNewItems)] + #[case(2, ActiveReadarrBlock::EditAuthorSelectQualityProfile)] + #[case(3, ActiveReadarrBlock::EditAuthorSelectMetadataProfile)] + #[case(4, ActiveReadarrBlock::EditAuthorPathInput)] + #[case(5, ActiveReadarrBlock::EditAuthorTagsInput)] + #[case(6, ActiveReadarrBlock::EditAuthorConfirmPrompt)] + fn test_edit_author_ui_renders_step( + #[case] step: usize, + #[case] selected_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + seed_distinct_profile_lists(&mut app); + let mut selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("edit_author_step_{step}_{selected_readarr_block}"), + output + ); + } + + #[rstest] + #[case(1, ActiveReadarrBlock::EditAuthorSelectMonitorNewItems)] + #[case(2, ActiveReadarrBlock::EditAuthorSelectQualityProfile)] + #[case(3, ActiveReadarrBlock::EditAuthorSelectMetadataProfile)] + #[case(4, ActiveReadarrBlock::EditAuthorPathInput)] + #[case(5, ActiveReadarrBlock::EditAuthorTagsInput)] + fn test_edit_author_ui_renders_active_block( + #[case] step: usize, + #[case] active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + seed_distinct_profile_lists(&mut app); + let mut selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("edit_author_active_{active_readarr_block}"), output); + } + + #[test] + fn test_edit_author_ui_renders_unmonitored_author() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + seed_distinct_profile_lists(&mut app); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app + .data + .readarr_data + .edit_author_modal + .as_mut() + .expect("edit_author_modal must exist in this context") + .monitored = Some(false); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edit_author_ui_renders_over_author_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + seed_distinct_profile_lists(&mut app); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.push_navigation_stack( + ( + ActiveReadarrBlock::EditAuthorPrompt, + Some(ActiveReadarrBlock::AuthorDetails), + ) + .into(), + ); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditAuthorUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/library/edition_details_ui.rs b/src/ui/readarr_ui/library/edition_details_ui.rs new file mode 100644 index 00000000..e2bdbb33 --- /dev/null +++ b/src/ui/readarr_ui/library/edition_details_ui.rs @@ -0,0 +1,105 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::Edition; +use crate::models::servarr_data::readarr::readarr_data::EDITION_DETAILS_BLOCKS; +use crate::ui::styles::{primary_style, unmonitored_style}; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{DrawUi, draw_popup}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Paragraph, Wrap}; + +#[cfg(test)] +#[path = "edition_details_ui_tests.rs"] +mod edition_details_ui_tests; + +pub(super) struct EditionDetailsUi; + +impl DrawUi for EditionDetailsUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + EDITION_DETAILS_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if let Some(book_details_modal) = app.data.readarr_data.book_details_modal.as_ref() + && book_details_modal.edition_details_modal.is_some() + { + let draw_edition_details_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + draw_edition_details(f, app, popup_area); + }; + + draw_popup(f, app, draw_edition_details_popup, Size::Large); + } + } +} + +fn draw_edition_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block_centered("Edition Details"); + + match app.data.readarr_data.book_details_modal.as_ref() { + Some(book_details_modal) if !app.is_loading => { + if let Some(edition_details_modal) = book_details_modal.edition_details_modal.as_ref() { + let style = if book_details_modal.editions.is_empty() { + primary_style() + } else { + style_from_edition(book_details_modal.editions.current_selection()) + }; + let edition_details = &edition_details_modal.edition_details; + let text = Text::from( + edition_details + .items + .iter() + .map(|line| edition_detail_line(line, style)) + .collect::>>(), + ); + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((edition_details.offset, 0)); + + f.render_widget(paragraph, area); + } + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading + || app + .data + .readarr_data + .book_details_modal + .as_ref() + .is_none_or(|book_details_modal| book_details_modal.edition_details_modal.is_none()), + block, + ), + area, + ), + } +} + +fn style_from_edition(edition: &Edition) -> Style { + if !edition.monitored { + return unmonitored_style(); + } + + primary_style() +} + +fn edition_detail_line(line: &str, style: Style) -> Line<'static> { + let line = line.trim_end(); + + match line.split_once(':') { + Some((label, value)) => Line::from(vec![ + Span::styled(format!("{label}:"), style.bold()), + Span::styled(value.to_owned(), style), + ]), + None => Line::from(Span::styled(line.to_owned(), style)), + } +} diff --git a/src/ui/readarr_ui/library/edition_details_ui_tests.rs b/src/ui/readarr_ui/library/edition_details_ui_tests.rs new file mode 100644 index 00000000..86343e50 --- /dev/null +++ b/src/ui/readarr_ui/library/edition_details_ui_tests.rs @@ -0,0 +1,247 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use ratatui::style::Modifier; + use ratatui::text::Line; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::readarr_models::Edition; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, EDITION_DETAILS_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::edition_details_ui::{ + EditionDetailsUi, edition_detail_line, style_from_edition, + }; + use crate::ui::styles::{primary_style, unmonitored_style}; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_edition_details_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if EDITION_DETAILS_BLOCKS.contains(&active_readarr_block) { + assert!(EditionDetailsUi::accepts(active_readarr_block.into())); + } else { + assert!(!EditionDetailsUi::accepts(active_readarr_block.into())); + } + }); + } + + #[test] + fn test_style_from_edition_monitored() { + let edition = Edition { + monitored: true, + ..Edition::default() + }; + + let style = style_from_edition(&edition); + + assert_eq!(style, primary_style()); + } + + #[test] + fn test_style_from_edition_unmonitored() { + let edition = Edition { + monitored: false, + ..Edition::default() + }; + + let style = style_from_edition(&edition); + + assert_eq!(style, unmonitored_style()); + } + + #[test] + fn test_edition_detail_line_splits_a_label_from_its_value() { + let line = edition_detail_line("Title: Test Edition", primary_style()); + + assert_eq!(span_contents(&line), vec!["Title:", " Test Edition"]); + } + + #[test] + fn test_edition_detail_line_renders_the_label_in_bold() { + let line = edition_detail_line("Title: Test Edition", unmonitored_style()); + + assert!(line.spans[0].style.add_modifier.contains(Modifier::BOLD)); + assert_eq!(line.spans[0].style.fg, unmonitored_style().fg); + assert!(!line.spans[1].style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_edition_detail_line_keeps_colons_within_a_value() { + let line = edition_detail_line("Release Date: 2023-01-05 15:00:00 UTC", primary_style()); + + assert_eq!( + span_contents(&line), + vec!["Release Date:", " 2023-01-05 15:00:00 UTC"] + ); + } + + #[test] + fn test_edition_detail_line_does_not_append_a_colon_to_a_line_without_one() { + let line = edition_detail_line( + "My name is Kvothe, pronounced nearly the same as \"quothe.\"", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["My name is Kvothe, pronounced nearly the same as \"quothe.\""] + ); + } + + #[test] + fn test_edition_detail_line_renders_a_carriage_return_only_line_as_blank() { + let line = edition_detail_line("\r", primary_style()); + + assert_eq!(span_contents(&line), vec![""]); + } + + #[test] + fn test_edition_detail_line_trims_a_trailing_carriage_return() { + let line = edition_detail_line( + "I have stolen princesses back from sleeping barrow kings.\r", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["I have stolen princesses back from sleeping barrow kings."] + ); + } + + fn span_contents(line: &Line<'_>) -> Vec { + line + .spans + .iter() + .map(|span| span.content.to_string()) + .collect() + } + + mod snapshot_tests { + use crate::models::servarr_data::readarr::modals::EditionDetailsModal; + use crate::models::stateful_table::StatefulTable; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_edition_details_ui_renders() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edition_details_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edition_details_ui_renders_empty() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .edition_details_modal = Some(EditionDetailsModal::default()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edition_details_ui_renders_with_zero_editions() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .editions = StatefulTable::default(); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edition_details_ui_renders_scrolled() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .edition_details_modal + .as_mut() + .unwrap() + .edition_details + .offset = 2; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edition_details_ui_renders_nothing_when_the_edition_details_modal_is_undefined() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + app + .data + .readarr_data + .book_details_modal + .as_mut() + .unwrap() + .edition_details_modal = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_edition_details_ui_renders_nothing_when_the_book_details_modal_is_undefined() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into()); + app.data.readarr_data.book_details_modal = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + EditionDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/library/library_ui_tests.rs b/src/ui/readarr_ui/library/library_ui_tests.rs new file mode 100644 index 00000000..f491ca53 --- /dev/null +++ b/src/ui/readarr_ui/library/library_ui_tests.rs @@ -0,0 +1,322 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::readarr_models::{Author, AuthorStatistics, AuthorStatus}; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_AUTHOR_BLOCKS, AUTHOR_DETAILS_BLOCKS, AUTHOR_OVERVIEW_BLOCKS, ActiveReadarrBlock, + BOOK_DETAILS_BLOCKS, DELETE_AUTHOR_BLOCKS, DELETE_BOOK_BLOCKS, EDIT_AUTHOR_BLOCKS, + EDITION_DETAILS_BLOCKS, LIBRARY_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::{LibraryUi, decorate_author_row_with_style}; + use crate::ui::styles::ManagarrStyle; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; + + #[test] + fn test_library_ui_accepts() { + let mut blocks = LIBRARY_BLOCKS.to_vec(); + blocks.extend(AUTHOR_DETAILS_BLOCKS); + blocks.extend(AUTHOR_OVERVIEW_BLOCKS); + blocks.extend(BOOK_DETAILS_BLOCKS); + blocks.extend(EDITION_DETAILS_BLOCKS); + blocks.extend(ADD_AUTHOR_BLOCKS); + blocks.extend(DELETE_AUTHOR_BLOCKS); + blocks.extend(DELETE_BOOK_BLOCKS); + blocks.extend(EDIT_AUTHOR_BLOCKS); + + for active_readarr_block in ActiveReadarrBlock::iter() { + if blocks.contains(&active_readarr_block) { + assert!( + LibraryUi::accepts(active_readarr_block.into()), + "{active_readarr_block} is not accepted by the LibraryUi" + ); + } else { + assert!( + !LibraryUi::accepts(active_readarr_block.into()), + "{active_readarr_block} should not be accepted by LibraryUi" + ); + } + } + } + + #[test] + fn test_decorate_row_with_style_unmonitored() { + let author = Author::default(); + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + + #[test] + fn test_decorate_row_with_style_downloaded_when_ended_and_all_books_present() { + let author = Author { + monitored: true, + status: AuthorStatus::Ended, + statistics: Some(AuthorStatistics { + book_file_count: 10, + total_book_count: 10, + ..AuthorStatistics::default() + }), + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_row_with_style_missing_when_ended_and_books_are_missing() { + let author = Author { + monitored: true, + status: AuthorStatus::Ended, + statistics: Some(AuthorStatistics { + book_file_count: 5, + total_book_count: 10, + ..AuthorStatistics::default() + }), + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_row_with_style_indeterminate_when_ended_and_no_statistics() { + let author = Author { + monitored: true, + status: AuthorStatus::Ended, + statistics: None, + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + + #[test] + fn test_decorate_row_with_style_indeterminate_when_ended_and_total_book_count_is_zero() { + let author = Author { + monitored: true, + status: AuthorStatus::Ended, + statistics: Some(AuthorStatistics { + book_file_count: 0, + total_book_count: 0, + ..AuthorStatistics::default() + }), + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_row_with_style_unreleased_when_continuing_and_all_books_present() { + let author = Author { + monitored: true, + status: AuthorStatus::Continuing, + statistics: Some(AuthorStatistics { + book_file_count: 10, + total_book_count: 10, + ..AuthorStatistics::default() + }), + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.unreleased()); + } + + #[test] + fn test_decorate_row_with_style_missing_when_continuing_and_books_are_missing() { + let author = Author { + monitored: true, + status: AuthorStatus::Continuing, + statistics: Some(AuthorStatistics { + book_file_count: 5, + total_book_count: 10, + ..AuthorStatistics::default() + }), + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_row_with_style_indeterminate_when_continuing_and_no_statistics() { + let author = Author { + monitored: true, + status: AuthorStatus::Continuing, + statistics: None, + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + + #[test] + fn test_decorate_row_with_style_defaults_to_indeterminate_for_deleted_status() { + let author = Author { + monitored: true, + status: AuthorStatus::Deleted, + ..Author::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_author_row_with_style(&author, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + + mod snapshot_tests { + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, DELETE_AUTHOR_SELECTION_BLOCKS, EDIT_AUTHOR_SELECTION_BLOCKS, + }; + use rstest::rstest; + + use crate::ui::DrawUi; + use crate::ui::readarr_ui::library::LibraryUi; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + #[rstest] + fn test_library_ui_renders( + #[values( + ActiveReadarrBlock::Authors, + ActiveReadarrBlock::AuthorsSortPrompt, + ActiveReadarrBlock::SearchAuthors, + ActiveReadarrBlock::SearchAuthorsError, + ActiveReadarrBlock::FilterAuthors, + ActiveReadarrBlock::FilterAuthorsError + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("readarr_library_{active_readarr_block}"), output); + } + + #[test] + fn test_library_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_library_ui_renders_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_library_ui_renders_update_all_authors_prompt() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::UpdateAllAuthorsPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_library_ui_renders_author_details_over_library() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AuthorDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_library_ui_renders_add_author_over_library() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_library_ui_renders_edit_author_over_library() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.selected_block = BlockSelectionState::new(EDIT_AUTHOR_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::EditAuthorPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_library_ui_renders_delete_author_over_library() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + app.data.readarr_data.delete_files = true; + app.data.readarr_data.add_import_list_exclusion = false; + app.data.readarr_data.selected_block = + BlockSelectionState::new(DELETE_AUTHOR_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::DeleteAuthorPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + LibraryUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + } +} diff --git a/src/ui/readarr_ui/library/mod.rs b/src/ui/readarr_ui/library/mod.rs new file mode 100644 index 00000000..8af624ee --- /dev/null +++ b/src/ui/readarr_ui/library/mod.rs @@ -0,0 +1,213 @@ +use add_author_ui::AddAuthorUi; +use author_details_ui::AuthorDetailsUi; +use delete_author_ui::DeleteAuthorUi; +use edit_author_ui::EditAuthorUi; +use ratatui::{ + Frame, + layout::{Constraint, Rect}, + widgets::{Cell, Row}, +}; + +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::{ + confirmation_prompt::ConfirmationPrompt, + popup::{Popup, Size}, +}; +use crate::utils::format_size; +use crate::{ + app::App, + models::{ + Route, + readarr_models::{Author, AuthorStatus}, + servarr_data::readarr::readarr_data::{ActiveReadarrBlock, LIBRARY_BLOCKS}, + }, + ui::{ + DrawUi, + styles::ManagarrStyle, + utils::{get_width_from_percentage, layout_block_top_border}, + }, +}; + +mod add_author_ui; +mod author_details_ui; +mod author_overview_ui; +mod book_details_ui; +mod delete_author_ui; +mod delete_book_ui; +mod edit_author_ui; +mod edition_details_ui; + +#[cfg(test)] +#[path = "library_ui_tests.rs"] +mod library_ui_tests; + +pub(super) struct LibraryUi; + +impl DrawUi for LibraryUi { + fn accepts(route: Route) -> bool { + if let Route::Readarr(active_readarr_block, _) = route { + return AddAuthorUi::accepts(route) + || DeleteAuthorUi::accepts(route) + || EditAuthorUi::accepts(route) + || AuthorDetailsUi::accepts(route) + || LIBRARY_BLOCKS.contains(&active_readarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let route = app.get_current_route(); + draw_library(f, app, area); + + match route { + _ if AddAuthorUi::accepts(route) => AddAuthorUi::draw(f, app, area), + _ if DeleteAuthorUi::accepts(route) => DeleteAuthorUi::draw(f, app, area), + _ if AuthorDetailsUi::accepts(route) => AuthorDetailsUi::draw(f, app, area), + _ if EditAuthorUi::accepts(route) => EditAuthorUi::draw(f, app, area), + Route::Readarr(ActiveReadarrBlock::UpdateAllAuthorsPrompt, _) => { + let confirmation_prompt = ConfirmationPrompt::new() + .title("Update All Authors") + .prompt("Do you want to update info and scan your disks for all of your authors?") + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + _ => (), + } + } +} + +fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let current_selection = if !app.data.readarr_data.authors.items.is_empty() { + app.data.readarr_data.authors.current_selection().clone() + } else { + Author::default() + }; + let quality_profile_map = &app.data.readarr_data.quality_profile_map; + let metadata_profile_map = &app.data.readarr_data.metadata_profile_map; + let tags_map = &app.data.readarr_data.tags_map; + let content = Some(&mut app.data.readarr_data.authors); + + let authors_table_row_mapping = |author: &Author| { + author.author_name.scroll_left_or_reset( + get_width_from_percentage(area, 25), + *author == current_selection, + app.should_text_scroll, + ); + let monitored = if author.monitored { "🏷" } else { "" }; + let size = author + .statistics + .as_ref() + .map_or(0, |stats| stats.size_on_disk); + let quality_profile = quality_profile_map + .get_by_left(&author.quality_profile_id) + .cloned() + .unwrap_or_default(); + let metadata_profile = metadata_profile_map + .get_by_left(&author.metadata_profile_id) + .cloned() + .unwrap_or_default(); + let books = author + .statistics + .as_ref() + .map_or(0, |stats| stats.book_count); + let tags = author + .tags + .iter() + .filter_map(|tag_id| { + let id = tag_id.as_i64()?; + tags_map.get_by_left(&id).cloned() + }) + .collect::>() + .join(", "); + + decorate_author_row_with_style( + author, + Row::new(vec![ + Cell::from(author.author_name.to_string()), + Cell::from(author.status.to_display_str()), + Cell::from(quality_profile), + Cell::from(metadata_profile), + Cell::from(books.to_string()), + Cell::from(format_size(size, 2)), + Cell::from(monitored.to_owned()), + Cell::from(tags), + ]), + ) + }; + let authors_table = ManagarrTable::new(content, authors_table_row_mapping) + .block(layout_block_top_border()) + .loading(app.is_loading) + .sorting(active_readarr_block == ActiveReadarrBlock::AuthorsSortPrompt) + .searching(active_readarr_block == ActiveReadarrBlock::SearchAuthors) + .filtering(active_readarr_block == ActiveReadarrBlock::FilterAuthors) + .search_produced_empty_results(active_readarr_block == ActiveReadarrBlock::SearchAuthorsError) + .filter_produced_empty_results(active_readarr_block == ActiveReadarrBlock::FilterAuthorsError) + .headers([ + "Name", + "Status", + "Quality Profile", + "Metadata Profile", + "Books", + "Size", + "Monitored", + "Tags", + ]) + .constraints([ + Constraint::Percentage(34), + Constraint::Percentage(8), + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(7), + Constraint::Percentage(8), + Constraint::Percentage(6), + Constraint::Percentage(11), + ]); + + if [ + ActiveReadarrBlock::SearchAuthors, + ActiveReadarrBlock::FilterAuthors, + ] + .contains(&active_readarr_block) + { + authors_table.show_cursor(f, area); + } + + f.render_widget(authors_table, area); + } +} + +fn decorate_author_row_with_style<'a>(author: &Author, row: Row<'a>) -> Row<'a> { + if !author.monitored { + return row.unmonitored(); + } + + match author.status { + AuthorStatus::Ended => { + if let Some(ref stats) = author.statistics { + return if stats.book_file_count == stats.total_book_count && stats.total_book_count > 0 { + row.downloaded() + } else { + row.missing() + }; + } + row.indeterminate() + } + AuthorStatus::Continuing => { + if let Some(ref stats) = author.statistics { + return if stats.book_file_count == stats.total_book_count && stats.total_book_count > 0 { + row.unreleased() + } else { + row.missing() + }; + } + row.indeterminate() + } + _ => row.indeterminate(), + } +} diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_already_in_library_ui_renders.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_already_in_library_ui_renders.snap new file mode 100644 index 00000000..143ccb74 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_already_in_library_ui_renders.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Author ─────────────────────────────────────────────────────╮ + │Test Author │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ✔ Name Type Status Rating Genres │ + │=> ✔ Test Author Person Continuing 4.5 science fiction │ + │ Unadded Author Group Ended fantasy, horror │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────── Error ───────────────╮ │ + │ │This author is already in your library │ │ + │ │ │ │ + │ ╰───────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMetadataProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMetadataProfile.snap new file mode 100644 index 00000000..4205bbc2 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╯ │ │ + │ │ │Author Only │──────────────────────────────╮ │ │ + │ │ Monito│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Qual│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Metad│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ │ │ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitor.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitor.snap new file mode 100644 index 00000000..e68a7977 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitor.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╯ │ │ + │ │ │All Books │──────────────────────────────╮ │ │ + │ │ Monito│Future Books │ ▼ │ │ │ + │ │ │Missing Books │──────────────────────────────╯ │ │ + │ │ │Existing Books │──────────────────────────────╮ │ │ + │ │ Qual│First Book │ ▼ │ │ │ + │ │ │Latest Book │──────────────────────────────╯ │ │ + │ │ │None │──────────────────────────────╮ │ │ + │ │ Metad│Unknown │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ │ │ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitorNewItems.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitorNewItems.snap new file mode 100644 index 00000000..9e0245a7 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╯ │ │ + │ │ │All Books │──────────────────────────────╮ │ │ + │ │ Monito│No New Books │ ▼ │ │ │ + │ │ │New Books │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Qual│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Metad│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ │ │ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectQualityProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectQualityProfile.snap new file mode 100644 index 00000000..fb335fb3 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╯ │ │ + │ │ │eBook │──────────────────────────────╮ │ │ + │ │ Monito│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Qual│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Metad│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ │ │ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectRootFolder.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectRootFolder.snap new file mode 100644 index 00000000..b6abb631 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorSelectRootFolder.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╭───────────────────────────────╮──────────────────────────────╯ │ │ + │ │ │/nfs │──────────────────────────────╮ │ │ + │ │ Monito│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Qual│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ Metad│ │ ▼ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │──────────────────────────────╮ │ │ + │ │ │ │ │ │ │ + │ │ │ │──────────────────────────────╯ │ │ + │ │ │ │ │ │ + │ │ ╰───────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorTagsInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorTagsInput.snap new file mode 100644 index 00000000..00c03203 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_active_AddAuthorTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_0_AddAuthorSelectRootFolder.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_0_AddAuthorSelectRootFolder.snap new file mode 100644 index 00000000..00c03203 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_0_AddAuthorSelectRootFolder.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_1_AddAuthorSelectMonitor.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_1_AddAuthorSelectMonitor.snap new file mode 100644 index 00000000..00c03203 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_1_AddAuthorSelectMonitor.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_2_AddAuthorSelectMonitorNewItems.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_2_AddAuthorSelectMonitorNewItems.snap new file mode 100644 index 00000000..00c03203 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_2_AddAuthorSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_3_AddAuthorSelectQualityProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_3_AddAuthorSelectQualityProfile.snap new file mode 100644 index 00000000..00c03203 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_3_AddAuthorSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_4_AddAuthorSelectMetadataProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_4_AddAuthorSelectMetadataProfile.snap new file mode 100644 index 00000000..00c03203 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_4_AddAuthorSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_5_AddAuthorTagsInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_5_AddAuthorTagsInput.snap new file mode 100644 index 00000000..00c03203 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_5_AddAuthorTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_6_AddAuthorConfirmPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_6_AddAuthorConfirmPrompt.snap new file mode 100644 index 00000000..00c03203 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_step_6_AddAuthorConfirmPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_an_author_with_no_overview.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_an_author_with_no_overview.snap new file mode 100644 index 00000000..b11223ab --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_an_author_with_no_overview.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ │───────╮ + │Test A│ │ │ + ╰──────│ │───────╯ + ╭──────│ │───────╮ + │ ✔ │ │ │ + │=> ✔ │ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_the_selected_author_overview.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_the_selected_author_overview.snap new file mode 100644 index 00000000..00c03203 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_modal_ui_renders_the_selected_author_overview.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + ╭──────────────────────────────── Add - Test Author (American novelist) ────────────────────────────────╮ + ╭──────│ Test Author is an American novelist whose science fiction has been translated into more than thirty │───────╮ + │Test A│languages. Born in a small river town, the author spent a decade as a marine biologist before turning to │ │ + ╰──────│ fiction, and the sea remains a constant presence in the work. The debut novel won several awards and │───────╯ + ╭──────│ introduced the sprawling shared universe that later books would return to again and again. Critics have │───────╮ + │ ✔ │ praised the meticulous world building and the quiet, humane attention to ordinary people caught up in │ │ + │=> ✔ │extraordinary events. The author also writes essays on the history of science, teaches an annual writing │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Root Folder: │/nfs ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Monitor New Items: │All Books ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Quality Profile: │eBook ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Metadata Profile: │Author Only ▼ │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ ╭─────────────────────────────────────────────────╮ │ │ + │ │ Tags: │usenet, testing │ │ │ + │ │ ╰─────────────────────────────────────────────────╯ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ │ + │ ││ Add ││ Cancel ││ │ + ╰──────│╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│───────╯ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorEmptySearchResults.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorEmptySearchResults.snap new file mode 100644 index 00000000..872a81f2 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorEmptySearchResults.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Author ─────────────────────────────────────────────────────╮ + │Test Author │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────── Error ───────────────╮ │ + │ │ No authors found matching your query! │ │ + │ │ │ │ + │ ╰───────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchInput.snap new file mode 100644 index 00000000..229f00ce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchInput.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Author ─────────────────────────────────────────────────────╮ + │Test Author │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchResults.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchResults.snap new file mode 100644 index 00000000..507e5116 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_AddAuthorSearchResults.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Author ─────────────────────────────────────────────────────╮ + │Test Author │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ✔ Name Type Status Rating Genres │ + │=> ✔ Test Author Person Continuing 4.5 science fiction │ + │ Unadded Author Group Ended fantasy, horror │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_renders_loading_for_search.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_renders_loading_for_search.snap new file mode 100644 index 00000000..9f640028 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__add_author_ui__add_author_ui_tests__tests__snapshot_tests__add_author_ui_renders_loading_for_search.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/readarr_ui/library/add_author_ui_tests.rs +expression: output +--- + + + + + + + + ╭───────────────────────────────────────────────────── Add Author ─────────────────────────────────────────────────────╮ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap new file mode 100644 index 00000000..6056c1d9 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorDetails_0.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Files Release Date Size ││ + ││=> 🏷 Test Book 3/7 2023-05-20 2.34 MB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap new file mode 100644 index 00000000..d8352fb7 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistoryDetails_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ─────────────╭─────────────────────────────────── Details ───────────────────────────────────╮───────────────────────────────╮│ + ││ Books │ History │ Author Searc│Source Title: Test Source Title │ ││ + ││───────────────────────────────│Event Type: grabbed │───────────────────────────────││ + ││ Source Title ▼ │Quality: EPUB │ ││ + ││=> Test Source Title │Date: 2023-05-20 21:29:16 UTC │-05-20 21:29:16 UTC ││ + ││ │Indexer: │ ││ + ││ │NZB Info URL: │ ││ + ││ │Release Group: │ ││ + ││ │Age: 0 days │ ││ + ││ │Published Date: 1970-01-01 00:00:00 UTC │ ││ + ││ │Download Client: │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰─────────────────────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap new file mode 100644 index 00000000..cd274d86 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistorySortPrompt_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Title Event Type Quality Date ││ + ││=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭───────────────────────────╮ ││ + ││ │Something │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰───────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap new file mode 100644 index 00000000..a54d032f --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AuthorHistory_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Title ▼ Event Type Quality Date ││ + ││=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap new file mode 100644 index 00000000..3b9cae99 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_0.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────── Automatic Author Search ────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ + ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ + ││ Monitored Title │ │Date Size ││ + ││=> 🏷 Test Book │ │20 2.34 MB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap new file mode 100644 index 00000000..744f8a99 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────── Automatic Author Search ────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ + ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ + ││ Source Title ▼ │ │ Date ││ + ││=> Test Source Title │ │ 2023-05-20 21:29:16 UTC ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap new file mode 100644 index 00000000..5c3954f2 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_AutomaticallySearchAuthorPrompt_2.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────── Automatic Author Search ────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ + ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ + ││ Source ▼ Age ⛔ Title │ │ Size Peers Quality ││ + ││=> torrent 1 days ⛔ Test Torren│ │ 0.0 MB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usenet│ │ 0.0 MB EPUB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap new file mode 100644 index 00000000..b000e8eb --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistoryError_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Title ▼ Event Type Quality Date ││ + ││=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭──────────── Error ─────────────╮ ││ + ││ │ The given filter produced empty │ ││ + ││ ╰──────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap new file mode 100644 index 00000000..a423ef9d --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_FilterAuthorHistory_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Title ▼ Event Type Quality Date ││ + ││=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭────────────── Filter ───────────────╮ ││ + ││ │author history filter │ ││ + ││ ╰───────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap new file mode 100644 index 00000000..58a0be00 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchConfirmPrompt_2.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ ╭───────────────── Download Rejected Release ──────────────────╮ │ + │╭ Author Details ──────────────────────│ Do you really want to download the rejected release: Test │───────────────────────────────────────╮│ + ││ Books │ History │ Author Search │ Torrent Release? │ ││ + ││────────────────────────────────────────│ │───────────────────────────────────────││ + ││ Source ▼ Age ⛔ Title │ │e Peers Quality ││ + ││=> torrent 1 days ⛔ Test Torre│Rejection reasons: │ MB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usene│• Unknown quality profile │ MB EPUB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭──────────────────────────────╮╭──────────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰──────────────────────────────╯╰──────────────────────────────╯│ ││ + ││ ╰────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap new file mode 100644 index 00000000..7bd3d5aa --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearchSortPrompt_2.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Age ⛔ Title Indexer Size Peers Quality ││ + ││=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 MB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 MB EPUB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭───────────────────────────╮ ││ + ││ │Something │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰───────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap new file mode 100644 index 00000000..9b9afa69 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ManualAuthorSearch_2.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source ▼ Age ⛔ Title Indexer Size Peers Quality ││ + ││=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 MB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 MB EPUB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap new file mode 100644 index 00000000..a224f0ea --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistoryError_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Title ▼ Event Type Quality Date ││ + ││=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭──────────── Error ─────────────╮ ││ + ││ │ No items found matching search │ ││ + ││ ╰──────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap new file mode 100644 index 00000000..838810c3 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchAuthorHistory_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Source Title ▼ Event Type Quality Date ││ + ││=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭────────────── Search ───────────────╮ ││ + ││ │author history search │ ││ + ││ ╰───────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap new file mode 100644 index 00000000..54c2b491 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooksError_0.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Files Release Date Size ││ + ││=> 🏷 Test Book 3/7 2023-05-20 2.34 MB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭──────────── Error ─────────────╮ ││ + ││ │ No items found matching search │ ││ + ││ ╰──────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap new file mode 100644 index 00000000..d4d1bdf6 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_SearchBooks_0.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Files Release Date Size ││ + ││=> 🏷 Test Book 3/7 2023-05-20 2.34 MB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ╭────────────── Search ───────────────╮ ││ + ││ │book search │ ││ + ││ ╰───────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap new file mode 100644 index 00000000..a68e61e9 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_0.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────────── Update and Scan ────────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ + ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ + ││ Monitored Title │ │Date Size ││ + ││=> 🏷 Test Book │ │20 2.34 MB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap new file mode 100644 index 00000000..2ffd5347 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_1.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────────── Update and Scan ────────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ + ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ + ││ Source Title ▼ │ │ Date ││ + ││=> Test Source Title │ │ 2023-05-20 21:29:16 UTC ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap new file mode 100644 index 00000000..abd34e3b --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_UpdateAndScanAuthorPrompt_2.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────────── Update and Scan ────────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ + ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ + ││ Source ▼ Age ⛔ Title │ │ Size Peers Quality ││ + ││=> torrent 1 days ⛔ Test Torren│ │ 0.0 MB 2 / 1 EPUB ││ + ││ usenet 1 days ⛔ Test Usenet│ │ 0.0 MB EPUB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_author_overview_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_author_overview_over_author_details.snap new file mode 100644 index 00000000..a9f49210 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_author_overview_over_author_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: sci╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ │ + │Rating: N/A│some interesting description of the author │ │ + │Path: /nfs/│ │ │ + │Quality Pro│He was born in Madison, Wisconsin: a city he has never really left. │ │ + │Metadata Pr│ │ │ + │Monitored: │His first novel took him seven years to finish. │ │ + │Overview: s│ │l took him │ + │seven years│ │ │ + │ │ │ │ + │ │ │ │ + │╭ Author D│ │───────────╮│ + ││ Books │ H│ │ ││ + ││──────────│ │───────────││ + ││ Monitor│ │ ││ + ││=> 🏷 │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap new file mode 100644 index 00000000..3b9cae99 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_automatic_search_prompt_over_author_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────── Automatic Author Search ────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │Do you want to trigger an automatic search of your indexers│ ││ + ││─────────────────────────────────────────│ for all monitored book(s) for the author: Test Author? │───────────────────────────────────────────││ + ││ Monitored Title │ │Date Size ││ + ││=> 🏷 Test Book │ │20 2.34 MB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap new file mode 100644 index 00000000..7f5765ba --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_details_over_author_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Auth╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ + │Stat│ Editions │ History │ File │ Manual Search │ │ + │Genr│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ + │Rati│ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ │ + │Path│=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ │ + │Qual│ │ │ + │Meta│ │ │ + │Moni│ │ │ + │Over│ │ him │ + │seve│ │ │ + │ │ │ │ + │ │ │ │ + │╭ A│ │─────╮│ + ││ Bo│ │ ││ + ││───│ │─────││ + ││ │ │ ││ + ││=> │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_without_statistics.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_without_statistics.snap new file mode 100644 index 00000000..b72b374b --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_book_without_statistics.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Files Release Date Size ││ + ││=> 🏷 Test Book N/A 2023-05-20 N/A ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap new file mode 100644 index 00000000..595aca7a --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_delete_book_over_author_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭────────────────────── Delete Book ──────────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │ Do you really want to delete the book: │ ││ + ││─────────────────────────────────────────│ Test Book? │───────────────────────────────────────────││ + ││ Monitored Title │ │Date Size ││ + ││=> 🏷 Test Book │ │20 2.34 MB ││ + ││ │ ╭───╮ │ ││ + ││ │ Delete Book Files: │ ✔ │ │ ││ + ││ │ ╰───╯ │ ││ + ││ │ ╭───╮ │ ││ + ││ │ Add List Exclusion: │ │ │ ││ + ││ │ ╰───╯ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap new file mode 100644 index 00000000..4166feb5 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_statistics.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Books: 3 │ + │Files: 2/3 │ + │Size on Disk: 3.30 GB │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Files Release Date Size ││ + ││=> 🏷 Test Book 1/1 2023-05-20 1.65 GB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap new file mode 100644 index 00000000..a68e61e9 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__author_details_ui_renders_update_and_scan_prompt_over_author_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────╭──────────────────── Update and Scan ────────────────────╮───────────────────────────────────────────╮│ + ││ Books │ History │ Author Search │ Do you want to trigger an update and disk scan for the │ ││ + ││─────────────────────────────────────────│ author: Test Author? │───────────────────────────────────────────││ + ││ Monitored Title │ │Date Size ││ + ││=> 🏷 Test Book │ │20 2.34 MB ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭────────────────────────────╮╭───────────────────────────╮│ ││ + ││ ││ Yes ││ No ││ ││ + ││ │╰────────────────────────────╯╰───────────────────────────╯│ ││ + ││ ╰───────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap new file mode 100644 index 00000000..520fd679 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorDetails.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap new file mode 100644 index 00000000..520fd679 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistory.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap new file mode 100644 index 00000000..bece33db --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_AuthorHistoryDetails.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ─────────────╭─────────────────────────────────── Details ───────────────────────────────────╮───────────────────────────────╮│ + ││ Books │ History │ Author Searc│Source Title: │ ││ + ││───────────────────────────────│Event Type: unknown │───────────────────────────────││ + ││ │Quality: │ ││ + ││ │Date: 1970-01-01 00:00:00 UTC │ ││ + ││ │No additional details available. │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰─────────────────────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap new file mode 100644 index 00000000..763b47b0 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__empty_author_details_ManualAuthorSearch.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ ││ + ││ ││ + ││ Loading ... ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap new file mode 100644 index 00000000..763b47b0 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorDetails.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ ││ + ││ ││ + ││ Loading ... ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap new file mode 100644 index 00000000..763b47b0 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_AuthorHistory.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ ││ + ││ ││ + ││ Loading ... ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap new file mode 100644 index 00000000..763b47b0 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_details_ui__author_details_ui_tests__tests__snapshot_tests__loading_author_details_ManualAuthorSearch.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/author_details_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ ││ + ││ ││ + ││ Loading ... ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders.snap new file mode 100644 index 00000000..75be124b --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/author_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │some interesting description of the author │ + │ │ + │He was born in Madison, Wisconsin: a city he has never really left. │ + │ │ + │His first novel took him seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_an_author_with_no_overview.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_an_author_with_no_overview.snap new file mode 100644 index 00000000..d4a877ae --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_an_author_with_no_overview.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/author_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_loading.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_loading.snap new file mode 100644 index 00000000..a8a3c9e0 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_loading.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/author_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_nothing_when_the_author_overview_modal_is_undefined.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_nothing_when_the_author_overview_modal_is_undefined.snap new file mode 100644 index 00000000..b080be01 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_nothing_when_the_author_overview_modal_is_undefined.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/library/author_overview_ui_tests.rs +expression: output +--- + diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_scrolled.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_scrolled.snap new file mode 100644 index 00000000..4eb9e128 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__author_overview_ui__author_overview_ui_tests__tests__snapshot_tests__author_overview_ui_renders_scrolled.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/author_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │He was born in Madison, Wisconsin: a city he has never really left. │ + │ │ + │His first novel took him seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_0.snap new file mode 100644 index 00000000..97b8c5a4 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────── Automatic Book Search ─────────────────╮ │ + │ │Do you want to trigger an automatic search of your indexers│ │ + │ │ for the book: Test Book? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_1.snap new file mode 100644 index 00000000..99278512 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────── Automatic Book Search ─────────────────╮ │ + │ │Do you want to trigger an automatic search of your indexers│ │ + │ │ for the book: Test Book? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_2.snap new file mode 100644 index 00000000..56d7d96b --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_2.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Path Size Quality Media Info │ + │=> /nfs/books/Test Author/Test Book.epub 3.20 MB EPUB MP3 - 2.0 - 128 kbps - 44100 - 16 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────── Automatic Book Search ─────────────────╮ │ + │ │Do you want to trigger an automatic search of your indexers│ │ + │ │ for the book: Test Book? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_3.snap new file mode 100644 index 00000000..feb16d8d --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_AutomaticallySearchBookPrompt_3.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ + │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 MB 2 / 1 EPUB │ + │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 MB EPUB │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────── Automatic Book Search ─────────────────╮ │ + │ │Do you want to trigger an automatic search of your indexers│ │ + │ │ for the book: Test Book? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookDetails_0.snap new file mode 100644 index 00000000..f23a696c --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookDetails_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookFileInfo_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookFileInfo_2.snap new file mode 100644 index 00000000..0b9b0686 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookFileInfo_2.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Path Size Quality Media Info │ + │=> /nfs/books/Test Author/Test Book.epub 3.20 MB EPUB MP3 - 2.0 - 128 kbps - 44100 - 16 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistoryDetails_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistoryDetails_1.snap new file mode 100644 index 00000000..b725ed90 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistoryDetails_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────── Details ───────────────────────────────────╮ │ + │ │Source Title: Test Source Title │ │ + │ │Event Type: grabbed │ │ + │ │Quality: EPUB │ │ + │ │Date: 2023-05-20 21:29:16 UTC │ │ + │ │Indexer: │ │ + │ │NZB Info URL: │ │ + │ │Release Group: │ │ + │ │Age: 0 days │ │ + │ │Published Date: 1970-01-01 00:00:00 UTC │ │ + │ │Download Client: │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistorySortPrompt_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistorySortPrompt_1.snap new file mode 100644 index 00000000..38337ddb --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistorySortPrompt_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────╮ │ + │ │Something │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistory_1.snap new file mode 100644 index 00000000..30471ec0 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_BookHistory_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_0.snap new file mode 100644 index 00000000..34e3d493 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────── Delete Book File ────────────────────╮ │ + │ │ Do you really want to delete this book file: │ │ + │ │ Test Book? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_2.snap new file mode 100644 index 00000000..8125a1df --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_DeleteBookFilePrompt_2.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Path Size Quality Media Info │ + │=> /nfs/books/Test Author/Test Book.epub 3.20 MB EPUB MP3 - 2.0 - 128 kbps - 44100 - 16 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────── Delete Book File ────────────────────╮ │ + │ │ Do you really want to delete this book file: │ │ + │ │ Test Book? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistoryError_1.snap new file mode 100644 index 00000000..7fd2c1f4 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistoryError_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────── Error ────────────╮ │ + │ │ The given filter produced empty│ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistory_1.snap new file mode 100644 index 00000000..d0a5de02 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_FilterBookHistory_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────── Filter ──────────────╮ │ + │ │book history filter │ │ + │ ╰─────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchConfirmPrompt_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchConfirmPrompt_3.snap new file mode 100644 index 00000000..069bd165 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchConfirmPrompt_3.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ + │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 MB 2 / 1 EPUB │ + │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 MB EPUB │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────────── Download Rejected Release ──────────────────╮ │ + │ │ Do you really want to download the rejected release: Test │ │ + │ │ Torrent Release? │ │ + │ │ │ │ + │ │ │ │ + │ │Rejection reasons: │ │ + │ │• Unknown quality profile │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭──────────────────────────────╮╭──────────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰──────────────────────────────╯╰──────────────────────────────╯│ │ + │ ╰────────────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchSortPrompt_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchSortPrompt_3.snap new file mode 100644 index 00000000..fcd51d70 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearchSortPrompt_3.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Age ⛔ Title Indexer Size Peers Quality │ + │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 MB 2 / 1 EPUB │ + │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 MB EPUB │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────╮ │ + │ │Something │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰─────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearch_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearch_3.snap new file mode 100644 index 00000000..c1290151 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_ManualBookSearch_3.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source ▼ Age ⛔ Title Indexer Size Peers Quality │ + │=> torrent 1 days ⛔ Test Torrent Release kickass torrents 0.0 MB 2 / 1 EPUB │ + │ usenet 1 days ⛔ Test Usenet Release DrunkenSlug 0.0 MB EPUB │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistoryError_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistoryError_1.snap new file mode 100644 index 00000000..c7da2a51 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistoryError_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────── Error ────────────╮ │ + │ │ No items found matching search │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistory_1.snap new file mode 100644 index 00000000..61d2bf2b --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchBookHistory_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Source Title ▼ Event Type Quality Date │ + │=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────── Search ──────────────╮ │ + │ │book history search │ │ + │ ╰─────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditionsError_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditionsError_0.snap new file mode 100644 index 00000000..da2f8e61 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditionsError_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────── Error ────────────╮ │ + │ │ No items found matching search │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditions_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditions_0.snap new file mode 100644 index 00000000..6d67ce02 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_renders_SearchEditions_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───────────── Search ──────────────╮ │ + │ │edition search │ │ + │ ╰─────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_edition_details_over_book_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_edition_details_over_book_details.snap new file mode 100644 index 00000000..b675a77a --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_edition_details_over_book_details.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │──────╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮─────│ + │ For│Title: Test Edition │d │ + │=> Har│Format: Hardcover │ │ + │ │Language: English │ │ + │ │Publisher: DAW Books │ │ + │ │Page Count: 288 │ │ + │ │Release Date: 2023-05-20 00:00:00 UTC │ │ + │ │ISBN13: 9780756404079 │ │ + │ │ASIN: B0043RSJ9S │ │ + │ │Ratings: 4.5 (1000 votes) │ │ + │ │Monitored: true │ │ + │ │Overview: original cover of ISBN 075640407X │ │ + │ │ │ │ + │ │My name is Kvothe, pronounced nearly the same as "quothe." │ │ + │ │ │ │ + │ │The Adem call me Maedre: which means The Flame. │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_a_single_edition.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_a_single_edition.snap new file mode 100644 index 00000000..f23a696c --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_a_single_edition.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Hardcover 9780756404079 B0043RSJ9S DAW Books 288 2023-05-20 🏷 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_an_unmonitored_edition.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_an_unmonitored_edition.snap new file mode 100644 index 00000000..74118def --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_an_unmonitored_edition.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ Format ISBN13 ASIN Publisher Page Count Release Date Monitored │ + │=> Paperback 9780756404741 B0043RSJ9S DAW Books 662 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_zero_editions.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_zero_editions.snap new file mode 100644 index 00000000..f7d1165c --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_editions_table_with_zero_editions.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap new file mode 100644 index 00000000..9b84f8c0 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__book_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookDetails_0.snap new file mode 100644 index 00000000..f7d1165c --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookDetails_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookFileInfo_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookFileInfo_2.snap new file mode 100644 index 00000000..f7d1165c --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookFileInfo_2.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookHistory_1.snap new file mode 100644 index 00000000..f7d1165c --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_BookHistory_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_ManualBookSearch_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_ManualBookSearch_3.snap new file mode 100644 index 00000000..dd9476ac --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__empty_book_details_ManualBookSearch_3.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookDetails_0.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookDetails_0.snap new file mode 100644 index 00000000..dd9476ac --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookDetails_0.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookFileInfo_2.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookFileInfo_2.snap new file mode 100644 index 00000000..dd9476ac --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookFileInfo_2.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookHistory_1.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookHistory_1.snap new file mode 100644 index 00000000..dd9476ac --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_BookHistory_1.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_ManualBookSearch_3.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_ManualBookSearch_3.snap new file mode 100644 index 00000000..dd9476ac --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__book_details_ui__book_details_ui_tests__tests__snapshot_tests__loading_book_details_ManualBookSearch_3.snap @@ -0,0 +1,50 @@ +--- +source: src/ui/readarr_ui/library/book_details_ui_tests.rs +expression: output +--- + + + + + ╭ Test Book Details ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Editions │ History │ File │ Manual Search │ + │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_0_DeleteAuthorToggleDeleteFile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_0_DeleteAuthorToggleDeleteFile.snap new file mode 100644 index 00000000..7847edec --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_0_DeleteAuthorToggleDeleteFile.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_author_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭───────────────────── Delete Author ─────────────────────╮ + │ Do you really want to delete the author: │ + │ Test Author? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Author Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_1_DeleteAuthorToggleAddListExclusion.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_1_DeleteAuthorToggleAddListExclusion.snap new file mode 100644 index 00000000..7847edec --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_1_DeleteAuthorToggleAddListExclusion.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_author_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭───────────────────── Delete Author ─────────────────────╮ + │ Do you really want to delete the author: │ + │ Test Author? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Author Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_2_DeleteAuthorConfirmPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_2_DeleteAuthorConfirmPrompt.snap new file mode 100644 index 00000000..7847edec --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_step_2_DeleteAuthorConfirmPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_author_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭───────────────────── Delete Author ─────────────────────╮ + │ Do you really want to delete the author: │ + │ Test Author? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Author Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_ui_renders_inverted_delete_preferences.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_ui_renders_inverted_delete_preferences.snap new file mode 100644 index 00000000..028d0ebe --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_author_ui__delete_author_ui_tests__tests__snapshot_tests__delete_author_ui_renders_inverted_delete_preferences.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_author_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭───────────────────── Delete Author ─────────────────────╮ + │ Do you really want to delete the author: │ + │ Test Author? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Author Files: │ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ ✔ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_0_DeleteBookToggleDeleteFile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_0_DeleteBookToggleDeleteFile.snap new file mode 100644 index 00000000..2b7fcb09 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_0_DeleteBookToggleDeleteFile.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_book_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭────────────────────── Delete Book ──────────────────────╮ + │ Do you really want to delete the book: │ + │ Test Book? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Book Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_1_DeleteBookToggleAddListExclusion.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_1_DeleteBookToggleAddListExclusion.snap new file mode 100644 index 00000000..2b7fcb09 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_1_DeleteBookToggleAddListExclusion.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_book_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭────────────────────── Delete Book ──────────────────────╮ + │ Do you really want to delete the book: │ + │ Test Book? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Book Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_2_DeleteBookConfirmPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_2_DeleteBookConfirmPrompt.snap new file mode 100644 index 00000000..2b7fcb09 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_step_2_DeleteBookConfirmPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_book_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭────────────────────── Delete Book ──────────────────────╮ + │ Do you really want to delete the book: │ + │ Test Book? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Book Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_ui_renders_inverted_delete_preferences.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_ui_renders_inverted_delete_preferences.snap new file mode 100644 index 00000000..d472cc86 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__delete_book_ui__delete_book_ui_tests__tests__snapshot_tests__delete_book_ui_renders_inverted_delete_preferences.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/delete_book_ui_tests.rs +expression: output +--- + + + + + + + + + + + + + + + + + ╭────────────────────── Delete Book ──────────────────────╮ + │ Do you really want to delete the book: │ + │ Test Book? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Book Files: │ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ ✔ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorPathInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorPathInput.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorPathInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMetadataProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMetadataProfile.snap new file mode 100644 index 00000000..769004c7 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───────────────────────────────╮──────────────────────────────╮ │ + │ Monito│Author Only │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ ╰───────────────────────────────╯──────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMonitorNewItems.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMonitorNewItems.snap new file mode 100644 index 00000000..aebf8f87 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───────────────────────────────╮──────────────────────────────╮ │ + │ Monito│All Books │ ▼ │ │ + │ │No New Books │──────────────────────────────╯ │ + │ │New Books │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ ╰───────────────────────────────╯──────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectQualityProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectQualityProfile.snap new file mode 100644 index 00000000..c09fd7f5 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───────────────────────────────╮──────────────────────────────╮ │ + │ Monito│eBook │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ │ │ │ │ + │ ╰───────────────────────────────╯──────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorTagsInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorTagsInput.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_active_EditAuthorTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_0_EditAuthorToggleMonitored.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_0_EditAuthorToggleMonitored.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_0_EditAuthorToggleMonitored.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_1_EditAuthorSelectMonitorNewItems.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_1_EditAuthorSelectMonitorNewItems.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_1_EditAuthorSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_2_EditAuthorSelectQualityProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_2_EditAuthorSelectQualityProfile.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_2_EditAuthorSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_3_EditAuthorSelectMetadataProfile.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_3_EditAuthorSelectMetadataProfile.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_3_EditAuthorSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_4_EditAuthorPathInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_4_EditAuthorPathInput.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_4_EditAuthorPathInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_5_EditAuthorTagsInput.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_5_EditAuthorTagsInput.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_5_EditAuthorTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_6_EditAuthorConfirmPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_6_EditAuthorConfirmPrompt.snap new file mode 100644 index 00000000..7a345fce --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_step_6_EditAuthorConfirmPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap new file mode 100644 index 00000000..eedd0a72 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_over_author_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fic╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ │ + │Rating: N/A │ │ │ + │Path: /nfs/books/Te│ │ │ + │Quality Profile: St│ │ │ + │Metadata Profile: C│ │ │ + │Monitored: Yes │ │ │ + │Overview: some inte│ │irst novel took him │ + │seven years to fini│ │ │ + │ │ │ │ + │ │ ╭───╮ │ │ + │╭ Author Details │ Monitored: │ ✔ │ │────────────────────╮│ + ││ Books │ History ││ ╰───╯ │ ││ + ││──────────────────│ ╭─────────────────────────────────────────────────╮ │────────────────────││ + ││ Monitored │ Monitor New Books: │All Books ▼ │ │ ││ + ││=> 🏷 │ ╰─────────────────────────────────────────────────╯ │B ││ + ││ │ ╭─────────────────────────────────────────────────╮ │ ││ + ││ │ Quality Profile: │eBook ▼ │ │ ││ + ││ │ ╰─────────────────────────────────────────────────╯ │ ││ + ││ │ ╭─────────────────────────────────────────────────╮ │ ││ + ││ │ Metadata Profile: │Author Only ▼ │ │ ││ + ││ │ ╰─────────────────────────────────────────────────╯ │ ││ + ││ │ ╭─────────────────────────────────────────────────╮ │ ││ + ││ │ Path: │/nfs/books │ │ ││ + ││ │ ╰─────────────────────────────────────────────────╯ │ ││ + ││ │ ╭─────────────────────────────────────────────────╮ │ ││ + ││ │ Tags: │alex │ │ ││ + ││ │ ╰─────────────────────────────────────────────────╯ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ ││ + ││ ││ Save ││ Cancel ││ ││ + ││ │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ ││ + ││ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_unmonitored_author.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_unmonitored_author.snap new file mode 100644 index 00000000..09a3883e --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edit_author_ui__edit_author_ui_tests__tests__snapshot_tests__edit_author_ui_renders_unmonitored_author.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edit_author_ui_tests.rs +expression: output +--- + + + + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders.snap new file mode 100644 index 00000000..a8f37c38 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮ + │Title: Test Edition │ + │Format: Hardcover │ + │Language: English │ + │Publisher: DAW Books │ + │Page Count: 288 │ + │Release Date: 2023-05-20 00:00:00 UTC │ + │ISBN13: 9780756404079 │ + │ASIN: B0043RSJ9S │ + │Ratings: 4.5 (1000 votes) │ + │Monitored: true │ + │Overview: original cover of ISBN 075640407X │ + │ │ + │My name is Kvothe, pronounced nearly the same as "quothe." │ + │ │ + │The Adem call me Maedre: which means The Flame. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_empty.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_empty.snap new file mode 100644 index 00000000..2c3e7f2d --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_empty.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_loading.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_loading.snap new file mode 100644 index 00000000..6f99c2ff --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_loading.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap new file mode 100644 index 00000000..8e88fe26 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_book_details_modal_is_undefined.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_edition_details_modal_is_undefined.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_edition_details_modal_is_undefined.snap new file mode 100644 index 00000000..8e88fe26 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_nothing_when_the_edition_details_modal_is_undefined.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_scrolled.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_scrolled.snap new file mode 100644 index 00000000..38e4b079 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_scrolled.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮ + │Language: English │ + │Publisher: DAW Books │ + │Page Count: 288 │ + │Release Date: 2023-05-20 00:00:00 UTC │ + │ISBN13: 9780756404079 │ + │ASIN: B0043RSJ9S │ + │Ratings: 4.5 (1000 votes) │ + │Monitored: true │ + │Overview: original cover of ISBN 075640407X │ + │ │ + │My name is Kvothe, pronounced nearly the same as "quothe." │ + │ │ + │The Adem call me Maedre: which means The Flame. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_with_zero_editions.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_with_zero_editions.snap new file mode 100644 index 00000000..a8f37c38 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__edition_details_ui__edition_details_ui_tests__tests__snapshot_tests__edition_details_ui_renders_with_zero_editions.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/edition_details_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────── Edition Details ────────────────────────────────────────────────────╮ + │Title: Test Edition │ + │Format: Hardcover │ + │Language: English │ + │Publisher: DAW Books │ + │Page Count: 288 │ + │Release Date: 2023-05-20 00:00:00 UTC │ + │ISBN13: 9780756404079 │ + │ASIN: B0043RSJ9S │ + │Ratings: 4.5 (1000 votes) │ + │Monitored: true │ + │Overview: original cover of ISBN 075640407X │ + │ │ + │My name is Kvothe, pronounced nearly the same as "quothe." │ + │ │ + │The Adem call me Maedre: which means The Flame. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap new file mode 100644 index 00000000..b0dadcd4 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_add_author_over_library.snap @@ -0,0 +1,47 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex + + + + + ╭───────────────────────────────────────────────────── Add Author ─────────────────────────────────────────────────────╮ + │Test Author │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ✔ Name Type Status Rating Genres │ + │=> ✔ Test Author Person Continuing 4.5 science fiction │ + │ Unadded Author Group Ended fantasy, horror │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap new file mode 100644 index 00000000..fe2d10c2 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_author_details_over_library.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex + ╭ Test Author ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Author: Test Author │ + │Status: Continuing │ + │Genres: science fiction │ + │Rating: N/A │ + │Path: /nfs/books/Test Author │ + │Quality Profile: Standard │ + │Metadata Profile: Comprehensive │ + │Monitored: Yes │ + │Overview: some interesting description of the author He was born in Madison, Wisconsin: a city he has never really left. His first novel took him │ + │seven years to finish. │ + │ │ + │ │ + │╭ Author Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Books │ History │ Author Search ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Title Files Release Date Size ││ + ││=> 🏷 Test Book 3/7 2023-05-20 2.34 MB ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap new file mode 100644 index 00000000..de77d3b3 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_delete_author_over_library.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex + + + + + + + + + + + + + + ╭───────────────────── Delete Author ─────────────────────╮ + │ Do you really want to delete the author: │ + │ Test Author? │ + │ │ + │ │ + │ ╭───╮ │ + │ Delete Author Files: │ ✔ │ │ + │ ╰───╯ │ + │ ╭───╮ │ + │ Add List Exclusion: │ │ │ + │ ╰───╯ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap new file mode 100644 index 00000000..7309c501 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_author_over_library.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex + + + + ╭───────────────────────────────────────── Edit - Test Author ──────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭───╮ │ + │ Monitored: │ ✔ │ │ + │ ╰───╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │Standard ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Standard ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │alex │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_empty.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_empty.snap new file mode 100644 index 00000000..fead8278 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_empty.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_loading.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_loading.snap new file mode 100644 index 00000000..90000a03 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap new file mode 100644 index 00000000..6a07d373 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_update_all_authors_prompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex + + + + + + + + + + + + + + ╭────────────────── Update All Authors ───────────────────╮ + │ Do you want to update info and scan your disks for all of │ + │ your authors? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap new file mode 100644 index 00000000..160bda7d --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_Authors.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap new file mode 100644 index 00000000..671ab115 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_AuthorsSortPrompt.snap @@ -0,0 +1,42 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex + + + + + + + + + + + ╭───────────────────────────────╮ + │Name │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰───────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap new file mode 100644 index 00000000..52b83327 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthors.snap @@ -0,0 +1,28 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex + + + + + + + + + + + + + + + + + + + ╭───────────────── Filter ──────────────────╮ + │author filter │ + ╰─────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap new file mode 100644 index 00000000..4ac7e1e4 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_FilterAuthorsError.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex + + + + + + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │The given filter produced empty results│ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap new file mode 100644 index 00000000..0c70718d --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthors.snap @@ -0,0 +1,28 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex + + + + + + + + + + + + + + + + + + + ╭───────────────── Search ──────────────────╮ + │author search │ + ╰─────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap new file mode 100644 index 00000000..cf31bc39 --- /dev/null +++ b/src/ui/readarr_ui/library/snapshots/managarr__ui__readarr_ui__library__library_ui_tests__tests__snapshot_tests__readarr_library_SearchAuthorsError.snap @@ -0,0 +1,31 @@ +--- +source: src/ui/readarr_ui/library/library_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags +=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex + + + + + + + + + + + + + + + + + + + + + ╭─────────────── Error ───────────────╮ + │ No items found matching search │ + │ │ + ╰───────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/mod.rs b/src/ui/readarr_ui/mod.rs new file mode 100644 index 00000000..23ce5dee --- /dev/null +++ b/src/ui/readarr_ui/mod.rs @@ -0,0 +1,236 @@ +#[cfg(test)] +use crate::ui::ui_test_utils::test_utils::Utc; +use chrono::Duration; +#[cfg(not(test))] +use chrono::Utc; +use history::HistoryUi; +use library::LibraryUi; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::Stylize, + text::Text, + widgets::Paragraph, +}; +use std::{cmp, iter}; + +use super::{ + DrawUi, draw_tabs, + styles::ManagarrStyle, + utils::{ + borderless_block, layout_block, line_gauge_with_label, line_gauge_with_title, title_block, + }, + widgets::loading_block::LoadingBlock, +}; +use crate::ui::readarr_ui::blocklist::BlocklistUi; +use crate::ui::readarr_ui::downloads::DownloadsUi; +use crate::ui::readarr_ui::indexers::IndexersUi; +use crate::ui::readarr_ui::root_folders::RootFoldersUi; +use crate::ui::readarr_ui::system::SystemUi; +use crate::ui::utils::{extract_monitored_disk_space_vec, extract_monitored_root_folders}; +use crate::{ + app::App, + logos::READARR_LOGO, + models::{ + Route, + readarr_models::DownloadRecord, + servarr_data::readarr::readarr_data::ReadarrData, + servarr_models::{DiskSpace, RootFolder}, + }, + utils::format_size, +}; + +mod blocklist; +mod downloads; +mod history; +mod indexers; +mod library; +mod readarr_ui_utils; +mod root_folders; +mod system; + +#[cfg(test)] +#[path = "readarr_ui_tests.rs"] +mod readarr_ui_tests; + +pub(super) struct ReadarrUi; + +impl DrawUi for ReadarrUi { + fn accepts(route: Route) -> bool { + matches!(route, Route::Readarr(_, _)) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let content_area = draw_tabs(f, area, "Authors", &app.data.readarr_data.main_tabs); + let route = app.get_current_route(); + + match route { + _ if LibraryUi::accepts(route) => LibraryUi::draw(f, app, content_area), + _ if DownloadsUi::accepts(route) => DownloadsUi::draw(f, app, content_area), + _ if BlocklistUi::accepts(route) => BlocklistUi::draw(f, app, content_area), + _ if HistoryUi::accepts(route) => HistoryUi::draw(f, app, content_area), + _ if RootFoldersUi::accepts(route) => RootFoldersUi::draw(f, app, content_area), + _ if IndexersUi::accepts(route) => IndexersUi::draw(f, app, content_area), + _ if SystemUi::accepts(route) => SystemUi::draw(f, app, content_area), + _ => (), + } + } + + fn draw_context_row(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let [main_area, logo_area] = + Layout::horizontal([Constraint::Fill(0), Constraint::Length(20)]).areas(area); + + let [stats_area, downloads_area] = + Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(main_area); + + draw_stats_context(f, app, stats_area); + draw_downloads_context(f, app, downloads_area); + draw_readarr_logo(f, logo_area); + } +} + +fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block("Stats"); + + if !app.data.readarr_data.version.is_empty() { + f.render_widget(block, area); + let ReadarrData { + root_folders, + disk_space_vec, + start_time, + .. + } = &app.data.readarr_data; + let monitored_disk_space_vec = extract_monitored_disk_space_vec(app, disk_space_vec.clone()); + let monitored_root_folders = extract_monitored_root_folders(app, root_folders.items.clone()); + + let mut constraints = vec![ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]; + + constraints.append( + &mut iter::repeat_n( + Constraint::Length(1), + monitored_disk_space_vec.len() + monitored_root_folders.len() + 1, + ) + .collect(), + ); + + let stat_item_areas = Layout::vertical(constraints).margin(1).split(area); + + let version_paragraph = Paragraph::new(Text::from(format!( + "Readarr Version: {}", + app.data.readarr_data.version + ))) + .block(borderless_block()) + .bold(); + + let uptime = Utc::now() - start_time.to_owned(); + let days = uptime.num_days(); + let day_difference = uptime - Duration::days(days); + let hours = day_difference.num_hours(); + let hour_difference = day_difference - Duration::hours(hours); + let minutes = hour_difference.num_minutes(); + let seconds = (hour_difference - Duration::minutes(minutes)).num_seconds(); + + let uptime_paragraph = Paragraph::new(Text::from(format!( + "Uptime: {days}d {hours:0width$}:{minutes:0width$}:{seconds:0width$}", + width = 2 + ))) + .block(borderless_block()) + .bold(); + + let storage = Paragraph::new(Text::from("Storage:")).block(borderless_block().bold()); + let folders = Paragraph::new(Text::from("Root Folders:")).block(borderless_block().bold()); + + f.render_widget(version_paragraph, stat_item_areas[0]); + f.render_widget(uptime_paragraph, stat_item_areas[1]); + f.render_widget(storage, stat_item_areas[2]); + + for i in 0..monitored_disk_space_vec.len() { + let DiskSpace { + path, + free_space, + total_space, + } = &monitored_disk_space_vec[i]; + let title = if let Some(path) = path { + path + } else { + &format!("Disk {}", i + 1) + }; + let ratio = if *total_space == 0 { + 0f64 + } else { + 1f64 - (*free_space as f64 / *total_space as f64) + }; + + let space_gauge = line_gauge_with_label(title.as_str(), ratio); + + f.render_widget(space_gauge, stat_item_areas[i + 3]); + } + + f.render_widget(folders, stat_item_areas[monitored_disk_space_vec.len() + 3]); + + for i in 0..monitored_root_folders.len() { + let RootFolder { + path, free_space, .. + } = &monitored_root_folders[i]; + let space = format_size(*free_space, 2); + let root_folder_space = Paragraph::new(format!("{path}: {space} free")) + .block(borderless_block()) + .default_color(); + + f.render_widget( + root_folder_space, + stat_item_areas[i + monitored_disk_space_vec.len() + 4], + ) + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} + +fn draw_downloads_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block("Downloads"); + let downloads_vec = &app.data.readarr_data.downloads.items; + + if !downloads_vec.is_empty() { + f.render_widget(block, area); + + let max_items = ((((area.height as f64 / 2.0).floor() * 2.0) as i64) / 2) - 1; + let items = cmp::min(downloads_vec.len(), max_items.unsigned_abs() as usize); + let download_item_areas = + Layout::vertical(iter::repeat_n(Constraint::Length(2), items).collect::>()) + .margin(1) + .split(area); + + for i in 0..items { + let DownloadRecord { + title, + sizeleft, + size, + .. + } = &downloads_vec[i]; + let percent = if *size == 0.0 { + 0.0 + } else { + 1f64 - (*sizeleft / *size) + }; + let download_gauge = line_gauge_with_title(title, percent); + + f.render_widget(download_gauge, download_item_areas[i]); + } + } else { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + } +} + +fn draw_readarr_logo(f: &mut Frame<'_>, area: Rect) { + let logo_text = Text::from(READARR_LOGO); + let logo = Paragraph::new(logo_text) + .light_magenta() + .block(layout_block().default_color()) + .centered(); + f.render_widget(logo, area); +} diff --git a/src/ui/readarr_ui/readarr_ui_tests.rs b/src/ui/readarr_ui/readarr_ui_tests.rs new file mode 100644 index 00000000..b6318b11 --- /dev/null +++ b/src/ui/readarr_ui/readarr_ui_tests.rs @@ -0,0 +1,108 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::models::Route; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::ReadarrUi; + + #[test] + fn test_readarr_ui_accepts() { + for readarr_block in ActiveReadarrBlock::iter() { + assert!(ReadarrUi::accepts(Route::Readarr(readarr_block, None))); + } + } + + mod snapshot_tests { + use super::*; + use crate::app::App; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + use rstest::rstest; + + #[test] + fn test_readarr_ui_renders_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ReadarrUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ReadarrUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_context_row() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ReadarrUi::draw_context_row(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_context_row_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ReadarrUi::draw_context_row(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_context_row_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::Authors.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ReadarrUi::draw_context_row(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + #[case(ActiveReadarrBlock::Authors, 0)] + #[case(ActiveReadarrBlock::Downloads, 1)] + #[case(ActiveReadarrBlock::Blocklist, 2)] + #[case(ActiveReadarrBlock::History, 3)] + #[case(ActiveReadarrBlock::RootFolders, 4)] + #[case(ActiveReadarrBlock::Indexers, 5)] + #[case(ActiveReadarrBlock::System, 6)] + fn test_readarr_ui_renders_readarr_tabs( + #[case] active_readarr_block: ActiveReadarrBlock, + #[case] index: usize, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + app.data.readarr_data.main_tabs.set_index(index); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ReadarrUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("readarr_tabs_{active_readarr_block}"), output); + } + } +} diff --git a/src/ui/readarr_ui/readarr_ui_utils.rs b/src/ui/readarr_ui/readarr_ui_utils.rs new file mode 100644 index 00000000..4d3e3ad4 --- /dev/null +++ b/src/ui/readarr_ui/readarr_ui_utils.rs @@ -0,0 +1,164 @@ +use crate::models::readarr_models::{ + ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, +}; +use ratatui::text::Line; + +#[cfg(test)] +#[path = "readarr_ui_utils_tests.rs"] +mod readarr_ui_utils_tests; + +pub(super) fn create_history_event_details(history_item: ReadarrHistoryItem) -> Vec> { + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item; + let ReadarrHistoryData { + indexer, + nzb_info_url, + release_group, + age, + published_date, + download_client_name, + download_client, + message, + reason, + dropped_path, + imported_path, + source_path, + path, + status_messages, + } = data; + + let mut lines = vec![ + Line::from(format!("Source Title: {}", source_title.text.trim_start())), + Line::from(format!("Event Type: {event_type}")), + Line::from(format!("Quality: {}", quality.quality.name.trim_start())), + Line::from(format!("Date: {date}")), + ]; + + match event_type { + ReadarrHistoryEventType::Grabbed => { + lines.push(Line::from(format!( + "Indexer: {}", + indexer.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "NZB Info URL: {}", + nzb_info_url.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Age: {} days", + age.unwrap_or("0".to_owned()).trim_start() + ))); + lines.push(Line::from(format!( + "Published Date: {}", + published_date.unwrap_or_default() + ))); + lines.push(Line::from(format!( + "Download Client: {}", + download_client_name + .unwrap_or(download_client.unwrap_or_default()) + .trim_start() + ))); + } + ReadarrHistoryEventType::DownloadImported => { + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + } + ReadarrHistoryEventType::DownloadFailed => { + lines.push(Line::from(format!( + "Download Client: {}", + download_client_name + .unwrap_or(download_client.unwrap_or_default()) + .trim_start() + ))); + lines.push(Line::from(format!( + "Message: {}", + message.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Indexer: {}", + indexer.unwrap_or_default().trim_start() + ))); + } + ReadarrHistoryEventType::BookFileDeleted => { + lines.push(Line::from(format!( + "Reason: {}", + reason.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + } + ReadarrHistoryEventType::BookFileImported => { + lines.push(Line::from(format!( + "Dropped Path: {}", + dropped_path.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Imported Path: {}", + imported_path.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Download Client: {}", + download_client_name + .unwrap_or(download_client.unwrap_or_default()) + .trim_start() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + } + ReadarrHistoryEventType::BookFileRenamed => { + lines.push(Line::from(format!( + "Source Path: {}", + source_path.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Path: {}", + path.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + } + ReadarrHistoryEventType::BookFileRetagged => { + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + } + ReadarrHistoryEventType::BookImportIncomplete => { + lines.push(Line::from(format!( + "Status Messages: {}", + status_messages.unwrap_or_default().trim_start() + ))); + lines.push(Line::from(format!( + "Release Group: {}", + release_group.unwrap_or_default().trim_start() + ))); + } + _ => { + lines.push(Line::from("No additional details available.".to_owned())); + } + } + + lines +} diff --git a/src/ui/readarr_ui/readarr_ui_utils_tests.rs b/src/ui/readarr_ui/readarr_ui_utils_tests.rs new file mode 100644 index 00000000..1b4fd3f9 --- /dev/null +++ b/src/ui/readarr_ui/readarr_ui_utils_tests.rs @@ -0,0 +1,477 @@ +#[cfg(test)] +mod tests { + use chrono::Utc; + use pretty_assertions::assert_eq; + use ratatui::text::Line; + + use crate::models::readarr_models::{ + ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, + }; + use crate::models::servarr_models::{Quality, QualityWrapper}; + use crate::ui::readarr_ui::readarr_ui_utils::create_history_event_details; + + #[test] + fn test_create_history_event_details_grabbed() { + let history_item = readarr_history_item(ReadarrHistoryEventType::Grabbed); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!("Indexer: {}", data.indexer.unwrap().trim_start())) + ); + assert_eq!( + result[5], + Line::from(format!( + "NZB Info URL: {}", + data.nzb_info_url.unwrap().trim_start() + )) + ); + assert_eq!( + result[6], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!( + result[7], + Line::from(format!("Age: {} days", data.age.unwrap().trim_start())) + ); + assert_eq!( + result[8], + Line::from(format!("Published Date: {}", data.published_date.unwrap())) + ); + assert_eq!( + result[9], + Line::from(format!( + "Download Client: {}", + data.download_client_name.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 10); + } + + #[test] + fn test_create_history_event_details_grabbed_uses_download_client_as_fallback() { + let mut history_item = readarr_history_item(ReadarrHistoryEventType::Grabbed); + history_item.data.download_client_name = None; + history_item.data.download_client = Some("\nFallback Client".to_owned()); + + let result = create_history_event_details(history_item); + + assert_eq!(result[9], Line::from("Download Client: Fallback Client")); + } + + #[test] + fn test_create_history_event_details_download_imported() { + let history_item = readarr_history_item(ReadarrHistoryEventType::DownloadImported); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 5); + } + + #[test] + fn test_create_history_event_details_download_failed() { + let history_item = readarr_history_item(ReadarrHistoryEventType::DownloadFailed); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Download Client: {}", + data.download_client_name.unwrap().trim_start() + )) + ); + assert_eq!( + result[5], + Line::from(format!("Message: {}", data.message.unwrap().trim_start())) + ); + assert_eq!( + result[6], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!( + result[7], + Line::from(format!("Indexer: {}", data.indexer.unwrap().trim_start())) + ); + assert_eq!(result.len(), 8); + } + + #[test] + fn test_create_history_event_details_book_file_deleted() { + let history_item = readarr_history_item(ReadarrHistoryEventType::BookFileDeleted); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!("Reason: {}", data.reason.unwrap().trim_start())) + ); + assert_eq!( + result[5], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 6); + } + + #[test] + fn test_create_history_event_details_book_file_imported() { + let history_item = readarr_history_item(ReadarrHistoryEventType::BookFileImported); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Dropped Path: {}", + data.dropped_path.unwrap().trim_start() + )) + ); + assert_eq!( + result[5], + Line::from(format!( + "Imported Path: {}", + data.imported_path.unwrap().trim_start() + )) + ); + assert_eq!( + result[6], + Line::from(format!( + "Download Client: {}", + data.download_client_name.unwrap().trim_start() + )) + ); + assert_eq!( + result[7], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 8); + } + + #[test] + fn test_create_history_event_details_book_file_renamed() { + let history_item = readarr_history_item(ReadarrHistoryEventType::BookFileRenamed); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Source Path: {}", + data.source_path.unwrap().trim_start() + )) + ); + assert_eq!( + result[5], + Line::from(format!("Path: {}", data.path.unwrap().trim_start())) + ); + assert_eq!( + result[6], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 7); + } + + #[test] + fn test_create_history_event_details_book_file_retagged() { + let history_item = readarr_history_item(ReadarrHistoryEventType::BookFileRetagged); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 5); + } + + #[test] + fn test_create_history_event_details_book_import_incomplete() { + let history_item = readarr_history_item(ReadarrHistoryEventType::BookImportIncomplete); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + data, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!( + result[4], + Line::from(format!( + "Status Messages: {}", + data.status_messages.unwrap().trim_start() + )) + ); + assert_eq!( + result[5], + Line::from(format!( + "Release Group: {}", + data.release_group.unwrap().trim_start() + )) + ); + assert_eq!(result.len(), 6); + } + + #[test] + fn test_create_history_event_details_unknown() { + let history_item = readarr_history_item(ReadarrHistoryEventType::Unknown); + let ReadarrHistoryItem { + source_title, + event_type, + quality, + date, + .. + } = history_item.clone(); + + let result = create_history_event_details(history_item); + + assert_eq!( + result[0], + Line::from(format!("Source Title: {}", source_title.text.trim_start())) + ); + assert_eq!(result[1], Line::from(format!("Event Type: {event_type}"))); + assert_eq!( + result[2], + Line::from(format!("Quality: {}", quality.quality.name.trim_start())) + ); + assert_eq!(result[3], Line::from(format!("Date: {date}"))); + assert_eq!(result[4], Line::from("No additional details available.")); + assert_eq!(result.len(), 5); + } + + #[test] + fn test_create_history_event_details_author_folder_imported_has_no_additional_details() { + let history_item = readarr_history_item(ReadarrHistoryEventType::AuthorFolderImported); + + let result = create_history_event_details(history_item); + + assert_eq!(result[4], Line::from("No additional details available.")); + assert_eq!(result.len(), 5); + } + + #[test] + fn test_create_history_event_details_download_ignored_has_no_additional_details() { + let history_item = readarr_history_item(ReadarrHistoryEventType::DownloadIgnored); + + let result = create_history_event_details(history_item); + + assert_eq!(result[4], Line::from("No additional details available.")); + assert_eq!(result.len(), 5); + } + + #[test] + fn test_create_history_event_details_with_empty_optional_fields() { + let mut history_item = readarr_history_item(ReadarrHistoryEventType::Grabbed); + history_item.data = ReadarrHistoryData::default(); + + let result = create_history_event_details(history_item); + + assert_eq!(result[4], Line::from("Indexer: ")); + assert_eq!(result[5], Line::from("NZB Info URL: ")); + assert_eq!(result[6], Line::from("Release Group: ")); + assert_eq!(result[7], Line::from("Age: 0 days")); + assert!(result[8].to_string().starts_with("Published Date:")); + assert_eq!(result[9], Line::from("Download Client: ")); + } + + fn readarr_history_item(event_type: ReadarrHistoryEventType) -> ReadarrHistoryItem { + ReadarrHistoryItem { + id: 1, + source_title: "\nTest Book - Author Name".into(), + author_id: 10, + book_id: 100, + event_type, + quality: QualityWrapper { + quality: Quality { + name: "\nEPUB".to_owned(), + }, + }, + date: Utc::now(), + data: readarr_history_data(), + } + } + + fn readarr_history_data() -> ReadarrHistoryData { + ReadarrHistoryData { + indexer: Some("\nTest Indexer".to_owned()), + release_group: Some("\nTest Release Group".to_owned()), + nzb_info_url: Some("\nhttps://test.url".to_owned()), + download_client_name: Some("\nTest Download Client".to_owned()), + download_client: Some("\nFallback Download Client".to_owned()), + age: Some("\n7".to_owned()), + published_date: Some(Utc::now()), + message: Some("\nTest failure message".to_owned()), + reason: Some("\nTest deletion reason".to_owned()), + dropped_path: Some("\n/downloads/completed/book".to_owned()), + imported_path: Some("\n/books/author/book".to_owned()), + source_path: Some("\n/books/author/old_book_name".to_owned()), + path: Some("\n/books/author/new_book_name".to_owned()), + status_messages: Some("\nMissing chapters: 1, 2, 3".to_owned()), + } + } +} diff --git a/src/ui/readarr_ui/root_folders/add_root_folder_ui.rs b/src/ui/readarr_ui/root_folders/add_root_folder_ui.rs new file mode 100644 index 00000000..c67d3298 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/add_root_folder_ui.rs @@ -0,0 +1,274 @@ +use std::sync::atomic::Ordering; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::prelude::Layout; +use ratatui::widgets::ListItem; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::modals::AddReadarrRootFolderModal; +use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, +}; +use crate::render_selectable_input_box; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::button::Button; +use crate::ui::widgets::input_box::InputBox; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::widgets::selectable_list::SelectableList; +use crate::ui::{DrawUi, draw_popup}; + +#[cfg(test)] +#[path = "add_root_folder_ui_tests.rs"] +mod add_root_folder_ui_tests; + +pub(super) struct AddRootFolderUi; + +impl DrawUi for AddRootFolderUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let draw_add_root_folder_prompt = + |f: &mut Frame<'_>, app: &mut App<'_>, prompt_area: Rect| { + draw_add_root_folder_confirmation_prompt(f, app, prompt_area); + + match active_readarr_block { + ActiveReadarrBlock::AddRootFolderSelectMonitor => { + draw_add_root_folder_select_monitor_popup(f, app); + } + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems => { + draw_add_root_folder_select_monitor_new_items_popup(f, app); + } + ActiveReadarrBlock::AddRootFolderSelectQualityProfile => { + draw_add_root_folder_select_quality_profile_popup(f, app); + } + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile => { + draw_add_root_folder_select_metadata_profile_popup(f, app); + } + _ => (), + } + }; + + draw_popup(f, app, draw_add_root_folder_prompt, Size::Long); + } + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +struct AddRootFolderPromptHighlights { + name: bool, + path: bool, + monitor: bool, + monitor_new_items: bool, + quality_profile: bool, + metadata_profile: bool, + tags: bool, + confirm: bool, +} + +fn add_root_folder_prompt_highlights( + selected_block: ActiveReadarrBlock, +) -> AddRootFolderPromptHighlights { + AddRootFolderPromptHighlights { + name: selected_block == ActiveReadarrBlock::AddRootFolderNameInput, + path: selected_block == ActiveReadarrBlock::AddRootFolderPathInput, + monitor: selected_block == ActiveReadarrBlock::AddRootFolderSelectMonitor, + monitor_new_items: selected_block == ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + quality_profile: selected_block == ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + metadata_profile: selected_block == ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + tags: selected_block == ActiveReadarrBlock::AddRootFolderTagsInput, + confirm: selected_block == ActiveReadarrBlock::AddRootFolderConfirmPrompt, + } +} + +fn draw_add_root_folder_confirmation_prompt(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let title = "Add Root Folder"; + f.render_widget(title_block_centered(title), area); + + let yes_no_value = app.data.readarr_data.prompt_confirm; + let selected_block = app.data.readarr_data.selected_block.get_active_block(); + let highlights = add_root_folder_prompt_highlights(selected_block); + let AddReadarrRootFolderModal { + name, + path, + monitor_list, + monitor_new_items_list, + quality_profile_list, + metadata_profile_list, + tags, + } = app + .data + .readarr_data + .add_root_folder_modal + .as_ref() + .expect("add_root_folder_modal must exist in this context"); + let selected_monitor = monitor_list.current_selection(); + let selected_monitor_new_items = monitor_new_items_list.current_selection(); + let selected_quality_profile = quality_profile_list.current_selection(); + let selected_metadata_profile = metadata_profile_list.current_selection(); + + let [ + _, + name_area, + path_area, + monitor_area, + monitor_new_items_area, + quality_profile_area, + metadata_profile_area, + tags_area, + _, + buttons_area, + ] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(3), + ]) + .margin(1) + .areas(area); + let [save_area, cancel_area] = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(buttons_area); + + let monitor_drop_down_button = Button::default() + .title(selected_monitor.to_display_str()) + .label("Monitor") + .icon("▼") + .selected(highlights.monitor); + let monitor_new_items_drop_down_button = Button::default() + .title(selected_monitor_new_items.to_display_str()) + .label("Monitor New Books") + .icon("▼") + .selected(highlights.monitor_new_items); + let quality_profile_drop_down_button = Button::default() + .title(selected_quality_profile) + .label("Quality Profile") + .icon("▼") + .selected(highlights.quality_profile); + let metadata_profile_drop_down_button = Button::default() + .title(selected_metadata_profile) + .label("Metadata Profile") + .icon("▼") + .selected(highlights.metadata_profile); + + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + let name_input_box = InputBox::new(&name.text) + .offset(name.offset.load(Ordering::SeqCst)) + .label("Name") + .highlighted(highlights.name) + .selected(active_readarr_block == ActiveReadarrBlock::AddRootFolderNameInput); + let path_input_box = InputBox::new(&path.text) + .offset(path.offset.load(Ordering::SeqCst)) + .label("Path") + .highlighted(highlights.path) + .selected(active_readarr_block == ActiveReadarrBlock::AddRootFolderPathInput); + let tags_input_box = InputBox::new(&tags.text) + .offset(tags.offset.load(Ordering::SeqCst)) + .label("Tags") + .highlighted(highlights.tags) + .selected(active_readarr_block == ActiveReadarrBlock::AddRootFolderTagsInput); + + match active_readarr_block { + ActiveReadarrBlock::AddRootFolderNameInput => name_input_box.show_cursor(f, name_area), + ActiveReadarrBlock::AddRootFolderPathInput => path_input_box.show_cursor(f, path_area), + ActiveReadarrBlock::AddRootFolderTagsInput => tags_input_box.show_cursor(f, tags_area), + _ => (), + } + + render_selectable_input_box!(name_input_box, f, name_area); + render_selectable_input_box!(path_input_box, f, path_area); + render_selectable_input_box!(tags_input_box, f, tags_area); + } + + let save_button = Button::default() + .title("Save") + .selected(yes_no_value && highlights.confirm); + let cancel_button = Button::default() + .title("Cancel") + .selected(!yes_no_value && highlights.confirm); + + f.render_widget(monitor_drop_down_button, monitor_area); + f.render_widget(monitor_new_items_drop_down_button, monitor_new_items_area); + f.render_widget(quality_profile_drop_down_button, quality_profile_area); + f.render_widget(metadata_profile_drop_down_button, metadata_profile_area); + f.render_widget(save_button, save_area); + f.render_widget(cancel_button, cancel_area); +} + +fn draw_add_root_folder_select_monitor_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .expect("add_root_folder_modal must exist in this context") + .monitor_list, + |monitor_type| ListItem::new(monitor_type.to_display_str().to_owned()), + ); + let popup = Popup::new(monitor_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_root_folder_select_monitor_new_items_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let monitor_new_items_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .expect("add_root_folder_modal must exist in this context") + .monitor_new_items_list, + |monitor_type| ListItem::new(monitor_type.to_display_str().to_owned()), + ); + let popup = Popup::new(monitor_new_items_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_root_folder_select_quality_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let quality_profile_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .expect("add_root_folder_modal must exist in this context") + .quality_profile_list, + |quality_profile| ListItem::new(quality_profile.clone()), + ); + let popup = Popup::new(quality_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} + +fn draw_add_root_folder_select_metadata_profile_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let metadata_profile_list = SelectableList::new( + &mut app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .expect("add_root_folder_modal must exist in this context") + .metadata_profile_list, + |metadata_profile| ListItem::new(metadata_profile.clone()), + ); + let popup = Popup::new(metadata_profile_list).size(Size::Dropdown); + + f.render_widget(popup, f.area()); +} diff --git a/src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs b/src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs new file mode 100644 index 00000000..2a9666a0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs @@ -0,0 +1,231 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ADD_ROOT_FOLDER_SELECTION_BLOCKS, ActiveReadarrBlock, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::root_folders::add_root_folder_ui::{ + AddRootFolderPromptHighlights, AddRootFolderUi, add_root_folder_prompt_highlights, + }; + + #[test] + fn test_add_root_folder_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) { + assert!(AddRootFolderUi::accepts(active_readarr_block.into())); + } else { + assert!(!AddRootFolderUi::accepts(active_readarr_block.into())); + } + }); + } + + #[rstest] + #[case( + ActiveReadarrBlock::AddRootFolderNameInput, + AddRootFolderPromptHighlights { + name: true, + path: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderPathInput, + AddRootFolderPromptHighlights { + name: false, + path: true, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderSelectMonitor, + AddRootFolderPromptHighlights { + name: false, + path: false, + monitor: true, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + AddRootFolderPromptHighlights { + name: false, + path: false, + monitor: false, + monitor_new_items: true, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + AddRootFolderPromptHighlights { + name: false, + path: false, + monitor: false, + monitor_new_items: false, + quality_profile: true, + metadata_profile: false, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + AddRootFolderPromptHighlights { + name: false, + path: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: true, + tags: false, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderTagsInput, + AddRootFolderPromptHighlights { + name: false, + path: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: true, + confirm: false, + } + )] + #[case( + ActiveReadarrBlock::AddRootFolderConfirmPrompt, + AddRootFolderPromptHighlights { + name: false, + path: false, + monitor: false, + monitor_new_items: false, + quality_profile: false, + metadata_profile: false, + tags: false, + confirm: true, + } + )] + fn test_add_root_folder_prompt_highlights( + #[case] selected_block: ActiveReadarrBlock, + #[case] expected_highlights: AddRootFolderPromptHighlights, + ) { + let highlights = add_root_folder_prompt_highlights(selected_block); + + assert_eq!(highlights, expected_highlights); + } + + #[test] + fn test_add_root_folder_prompt_highlights_covers_every_selection_step() { + for step in ADD_ROOT_FOLDER_SELECTION_BLOCKS { + let highlights = add_root_folder_prompt_highlights(step[0]); + + assert_ne!( + highlights, + add_root_folder_prompt_highlights(ActiveReadarrBlock::RootFolders) + ); + } + } + + mod snapshot_tests { + use super::*; + use crate::app::App; + use crate::models::BlockSelectionState; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; + + fn seed_distinct_profile_lists(app: &mut App<'_>) { + let add_root_folder_modal = app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .expect("add_root_folder_modal must exist in this context"); + add_root_folder_modal + .quality_profile_list + .set_items(vec!["eBook".to_owned()]); + add_root_folder_modal + .metadata_profile_list + .set_items(vec!["Author Only".to_owned()]); + } + + #[rstest] + fn test_add_root_folder_ui_renders( + #[values( + ActiveReadarrBlock::AddRootFolderPrompt, + ActiveReadarrBlock::AddRootFolderConfirmPrompt, + ActiveReadarrBlock::AddRootFolderNameInput, + ActiveReadarrBlock::AddRootFolderPathInput, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + ActiveReadarrBlock::AddRootFolderTagsInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + seed_distinct_profile_lists(&mut app); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddRootFolderUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("add_root_folder_{active_readarr_block}"), output); + } + + #[rstest] + #[case(0, ActiveReadarrBlock::AddRootFolderNameInput)] + #[case(1, ActiveReadarrBlock::AddRootFolderPathInput)] + #[case(2, ActiveReadarrBlock::AddRootFolderSelectMonitor)] + #[case(3, ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems)] + #[case(4, ActiveReadarrBlock::AddRootFolderSelectQualityProfile)] + #[case(5, ActiveReadarrBlock::AddRootFolderSelectMetadataProfile)] + #[case(6, ActiveReadarrBlock::AddRootFolderTagsInput)] + #[case(7, ActiveReadarrBlock::AddRootFolderConfirmPrompt)] + fn test_add_root_folder_modal_ui_renders_step( + #[case] step: usize, + #[case] selected_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + seed_distinct_profile_lists(&mut app); + let mut selected_block = BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + selected_block.set_index(0, step); + app.data.readarr_data.selected_block = selected_block; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + AddRootFolderUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("add_root_folder_step_{step}_{selected_readarr_block}"), + output + ); + } + } +} diff --git a/src/ui/readarr_ui/root_folders/mod.rs b/src/ui/readarr_ui/root_folders/mod.rs new file mode 100644 index 00000000..8fa93f8f --- /dev/null +++ b/src/ui/readarr_ui/root_folders/mod.rs @@ -0,0 +1,102 @@ +use add_root_folder_ui::AddRootFolderUi; +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::widgets::{Cell, Row}; + +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, ROOT_FOLDERS_BLOCKS, +}; +use crate::models::servarr_models::RootFolder; +use crate::ui::DrawUi; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::layout_block_top_border; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::utils::format_size; + +mod add_root_folder_ui; + +#[cfg(test)] +#[path = "root_folders_ui_tests.rs"] +mod root_folders_ui_tests; + +pub(super) struct RootFoldersUi; + +impl DrawUi for RootFoldersUi { + fn accepts(route: Route) -> bool { + if let Route::Readarr(active_readarr_block, _) = route { + return ROOT_FOLDERS_BLOCKS.contains(&active_readarr_block) + || ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block); + } + + false + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + draw_root_folders(f, app, area); + + if ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) { + AddRootFolderUi::draw(f, app, area); + } else if active_readarr_block == ActiveReadarrBlock::DeleteRootFolderPrompt { + let prompt = format!( + "Do you really want to delete this root folder: \n{}?", + app.data.readarr_data.root_folders.current_selection().path + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Delete Root Folder") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } + } + } +} + +fn draw_root_folders(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let root_folders_row_mapping = |root_folders: &RootFolder| { + let RootFolder { + path, + free_space, + unmapped_folders, + .. + } = root_folders; + + let space = format_size(*free_space, 2); + + Row::new(vec![ + Cell::from(path.to_owned()), + Cell::from(space), + Cell::from( + unmapped_folders + .as_ref() + .unwrap_or(&Vec::new()) + .len() + .to_string(), + ), + ]) + .primary() + }; + + let root_folders_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.root_folders), + root_folders_row_mapping, + ) + .block(layout_block_top_border()) + .loading(app.is_loading) + .headers(["Path", "Free Space", "Unmapped Folders"]) + .constraints([ + Constraint::Ratio(3, 5), + Constraint::Ratio(1, 5), + Constraint::Ratio(1, 5), + ]); + + f.render_widget(root_folders_table, area); +} diff --git a/src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs b/src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs new file mode 100644 index 00000000..791f0279 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs @@ -0,0 +1,190 @@ +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::readarr::readarr_data::{ + ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, ROOT_FOLDERS_BLOCKS, + }; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::root_folders::RootFoldersUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_root_folders_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if ROOT_FOLDERS_BLOCKS.contains(&active_readarr_block) + || ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) + { + assert!(RootFoldersUi::accepts(active_readarr_block.into())); + } else { + assert!(!RootFoldersUi::accepts(active_readarr_block.into())); + } + }); + } + + mod snapshot_tests { + use super::*; + use crate::models::BlockSelectionState; + use crate::models::Scrollable; + use crate::models::servarr_data::readarr::readarr_data::ADD_ROOT_FOLDER_SELECTION_BLOCKS; + use crate::models::servarr_models::{RootFolder, UnmappedFolder}; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + use rstest::rstest; + + fn seed_distinct_root_folders(app: &mut App<'_>) { + app.data.readarr_data.root_folders.set_items(vec![ + RootFolder { + id: 1, + path: "/nfs/books".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs/audiobooks".to_owned(), + accessible: false, + free_space: 1099511627776, + unmapped_folders: Some(vec![ + UnmappedFolder { + name: "Unsorted Anthologies".to_owned(), + path: "/nfs/audiobooks/unsorted-anthologies".to_owned(), + }, + UnmappedFolder { + name: "Pending Imports".to_owned(), + path: "/nfs/audiobooks/pending-imports".to_owned(), + }, + UnmappedFolder { + name: "Damaged Rips".to_owned(), + path: "/nfs/audiobooks/damaged-rips".to_owned(), + }, + ]), + }, + ]); + } + + fn seed_distinct_profile_lists(app: &mut App<'_>) { + let add_root_folder_modal = app + .data + .readarr_data + .add_root_folder_modal + .as_mut() + .expect("add_root_folder_modal must exist in this context"); + add_root_folder_modal + .quality_profile_list + .set_items(vec!["eBook".to_owned()]); + add_root_folder_modal + .metadata_profile_list + .set_items(vec!["Author Only".to_owned()]); + } + + #[test] + fn test_root_folders_ui_renders_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + RootFoldersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_root_folders_ui_renders_empty_root_folders() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::RootFolders.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + RootFoldersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_root_folders_ui_renders_root_folders_tab( + #[values( + ActiveReadarrBlock::RootFolders, + ActiveReadarrBlock::DeleteRootFolderPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + RootFoldersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(active_readarr_block.to_string(), output); + } + + #[rstest] + fn test_root_folders_ui_renders_distinct_root_folders( + #[values( + ActiveReadarrBlock::RootFolders, + ActiveReadarrBlock::DeleteRootFolderPrompt + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + seed_distinct_root_folders(&mut app); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + RootFoldersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("distinct_root_folders_{active_readarr_block}"), + output + ); + } + + #[test] + fn test_root_folders_ui_renders_delete_prompt_for_non_default_selection() { + let mut app = App::test_default_fully_populated(); + seed_distinct_root_folders(&mut app); + app.data.readarr_data.root_folders.scroll_down(); + app.push_navigation_stack(ActiveReadarrBlock::DeleteRootFolderPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + RootFoldersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_root_folders_ui_renders_add_root_folder_popup_over_root_folders_table( + #[values( + ActiveReadarrBlock::AddRootFolderPrompt, + ActiveReadarrBlock::AddRootFolderConfirmPrompt, + ActiveReadarrBlock::AddRootFolderNameInput, + ActiveReadarrBlock::AddRootFolderPathInput, + ActiveReadarrBlock::AddRootFolderSelectMonitor, + ActiveReadarrBlock::AddRootFolderSelectMonitorNewItems, + ActiveReadarrBlock::AddRootFolderSelectQualityProfile, + ActiveReadarrBlock::AddRootFolderSelectMetadataProfile, + ActiveReadarrBlock::AddRootFolderTagsInput + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + seed_distinct_profile_lists(&mut app); + app.data.readarr_data.selected_block = + BlockSelectionState::new(ADD_ROOT_FOLDER_SELECTION_BLOCKS); + app.push_navigation_stack(ActiveReadarrBlock::AddRootFolderPrompt.into()); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + RootFoldersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(active_readarr_block.to_string(), output); + } + } +} diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderConfirmPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderConfirmPrompt.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderConfirmPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderNameInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderNameInput.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderNameInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPathInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPathInput.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPathInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPrompt.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMetadataProfile.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMetadataProfile.snap new file mode 100644 index 00000000..4bae78d6 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │Author Only │──────────────────────────────╮ │ + │ │ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Monito│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitor.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitor.snap new file mode 100644 index 00000000..2d9649ae --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitor.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │All Books │──────────────────────────────╮ │ + │ │Future Books │ ▼ │ │ + │ │Missing Books │──────────────────────────────╯ │ + │ │Existing Books │──────────────────────────────╮ │ + │ Monito│First Book │ ▼ │ │ + │ │Latest Book │──────────────────────────────╯ │ + │ │None │──────────────────────────────╮ │ + │ Qual│Unknown │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitorNewItems.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitorNewItems.snap new file mode 100644 index 00000000..65e7198a --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │All Books │──────────────────────────────╮ │ + │ │No New Books │ ▼ │ │ + │ │New Books │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Monito│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectQualityProfile.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectQualityProfile.snap new file mode 100644 index 00000000..f3dc8c7d --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │eBook │──────────────────────────────╮ │ + │ │ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Monito│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderTagsInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderTagsInput.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_AddRootFolderTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_0_AddRootFolderNameInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_0_AddRootFolderNameInput.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_0_AddRootFolderNameInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_1_AddRootFolderPathInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_1_AddRootFolderPathInput.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_1_AddRootFolderPathInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_2_AddRootFolderSelectMonitor.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_2_AddRootFolderSelectMonitor.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_2_AddRootFolderSelectMonitor.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_3_AddRootFolderSelectMonitorNewItems.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_3_AddRootFolderSelectMonitorNewItems.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_3_AddRootFolderSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_4_AddRootFolderSelectQualityProfile.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_4_AddRootFolderSelectQualityProfile.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_4_AddRootFolderSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_5_AddRootFolderSelectMetadataProfile.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_5_AddRootFolderSelectMetadataProfile.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_5_AddRootFolderSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_6_AddRootFolderTagsInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_6_AddRootFolderTagsInput.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_6_AddRootFolderTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_7_AddRootFolderConfirmPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_7_AddRootFolderConfirmPrompt.snap new file mode 100644 index 00000000..784a3bb0 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__add_root_folder_ui__add_root_folder_ui_tests__tests__snapshot_tests__add_root_folder_step_7_AddRootFolderConfirmPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/add_root_folder_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderConfirmPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderConfirmPrompt.snap new file mode 100644 index 00000000..189b2290 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderConfirmPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderNameInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderNameInput.snap new file mode 100644 index 00000000..189b2290 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderNameInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPathInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPathInput.snap new file mode 100644 index 00000000..189b2290 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPathInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPrompt.snap new file mode 100644 index 00000000..189b2290 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMetadataProfile.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMetadataProfile.snap new file mode 100644 index 00000000..60259596 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMetadataProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │Author Only │──────────────────────────────╮ │ + │ │ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Monito│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitor.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitor.snap new file mode 100644 index 00000000..9d8d5dbd --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitor.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │All Books │──────────────────────────────╮ │ + │ │Future Books │ ▼ │ │ + │ │Missing Books │──────────────────────────────╯ │ + │ │Existing Books │──────────────────────────────╮ │ + │ Monito│First Book │ ▼ │ │ + │ │Latest Book │──────────────────────────────╯ │ + │ │None │──────────────────────────────╮ │ + │ Qual│Unknown │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitorNewItems.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitorNewItems.snap new file mode 100644 index 00000000..30be8e86 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectMonitorNewItems.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │All Books │──────────────────────────────╮ │ + │ │No New Books │ ▼ │ │ + │ │New Books │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Monito│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectQualityProfile.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectQualityProfile.snap new file mode 100644 index 00000000..da678d02 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderSelectQualityProfile.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╭───────────────────────────────╮──────────────────────────────╯ │ + │ │eBook │──────────────────────────────╮ │ + │ │ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Monito│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Qual│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ Metad│ │ ▼ │ │ + │ │ │──────────────────────────────╯ │ + │ │ │──────────────────────────────╮ │ + │ ╰───────────────────────────────╯ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderTagsInput.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderTagsInput.snap new file mode 100644 index 00000000..189b2290 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__AddRootFolderTagsInput.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + ╭─────────────────────────────────────────── Add Root Folder ───────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Name: │Test Root Folder │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Path: │/nfs/books │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Monitor New Books: │All Books ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Quality Profile: │eBook ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Metadata Profile: │Author Only ▼ │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ ╭─────────────────────────────────────────────────╮ │ + │ Tags: │test │ │ + │ ╰─────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭───────────────────────────────────────────────────╮╭──────────────────────────────────────────────────╮│ + ││ Save ││ Cancel ││ + │╰───────────────────────────────────────────────────╯╰──────────────────────────────────────────────────╯│ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__DeleteRootFolderPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__DeleteRootFolderPrompt.snap new file mode 100644 index 00000000..1ab60315 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__DeleteRootFolderPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 + + + + + + + + + + + + + + ╭────────────────── Delete Root Folder ───────────────────╮ + │ Do you really want to delete this root folder: │ + │ /nfs? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__RootFolders.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__RootFolders.snap new file mode 100644 index 00000000..861bb5ca --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__RootFolders.snap @@ -0,0 +1,7 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs 204800.00 GB 0 diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_DeleteRootFolderPrompt.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_DeleteRootFolderPrompt.snap new file mode 100644 index 00000000..aa610de4 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_DeleteRootFolderPrompt.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs/books 204800.00 GB 0 + /nfs/audiobooks 1024.00 GB 3 + + + + + + + + + + + + + ╭────────────────── Delete Root Folder ───────────────────╮ + │ Do you really want to delete this root folder: │ + │ /nfs/books? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_RootFolders.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_RootFolders.snap new file mode 100644 index 00000000..5949277a --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__distinct_root_folders_RootFolders.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders +=> /nfs/books 204800.00 GB 0 + /nfs/audiobooks 1024.00 GB 3 diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_delete_prompt_for_non_default_selection.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_delete_prompt_for_non_default_selection.snap new file mode 100644 index 00000000..4e7aec89 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_delete_prompt_for_non_default_selection.snap @@ -0,0 +1,38 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Path Free Space Unmapped Folders + /nfs/books 204800.00 GB 0 +=> /nfs/audiobooks 1024.00 GB 3 + + + + + + + + + + + + + ╭────────────────── Delete Root Folder ───────────────────╮ + │ Do you really want to delete this root folder: │ + │ /nfs/audiobooks? │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │╭────────────────────────────╮╭───────────────────────────╮│ + ││ Yes ││ No ││ + │╰────────────────────────────╯╰───────────────────────────╯│ + ╰───────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_empty_root_folders.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_empty_root_folders.snap new file mode 100644 index 00000000..84395021 --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_empty_root_folders.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_loading.snap b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_loading.snap new file mode 100644 index 00000000..894b060a --- /dev/null +++ b/src/ui/readarr_ui/root_folders/snapshots/managarr__ui__readarr_ui__root_folders__root_folders_ui_tests__tests__snapshot_tests__root_folders_ui_renders_loading.snap @@ -0,0 +1,8 @@ +--- +source: src/ui/readarr_ui/root_folders/root_folders_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + Loading ... diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap new file mode 100644 index 00000000..2f9793fb --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Authors.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Blocklist.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Blocklist.snap new file mode 100644 index 00000000..39570c7c --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Blocklist.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Author Name ▼ Source Title Quality Date │ +│=> Test Author Test Source Title EPUB 2023-05-20 21:29:16 UTC │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap new file mode 100644 index 00000000..86d9b348 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Downloads.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Title Percent Complete Size Output Path Indexer Download Client │ +│=> Test Download Title 50% 3.30 GB /nfs/books/Test Author kickass torrents transmission │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_History.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_History.snap new file mode 100644 index 00000000..f1ef5ee4 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_History.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Source Title ▼ Event Type Quality Date │ +│=> Test Source Title grabbed EPUB 2023-05-20 21:29:16 UTC │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Indexers.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Indexers.snap new file mode 100644 index 00000000..de20de5f --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_Indexers.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Indexer RSS Automatic Search Interactive Search Priority Tags │ +│=> Test Indexer Enabled Enabled Enabled 25 alex │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_RootFolders.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_RootFolders.snap new file mode 100644 index 00000000..408c386b --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_RootFolders.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Path Free Space Unmapped Folders │ +│=> /nfs 204800.00 GB 0 │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_System.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_System.snap new file mode 100644 index 00000000..8ee87793 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_tabs_System.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│╭ Tasks ───────────────────────────────────────────────────────────────────────╮╭ Queued Events ──────────────────────────────────────────────────────────────╮│ +││Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration ││ +││Backup 1 hour now 59 minutes ││manual completed Refresh Monitored 4 minutes ago 4 minutes a 00:03:03 ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +││ ││ ││ +│╰────────────────────────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────────────────────╯│ +│╭ Logs ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ +││2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|No list items to process ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +││ ││ +│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row.snap new file mode 100644 index 00000000..b53029b6 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│Readarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│Uptime: 0d 00:00:44 ││50% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│Storage: ││ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│/path: 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│Root Folders: ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│/nfs: 204800.00 GB free ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_empty.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_empty.snap new file mode 100644 index 00000000..1b8ebccf --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_empty.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│ ││ ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│ ││ ││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│ ││ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│ ││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│ ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│ ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_loading.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_loading.snap new file mode 100644 index 00000000..1cc60a50 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_context_row_loading.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│ ││ ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│ ││ ││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│ Loading ... ││ Loading ... ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│ ││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│ ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│ ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_empty.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_empty.snap new file mode 100644 index 00000000..90973155 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_empty.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_loading.snap b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_loading.snap new file mode 100644 index 00000000..27ac8502 --- /dev/null +++ b/src/ui/readarr_ui/snapshots/managarr__ui__readarr_ui__readarr_ui_tests__tests__snapshot_tests__readarr_ui_renders_loading.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/readarr_ui_tests.rs +expression: output +--- +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ │ +│ │ +│ Loading ... │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/mod.rs b/src/ui/readarr_ui/system/mod.rs new file mode 100644 index 00000000..d353adae --- /dev/null +++ b/src/ui/readarr_ui/system/mod.rs @@ -0,0 +1,203 @@ +use std::ops::Sub; + +#[cfg(not(test))] +use chrono::Utc; +use ratatui::text::{Span, Text}; +use ratatui::widgets::{Cell, Row}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + widgets::ListItem, +}; + +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::ReadarrTask; +use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; +use crate::models::servarr_models::QueueEvent; +use crate::ui::DrawUi; +use crate::ui::readarr_ui::system::system_details_ui::SystemDetailsUi; +use crate::ui::styles::{ManagarrStyle, default_style}; +#[cfg(test)] +use crate::ui::ui_test_utils::test_utils::Utc; +use crate::ui::utils::{convert_to_minutes_hours_days, style_log_list_item, title_block}; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::selectable_list::SelectableList; + +mod system_details_ui; + +#[cfg(test)] +#[path = "system_ui_tests.rs"] +mod system_ui_tests; + +pub(super) const TASK_TABLE_HEADERS: [&str; 4] = + ["Name", "Interval", "Last Execution", "Next Execution"]; + +pub(super) const TASK_TABLE_CONSTRAINTS: [Constraint; 4] = [ + Constraint::Percentage(30), + Constraint::Percentage(23), + Constraint::Percentage(23), + Constraint::Percentage(23), +]; + +pub(super) struct SystemUi; + +impl DrawUi for SystemUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + SystemDetailsUi::accepts(route) || active_readarr_block == ActiveReadarrBlock::System + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let route = app.get_current_route(); + draw_system_ui_layout(f, app, area); + + if SystemDetailsUi::accepts(route) { + SystemDetailsUi::draw(f, app, area); + } + } +} + +fn draw_system_ui_layout(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let [activities_area, logs_area] = + Layout::vertical([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(area); + + let [tasks_area, events_area] = + Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(activities_area); + + draw_tasks(f, app, tasks_area); + draw_queued_events(f, app, events_area); + draw_logs(f, app, logs_area); +} + +fn draw_tasks(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let tasks_row_mapping = |task: &ReadarrTask| { + let task_props = extract_task_props(task); + + Row::new(vec![ + Cell::from(task_props.name), + Cell::from(task_props.interval), + Cell::from(task_props.last_execution), + Cell::from(task_props.next_execution), + ]) + .primary() + }; + let tasks_table = ManagarrTable::new(Some(&mut app.data.readarr_data.tasks), tasks_row_mapping) + .block(title_block("Tasks")) + .loading(app.is_loading) + .highlight_rows(false) + .headers(TASK_TABLE_HEADERS) + .constraints(TASK_TABLE_CONSTRAINTS); + + f.render_widget(tasks_table, area); +} + +pub(super) fn draw_queued_events(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let events_row_mapping = |event: &QueueEvent| { + let queued = convert_to_minutes_hours_days(Utc::now().sub(event.queued).num_minutes()); + let queued_string = if queued != "now" { + format!("{queued} ago") + } else { + queued + }; + let started_string = if let Some(date_time) = event.started { + let started = convert_to_minutes_hours_days(Utc::now().sub(date_time).num_minutes()); + + if started != "now" { + format!("{started} ago") + } else { + started + } + } else { + String::new() + }; + + let duration = if let Some(dur) = &event.duration { + &dur[..8] + } else { + "" + }; + + Row::new(vec![ + Cell::from(event.trigger.clone()), + Cell::from(event.status.clone()), + Cell::from(event.command_name.clone()), + Cell::from(queued_string), + Cell::from(started_string), + Cell::from(duration.to_owned()), + ]) + .primary() + }; + let events_table = ManagarrTable::new( + Some(&mut app.data.readarr_data.queued_events), + events_row_mapping, + ) + .block(title_block("Queued Events")) + .loading(app.is_loading) + .highlight_rows(false) + .headers(["Trigger", "Status", "Name", "Queued", "Started", "Duration"]) + .constraints([ + Constraint::Percentage(13), + Constraint::Percentage(13), + Constraint::Percentage(30), + Constraint::Percentage(16), + Constraint::Percentage(14), + Constraint::Percentage(14), + ]); + + f.render_widget(events_table, area); +} + +fn draw_logs(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let block = title_block("Logs"); + + if app.data.readarr_data.logs.items.is_empty() { + f.render_widget(LoadingBlock::new(app.is_loading, block), area); + return; + } + + let logs_box = SelectableList::new(&mut app.data.readarr_data.logs, |log| { + let log_line = log.to_string(); + let level = extract_log_level(&log.text); + + style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) + }) + .block(block) + .highlight_style(default_style()); + + f.render_widget(logs_box, area); +} + +pub(super) fn extract_log_level(log_line: &str) -> String { + log_line.split('|').collect::>()[1].to_string() +} + +pub(super) struct TaskProps { + pub(super) name: String, + pub(super) interval: String, + pub(super) last_execution: String, + pub(super) next_execution: String, +} + +pub(super) fn extract_task_props(task: &ReadarrTask) -> TaskProps { + let interval = convert_to_minutes_hours_days(task.interval); + let next_execution = + convert_to_minutes_hours_days((task.next_execution - Utc::now()).num_minutes()); + let last_execution = + convert_to_minutes_hours_days((Utc::now() - task.last_execution).num_minutes()); + let last_execution_string = if last_execution != "now" { + format!("{last_execution} ago") + } else { + last_execution + }; + + TaskProps { + name: task.name.clone(), + interval, + last_execution: last_execution_string, + next_execution, + } +} diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemLogs.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemLogs.snap new file mode 100644 index 00000000..77cc4918 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemLogs.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Log Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemQueuedEvents.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemQueuedEvents.snap new file mode 100644 index 00000000..06f90228 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemQueuedEvents.snap @@ -0,0 +1,44 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + + + + + ╭ Queued Events ────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemTasks.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemTasks.snap new file mode 100644 index 00000000..42783c04 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__empty_SystemTasks.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemLogs.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemLogs.snap new file mode 100644 index 00000000..aed87ded --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemLogs.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Log Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemQueuedEvents.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemQueuedEvents.snap new file mode 100644 index 00000000..bd8e76f4 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemQueuedEvents.snap @@ -0,0 +1,44 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + + + + + ╭ Queued Events ────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemTasks.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemTasks.snap new file mode 100644 index 00000000..1f14d555 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemTasks.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemUpdates.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemUpdates.snap new file mode 100644 index 00000000..944d9739 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__loading_SystemUpdates.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemLogs.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemLogs.snap new file mode 100644 index 00000000..ca13cf1d --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemLogs.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Log Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha │ + │2025-12-16 16:41:12 UTC|WARN|BookScanService|log line beta │ + │2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemQueuedEvents.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemQueuedEvents.snap new file mode 100644 index 00000000..7fa24517 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemQueuedEvents.snap @@ -0,0 +1,44 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + + + + + ╭ Queued Events ────────────────────────────────────────────────────────────────────────────────╮ + │Trigger Status Name Queued Started Duration │ + │manual completed queued event one 1 hour ago 30 minutes ag 00:29:14 │ + │scheduled started queued event two 15 minutes ago 4 minutes ago 01:02:03 │ + │external queued queued event three 1 minute ago │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTaskStartConfirmPrompt.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTaskStartConfirmPrompt.snap new file mode 100644 index 00000000..4cd6d8c4 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTaskStartConfirmPrompt.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name Interval Last Execution Next Execution │ + │=> Backup task 2 hours 30 minutes ago 1 hour │ + │ Rss sync task 15 minutes 8 minutes ago 3 minutes │ + │ Housekeeping task 1 day 4 hours ago 18 hours │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭────────────────────── Start Task ───────────────────────╮ │ + │ │ Do you want to manually start this task: Backup task? │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTasks.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTasks.snap new file mode 100644 index 00000000..2950c7dd --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemTasks.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name Interval Last Execution Next Execution │ + │=> Backup task 2 hours 30 minutes ago 1 hour │ + │ Rss sync task 15 minutes 8 minutes ago 3 minutes │ + │ Housekeeping task 1 day 4 hours ago 18 hours │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemUpdates.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemUpdates.snap new file mode 100644 index 00000000..0a4cb97d --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__popup_SystemUpdates.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │The latest version of Sonarr is already installed │ + │ │ + │4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed) │ + │--------------------------------------------------------------------------------------------------------------------------│ + │New: │ + │ * Cool new thing │ + │Fixed: │ + │ * Some bugs killed │ + │ │ + │ │ + │3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed) │ + │--------------------------------------------------------------------------------------------------------------------------│ + │New: │ + │ * Cool new thing (old) │ + │ * Other cool new thing (old) │ + │ │ + │ │ + │2.1.0 - 2023-04-15 02:02:53 UTC │ + │--------------------------------------------------------------------------------------------------------------------------│ + │Fixed: │ + │ * Killed bug 1 │ + │ * Fixed bug 2 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_task_start_confirm_prompt_names_the_selected_task.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_task_start_confirm_prompt_names_the_selected_task.snap new file mode 100644 index 00000000..bece945f --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_task_start_confirm_prompt_names_the_selected_task.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name Interval Last Execution Next Execution │ + │ Backup task 2 hours 30 minutes ago 1 hour │ + │ Rss sync task 15 minutes 8 minutes ago 3 minutes │ + │=> Housekeeping task 1 day 4 hours ago 18 hours │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ ╭────────────────────── Start Task ───────────────────────╮ │ + │ │Do you want to manually start this task: Housekeeping task?│ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │╭────────────────────────────╮╭───────────────────────────╮│ │ + │ ││ Yes ││ No ││ │ + │ │╰────────────────────────────╯╰───────────────────────────╯│ │ + │ ╰───────────────────────────────────────────────────────────╯ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap new file mode 100644 index 00000000..944d9739 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_loading_when_empty.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_loading_when_empty.snap new file mode 100644 index 00000000..944d9739 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_loading_when_empty.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/readarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemLogs.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemLogs.snap new file mode 100644 index 00000000..1587e814 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemLogs.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration │ +│Backup task 2 hours 30 minutes ago 1 hour ││manual completed queued event one 1 hour ago 30 minutes 00:29:14 │ +│Rss sync task 15 minutes 8 minutes ago 3 minutes ││scheduled started queued event two 15 minutes ag 4 minutes a 01:02:03 │ +│Housekeeping task 1 day 4 hours ago 18 hours ││external queued queued event three 1 minute ago │ +│ ││ │ +│ ╭ Log Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha │ │ +│ │2025-12-16 16:41:12 UTC|WARN|BookScanService|log line beta │ │ +│ │2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +╰───────────────────│ │────────────────────╯ +╭ Logs ───────────│ │────────────────────╮ +│2025-12-16 16:40:59│ │ │ +│2025-12-16 16:41:12│ │ │ +│2025-12-16 16:41:30│ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemQueuedEvents.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemQueuedEvents.snap new file mode 100644 index 00000000..45db2d79 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemQueuedEvents.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration │ +│Backup task 2 hours 30 minutes ago 1 hour ││manual completed queued event one 1 hour ago 30 minutes 00:29:14 │ +│Rss sync task 15 minutes 8 minutes ago 3 minutes ││scheduled started queued event two 15 minutes ag 4 minutes a 01:02:03 │ +│Housekeeping task 1 day 4 hours ago 18 hours ││external queued queued event three 1 minute ago │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ╭ Queued Events ────────────────────────────────────────────────────────────────────────────────╮ │ +│ │Trigger Status Name Queued Started Duration │ │ +│ │manual completed queued event one 1 hour ago 30 minutes ag 00:29:14 │ │ +│ │scheduled started queued event two 15 minutes ago 4 minutes ago 01:02:03 │ │ +│ │external queued queued event three 1 minute ago │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +╰────────────────────────────────│ │────────────────────────────────╯ +╭ Logs ────────────────────────│ │────────────────────────────────╮ +│2025-12-16 16:40:59 UTC|INFO|Imp│ │ │ +│2025-12-16 16:41:12 UTC|WARN|Boo│ │ │ +│2025-12-16 16:41:30 UTC|FATAL|Co│ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTaskStartConfirmPrompt.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTaskStartConfirmPrompt.snap new file mode 100644 index 00000000..610f58ba --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTaskStartConfirmPrompt.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration │ +│Backup task 2 hours 30 minutes ago 1 hour ││manual completed queued event one 1 hour ago 30 minutes 00:29:14 │ +│Rss sync task 15 minutes 8 minutes ago 3 minutes ││scheduled started queued event two 15 minutes ag 4 minutes a 01:02:03 │ +│Housekeeping task 1 day 4 hours ago 18 hours ││external queued queued event three 1 minute ago │ +│ ││ │ +│ ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ Name Interval Last Execution Next Execution │ │ +│ │=> Backup task 2 hours 30 minutes ago 1 hour │ │ +│ │ Rss sync task 15 minutes 8 minutes ago 3 minutes │ │ +│ │ Housekeeping task 1 day 4 hours ago 18 hours │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ ╭────────────────────── Start Task ───────────────────────╮ │ │ +│ │ │ Do you want to manually start this task: Backup task? │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │ │ │ │ +╰───────────────────│ │ │ │────────────────────╯ +╭ Logs ───────────│ │ │ │────────────────────╮ +│2025-12-16 16:40:59│ │ │ │ │ +│2025-12-16 16:41:12│ │ │ │ │ +│2025-12-16 16:41:30│ │ │ │ │ +│ │ │ │ │ │ +│ │ │╭────────────────────────────╮╭───────────────────────────╮│ │ │ +│ │ ││ Yes ││ No ││ │ │ +│ │ │╰────────────────────────────╯╰───────────────────────────╯│ │ │ +│ │ ╰───────────────────────────────────────────────────────────╯ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTasks.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTasks.snap new file mode 100644 index 00000000..6d027532 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemTasks.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration │ +│Backup task 2 hours 30 minutes ago 1 hour ││manual completed queued event one 1 hour ago 30 minutes 00:29:14 │ +│Rss sync task 15 minutes 8 minutes ago 3 minutes ││scheduled started queued event two 15 minutes ag 4 minutes a 01:02:03 │ +│Housekeeping task 1 day 4 hours ago 18 hours ││external queued queued event three 1 minute ago │ +│ ││ │ +│ ╭ Tasks ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │ Name Interval Last Execution Next Execution │ │ +│ │=> Backup task 2 hours 30 minutes ago 1 hour │ │ +│ │ Rss sync task 15 minutes 8 minutes ago 3 minutes │ │ +│ │ Housekeeping task 1 day 4 hours ago 18 hours │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +╰───────────────────│ │────────────────────╯ +╭ Logs ───────────│ │────────────────────╮ +│2025-12-16 16:40:59│ │ │ +│2025-12-16 16:41:12│ │ │ +│2025-12-16 16:41:30│ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemUpdates.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemUpdates.snap new file mode 100644 index 00000000..c14b945c --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__popups_over_system_ui_SystemUpdates.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration │ +│Backup task 2 hours 30 minutes ago 1 hour ││manual completed queued event one 1 hour ago 30 minutes 00:29:14 │ +│Rss sync task 15 minutes 8 minutes ago 3 minutes ││scheduled started queued event two 15 minutes ag 4 minutes a 01:02:03 │ +│Housekeeping task 1 day 4 hours ago 18 hours ││external queued queued event three 1 minute ago │ +│ ││ │ +│ ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ +│ │The latest version of Sonarr is already installed │ │ +│ │ │ │ +│ │4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed) │ │ +│ │--------------------------------------------------------------------------------------------------------------------------│ │ +│ │New: │ │ +│ │ * Cool new thing │ │ +│ │Fixed: │ │ +│ │ * Some bugs killed │ │ +│ │ │ │ +│ │ │ │ +│ │3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed) │ │ +│ │--------------------------------------------------------------------------------------------------------------------------│ │ +│ │New: │ │ +│ │ * Cool new thing (old) │ │ +│ │ * Other cool new thing (old) │ │ +│ │ │ │ +│ │ │ │ +╰───────────────────│2.1.0 - 2023-04-15 02:02:53 UTC │────────────────────╯ +╭ Logs ───────────│--------------------------------------------------------------------------------------------------------------------------│────────────────────╮ +│2025-12-16 16:40:59│Fixed: │ │ +│2025-12-16 16:41:12│ * Killed bug 1 │ │ +│2025-12-16 16:41:30│ * Fixed bug 2 │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_logs_loading.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_logs_loading.snap new file mode 100644 index 00000000..dcab9378 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_logs_loading.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│ ││ │ +│ ││ │ +│ Loading ... ││ Loading ... │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰─────────────────────────────────────────────────────────────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +╭ Logs ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ Loading ... │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab.snap new file mode 100644 index 00000000..262148f0 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│Name Interval Last Execution Next Execution ││Trigger Status Name Queued Started Duration │ +│Backup task 2 hours 30 minutes ago 1 hour ││manual completed queued event one 1 hour ago 30 minutes 00:29:14 │ +│Rss sync task 15 minutes 8 minutes ago 3 minutes ││scheduled started queued event two 15 minutes ag 4 minutes a 01:02:03 │ +│Housekeeping task 1 day 4 hours ago 18 hours ││external queued queued event three 1 minute ago │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰─────────────────────────────────────────────────────────────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +╭ Logs ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha │ +│2025-12-16 16:41:12 UTC|WARN|BookScanService|log line beta │ +│2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_empty.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_empty.snap new file mode 100644 index 00000000..c7a4efc5 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_empty.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰─────────────────────────────────────────────────────────────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +╭ Logs ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_loading.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_loading.snap new file mode 100644 index 00000000..dcab9378 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_loading.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│ ││ │ +│ ││ │ +│ Loading ... ││ Loading ... │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰─────────────────────────────────────────────────────────────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +╭ Logs ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ Loading ... │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_task_and_events_loading.snap b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_task_and_events_loading.snap new file mode 100644 index 00000000..867ae2a1 --- /dev/null +++ b/src/ui/readarr_ui/system/snapshots/managarr__ui__readarr_ui__system__system_ui_tests__tests__snapshot_tests__system_ui_renders_system_tab_task_and_events_loading.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/readarr_ui/system/system_ui_tests.rs +expression: output +--- +╭ Tasks ────────────────────────────────────────────────────────────────────────╮╭ Queued Events ───────────────────────────────────────────────────────────────╮ +│ ││ │ +│ ││ │ +│ Loading ... ││ Loading ... │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰─────────────────────────────────────────────────────────────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +╭ Logs ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha │ +│2025-12-16 16:41:12 UTC|WARN|BookScanService|log line beta │ +│2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/readarr_ui/system/system_details_ui.rs b/src/ui/readarr_ui/system/system_details_ui.rs new file mode 100644 index 00000000..7473ae91 --- /dev/null +++ b/src/ui/readarr_ui/system/system_details_ui.rs @@ -0,0 +1,147 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::text::{Span, Text}; +use ratatui::widgets::{Cell, ListItem, Paragraph, Row}; + +use crate::app::App; +use crate::models::Route; +use crate::models::readarr_models::ReadarrTask; +use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, SYSTEM_DETAILS_BLOCKS, +}; +use crate::ui::readarr_ui::system::{ + TASK_TABLE_CONSTRAINTS, TASK_TABLE_HEADERS, draw_queued_events, extract_log_level, + extract_task_props, +}; +use crate::ui::styles::ManagarrStyle; +use crate::ui::utils::{borderless_block, style_log_list_item, title_block}; +use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::managarr_table::ManagarrTable; +use crate::ui::widgets::popup::{Popup, Size}; +use crate::ui::widgets::selectable_list::SelectableList; +use crate::ui::{DrawUi, draw_popup}; + +#[cfg(test)] +#[path = "system_details_ui_tests.rs"] +mod system_details_ui_tests; + +pub(super) struct SystemDetailsUi; + +impl DrawUi for SystemDetailsUi { + fn accepts(route: Route) -> bool { + let Route::Readarr(active_readarr_block, _) = route else { + return false; + }; + SYSTEM_DETAILS_BLOCKS.contains(&active_readarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if let Route::Readarr(active_readarr_block, _) = app.get_current_route() { + match active_readarr_block { + ActiveReadarrBlock::SystemLogs => { + draw_logs_popup(f, app); + } + ActiveReadarrBlock::SystemTasks | ActiveReadarrBlock::SystemTaskStartConfirmPrompt => { + draw_popup(f, app, draw_tasks_popup, Size::Large) + } + ActiveReadarrBlock::SystemQueuedEvents => { + draw_popup(f, app, draw_queued_events, Size::Medium) + } + ActiveReadarrBlock::SystemUpdates => { + draw_updates_popup(f, app); + } + _ => (), + } + } + } +} + +fn draw_logs_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let block = title_block("Log Details"); + + if app.data.readarr_data.log_details.items.is_empty() { + let loading = LoadingBlock::new(app.is_loading, borderless_block()); + let popup = Popup::new(loading).size(Size::Large).block(block).margin(1); + + f.render_widget(popup, f.area()); + return; + } + + let logs_list = SelectableList::new(&mut app.data.readarr_data.log_details, |log| { + let log_line = log.to_string(); + let level = extract_log_level(&log.text); + + style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) + }) + .block(borderless_block()); + let popup = Popup::new(logs_list) + .size(Size::Large) + .block(block) + .margin(1); + + f.render_widget(popup, f.area()); +} + +fn draw_tasks_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { + let tasks_row_mapping = |task: &ReadarrTask| { + let task_props = extract_task_props(task); + + Row::new(vec![ + Cell::from(task_props.name), + Cell::from(task_props.interval), + Cell::from(task_props.last_execution), + Cell::from(task_props.next_execution), + ]) + .primary() + }; + let tasks_table = ManagarrTable::new(Some(&mut app.data.readarr_data.tasks), tasks_row_mapping) + .loading(app.is_loading) + .margin(1) + .headers(TASK_TABLE_HEADERS) + .constraints(TASK_TABLE_CONSTRAINTS); + + f.render_widget(title_block("Tasks"), area); + f.render_widget(tasks_table, area); + + if matches!( + app.get_current_route(), + Route::Readarr(ActiveReadarrBlock::SystemTaskStartConfirmPrompt, _) + ) { + let prompt = format!( + "Do you want to manually start this task: {}?", + app.data.readarr_data.tasks.current_selection().name + ); + let confirmation_prompt = ConfirmationPrompt::new() + .title("Start Task") + .prompt(&prompt) + .yes_no_value(app.data.readarr_data.prompt_confirm); + + f.render_widget( + Popup::new(confirmation_prompt).size(Size::MediumPrompt), + f.area(), + ); + } +} + +fn draw_updates_popup(f: &mut Frame<'_>, app: &mut App<'_>) { + let updates = app.data.readarr_data.updates.get_text(); + let block = title_block("Updates"); + + if !updates.is_empty() && !app.is_loading { + let updates_paragraph = Paragraph::new(Text::from(updates)) + .block(borderless_block()) + .scroll((app.data.readarr_data.updates.offset, 0)); + let popup = Popup::new(updates_paragraph) + .size(Size::Large) + .block(block) + .margin(1); + + f.render_widget(popup, f.area()); + } else { + let loading = LoadingBlock::new(app.is_loading, borderless_block()); + let popup = Popup::new(loading).size(Size::Large).block(block).margin(1); + + f.render_widget(popup, f.area()); + } +} diff --git a/src/ui/readarr_ui/system/system_details_ui_tests.rs b/src/ui/readarr_ui/system/system_details_ui_tests.rs new file mode 100644 index 00000000..4fa1d960 --- /dev/null +++ b/src/ui/readarr_ui/system/system_details_ui_tests.rs @@ -0,0 +1,242 @@ +#[cfg(test)] +mod tests { + use chrono::DateTime; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{ReadarrTask, ReadarrTaskName}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, SYSTEM_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::QueueEvent; + + use crate::ui::DrawUi; + use crate::ui::readarr_ui::system::system_details_ui::SystemDetailsUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_system_details_ui_accepts() { + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if SYSTEM_DETAILS_BLOCKS.contains(&active_readarr_block) { + assert!(SystemDetailsUi::accepts(active_readarr_block.into())); + } else { + assert!(!SystemDetailsUi::accepts(active_readarr_block.into())); + } + }); + } + + mod snapshot_tests { + use super::*; + use crate::models::ScrollableText; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + use rstest::rstest; + + #[rstest] + fn test_system_details_ui_popups( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + populate_system_data(&mut app); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("popup_{active_readarr_block}"), output); + } + + #[test] + fn test_system_details_ui_task_start_confirm_prompt_names_the_selected_task() { + let mut app = App::test_default_fully_populated(); + populate_system_data(&mut app); + app.data.readarr_data.tasks.select_index(Some(2)); + app.push_navigation_stack(ActiveReadarrBlock::SystemTaskStartConfirmPrompt.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + assert!(output.contains("Do you want to manually start this task: Housekeeping task?")); + insta::assert_snapshot!(output); + } + + #[test] + fn test_system_details_ui_updates_popup_is_loading_when_updates_are_present() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::SystemUpdates.into()); + + assert!(!app.data.readarr_data.updates.get_text().is_empty()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_system_details_ui_loading( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("loading_{active_readarr_block}"), output); + } + + #[test] + fn test_system_details_ui_updates_popup_loading_when_empty() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::SystemUpdates.into()); + app.data.readarr_data.updates = ScrollableText::with_string("".to_string()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_system_details_ui_popups_empty( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default(); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(format!("empty_{active_readarr_block}"), output); + } + } + + fn populate_system_data(app: &mut App<'_>) { + app.data.readarr_data.tasks.set_items(tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + app.data.readarr_data.logs.set_items(log_lines_vec()); + app.data.readarr_data.log_details.set_items(log_lines_vec()); + } + + fn tasks_vec() -> Vec { + vec![ + ReadarrTask { + name: "Backup task".to_owned(), + task_name: ReadarrTaskName::Backup, + interval: 120, + last_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:00:00Z").unwrap(), + ), + last_duration: "00:00:00.5111547".to_owned(), + next_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T22:30:00Z").unwrap(), + ), + }, + ReadarrTask { + name: "Rss sync task".to_owned(), + task_name: ReadarrTaskName::RssSync, + interval: 15, + last_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:22:00Z").unwrap(), + ), + last_duration: "00:00:01.2233445".to_owned(), + next_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:33:00Z").unwrap(), + ), + }, + ReadarrTask { + name: "Housekeeping task".to_owned(), + task_name: ReadarrTaskName::Housekeeping, + interval: 1440, + last_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T17:30:00Z").unwrap(), + ), + last_duration: "00:00:09.9887766".to_owned(), + next_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-21T15:30:00Z").unwrap(), + ), + }, + ] + } + + fn queued_events_vec() -> Vec { + vec![ + QueueEvent { + trigger: "manual".to_owned(), + name: "event name one".to_owned(), + command_name: "queued event one".to_owned(), + status: "completed".to_owned(), + queued: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T20:30:00Z").unwrap()), + started: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:00:00Z").unwrap(), + )), + ended: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:29:14Z").unwrap(), + )), + duration: Some("00:29:14.1234567".to_owned()), + }, + QueueEvent { + trigger: "scheduled".to_owned(), + name: "event name two".to_owned(), + command_name: "queued event two".to_owned(), + status: "started".to_owned(), + queued: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:15:00Z").unwrap()), + started: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:26:00Z").unwrap(), + )), + ended: None, + duration: Some("01:02:03.7654321".to_owned()), + }, + QueueEvent { + trigger: "external".to_owned(), + name: "event name three".to_owned(), + command_name: "queued event three".to_owned(), + status: "queued".to_owned(), + queued: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:00Z").unwrap()), + started: None, + ended: None, + duration: None, + }, + ] + } + + fn log_lines_vec() -> Vec { + vec![ + "2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha".into(), + "2025-12-16 16:41:12 UTC|WARN|BookScanService|log line beta".into(), + "2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma".into(), + ] + } +} diff --git a/src/ui/readarr_ui/system/system_ui_tests.rs b/src/ui/readarr_ui/system/system_ui_tests.rs new file mode 100644 index 00000000..8aa54a35 --- /dev/null +++ b/src/ui/readarr_ui/system/system_ui_tests.rs @@ -0,0 +1,335 @@ +#[cfg(test)] +mod tests { + use chrono::DateTime; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::HorizontallyScrollableText; + use crate::models::readarr_models::{ReadarrTask, ReadarrTaskName}; + use crate::models::servarr_data::readarr::readarr_data::{ + ActiveReadarrBlock, SYSTEM_DETAILS_BLOCKS, + }; + use crate::models::servarr_models::QueueEvent; + use crate::ui::DrawUi; + use crate::ui::readarr_ui::system::SystemUi; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_system_ui_accepts() { + let mut system_ui_blocks = Vec::new(); + system_ui_blocks.push(ActiveReadarrBlock::System); + system_ui_blocks.extend(SYSTEM_DETAILS_BLOCKS); + + ActiveReadarrBlock::iter().for_each(|active_readarr_block| { + if system_ui_blocks.contains(&active_readarr_block) { + assert!(SystemUi::accepts(active_readarr_block.into())); + } else { + assert!(!SystemUi::accepts(active_readarr_block.into())); + } + }); + } + + mod test_extract_log_level { + use pretty_assertions::assert_str_eq; + + use crate::ui::readarr_ui::system::extract_log_level; + + #[test] + fn test_extract_log_level_returns_the_field_after_the_timestamp() { + let level = extract_log_level("2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|Message"); + + assert_str_eq!(level, "INFO"); + } + + #[test] + fn test_extract_log_level_preserves_case() { + let level = extract_log_level("2025-12-16 16:41:30 UTC|FaTaL|CommandExecutor|Message"); + + assert_str_eq!(level, "FaTaL"); + } + + #[test] + fn test_extract_log_level_ignores_later_fields() { + let level = extract_log_level("timestamp|WARN|logger|message|with|extra|pipes"); + + assert_str_eq!(level, "WARN"); + } + + #[test] + fn test_extract_log_level_returns_an_empty_string_when_the_level_is_absent() { + let level = extract_log_level("2025-12-16 16:41:12 UTC||BookScanService|Message"); + + assert_str_eq!(level, ""); + } + } + + mod test_log_level_styling { + use std::sync::atomic::Ordering; + + use pretty_assertions::assert_eq; + use ratatui::style::{Modifier, Style}; + + use crate::ui::styles::failure_style; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, create_test_terminal}; + + use super::*; + + #[test] + fn test_system_ui_styles_a_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.logs.set_items(log_lines_vec()); + app.data.readarr_data.logs.items[2] + .offset + .store(30, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "CommandExecutor|log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_system_ui_styles_a_fully_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.logs.set_items(log_lines_vec()); + app.data.readarr_data.logs.items[2] + .offset + .store(46, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + SystemUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + } + + mod snapshot_tests { + use crate::models::stateful_list::StatefulList; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + use rstest::rstest; + + use super::*; + + #[test] + fn test_system_ui_renders_system_tab_loading() { + let mut app = App::test_default(); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_system_ui_renders_logs_loading() { + let mut app = App::test_default_fully_populated(); + populate_system_data(&mut app); + app.is_loading = true; + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.data.readarr_data.logs = StatefulList::default(); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_system_ui_renders_system_tab_task_and_events_loading() { + let mut app = App::test_default_fully_populated(); + populate_system_data(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + app.is_loading = true; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_system_ui_renders_system_tab() { + let mut app = App::test_default_fully_populated(); + populate_system_data(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_system_ui_renders_system_tab_empty() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveReadarrBlock::System.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[rstest] + fn test_system_details_ui_renders_popups_over_system_ui( + #[values( + ActiveReadarrBlock::SystemLogs, + ActiveReadarrBlock::SystemQueuedEvents, + ActiveReadarrBlock::SystemTasks, + ActiveReadarrBlock::SystemTaskStartConfirmPrompt, + ActiveReadarrBlock::SystemUpdates + )] + active_readarr_block: ActiveReadarrBlock, + ) { + let mut app = App::test_default_fully_populated(); + populate_system_data(&mut app); + app.push_navigation_stack(active_readarr_block.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!( + format!("popups_over_system_ui_{active_readarr_block}"), + output + ); + } + } + + fn populate_system_data(app: &mut App<'_>) { + app.data.readarr_data.tasks.set_items(tasks_vec()); + app + .data + .readarr_data + .queued_events + .set_items(queued_events_vec()); + app.data.readarr_data.logs.set_items(log_lines_vec()); + app.data.readarr_data.log_details.set_items(log_lines_vec()); + } + + fn tasks_vec() -> Vec { + vec![ + ReadarrTask { + name: "Backup task".to_owned(), + task_name: ReadarrTaskName::Backup, + interval: 120, + last_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:00:00Z").unwrap(), + ), + last_duration: "00:00:00.5111547".to_owned(), + next_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T22:30:00Z").unwrap(), + ), + }, + ReadarrTask { + name: "Rss sync task".to_owned(), + task_name: ReadarrTaskName::RssSync, + interval: 15, + last_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:22:00Z").unwrap(), + ), + last_duration: "00:00:01.2233445".to_owned(), + next_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:33:00Z").unwrap(), + ), + }, + ReadarrTask { + name: "Housekeeping task".to_owned(), + task_name: ReadarrTaskName::Housekeeping, + interval: 1440, + last_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T17:30:00Z").unwrap(), + ), + last_duration: "00:00:09.9887766".to_owned(), + next_execution: DateTime::from( + DateTime::parse_from_rfc3339("2023-05-21T15:30:00Z").unwrap(), + ), + }, + ] + } + + fn queued_events_vec() -> Vec { + vec![ + QueueEvent { + trigger: "manual".to_owned(), + name: "event name one".to_owned(), + command_name: "queued event one".to_owned(), + status: "completed".to_owned(), + queued: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T20:30:00Z").unwrap()), + started: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:00:00Z").unwrap(), + )), + ended: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:29:14Z").unwrap(), + )), + duration: Some("00:29:14.1234567".to_owned()), + }, + QueueEvent { + trigger: "scheduled".to_owned(), + name: "event name two".to_owned(), + command_name: "queued event two".to_owned(), + status: "started".to_owned(), + queued: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:15:00Z").unwrap()), + started: Some(DateTime::from( + DateTime::parse_from_rfc3339("2023-05-20T21:26:00Z").unwrap(), + )), + ended: None, + duration: Some("01:02:03.7654321".to_owned()), + }, + QueueEvent { + trigger: "external".to_owned(), + name: "event name three".to_owned(), + command_name: "queued event three".to_owned(), + status: "queued".to_owned(), + queued: DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:00Z").unwrap()), + started: None, + ended: None, + duration: None, + }, + ] + } + + fn log_lines_vec() -> Vec { + vec![ + "2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha".into(), + "2025-12-16 16:41:12 UTC|WARN|BookScanService|log line beta".into(), + "2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma".into(), + ] + } +} diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap index 1e66720a..8a709b87 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Lidarr Version: 1.2.3.4 ││Test download title ││ ⠀⠀⠀⣠⣴⣶⡿⠻⣿⣶⣦⣄⠀⠀⠀ │ @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ -│=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │ +│=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap index 439c7410..a6922ef9 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_error_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Lidarr Version: 1.2.3.4 ╭ Keybindings ──────────────────────────────────────────────────────────────────────────╮ ││ ⠀⠀⠀⣠⣴⣶⡿⠻⣿⣶⣦⣄⠀⠀⠀ │ @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ │ u update all │ │ │───────────────────────────────────│ enter details │─────────────────────────────────────│ │ Name ▼ Typ│ esc cancel filter │e Monitored Tags │ -│=> Alex Per│ ↑ k scroll up │0 GB 🏷 alex │ +│=> Alex Per│ ↑ k scroll up │1 MB 🏷 alex │ │ │ ↓ j scroll down │ │ │ │ ← h previous tab │ │ │ │ → l next tab │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap index 007e357d..544110f3 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_library_tab_with_error.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Error | to close ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Some error │ @@ -22,7 +22,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ -│=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │ +│=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap index 201b8a9f..6ed014c7 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__lidarr_ui_renders_notification_success_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Lidarr Version: 1.2.3.4 ││Test download title ││ ⠀⠀⠀⣠⣴⣶⡿⠻⣿⣶⣦⣄⠀⠀⠀ │ @@ -19,7 +19,7 @@ expression: output │ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags │ -│=> Alex Person Continuing Lossless Standard 1 15/15 0.00 GB 🏷 alex │ +│=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex │ │ │ │ │ │ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab.snap index 94ea3318..90709a10 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Radarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_error_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_error_popup.snap index 321fb196..1238a5d7 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_error_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_error_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Radarr Version: 1.2.3.4 ╭ Keybindings ──────────────────────────────────────────────────────────────────────────╮ ││ ⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_with_error.snap index 12f05f7d..53dd902a 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_with_error.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_library_tab_with_error.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Error | to close ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Some error │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_failure_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_failure_popup.snap index ce9f2c0c..21403c2d 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_failure_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_failure_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Radarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_success_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_success_popup.snap index ed22e921..84554a8a 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_success_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__radarr_ui_renders_notification_success_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Radarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⣠⣶⢶⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap new file mode 100644 index 00000000..06339f5d --- /dev/null +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/ui_tests.rs +expression: output +--- +╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│Readarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│Uptime: 0d 00:00:44 ││50% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│Storage: ││ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│/path: 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│Root Folders: ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│/nfs: 204800.00 GB free ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_error_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_error_popup.snap new file mode 100644 index 00000000..dadc6114 --- /dev/null +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_error_popup.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/ui_tests.rs +expression: output +--- +╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│Readarr Version: 1.2.3.4 ╭ Keybindings ──────────────────────────────────────────────────────────────────────────╮ ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│Uptime: 0d 00:00:44 │ Key Alt Key Description │━━━━━━━━━━━━━━━━━││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│Storage: │=> a add │ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│/path: 100% ━━━━━━━━━━━━━━━━━━━━━━━│ e edit │ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│Root Folders: │ m toggle monitoring │ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│/nfs: 204800.00 GB free │ o sort │ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ │ del delete │ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ │ s search │ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +╰───────────────────────────────────│ f filter │─────────────────╯╰──────────────────╯ +╭ Authors ────────────────────────│ ctrl-r refresh │─────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ │ u update all │ │ +│───────────────────────────────────│ enter details │─────────────────────────────────────│ +│ Name ▼ │ esc cancel filter │e Monitored Tags │ +│=> Test Author │ ↑ k scroll up │0 MB 🏷 alex │ +│ │ ↓ j scroll down │ │ +│ │ ← h previous tab │ │ +│ │ → l next tab │ │ +│ │ pgUp ctrl-u page up │ │ +│ │ pgDown ctrl-d page down │ │ +│ │ tab next servarr │ │ +│ │ shift-tab previous servarr │ │ +│ │ q quit │ │ +│ │ ? show/hide keybindings │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap new file mode 100644 index 00000000..87e67a20 --- /dev/null +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_library_tab_with_error.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/ui_tests.rs +expression: output +--- +╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Error | to close ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│Some error │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│Readarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│Uptime: 0d 00:00:44 ││50% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│Storage: ││ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│/path: 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│Root Folders: ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│/nfs: 204800.00 GB free ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap new file mode 100644 index 00000000..1baa65ab --- /dev/null +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_failure_popup.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/ui_tests.rs +expression: output +--- +╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│Readarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│Uptime: 0d 00:00:44 ││50% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│Storage: ││ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│/path: 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│Root Folders: ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│/nfs: 204800.00 GB free ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ╭────────── Download Failed ──────────╮ │ +│ │ Request failed. Received 500 response │ │ +│ │ code │ │ +│ ╰───────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap new file mode 100644 index 00000000..1916849e --- /dev/null +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__readarr_ui_renders_notification_success_popup.snap @@ -0,0 +1,54 @@ +--- +source: src/ui/ui_tests.rs +expression: output +--- +╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ +│Readarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⠀⠀⣀⣠⣤⣄⣀⠀⠀⠀⠀⠀ │ +│Uptime: 0d 00:00:44 ││50% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ⠀⠀⢀⡴⠛⠉⠀⠀⠀⠉⠛⢦⡀⠀⠀ │ +│Storage: ││ ││ ⠀⢠⣯⣄⣀⣐⠻⣿⠟⣂⣀⣠⣽⡄⠀ │ +│/path: 100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━││ ││ ⠀⣾⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣷⠀ │ +│Root Folders: ││ ││ ⠀⢿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⡿⠀ │ +│/nfs: 204800.00 GB free ││ ││ ⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀ │ +│ ││ ││ ⠀⠀⠈⠳⣬⣙⠻⠿⠟⣋⣥⠞⠁⠀⠀ │ +│ ││ ││ ⠀⠀⠀⠀⠀⠉⠙⠛⠋⠉⠀⠀⠀⠀⠀ │ +╰───────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────────────────╯╰──────────────────╯ +╭ Authors ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Library │ Downloads │ Blocklist │ History │ Root Folders │ Indexers │ System │ +│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ +│ Name ▼ Status Quality Profile Metadata Profile Books Size Monitored Tags │ +│=> Test Author Continuing Standard Comprehensive 0 0.00 MB 🏷 alex │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ╭────────── Download Result ──────────╮ │ +│ │ Download request sent successfully │ │ +│ │ │ │ +│ ╰───────────────────────────────────────╯ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab.snap index 987742eb..187aaf8c 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Sonarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⣠⣴⣶⣿⣿⣿⣿⣶⣦⣄⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_error_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_error_popup.snap index 4bbce1e3..6a52d517 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_error_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_error_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Sonarr Version: 1.2.3.4 ╭ Keybindings ──────────────────────────────────────────────────────────────────────────╮ ││ ⠀⠀⠀⣠⣴⣶⣿⣿⣿⣿⣶⣦⣄⠀⠀⠀ │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_with_error.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_with_error.snap index ffbf3868..e839c02a 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_with_error.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_library_tab_with_error.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Error | to close ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Some error │ diff --git a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_notification_success_popup.snap b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_notification_success_popup.snap index 7cd77bf0..3b7e7de1 100644 --- a/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_notification_success_popup.snap +++ b/src/ui/snapshots/managarr__ui__ui_tests__snapshot_tests__sonarr_ui_renders_notification_success_popup.snap @@ -3,7 +3,7 @@ source: src/ui/ui_tests.rs expression: output --- ╭ Managarr - A Servarr management TUI ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Radarr │ Sonarr │ Lidarr to open help│ +│ Radarr │ Sonarr │ Lidarr │ Readarr to open help│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Stats ──────────────────────────────────────────────────────────────╮╭ Downloads ─────────────────────────────────────────────────────────╮╭──────────────────╮ │Sonarr Version: 1.2.3.4 ││Test Download Title ││ ⠀⠀⠀⣠⣴⣶⣿⣿⣿⣿⣶⣦⣄⠀⠀⠀ │ diff --git a/src/ui/sonarr_ui/downloads/mod.rs b/src/ui/sonarr_ui/downloads/mod.rs index bf46b5bc..a1a0ba1d 100644 --- a/src/ui/sonarr_ui/downloads/mod.rs +++ b/src/ui/sonarr_ui/downloads/mod.rs @@ -12,7 +12,7 @@ use crate::ui::utils::{get_width_from_percentage, layout_block_top_border}; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::{Popup, Size}; -use crate::utils::convert_f64_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "downloads_ui_tests.rs"] @@ -97,12 +97,12 @@ fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } else { 1f64 - (*sizeleft / *size) }; - let file_size: f64 = convert_f64_to_gb(*size); + let file_size = format_size(*size as i64, 2); Row::new(vec![ Cell::from(title.to_owned()), Cell::from(format!("{:.0}%", percent * 100.0)), - Cell::from(format!("{file_size:.2} GB")), + Cell::from(file_size), Cell::from( output_path .as_ref() diff --git a/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs b/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs index 0db0575e..0cbf8bfc 100644 --- a/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs +++ b/src/ui/sonarr_ui/indexers/indexers_ui_tests.rs @@ -32,6 +32,7 @@ mod tests { use crate::models::BlockSelectionState; use crate::models::servarr_data::sonarr::sonarr_data::{ EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, + INDEXER_SETTINGS_SELECTION_BLOCKS, }; use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::indexer; use crate::ui::ui_test_utils::test_utils::TerminalSize; @@ -152,5 +153,19 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_indexers_ui_renders_indexer_settings_over_indexers() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::AllIndexerSettingsPrompt.into()); + app.data.sonarr_data.selected_block = + BlockSelectionState::new(INDEXER_SETTINGS_SELECTION_BLOCKS); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + IndexersUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } } } diff --git a/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap b/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap index 5452cda8..30dcd865 100644 --- a/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap +++ b/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexer_settings_ui__indexer_settings_ui_tests__tests__snapshot_tests__indexer_settings_ui_renders_indexer_settings.snap @@ -21,10 +21,10 @@ expression: output │ │ │ │ │ ╭────────────────────────────────╮ │ - │ Minimum Age (minutes) ▴▾: │1 │ │ + │ Minimum Age (minutes) ▴▾: │13 │ │ │ ╰────────────────────────────────╯ │ │ ╭────────────────────────────────╮ │ - │ Retention (days) ▴▾: │1 │ │ + │ Retention (days) ▴▾: │21 │ │ │ ╰────────────────────────────────╯ │ │ ╭────────────────────────────────╮ │ │ Maximum Size (MB) ▴▾: │12345 │ │ diff --git a/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap b/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap new file mode 100644 index 00000000..de0fee50 --- /dev/null +++ b/src/ui/sonarr_ui/indexers/snapshots/managarr__ui__sonarr_ui__indexers__indexers_ui_tests__tests__snapshot_tests__indexers_ui_renders_indexer_settings_over_indexers.snap @@ -0,0 +1,40 @@ +--- +source: src/ui/sonarr_ui/indexers/indexers_ui_tests.rs +expression: output +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + Indexer RSS Automatic Search Interactive Search Priority Tags +=> Test Indexer Enabled Enabled Enabled 25 alex + + + + + + + + + + + + ╭─────────────────── Configure All Indexer Settings ───────────────────╮ + │ │ + │ │ + │ │ + │ ╭────────────────────────────────╮ │ + │ Minimum Age (minutes) ▴▾: │13 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Retention (days) ▴▾: │21 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ Maximum Size (MB) ▴▾: │12345 │ │ + │ ╰────────────────────────────────╯ │ + │ ╭────────────────────────────────╮ │ + │ RSS Sync Interval (minutes) ▴▾: │60 │ │ + │ ╰────────────────────────────────╯ │ + │ │ + │ │ + │ ╭────────────────╮╭────────────────╮ │ + │ │ Save ││ Cancel │ │ + │ ╰────────────────╯╰────────────────╯ │ + ╰────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/library/episode_details_ui.rs b/src/ui/sonarr_ui/library/episode_details_ui.rs index c1776e1b..80a8d77f 100644 --- a/src/ui/sonarr_ui/library/episode_details_ui.rs +++ b/src/ui/sonarr_ui/library/episode_details_ui.rs @@ -1,10 +1,8 @@ use crate::app::App; use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS}; -use crate::models::servarr_models::Language; -use crate::models::sonarr_models::{ - DownloadRecord, DownloadStatus, Episode, SonarrHistoryItem, SonarrRelease, -}; +use crate::models::servarr_models::{DownloadStatus, Language}; +use crate::models::sonarr_models::{DownloadRecord, Episode, SonarrHistoryItem, SonarrRelease}; use crate::ui::sonarr_ui::sonarr_ui_utils::create_history_event_details; use crate::ui::styles::ManagarrStyle; use crate::ui::styles::{ @@ -21,7 +19,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use chrono::Utc; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; @@ -157,7 +155,7 @@ fn draw_episode_details(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let style = style_from_status(download, &episode); Line::from(vec![ - title.bold().style(style), + Span::styled(title, style.bold()), Span::styled(split[1..].join(":"), style), ]) }) @@ -416,7 +414,7 @@ fn draw_episode_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { && active_sonarr_block != ActiveSonarrBlock::ManualEpisodeSearchConfirmPrompt, app.should_text_scroll, ); - let size = convert_to_gb(*size); + let size = format_size(*size, 1); let rejected_str = if *rejected { "⛔" } else { "" }; let peers = if seeders.is_none() || leechers.is_none() { Text::from("") @@ -456,7 +454,7 @@ fn draw_episode_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(rejected_str), Cell::from(title.to_string()), Cell::from(indexer.clone()), - Cell::from(format!("{size:.1} GB")), + Cell::from(size), Cell::from(peers), Cell::from(language), Cell::from(quality), @@ -537,12 +535,12 @@ fn draw_manual_episode_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_ let prompt = if current_selection.rejected { format!( "Do you really want to download the rejected release: {}?", - ¤t_selection.title.text + current_selection.title.text ) } else { format!( "Do you want to download the release: {}?", - ¤t_selection.title.text + current_selection.title.text ) }; diff --git a/src/ui/sonarr_ui/library/episode_details_ui_tests.rs b/src/ui/sonarr_ui/library/episode_details_ui_tests.rs index 5eb2c4d9..f0c476b6 100644 --- a/src/ui/sonarr_ui/library/episode_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/episode_details_ui_tests.rs @@ -1,13 +1,21 @@ #[cfg(test)] mod tests { + use chrono::{Duration, Utc}; + use pretty_assertions::assert_eq; use strum::IntoEnumIterator; use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, }; + use crate::models::servarr_models::DownloadStatus; + use crate::models::sonarr_models::{DownloadRecord, Episode}; use crate::ui::DrawUi; - use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi; + use crate::ui::sonarr_ui::library::episode_details_ui::{EpisodeDetailsUi, style_from_status}; + use crate::ui::styles::{ + awaiting_import_style, downloaded_style, downloading_style, missing_style, + unmonitored_missing_style, unmonitored_style, unreleased_style, + }; use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; #[test] @@ -21,6 +29,157 @@ mod tests { }); } + #[test] + fn test_style_from_status_downloading_when_episode_has_no_file_and_download_is_downloading() { + let download = DownloadRecord { + status: DownloadStatus::Downloading, + ..DownloadRecord::default() + }; + let episode = Episode { + has_file: false, + monitored: true, + ..Episode::default() + }; + + let style = style_from_status(Some(&download), &episode); + + assert_eq!(style, downloading_style()); + } + + #[test] + fn test_style_from_status_awaiting_import_when_episode_has_no_file_and_download_is_completed() { + let download = DownloadRecord { + status: DownloadStatus::Completed, + ..DownloadRecord::default() + }; + let episode = Episode { + has_file: false, + monitored: true, + ..Episode::default() + }; + + let style = style_from_status(Some(&download), &episode); + + assert_eq!(style, awaiting_import_style()); + } + + #[test] + fn test_style_from_status_ignores_download_that_is_neither_downloading_nor_completed() { + let download = DownloadRecord { + status: DownloadStatus::Queued, + ..DownloadRecord::default() + }; + let episode = Episode { + has_file: false, + monitored: true, + air_date_utc: None, + ..Episode::default() + }; + + let style = style_from_status(Some(&download), &episode); + + assert_eq!(style, missing_style()); + } + + #[test] + fn test_style_from_status_unmonitored_missing_when_episode_has_no_file_and_is_unmonitored() { + let episode = Episode { + has_file: false, + monitored: false, + air_date_utc: Some(Utc::now() + Duration::days(1)), + ..Episode::default() + }; + + let style = style_from_status(None, &episode); + + assert_eq!(style, unmonitored_missing_style()); + } + + #[test] + fn test_style_from_status_unreleased_when_episode_has_no_file_and_air_date_is_future() { + let episode = Episode { + has_file: false, + monitored: true, + air_date_utc: Some(Utc::now() + Duration::days(1)), + ..Episode::default() + }; + + let style = style_from_status(None, &episode); + + assert_eq!(style, unreleased_style()); + } + + #[test] + fn test_style_from_status_missing_when_episode_has_no_file_and_air_date_has_passed() { + let episode = Episode { + has_file: false, + monitored: true, + air_date_utc: Some(Utc::now() - Duration::days(1)), + ..Episode::default() + }; + + let style = style_from_status(None, &episode); + + assert_eq!(style, missing_style()); + } + + #[test] + fn test_style_from_status_missing_when_episode_has_no_file_and_no_air_date() { + let episode = Episode { + has_file: false, + monitored: true, + air_date_utc: None, + ..Episode::default() + }; + + let style = style_from_status(None, &episode); + + assert_eq!(style, missing_style()); + } + + #[test] + fn test_style_from_status_unmonitored_when_episode_has_file_and_is_unmonitored() { + let episode = Episode { + has_file: true, + monitored: false, + ..Episode::default() + }; + + let style = style_from_status(None, &episode); + + assert_eq!(style, unmonitored_style()); + } + + #[test] + fn test_style_from_status_downloaded_when_episode_has_file_and_is_monitored() { + let episode = Episode { + has_file: true, + monitored: true, + ..Episode::default() + }; + + let style = style_from_status(None, &episode); + + assert_eq!(style, downloaded_style()); + } + + #[test] + fn test_style_from_status_ignores_download_when_episode_has_file() { + let download = DownloadRecord { + status: DownloadStatus::Downloading, + ..DownloadRecord::default() + }; + let episode = Episode { + has_file: true, + monitored: true, + ..Episode::default() + }; + + let style = style_from_status(Some(&download), &episode); + + assert_eq!(style, downloaded_style()); + } + mod snapshot_tests { use crate::ui::ui_test_utils::test_utils::TerminalSize; use rstest::rstest; diff --git a/src/ui/sonarr_ui/library/library_ui_tests.rs b/src/ui/sonarr_ui/library/library_ui_tests.rs index 0f73c611..edddd0b1 100644 --- a/src/ui/sonarr_ui/library/library_ui_tests.rs +++ b/src/ui/sonarr_ui/library/library_ui_tests.rs @@ -2,7 +2,7 @@ mod tests { use crate::models::servarr_data::sonarr::sonarr_data::{ ADD_SERIES_BLOCKS, ActiveSonarrBlock, DELETE_SERIES_BLOCKS, EDIT_SERIES_BLOCKS, - EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, + EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, SERIES_OVERVIEW_BLOCKS, }; use crate::models::{ servarr_data::sonarr::sonarr_data::LIBRARY_BLOCKS, sonarr_models::SeriesStatus, @@ -27,6 +27,7 @@ mod tests { library_ui_blocks.extend(DELETE_SERIES_BLOCKS); library_ui_blocks.extend(EDIT_SERIES_BLOCKS); library_ui_blocks.extend(SERIES_DETAILS_BLOCKS); + library_ui_blocks.extend(SERIES_OVERVIEW_BLOCKS); library_ui_blocks.extend(SEASON_DETAILS_BLOCKS); library_ui_blocks.extend(EPISODE_DETAILS_BLOCKS); diff --git a/src/ui/sonarr_ui/library/mod.rs b/src/ui/sonarr_ui/library/mod.rs index 2cef8648..e44ea2c0 100644 --- a/src/ui/sonarr_ui/library/mod.rs +++ b/src/ui/sonarr_ui/library/mod.rs @@ -12,7 +12,7 @@ use crate::ui::widgets::{ confirmation_prompt::ConfirmationPrompt, popup::{Popup, Size}, }; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use crate::{ app::App, models::{ @@ -38,6 +38,7 @@ mod episode_details_ui; #[path = "library_ui_tests.rs"] mod library_ui_tests; mod season_details_ui; +mod series_overview_ui; pub(super) struct LibraryUi; @@ -103,7 +104,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let size = series .statistics .as_ref() - .map_or(0f64, |stats| convert_to_gb(stats.size_on_disk)); + .map_or(0, |stats| stats.size_on_disk); let quality_profile = quality_profile_map .get_by_left(&series.quality_profile_id) .expect("Quality profile ID must exist in quality_profile_map") @@ -133,7 +134,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(series.series_type.to_display_str()), Cell::from(quality_profile), Cell::from(language_profile), - Cell::from(format!("{size:.2} GB")), + Cell::from(format_size(size, 2)), Cell::from(monitored.to_owned()), Cell::from(tags), ]), diff --git a/src/ui/sonarr_ui/library/season_details_ui.rs b/src/ui/sonarr_ui/library/season_details_ui.rs index 92926a3e..a06931b3 100644 --- a/src/ui/sonarr_ui/library/season_details_ui.rs +++ b/src/ui/sonarr_ui/library/season_details_ui.rs @@ -1,9 +1,8 @@ use crate::app::App; use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SEASON_DETAILS_BLOCKS}; -use crate::models::sonarr_models::{ - DownloadRecord, DownloadStatus, Episode, SonarrHistoryItem, SonarrRelease, -}; +use crate::models::servarr_models::DownloadStatus; +use crate::models::sonarr_models::{DownloadRecord, Episode, SonarrHistoryItem, SonarrRelease}; use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi; use crate::ui::sonarr_ui::sonarr_ui_utils::create_history_event_details; use crate::ui::styles::ManagarrStyle; @@ -17,7 +16,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; use chrono::Utc; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Rect}; @@ -189,7 +188,7 @@ fn draw_episodes_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { }; let episode_monitored = if episode.monitored { "🏷" } else { "" }; - let size = convert_to_gb(size_on_disk); + let size = format_size(size_on_disk, 2); let air_date = if let Some(air_date) = air_date_utc.as_ref() { air_date.to_string() } else { @@ -204,7 +203,7 @@ fn draw_episodes_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(episode_number.to_string()), Cell::from(title.clone()), Cell::from(air_date), - Cell::from(format!("{size:.2} GB")), + Cell::from(size), Cell::from(quality_profile), ]), ) @@ -379,7 +378,7 @@ fn draw_season_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { && active_sonarr_block != ActiveSonarrBlock::ManualSeasonSearchConfirmPrompt, app.should_text_scroll, ); - let size = convert_to_gb(*size); + let size = format_size(*size, 1); let rejected_str = if *rejected { "⛔" } else { "" }; let peers = if seeders.is_none() || leechers.is_none() { Text::from("") @@ -419,7 +418,7 @@ fn draw_season_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { Cell::from(rejected_str), Cell::from(title.to_string()), Cell::from(indexer.clone()), - Cell::from(format!("{size:.1} GB")), + Cell::from(size), Cell::from(peers), Cell::from(language), Cell::from(quality), @@ -483,12 +482,12 @@ fn draw_manual_season_search_confirm_prompt(f: &mut Frame<'_>, app: &mut App<'_> let prompt = if current_selection.rejected { format!( "Do you really want to download the rejected release: {}?", - ¤t_selection.title.text + current_selection.title.text ) } else { format!( "Do you want to download the release: {}?", - ¤t_selection.title.text + current_selection.title.text ) }; diff --git a/src/ui/sonarr_ui/library/season_details_ui_tests.rs b/src/ui/sonarr_ui/library/season_details_ui_tests.rs index 26cd5030..1d8c1fc4 100644 --- a/src/ui/sonarr_ui/library/season_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/season_details_ui_tests.rs @@ -1,14 +1,24 @@ #[cfg(test)] mod tests { + use chrono::{Duration, Utc}; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; + use serde_json::Number; use strum::IntoEnumIterator; use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, }; + use crate::models::servarr_models::DownloadStatus; + use crate::models::sonarr_models::{DownloadRecord, Episode}; use crate::models::stateful_table::StatefulTable; + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::download_record; use crate::ui::DrawUi; - use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi; + use crate::ui::sonarr_ui::library::season_details_ui::{ + SeasonDetailsUi, decorate_with_row_style, + }; + use crate::ui::styles::ManagarrStyle; use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; #[test] @@ -25,6 +35,310 @@ mod tests { }); } + #[test] + fn test_decorate_with_row_style_downloaded_when_episode_has_file() { + let episode = Episode { + id: 5, + has_file: true, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_with_row_style_unmonitored_when_episode_has_file_and_is_unmonitored() { + let episode = Episode { + id: 5, + has_file: true, + monitored: false, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + + #[test] + fn test_decorate_with_row_style_missing_when_episode_has_no_file() { + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_with_row_style_unmonitored_missing_when_episode_has_no_file_and_is_unmonitored() + { + let episode = Episode { + id: 5, + has_file: false, + monitored: false, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.unmonitored_missing()); + } + + #[test] + fn test_decorate_with_row_style_unreleased_when_episode_has_no_file_and_air_date_is_in_the_future() + { + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + air_date_utc: Some(Utc::now() + Duration::days(1)), + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.unreleased()); + } + + #[test] + fn test_decorate_with_row_style_missing_when_episode_has_no_file_and_air_date_is_in_the_past() { + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + air_date_utc: Some(Utc::now() - Duration::days(1)), + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_with_row_style_unmonitored_missing_when_unmonitored_episode_has_no_file_and_air_date_is_in_the_future() + { + let episode = Episode { + id: 5, + has_file: false, + monitored: false, + air_date_utc: Some(Utc::now() + Duration::days(1)), + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&[], &episode, row.clone()); + + assert_eq!(style, row.unmonitored_missing()); + } + + #[test] + fn test_decorate_with_row_style_downloading_when_episode_has_no_file_and_is_downloading() { + let downloads_vec = vec![episode_download(5, DownloadStatus::Downloading)]; + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.downloading()); + } + + #[test] + fn test_decorate_with_row_style_awaiting_import_when_episode_has_no_file_and_download_is_completed() + { + let downloads_vec = vec![episode_download(5, DownloadStatus::Completed)]; + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.awaiting_import()); + } + + #[test] + fn test_decorate_with_row_style_missing_when_episode_download_is_neither_downloading_nor_completed() + { + let downloads_vec = vec![episode_download(5, DownloadStatus::Queued)]; + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_with_row_style_missing_when_only_a_different_episode_is_downloading() { + let downloads_vec = vec![episode_download(6, DownloadStatus::Downloading)]; + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_with_row_style_missing_when_download_has_no_episode_id() { + let downloads_vec = vec![DownloadRecord { + episode_id: None, + ..episode_download(5, DownloadStatus::Downloading) + }]; + let episode = Episode { + id: 5, + has_file: false, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_with_row_style_downloading_when_unmonitored_episode_has_no_file_and_is_downloading() + { + let downloads_vec = vec![episode_download(5, DownloadStatus::Downloading)]; + let episode = Episode { + id: 5, + has_file: false, + monitored: false, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.downloading()); + } + + #[test] + fn test_decorate_with_row_style_downloaded_when_episode_has_file_and_is_downloading() { + let downloads_vec = vec![episode_download(5, DownloadStatus::Downloading)]; + let episode = Episode { + id: 5, + has_file: true, + monitored: true, + ..Episode::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_with_row_style(&downloads_vec, &episode, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + mod test_episode_row_styling { + use pretty_assertions::assert_eq; + use ratatui::style::Style; + + use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::episode; + use crate::ui::styles::{downloading_style, missing_style}; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, create_test_terminal}; + + use super::*; + + #[test] + fn test_season_details_ui_renders_downloading_episode_with_downloading_style() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + set_episodes_with_unselected_probe(&mut app); + + let style = rendered_row_style(&mut app, "Unselected episode"); + + assert_eq!(style.fg, downloading_style().fg); + } + + #[test] + fn test_season_details_ui_renders_missing_episode_with_missing_style_when_queue_is_empty() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeasonDetails.into()); + app.data.sonarr_data.downloads.set_items(vec![]); + set_episodes_with_unselected_probe(&mut app); + + let style = rendered_row_style(&mut app, "Unselected episode"); + + assert_eq!(style.fg, missing_style().fg); + } + + fn set_episodes_with_unselected_probe(app: &mut App<'_>) { + let season_details_modal = app.data.sonarr_data.season_details_modal.as_mut().unwrap(); + season_details_modal.season_details_tabs.set_index(0); + season_details_modal.episodes.set_items(vec![ + episode(), + Episode { + has_file: false, + monitored: true, + title: "Unselected episode".to_owned(), + ..episode() + }, + ]); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + SeasonDetailsUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + } + mod snapshot_tests { use crate::ui::ui_test_utils::test_utils::TerminalSize; use rstest::rstest; @@ -145,4 +459,13 @@ mod tests { insta::assert_snapshot!(output); } } + + fn episode_download(episode_id: i64, status: DownloadStatus) -> DownloadRecord { + DownloadRecord { + status, + id: 9, + episode_id: Some(Number::from(episode_id)), + ..download_record() + } + } } diff --git a/src/ui/sonarr_ui/library/series_details_ui.rs b/src/ui/sonarr_ui/library/series_details_ui.rs index 3958763c..36601f4c 100644 --- a/src/ui/sonarr_ui/library/series_details_ui.rs +++ b/src/ui/sonarr_ui/library/series_details_ui.rs @@ -6,18 +6,19 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::{Line, Text}; use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; -use regex::Regex; use crate::app::App; use crate::models::Route; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SERIES_DETAILS_BLOCKS}; -use crate::models::sonarr_models::{Season, SeasonStatistics, SonarrHistoryItem}; +use crate::models::sonarr_models::{Season, SonarrHistoryItem}; use crate::ui::sonarr_ui::library::episode_details_ui::EpisodeDetailsUi; use crate::ui::sonarr_ui::library::season_details_ui::SeasonDetailsUi; +use crate::ui::sonarr_ui::library::series_overview_ui::SeriesOverviewUi; use crate::ui::sonarr_ui::sonarr_ui_utils::create_history_event_details; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{ - borderless_block, get_width_from_percentage, layout_block_top_border, title_block, + borderless_block, collapse_whitespace, get_width_from_percentage, layout_block_top_border, + title_block, }; use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::loading_block::LoadingBlock; @@ -25,7 +26,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::message::Message; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_popup, draw_tabs}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "series_details_ui_tests.rs"] @@ -40,6 +41,7 @@ impl DrawUi for SeriesDetailsUi { }; SeasonDetailsUi::accepts(route) || EpisodeDetailsUi::accepts(route) + || SeriesOverviewUi::accepts(route) || SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) } @@ -107,6 +109,10 @@ impl DrawUi for SeriesDetailsUi { if SeasonDetailsUi::accepts(route) { SeasonDetailsUi::draw(f, app, area); } + + if SeriesOverviewUi::accepts(route) { + SeriesOverviewUi::draw(f, app, area); + } } } } @@ -132,18 +138,12 @@ fn draw_series_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .get_by_left(¤t_selection.language_profile_id) .unwrap() .to_owned(); - let overview = Regex::new(r"[\r\n\t]") - .unwrap() - .replace_all( - &deunicode( - current_selection - .overview - .as_ref() - .unwrap_or(&String::new()), - ), - "", - ) - .to_string(); + let overview = collapse_whitespace(&deunicode( + current_selection + .overview + .as_ref() + .unwrap_or(&String::new()), + )); let mut series_description = vec![ Line::from(vec![ @@ -200,10 +200,10 @@ fn draw_series_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { ]), ]; if let Some(stats) = current_selection.statistics.as_ref() { - let size = convert_to_gb(stats.size_on_disk); + let size = format_size(stats.size_on_disk, 2); series_description.extend(vec![Line::from(vec![ "Size on Disk: ".primary().bold(), - format!("{size:.2} GB").default_color(), + size.default_color(), ])]); } @@ -229,45 +229,25 @@ fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { if let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() { let content = Some(&mut app.data.sonarr_data.seasons); let season_row_mapping = |season: &Season| { - let Season { - title, - monitored, - statistics, - .. - } = season; - let SeasonStatistics { - episode_file_count, - episode_count, - size_on_disk, - next_airing, - .. - } = if let Some(stats) = statistics { - stats - } else { - &SeasonStatistics::default() - }; let season_monitored = if season.monitored { "🏷" } else { "" }; - let size = convert_to_gb(*size_on_disk); + let episode_count = season.statistics.as_ref().map_or_else( + || "N/A".to_owned(), + |s| format!("{}/{}", s.episode_file_count, s.episode_count), + ); + let size = season + .statistics + .as_ref() + .map_or_else(|| "N/A".to_owned(), |s| format_size(s.size_on_disk, 2)); - let row = Row::new(vec![ - Cell::from(season_monitored.to_owned()), - Cell::from(title.clone().unwrap_or_default()), - Cell::from(format!("{episode_file_count}/{episode_count}")), - Cell::from(format!("{size:.2} GB")), - ]); - if !monitored { - row.unmonitored() - } else if episode_file_count == episode_count { - row.downloaded() - } else if let Some(next_airing_utc) = next_airing.as_ref() { - if next_airing_utc > &Utc::now() { - row.unreleased() - } else { - row.missing() - } - } else { - row.missing() - } + decorate_season_row_with_style( + season, + Row::new(vec![ + Cell::from(season_monitored.to_owned()), + Cell::from(season.title.clone().unwrap_or_default()), + Cell::from(episode_count), + Cell::from(size), + ]), + ) }; let is_searching = active_sonarr_block == ActiveSonarrBlock::SearchSeason; let season_table = ManagarrTable::new(content, season_row_mapping) @@ -291,6 +271,26 @@ fn draw_seasons_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { } } +fn decorate_season_row_with_style<'a>(season: &Season, row: Row<'a>) -> Row<'a> { + if !season.monitored { + row.unmonitored() + } else if let Some(stats) = season.statistics.as_ref() { + if stats.episode_file_count == stats.episode_count && stats.episode_count > 0 { + row.downloaded() + } else if let Some(next_airing) = stats.next_airing.as_ref() { + if next_airing > &Utc::now() { + row.unreleased() + } else { + row.missing() + } + } else { + row.missing() + } + } else { + row.indeterminate() + } +} + fn draw_series_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { match app.data.sonarr_data.series_history.as_ref() { Some(series_history) if !app.is_loading => { diff --git a/src/ui/sonarr_ui/library/series_details_ui_tests.rs b/src/ui/sonarr_ui/library/series_details_ui_tests.rs index 85ab39cf..517a2649 100644 --- a/src/ui/sonarr_ui/library/series_details_ui_tests.rs +++ b/src/ui/sonarr_ui/library/series_details_ui_tests.rs @@ -1,13 +1,21 @@ #[cfg(test)] mod tests { + use chrono::{Duration, Utc}; + use pretty_assertions::assert_eq; + use ratatui::widgets::{Cell, Row}; use strum::IntoEnumIterator; use crate::app::App; use crate::models::servarr_data::sonarr::sonarr_data::{ ActiveSonarrBlock, EPISODE_DETAILS_BLOCKS, SEASON_DETAILS_BLOCKS, SERIES_DETAILS_BLOCKS, + SERIES_OVERVIEW_BLOCKS, }; + use crate::models::sonarr_models::{Season, SeasonStatistics}; use crate::ui::DrawUi; - use crate::ui::sonarr_ui::library::series_details_ui::SeriesDetailsUi; + use crate::ui::sonarr_ui::library::series_details_ui::{ + SeriesDetailsUi, decorate_season_row_with_style, + }; + use crate::ui::styles::ManagarrStyle; use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; #[test] @@ -15,6 +23,7 @@ mod tests { let mut blocks = SERIES_DETAILS_BLOCKS.clone().to_vec(); blocks.extend(SEASON_DETAILS_BLOCKS); blocks.extend(EPISODE_DETAILS_BLOCKS); + blocks.extend(SERIES_OVERVIEW_BLOCKS); ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { if blocks.contains(&active_sonarr_block) { @@ -25,9 +34,128 @@ mod tests { }); } + #[test] + fn test_decorate_season_row_with_style_unmonitored() { + let season = Season::default(); + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.unmonitored()); + } + + #[test] + fn test_decorate_season_row_with_style_downloaded_when_all_episodes_present() { + let season = Season { + monitored: true, + statistics: Some(SeasonStatistics { + episode_file_count: 3, + episode_count: 3, + ..SeasonStatistics::default() + }), + ..Season::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.downloaded()); + } + + #[test] + fn test_decorate_season_row_with_style_unreleased_when_episodes_are_missing_and_next_airing_is_future() + { + let season = Season { + monitored: true, + statistics: Some(SeasonStatistics { + episode_file_count: 0, + episode_count: 3, + next_airing: Some(Utc::now() + Duration::days(1)), + ..SeasonStatistics::default() + }), + ..Season::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.unreleased()); + } + + #[test] + fn test_decorate_season_row_with_style_missing_when_episodes_are_missing_and_next_airing_has_passed() + { + let season = Season { + monitored: true, + statistics: Some(SeasonStatistics { + episode_file_count: 1, + episode_count: 3, + next_airing: Some(Utc::now() - Duration::days(1)), + ..SeasonStatistics::default() + }), + ..Season::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_season_row_with_style_missing_when_episodes_are_missing_and_no_next_airing() { + let season = Season { + monitored: true, + statistics: Some(SeasonStatistics { + episode_file_count: 1, + episode_count: 3, + next_airing: None, + ..SeasonStatistics::default() + }), + ..Season::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_season_row_with_style_missing_when_episode_count_is_zero() { + let season = Season { + monitored: true, + statistics: Some(SeasonStatistics { + next_airing: Some(Utc::now() - Duration::days(1)), + ..SeasonStatistics::default() + }), + ..Season::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.missing()); + } + + #[test] + fn test_decorate_season_row_with_style_indeterminate_when_no_statistics() { + let season = Season { + monitored: true, + statistics: None, + ..Season::default() + }; + let row = Row::new(vec![Cell::from("test".to_owned())]); + + let style = decorate_season_row_with_style(&season, row.clone()); + + assert_eq!(style, row.indeterminate()); + } + mod snapshot_tests { use crate::models::stateful_table::StatefulTable; use crate::ui::ui_test_utils::test_utils::TerminalSize; + use pretty_assertions::assert_str_eq; use rstest::rstest; use super::*; @@ -111,6 +239,19 @@ mod tests { ); } + #[test] + fn test_series_details_ui_renders_season_without_statistics() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.seasons.items[0].statistics = None; + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[test] fn test_series_details_ui_renders_season_details_over_series_details() { let mut app = App::test_default_fully_populated(); @@ -134,5 +275,43 @@ mod tests { insta::assert_snapshot!(output); } + + #[test] + fn test_series_details_ui_renders_series_overview_over_series_details() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_scrolling_the_series_overview_does_not_scroll_the_series_details_background() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesDetails.into()); + let before = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesDetailsUi::draw(f, app, f.area()); + }); + + app + .data + .sonarr_data + .series_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 3; + + let after = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesDetailsUi::draw(f, app, f.area()); + }); + + assert_str_eq!(before, after); + assert_contains!(before, "Overview: Blah blah blah"); + } } } diff --git a/src/ui/sonarr_ui/library/series_overview_ui.rs b/src/ui/sonarr_ui/library/series_overview_ui.rs new file mode 100644 index 00000000..06b095d2 --- /dev/null +++ b/src/ui/sonarr_ui/library/series_overview_ui.rs @@ -0,0 +1,87 @@ +use crate::app::App; +use crate::models::Route; +use crate::models::servarr_data::sonarr::sonarr_data::SERIES_OVERVIEW_BLOCKS; +use crate::models::sonarr_models::Series; +use crate::ui::styles::{primary_style, unmonitored_style}; +use crate::ui::utils::title_block_centered; +use crate::ui::widgets::loading_block::LoadingBlock; +use crate::ui::widgets::popup::Size; +use crate::ui::{DrawUi, draw_popup}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Paragraph, Wrap}; + +#[cfg(test)] +#[path = "series_overview_ui_tests.rs"] +mod series_overview_ui_tests; + +pub(super) struct SeriesOverviewUi; + +impl DrawUi for SeriesOverviewUi { + fn accepts(route: Route) -> bool { + let Route::Sonarr(active_sonarr_block, _) = route else { + return false; + }; + SERIES_OVERVIEW_BLOCKS.contains(&active_sonarr_block) + } + + fn draw(f: &mut Frame<'_>, app: &mut App<'_>, _area: Rect) { + if app.data.sonarr_data.series_overview_modal.is_some() { + let draw_series_overview_popup = |f: &mut Frame<'_>, app: &mut App<'_>, popup_area: Rect| { + draw_series_overview(f, app, popup_area); + }; + + draw_popup(f, app, draw_series_overview_popup, Size::Large); + } + } +} + +fn draw_series_overview(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { + let block = title_block_centered("Overview"); + + match app.data.sonarr_data.series_overview_modal.as_ref() { + Some(series_overview_modal) if !app.is_loading => { + let style = if app.data.sonarr_data.series.is_empty() { + primary_style() + } else { + style_from_series(app.data.sonarr_data.series.current_selection()) + }; + let overview = &series_overview_modal.overview; + let text = Text::from( + overview + .items + .iter() + .map(|line| overview_line(line, style)) + .collect::>>(), + ); + + let paragraph = Paragraph::new(text) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((overview.offset, 0)); + + f.render_widget(paragraph, area); + } + _ => f.render_widget( + LoadingBlock::new( + app.is_loading || app.data.sonarr_data.series_overview_modal.is_none(), + block, + ), + area, + ), + } +} + +fn style_from_series(series: &Series) -> Style { + if !series.monitored { + return unmonitored_style(); + } + + primary_style() +} + +fn overview_line(line: &str, style: Style) -> Line<'static> { + Line::from(Span::styled(line.trim_end().to_owned(), style)) +} diff --git a/src/ui/sonarr_ui/library/series_overview_ui_tests.rs b/src/ui/sonarr_ui/library/series_overview_ui_tests.rs new file mode 100644 index 00000000..27e524cd --- /dev/null +++ b/src/ui/sonarr_ui/library/series_overview_ui_tests.rs @@ -0,0 +1,205 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use ratatui::style::Modifier; + use ratatui::text::Line; + use strum::IntoEnumIterator; + + use crate::app::App; + use crate::models::servarr_data::sonarr::sonarr_data::{ + ActiveSonarrBlock, SERIES_OVERVIEW_BLOCKS, + }; + use crate::models::sonarr_models::Series; + use crate::ui::DrawUi; + use crate::ui::sonarr_ui::library::series_overview_ui::{ + SeriesOverviewUi, overview_line, style_from_series, + }; + use crate::ui::styles::{primary_style, unmonitored_style}; + use crate::ui::ui_test_utils::test_utils::render_to_string_with_app; + + #[test] + fn test_series_overview_ui_accepts() { + ActiveSonarrBlock::iter().for_each(|active_sonarr_block| { + if SERIES_OVERVIEW_BLOCKS.contains(&active_sonarr_block) { + assert!(SeriesOverviewUi::accepts(active_sonarr_block.into())); + } else { + assert!(!SeriesOverviewUi::accepts(active_sonarr_block.into())); + } + }); + } + + #[test] + fn test_style_from_series_monitored() { + let series = Series { + monitored: true, + ..Series::default() + }; + + let style = style_from_series(&series); + + assert_eq!(style, primary_style()); + } + + #[test] + fn test_style_from_series_unmonitored() { + let series = Series { + monitored: false, + ..Series::default() + }; + + let style = style_from_series(&series); + + assert_eq!(style, unmonitored_style()); + } + + #[test] + fn test_overview_line_does_not_split_a_label_from_a_colon_in_the_prose() { + let line = overview_line( + "It was filmed in Madison, Wisconsin: a city the crew never really left.", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["It was filmed in Madison, Wisconsin: a city the crew never really left."] + ); + } + + #[test] + fn test_overview_line_renders_the_prose_without_bolding_it() { + let line = overview_line("Overview: a prose line", primary_style()); + + assert_eq!(line.spans.len(), 1); + assert!(!line.spans[0].style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_overview_line_applies_the_given_style() { + let line = overview_line("an unmonitored series", unmonitored_style()); + + assert_eq!(line.spans[0].style.fg, unmonitored_style().fg); + } + + #[test] + fn test_overview_line_trims_a_trailing_carriage_return() { + let line = overview_line( + "The first season took seven years to finish.\r", + primary_style(), + ); + + assert_eq!( + span_contents(&line), + vec!["The first season took seven years to finish."] + ); + } + + #[test] + fn test_overview_line_renders_a_carriage_return_only_line_as_blank() { + let line = overview_line("\r", primary_style()); + + assert_eq!(span_contents(&line), vec![""]); + } + + fn span_contents(line: &Line<'_>) -> Vec { + line + .spans + .iter() + .map(|span| span.content.to_string()) + .collect() + } + + mod snapshot_tests { + use crate::models::ScrollableText; + use crate::models::servarr_data::sonarr::modals::SeriesOverviewModal; + use crate::models::stateful_table::StatefulTable; + use crate::ui::ui_test_utils::test_utils::TerminalSize; + + use super::*; + + #[test] + fn test_series_overview_ui_renders() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_series_overview_ui_renders_loading() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_series_overview_ui_renders_scrolled() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + app + .data + .sonarr_data + .series_overview_modal + .as_mut() + .unwrap() + .overview + .offset = 2; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_series_overview_ui_renders_a_series_with_no_overview() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + app.data.sonarr_data.series_overview_modal = Some(SeriesOverviewModal { + overview: ScrollableText::with_string(String::new()), + }); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_series_overview_ui_renders_nothing_when_the_series_overview_modal_is_undefined() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + app.data.sonarr_data.series_overview_modal = None; + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesOverviewUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_series_overview_ui_renders_with_zero_series() { + let mut app = App::test_default_fully_populated(); + app.data.sonarr_data.series = StatefulTable::default(); + app.push_navigation_stack(ActiveSonarrBlock::SeriesOverview.into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SeriesOverviewUi::draw(f, app, f.area()); + }); + + assert_contains!(output, "Blah blah blah"); + } + } +} diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt.snap index fd06895e..df1ec130 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt.snap @@ -11,9 +11,9 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ - │ │ - │ │ + │ The first season took seven years to finish. │ │ │ │ ╭───╮ │ │ Monitored: │ ✔ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt_SeriesDetails.snap index be8a8fe2..8fd63fef 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesConfirmPrompt_SeriesDetails.snap @@ -7,13 +7,13 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ - │Runtime: 63 minutes│ │ │ + │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ │Quality Profile: Bl│ ╭───╮ │ │ │Language Profile: E│ Monitored: │ ✔ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt.snap index fd06895e..df1ec130 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt.snap @@ -11,9 +11,9 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ - │ │ - │ │ + │ The first season took seven years to finish. │ │ │ │ ╭───╮ │ │ Monitored: │ ✔ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt_SeriesDetails.snap index be8a8fe2..8fd63fef 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesPrompt_SeriesDetails.snap @@ -7,13 +7,13 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ - │Runtime: 63 minutes│ │ │ + │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ │Quality Profile: Bl│ ╭───╮ │ │ │Language Profile: E│ Monitored: │ ✔ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile.snap index 9990fb34..8336957f 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile.snap @@ -11,9 +11,9 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ - │ │ - │ │ + │ The first season took seven years to finish. │ │ │ │ ╭───╮ │ │ Monitored: │ ✔ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile_SeriesDetails.snap index f882c48d..1cdb236f 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectLanguageProfile_SeriesDetails.snap @@ -7,13 +7,13 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ - │Runtime: 63 minutes│ │ │ + │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ │Quality Profile: Bl│ ╭───╮ │ │ │Language Profile: E│ Monitored: │ ✔ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile.snap index 3cc0c3e1..18ba7e6e 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile.snap @@ -11,9 +11,9 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ - │ │ - │ │ + │ The first season took seven years to finish. │ │ │ │ ╭───╮ │ │ Monitored: │ ✔ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile_SeriesDetails.snap index 7f9f5449..ec1c4fb5 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectQualityProfile_SeriesDetails.snap @@ -7,13 +7,13 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ - │Runtime: 63 minutes│ │ │ + │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ │Quality Profile: Bl│ ╭───╮ │ │ │Language Profile: E│ Monitored: │ ✔ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType.snap index cd0e5be2..8ead2f7c 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType.snap @@ -11,9 +11,9 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ - │ │ - │ │ + │ The first season took seven years to finish. │ │ │ │ ╭───╮ │ │ Monitored: │ ✔ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType_SeriesDetails.snap index 8d22ec94..9bfa2ad3 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__edit_series_ui__edit_series_ui_tests__tests__snapshot_tests__edit_series_EditSeriesSelectSeriesType_SeriesDetails.snap @@ -7,13 +7,13 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ │Status: Continuing │ Blah blah blah │ │ │Genres: cool, famil│ │ │ - │Rating: 84% │ │ │ + │Rating: 84% │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │Year: 2022 │ │ │ - │Runtime: 63 minutes│ │ │ + │Runtime: 63 minutes│ The first season took seven years to finish. │ │ │Path: /nfs/tv/Test │ │ │ │Quality Profile: Bl│ ╭───╮ │ │ │Language Profile: E│ Monitored: │ ✔ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_AutomaticallySearchEpisodePrompt_3.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_AutomaticallySearchEpisodePrompt_3.snap index 19969df8..34949968 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_AutomaticallySearchEpisodePrompt_3.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_AutomaticallySearchEpisodePrompt_3.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p│ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p│ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p│ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p│ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchConfirmPrompt_3.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchConfirmPrompt_3.snap index 5822cba7..7660cf10 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchConfirmPrompt_3.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchConfirmPrompt_3.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p│ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p│ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p│ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p│ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchSortPrompt_3.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchSortPrompt_3.snap index 2c176471..423dff02 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchSortPrompt_3.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearchSortPrompt_3.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p│ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p│ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p│ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p│ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearch_3.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearch_3.snap index cb7aac14..4914a70c 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearch_3.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__episode_details_ui__episode_details_ui_tests__tests__snapshot_tests__episode_details_ManualEpisodeSearch_3.snap @@ -12,8 +12,8 @@ expression: output │ Details │ History │ File │ Manual Search │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p│ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p│ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p│ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p│ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_series_over_series.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_series_over_series.snap index 7fe1a718..3983b212 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_series_over_series.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_edit_series_over_series.snap @@ -11,9 +11,9 @@ expression: output ╭───────────────────────────────────────────── Edit - Test ─────────────────────────────────────────────╮ │ Blah blah blah │ │ │ + │ It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ │ - │ │ - │ │ + │ The first season took seven years to finish. │ │ │ │ ╭───╮ │ │ Monitored: │ ✔ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap index 81ab929e..c66ebf07 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__library_ui_tests__tests__snapshot_tests__library_ui_renders_series_details_over_series.snap @@ -7,7 +7,7 @@ expression: output => Test 2022 HBO Continuin TV-MA Standard Bluray-1080p English 59.51 GB 🏷 ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ @@ -26,7 +26,7 @@ expression: output ││ Seasons │ History ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Season Episode Count Size on Disk ││ - ││=> 🏷 Season title 10/10 34.19 GB ││ + ││=> 🏷 Season title 7/10 34.19 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_AutomaticallySearchSeasonPrompt_2.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_AutomaticallySearchSeasonPrompt_2.snap index 4dcfb7ee..935783b9 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_AutomaticallySearchSeasonPrompt_2.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_AutomaticallySearchSeasonPrompt_2.snap @@ -10,8 +10,8 @@ expression: output │ Episodes │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p │ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchConfirmPrompt_2.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchConfirmPrompt_2.snap index b5d18146..ec4c5072 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchConfirmPrompt_2.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchConfirmPrompt_2.snap @@ -10,8 +10,8 @@ expression: output │ Episodes │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p │ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchSortPrompt_2.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchSortPrompt_2.snap index 58928493..e2c528fe 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchSortPrompt_2.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearchSortPrompt_2.snap @@ -10,8 +10,8 @@ expression: output │ Episodes │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p │ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearch_2.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearch_2.snap index 8f62f051..10e836d4 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearch_2.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__season_details_ui__season_details_ui_tests__tests__snapshot_tests__season_details_renders_ManualSeasonSearch_2.snap @@ -10,8 +10,8 @@ expression: output │ Episodes │ History │ Manual Search │ │───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ Source ▼ Age ⛔ Title Indexer Size Peers Language Quality │ - │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 English Bluray-1080p │ - │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB English Bluray-1080p │ + │=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 English Bluray-1080p │ + │ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB English Bluray-1080p │ │ │ │ │ │ │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesDetails.snap index 8d7f917d..e8890696 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesDetails.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistory.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistory.snap index 8d7f917d..e8890696 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistory.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistory.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistoryDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistoryDetails.snap index 12609fd0..846fe029 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistoryDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__empty_series_details_SeriesHistoryDetails.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesDetails.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesDetails.snap index f7e287f0..639490c7 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesDetails.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesDetails.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesHistory.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesHistory.snap index f7e287f0..639490c7 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesHistory.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__loading_series_details_SeriesHistory.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_0.snap index 385ee3ea..3dbe2d5c 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_1.snap index accf608b..399b3102 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_AutomaticallySearchSeriesPrompt_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistoryError_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistoryError_1.snap index 7e0ca69a..ddbcd68f 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistoryError_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistoryError_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistory_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistory_1.snap index 3e37c149..9939fcf7 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistory_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_FilterSeriesHistory_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap index 3bd7da7f..0c9f2d32 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeasonError_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ @@ -26,7 +26,7 @@ expression: output ││ Seasons │ History ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Season Episode Count Size on Disk ││ - ││=> 🏷 Season title 10/10 34.19 GB ││ + ││=> 🏷 Season title 7/10 34.19 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap index a5940730..947b56d1 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeason_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ @@ -26,7 +26,7 @@ expression: output ││ Seasons │ History ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Season Episode Count Size on Disk ││ - ││=> 🏷 Season title 10/10 34.19 GB ││ + ││=> 🏷 Season title 7/10 34.19 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistoryError_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistoryError_1.snap index c213bf22..4fd79b79 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistoryError_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistoryError_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistory_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistory_1.snap index afd0fa0e..1ca0b628 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistory_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SearchSeriesHistory_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap index 1f91c3aa..040aa0db 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesDetails_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ @@ -26,7 +26,7 @@ expression: output ││ Seasons │ History ││ ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ ││ Monitored Season Episode Count Size on Disk ││ - ││=> 🏷 Season title 10/10 34.19 GB ││ + ││=> 🏷 Season title 7/10 34.19 GB ││ ││ ││ ││ ││ ││ ││ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistoryDetails_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistoryDetails_1.snap index 851a727c..1912eae8 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistoryDetails_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistoryDetails_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistorySortPrompt_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistorySortPrompt_1.snap index 4c982de4..9fff76f4 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistorySortPrompt_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistorySortPrompt_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistory_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistory_1.snap index 7633027b..9e57c838 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistory_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_SeriesHistory_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_0.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_0.snap index 05966110..7ba42e9d 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_0.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_0.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_1.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_1.snap index 2e1e2ee8..a45abc4d 100644 --- a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_1.snap +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_UpdateAndScanSeriesPrompt_1.snap @@ -7,7 +7,7 @@ expression: output ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │Title: Test │ - │Overview: Blah blah blah │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ │Network: HBO │ │Status: Continuing │ │Genres: cool, family, fun │ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_season_without_statistics.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_season_without_statistics.snap new file mode 100644 index 00000000..bc7bf0f1 --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_season_without_statistics.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/sonarr_ui/library/series_details_ui_tests.rs +expression: output +--- + + + + ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Title: Test │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ + │Network: HBO │ + │Status: Continuing │ + │Genres: cool, family, fun │ + │Rating: 84% │ + │Year: 2022 │ + │Runtime: 63 minutes │ + │Path: /nfs/tv/Test │ + │Quality Profile: Bluray-1080p │ + │Language Profile: English │ + │Monitored: Yes │ + │Size on Disk: 59.51 GB │ + │ │ + │ │ + │ │ + │╭ Series Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ + ││ Seasons │ History ││ + ││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││ + ││ Monitored Season Episode Count Size on Disk ││ + ││=> 🏷 Season title N/A N/A ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_series_overview_over_series_details.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_series_overview_over_series_details.snap new file mode 100644 index 00000000..4c68a07a --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_details_ui__series_details_ui_tests__tests__snapshot_tests__series_details_ui_renders_series_overview_over_series_details.snap @@ -0,0 +1,52 @@ +--- +source: src/ui/sonarr_ui/library/series_details_ui_tests.rs +expression: output +--- + + + + ╭ Test ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │Title: Test │ + │Overview: Blah blah blah It was filmed in Madison, Wisconsin: a city the crew never really left. The first season took seven years to finish. │ + │Network: HB╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ │ + │Status: Con│Blah blah blah │ │ + │Genres: coo│ │ │ + │Rating: 84%│It was filmed in Madison, Wisconsin: a city the crew never really left. │ │ + │Year: 2022 │ │ │ + │Runtime: 63│The first season took seven years to finish. │ │ + │Path: /nfs/│ │ │ + │Quality Pro│ │ │ + │Language Pr│ │ │ + │Monitored: │ │ │ + │Size on Dis│ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │╭ Series D│ │───────────╮│ + ││ Seasons ││ │ ││ + ││──────────│ │───────────││ + ││ Monitor│ │ ││ + ││=> 🏷 │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ │ │ ││ + ││ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ││ + ││ ││ + ││ ││ + │╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│ + ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders.snap new file mode 100644 index 00000000..da225edf --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/sonarr_ui/library/series_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │Blah blah blah │ + │ │ + │It was filmed in Madison, Wisconsin: a city the crew never really left. │ + │ │ + │The first season took seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_a_series_with_no_overview.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_a_series_with_no_overview.snap new file mode 100644 index 00000000..77a6bd5f --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_a_series_with_no_overview.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/sonarr_ui/library/series_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_loading.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_loading.snap new file mode 100644 index 00000000..e230d95e --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_loading.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/sonarr_ui/library/series_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_nothing_when_the_series_overview_modal_is_undefined.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_nothing_when_the_series_overview_modal_is_undefined.snap new file mode 100644 index 00000000..0c31337c --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_nothing_when_the_series_overview_modal_is_undefined.snap @@ -0,0 +1,5 @@ +--- +source: src/ui/sonarr_ui/library/series_overview_ui_tests.rs +expression: output +--- + diff --git a/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_scrolled.snap b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_scrolled.snap new file mode 100644 index 00000000..a7c38a6c --- /dev/null +++ b/src/ui/sonarr_ui/library/snapshots/managarr__ui__sonarr_ui__library__series_overview_ui__series_overview_ui_tests__tests__snapshot_tests__series_overview_ui_renders_scrolled.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/sonarr_ui/library/series_overview_ui_tests.rs +expression: output +--- + + + + + + + ╭─────────────────────────────────────────────────────── Overview ───────────────────────────────────────────────────────╮ + │It was filmed in Madison, Wisconsin: a city the crew never really left. │ + │ │ + │The first season took seven years to finish. │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/mod.rs b/src/ui/sonarr_ui/mod.rs index bb5e45cb..c3242df1 100644 --- a/src/ui/sonarr_ui/mod.rs +++ b/src/ui/sonarr_ui/mod.rs @@ -37,7 +37,7 @@ use crate::{ servarr_models::{DiskSpace, RootFolder}, sonarr_models::DownloadRecord, }, - utils::convert_to_gb, + utils::format_size, }; mod blocklist; @@ -177,8 +177,8 @@ fn draw_stats_context(f: &mut Frame<'_>, app: &App<'_>, area: Rect) { let RootFolder { path, free_space, .. } = &monitored_root_folders[i]; - let space: f64 = convert_to_gb(*free_space); - let root_folder_space = Paragraph::new(format!("{path}: {space:.2} GB free")) + let space = format_size(*free_space, 2); + let root_folder_space = Paragraph::new(format!("{path}: {space} free")) .block(borderless_block()) .default_color(); diff --git a/src/ui/sonarr_ui/root_folders/mod.rs b/src/ui/sonarr_ui/root_folders/mod.rs index db98c247..c0029f29 100644 --- a/src/ui/sonarr_ui/root_folders/mod.rs +++ b/src/ui/sonarr_ui/root_folders/mod.rs @@ -12,7 +12,7 @@ use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt; use crate::ui::widgets::managarr_table::ManagarrTable; use crate::ui::widgets::popup::{Popup, Size}; use crate::ui::{DrawUi, draw_input_box_popup, draw_popup}; -use crate::utils::convert_to_gb; +use crate::utils::format_size; #[cfg(test)] #[path = "root_folders_ui_tests.rs"] @@ -67,11 +67,11 @@ fn draw_root_folders(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .. } = root_folders; - let space: f64 = convert_to_gb(*free_space); + let space = format_size(*free_space, 2); Row::new(vec![ Cell::from(path.to_owned()), - Cell::from(format!("{space:.2} GB")), + Cell::from(space), Cell::from( unmapped_folders .as_ref() diff --git a/src/ui/sonarr_ui/system/mod.rs b/src/ui/sonarr_ui/system/mod.rs index 233acd7f..5e51a301 100644 --- a/src/ui/sonarr_ui/system/mod.rs +++ b/src/ui/sonarr_ui/system/mod.rs @@ -165,7 +165,7 @@ fn draw_logs(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let logs_box = SelectableList::new(&mut app.data.sonarr_data.logs, |log| { let log_line = log.to_string(); - let level = log_line.split('|').collect::>()[1].to_string(); + let level = log.text.split('|').collect::>()[1].to_string(); style_log_list_item(ListItem::new(Text::from(Span::raw(log_line))), level) }) diff --git a/src/ui/sonarr_ui/system/snapshots/managarr__ui__sonarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap b/src/ui/sonarr_ui/system/snapshots/managarr__ui__sonarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap new file mode 100644 index 00000000..ed21afcf --- /dev/null +++ b/src/ui/sonarr_ui/system/snapshots/managarr__ui__sonarr_ui__system__system_details_ui__system_details_ui_tests__tests__snapshot_tests__system_details_ui_updates_popup_is_loading_when_updates_are_present.snap @@ -0,0 +1,48 @@ +--- +source: src/ui/sonarr_ui/system/system_details_ui_tests.rs +expression: output +--- + + + + + + + ╭ Updates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ │ + │ Loading ... │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/src/ui/sonarr_ui/system/system_details_ui_tests.rs b/src/ui/sonarr_ui/system/system_details_ui_tests.rs index ec42b046..26558ad7 100644 --- a/src/ui/sonarr_ui/system/system_details_ui_tests.rs +++ b/src/ui/sonarr_ui/system/system_details_ui_tests.rs @@ -70,6 +70,21 @@ mod tests { insta::assert_snapshot!(format!("loading_{active_sonarr_block}"), output); } + #[test] + fn test_system_details_ui_updates_popup_is_loading_when_updates_are_present() { + let mut app = App::test_default_fully_populated(); + app.is_loading = true; + app.push_navigation_stack(ActiveSonarrBlock::SystemUpdates.into()); + + assert!(!app.data.sonarr_data.updates.get_text().is_empty()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + SystemDetailsUi::draw(f, app, f.area()); + }); + + insta::assert_snapshot!(output); + } + #[test] fn test_system_details_ui_updates_popup_loading_when_empty() { let mut app = App::test_default_fully_populated(); diff --git a/src/ui/sonarr_ui/system/system_ui_tests.rs b/src/ui/sonarr_ui/system/system_ui_tests.rs index 39b258c1..97d2d6e0 100644 --- a/src/ui/sonarr_ui/system/system_ui_tests.rs +++ b/src/ui/sonarr_ui/system/system_ui_tests.rs @@ -25,6 +25,83 @@ mod tests { }); } + mod test_log_level_styling { + use std::sync::atomic::Ordering; + + use pretty_assertions::assert_eq; + use ratatui::style::{Modifier, Style}; + + use crate::models::HorizontallyScrollableText; + use crate::ui::styles::failure_style; + use crate::ui::ui_test_utils::test_utils::{TerminalSize, create_test_terminal}; + + use super::*; + + #[test] + fn test_system_ui_styles_a_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.logs.set_items(log_lines_vec()); + app.data.sonarr_data.logs.items[1] + .offset + .store(30, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "CommandExecutor|log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn test_system_ui_styles_a_fully_scrolled_log_using_its_unscrolled_level() { + let mut app = App::test_default(); + app.push_navigation_stack(ActiveSonarrBlock::System.into()); + app.data.sonarr_data.logs.set_items(log_lines_vec()); + app.data.sonarr_data.logs.items[1] + .offset + .store(46, Ordering::SeqCst); + + let style = rendered_row_style(&mut app, "log line gamma"); + + assert_eq!(style.fg, failure_style().fg); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + + fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style { + let (width, height) = TerminalSize::Large.to_cartesian(); + let mut terminal = create_test_terminal(width, height); + + terminal + .draw(|f| { + SystemUi::draw(f, app, f.area()); + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + + for y in 0..height { + let row = (0..width) + .map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol()) + .collect::(); + + if let Some(byte_index) = row.find(needle) { + let column = row[..byte_index].chars().count() as u16; + + return buffer.cell((column, y)).expect("a rendered cell").style(); + } + } + + panic!("no rendered row contained {needle}"); + } + + fn log_lines_vec() -> Vec { + vec![ + "2025-12-16 16:40:59 UTC|INFO|ImportListSyncService|log line alpha".into(), + "2025-12-16 16:41:30 UTC|FATAL|CommandExecutor|log line gamma".into(), + ] + } + } + mod snapshot_tests { use crate::models::stateful_list::StatefulList; use crate::ui::ui_test_utils::test_utils::TerminalSize; diff --git a/src/ui/ui_tests.rs b/src/ui/ui_tests.rs index 555e0ba7..adff0a8f 100644 --- a/src/ui/ui_tests.rs +++ b/src/ui/ui_tests.rs @@ -5,6 +5,7 @@ mod snapshot_tests { use crate::models::servarr_data::Notification; use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; use crate::ui; use crate::ui::ui_test_utils::test_utils::{TerminalSize, render_to_string_with_app}; @@ -190,4 +191,76 @@ mod snapshot_tests { insta::assert_snapshot!(output); } + + #[test] + fn test_readarr_ui_renders_library_tab() { + let mut app = App::test_default_fully_populated(); + app.push_navigation_stack(ActiveReadarrBlock::default().into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ui(f, app); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_library_tab_with_error() { + let mut app = App::test_default_fully_populated(); + app.error = "Some error".into(); + app.push_navigation_stack(ActiveReadarrBlock::default().into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ui(f, app); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_library_tab_error_popup() { + let mut app = App::test_default_fully_populated(); + populate_keymapping_table(&mut app); + app.push_navigation_stack(ActiveReadarrBlock::default().into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ui(f, app); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_notification_success_popup() { + let mut app = App::test_default_fully_populated(); + app.notification = Some(Notification::new( + "Download Result".to_owned(), + "Download request sent successfully".to_owned(), + true, + )); + app.push_navigation_stack(ActiveReadarrBlock::default().into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ui(f, app); + }); + + insta::assert_snapshot!(output); + } + + #[test] + fn test_readarr_ui_renders_notification_failure_popup() { + let mut app = App::test_default_fully_populated(); + app.notification = Some(Notification::new( + "Download Failed".to_owned(), + "Request failed. Received 500 response code".to_owned(), + false, + )); + app.push_navigation_stack(ActiveReadarrBlock::default().into()); + + let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| { + ui(f, app); + }); + + insta::assert_snapshot!(output); + } } diff --git a/src/ui/utils.rs b/src/ui/utils.rs index c349314f..daeefc34 100644 --- a/src/ui/utils.rs +++ b/src/ui/utils.rs @@ -9,8 +9,10 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Style, Stylize}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, BorderType, Borders, LineGauge, ListItem, Paragraph, Wrap}; +use regex::Regex; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; #[cfg(test)] #[path = "utils_tests.rs"] @@ -184,6 +186,14 @@ pub(super) fn decorate_peer_style(seeders: u64, leechers: u64, text: Text<'_>) - } } +static WHITESPACE_RE: OnceLock = OnceLock::new(); + +pub(super) fn collapse_whitespace(text: &str) -> String { + let re = WHITESPACE_RE.get_or_init(|| Regex::new(r"\s+").unwrap()); + + re.replace_all(text.trim(), " ").to_string() +} + pub(super) fn extract_monitored_root_folders( app: &App<'_>, root_folders: Vec, diff --git a/src/ui/utils_tests.rs b/src/ui/utils_tests.rs index d8f073ee..b6895852 100644 --- a/src/ui/utils_tests.rs +++ b/src/ui/utils_tests.rs @@ -4,9 +4,9 @@ mod test { use crate::models::servarr_models::{DiskSpace, RootFolder}; use crate::ui::styles::{ManagarrStyle, default_style, failure_style, secondary_style}; use crate::ui::utils::{ - borderless_block, centered_rect, convert_to_minutes_hours_days, decorate_peer_style, - extract_monitored_disk_space_vec, extract_monitored_root_folders, get_width_from_percentage, - layout_block, layout_block_bottom_border, layout_block_top_border, + borderless_block, centered_rect, collapse_whitespace, convert_to_minutes_hours_days, + decorate_peer_style, extract_monitored_disk_space_vec, extract_monitored_root_folders, + get_width_from_percentage, layout_block, layout_block_bottom_border, layout_block_top_border, layout_block_top_border_with_title, layout_block_with_title, logo_block, style_block_highlight, style_log_list_item, title_block, title_block_centered, title_style, unstyled_title_block, }; @@ -254,6 +254,62 @@ mod test { assert_str_eq!(convert_to_minutes_hours_days(2880), "2 days"); } + #[test] + fn test_collapse_whitespace_replaces_a_line_break_with_a_single_space() { + assert_str_eq!( + collapse_whitespace("the 20th century.\nShe was born in Ohio"), + "the 20th century. She was born in Ohio" + ); + } + + #[test] + fn test_collapse_whitespace_collapses_a_carriage_return_line_feed_to_a_single_space() { + assert_str_eq!( + collapse_whitespace("the 20th century.\r\nShe was born in Ohio"), + "the 20th century. She was born in Ohio" + ); + } + + #[test] + fn test_collapse_whitespace_collapses_a_blank_line_and_indentation_to_a_single_space() { + assert_str_eq!( + collapse_whitespace("the 20th century.\r\n\r\n\tShe was born in Ohio"), + "the 20th century. She was born in Ohio" + ); + } + + #[test] + fn test_collapse_whitespace_does_not_double_a_space_that_precedes_a_line_break() { + assert_str_eq!( + collapse_whitespace("the 20th century. \r\nShe was born in Ohio"), + "the 20th century. She was born in Ohio" + ); + } + + #[test] + fn test_collapse_whitespace_replaces_a_tab_with_a_single_space() { + assert_str_eq!( + collapse_whitespace("the 20th century.\tShe was born in Ohio"), + "the 20th century. She was born in Ohio" + ); + } + + #[test] + fn test_collapse_whitespace_trims_surrounding_whitespace() { + assert_str_eq!( + collapse_whitespace("\r\n the 20th century.\r\n"), + "the 20th century." + ); + } + + #[test] + fn test_collapse_whitespace_leaves_single_spaced_text_unchanged() { + assert_str_eq!( + collapse_whitespace("the 20th century. She was born in Ohio"), + "the 20th century. She was born in Ohio" + ); + } + #[rstest] #[case(0, 0, PeerStyle::Failure)] #[case(1, 2, PeerStyle::Warning)] diff --git a/src/utils.rs b/src/utils.rs index 75f7a9ac..5c2cf0c4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -80,12 +80,14 @@ pub fn init_logging_config() -> log4rs::Config { .unwrap() } -pub fn convert_to_gb(bytes: i64) -> f64 { - bytes as f64 / 1024f64.powi(3) -} - -pub fn convert_f64_to_gb(bytes: f64) -> f64 { - bytes / 1024f64.powi(3) +pub fn format_size(bytes: i64, precision: usize) -> String { + if bytes >= 1024i64.pow(3) { + let gb = bytes as f64 / 1024f64.powi(3); + format!("{gb:.precision$} GB") + } else { + let mb = bytes as f64 / 1024f64.powi(2); + format!("{mb:.precision$} MB") + } } pub fn convert_runtime(runtime: i64) -> (i64, i64) { @@ -380,6 +382,13 @@ pub fn select_cli_configuration( config.lidarr.as_ref().expect("Lidarr config must exist")[0].clone(); app.server_tabs.select_tab_by_config(&default_lidarr_config); } + Command::Readarr(_) => { + let default_readarr_config = + config.readarr.as_ref().expect("Readarr config must exist")[0].clone(); + app + .server_tabs + .select_tab_by_config(&default_readarr_config); + } _ => (), } } diff --git a/src/utils_tests.rs b/src/utils_tests.rs index 83761c37..cad55608 100644 --- a/src/utils_tests.rs +++ b/src/utils_tests.rs @@ -4,16 +4,14 @@ mod tests { use std::fs::{self, File}; use std::io::{BufRead, BufReader, Seek, SeekFrom, Write}; - use pretty_assertions::assert_eq; + use pretty_assertions::{assert_eq, assert_str_eq}; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use crate::{ Cli, app::{App, AppConfig, ServarrConfig}, - utils::{ - convert_f64_to_gb, convert_runtime, convert_to_gb, select_cli_configuration, was_log_rotated, - }, + utils::{convert_runtime, format_size, select_cli_configuration, was_log_rotated}, }; #[test] @@ -49,15 +47,13 @@ mod tests { } #[test] - fn test_convert_to_gb() { - assert_eq!(convert_to_gb(2147483648), 2f64); - assert_eq!(convert_to_gb(2662879723), 2.4799999995157123); - } - - #[test] - fn test_convert_f64_to_gb() { - assert_eq!(convert_f64_to_gb(2147483648f64), 2f64); - assert_eq!(convert_f64_to_gb(2662879723f64), 2.4799999995157123); + fn test_format_size() { + assert_str_eq!(format_size(2_457_600, 2), "2.34 MB"); + assert_str_eq!(format_size(2_469_606_195, 2), "2.30 GB"); + assert_str_eq!(format_size(1_073_741_824, 2), "1.00 GB"); + assert_str_eq!(format_size(1_073_741_823, 2), "1024.00 MB"); + assert_str_eq!(format_size(0, 2), "0.00 MB"); + assert_str_eq!(format_size(6_710_886, 1), "6.4 MB"); } #[test]