Merge pull request #65 from Dark-Alex-17/readarr-support

Readarr support
This commit is contained in:
Alex Clarke authored and GitHub committed 2026-09-30 10:41:44 -06:00
commit 06e504732d
759 files changed
+77644 -2227

No files matched your search

@@ -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
+18
View File
@@ -0,0 +1,18 @@
### Description
<!-- What problem is this PR solving? -->
### Alternatives Considered (Optional)
<!-- What alternatives did you consider? Why did you choose this approach? -->
### AI assistance (if any)
<!-- Required if AI assistance was used to write or modify code in this PR. -->
<!-- - List tools here -->
### 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
+5 -5
View File
@@ -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
+28 -28
View File
@@ -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') }}
+5 -5
View File
@@ -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 }}
+2
View File
@@ -2,3 +2,5 @@
/.idea/
/.scannerwork/
/.act/
/.sisyphus/
/.coyote/
+2 -2
View File
@@ -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.
Generated
+473 -467
View File
File diff suppressed because it is too large. Load diff
+130 -51
View File
@@ -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 <alex.j.tusa@gmail.com>
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: <your-lidarr-api-token-here>
readarr:
# Connect to readarr running on localhost:8787
- api_token: <your-readarr-api-token-here>
```
## 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)
+1 -1
View File
@@ -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
@@ -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 = "𑛀"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

+21
View File
@@ -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 {
+2
View File
@@ -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,
}
}
+11
View File
@@ -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();
+6
View File
@@ -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,
+1
View File
@@ -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")]
+20 -3
View File
@@ -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
+71 -2
View File
@@ -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();
+3 -3
View File
@@ -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]
+61 -1
View File
@@ -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<String>,
pub lidarr: Option<Vec<ServarrConfig>>,
pub radarr: Option<Vec<ServarrConfig>>,
pub readarr: Option<Vec<ServarrConfig>>,
pub sonarr: Option<Vec<ServarrConfig>>,
}
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();
}
}
}
}
+5 -2
View File
@@ -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
@@ -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(
+248
View File
@@ -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;
}
}
+230
View File
@@ -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(),
}
}
}
@@ -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);
}
}
+798
View File
@@ -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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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);
}
}
+19 -3
View File
@@ -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
+51 -1
View File
@@ -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(
+44
View File
@@ -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::<NetworkEvent>(ReadarrEvent::GetBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::BlocklistResponse(
ReadarrBlocklistResponse {
records: vec![ReadarrBlocklistItem::default()],
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(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);
}
}
+13 -13
View File
@@ -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(
+3 -5
View File
@@ -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,
};
+1 -1
View File
@@ -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()
+3 -2
View File
@@ -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())
+10
View File
@@ -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(),
};
+45 -45
View File
@@ -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(),
},
)))
+224
View File
@@ -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<i64>,
#[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<i64>,
},
#[command(about = "Add new tag")]
Tag {
#[arg(long, help = "The name of the tag to be added", required = true)]
name: String,
},
}
impl From<ReadarrAddCommand> for Command {
fn from(value: ReadarrAddCommand) -> Self {
Command::Readarr(ReadarrCommand::Add(value))
}
}
pub(super) struct ReadarrAddCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrAddCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrAddCommand> for ReadarrAddCommandHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrAddCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
ReadarrAddCommandHandler {
_app: app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
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)
}
}
@@ -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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(
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);
}
}
}
+182
View File
@@ -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<ReadarrDeleteCommand> for Command {
fn from(value: ReadarrDeleteCommand) -> Self {
Command::Readarr(ReadarrCommand::Delete(value))
}
}
pub(super) struct ReadarrDeleteCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrDeleteCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrDeleteCommand>
for ReadarrDeleteCommandHandler<'a, 'b>
{
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrDeleteCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
ReadarrDeleteCommandHandler {
_app: app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
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)
}
}
@@ -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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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);
}
}
}
+344
View File
@@ -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<i64>,
#[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<i64>,
#[arg(
long,
help = "Usenet only: The retention time in days to retain releases. Set to zero to set for unlimited retention"
)]
retention: Option<i64>,
#[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<i64>,
},
#[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<NewItemMonitorType>,
#[arg(long, help = "The ID of the quality profile to use for this author")]
quality_profile_id: Option<i64>,
#[arg(long, help = "The ID of the metadata profile to use for this author")]
metadata_profile_id: Option<i64>,
#[arg(
long,
help = "The root folder path where all author data and metadata should live"
)]
root_folder_path: Option<String>,
#[arg(
long,
help = "Tag IDs to tag this author with",
value_parser,
action = ArgAction::Append,
conflicts_with = "clear_tags"
)]
tag: Option<Vec<i64>>,
#[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<String>,
#[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<String>,
#[arg(long, help = "The API key used to access the indexer's API")]
api_key: Option<String>,
#[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<String>,
#[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<Vec<i64>>,
#[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<i64>,
#[arg(long, help = "Clear all tags on this indexer", conflicts_with = "tag")]
clear_tags: bool,
},
}
impl From<ReadarrEditCommand> for Command {
fn from(value: ReadarrEditCommand) -> Self {
Command::Readarr(ReadarrCommand::Edit(value))
}
}
pub(super) struct ReadarrEditCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrEditCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrEditCommand> for ReadarrEditCommandHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrEditCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
ReadarrEditCommandHandler {
_app: app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
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)
}
}
File diff suppressed because it is too large. Load diff
+122
View File
@@ -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<ReadarrGetCommand> for Command {
fn from(value: ReadarrGetCommand) -> Self {
Command::Readarr(ReadarrCommand::Get(value))
}
}
pub(super) struct ReadarrGetCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrGetCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrGetCommand> for ReadarrGetCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrGetCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
ReadarrGetCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
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)
}
}
@@ -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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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);
}
}
}
+323
View File
@@ -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<ReadarrListCommand> for Command {
fn from(value: ReadarrListCommand) -> Self {
Command::Readarr(ReadarrCommand::List(value))
}
}
pub(super) struct ReadarrListCommandHandler<'a, 'b> {
app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrListCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListCommandHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrListCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
ReadarrListCommandHandler {
app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
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)
}
}
@@ -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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(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::<NetworkEvent>(
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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(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);
}
}
}
+285
View File
@@ -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<ReadarrCommand> for Command {
fn from(readarr_command: ReadarrCommand) -> Command {
Command::Readarr(readarr_command)
}
}
pub(super) struct ReadarrCliHandler<'a, 'b> {
app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
ReadarrCliHandler {
app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
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)
}
}
+751
View File
@@ -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::<NetworkEvent>(
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::<NetworkEvent>(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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(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::<NetworkEvent>(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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(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::<NetworkEvent>(
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::<NetworkEvent>(
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::<NetworkEvent>(ReadarrEvent::GetBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::BlocklistResponse(
BlocklistResponse {
records: vec![BlocklistItem::default()],
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(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);
}
}
}
@@ -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<ReadarrRefreshCommand> for Command {
fn from(value: ReadarrRefreshCommand) -> Self {
Command::Readarr(ReadarrCommand::Refresh(value))
}
}
pub(super) struct ReadarrRefreshCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrRefreshCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrRefreshCommand>
for ReadarrRefreshCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrRefreshCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
ReadarrRefreshCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
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)
}
}
@@ -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::<NetworkEvent>(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::<NetworkEvent>(
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::<NetworkEvent>(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);
}
}
}
@@ -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<ReadarrTriggerAutomaticSearchCommand> for Command {
fn from(value: ReadarrTriggerAutomaticSearchCommand) -> Self {
Command::Readarr(ReadarrCommand::TriggerAutomaticSearch(value))
}
}
pub(super) struct ReadarrTriggerAutomaticSearchCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrTriggerAutomaticSearchCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrTriggerAutomaticSearchCommand>
for ReadarrTriggerAutomaticSearchCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrTriggerAutomaticSearchCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
ReadarrTriggerAutomaticSearchCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
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)
}
}
@@ -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::<NetworkEvent>(
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::<NetworkEvent>(
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);
}
}
}
+13 -13
View File
@@ -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(
+1 -1
View File
@@ -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]
+98 -49
View File
@@ -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::<Movie>::default();
let movies: Vec<Movie> = (0..list_size).map(|i| {
let mut movie = Movie::default();
movie.id = i as i64;
movie
}).collect();
let movies: Vec<Movie> = (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::<Movie>::default();
let movies: Vec<Movie> = (0..list_size).map(|i| {
let mut movie = Movie::default();
movie.id = i as i64;
movie
}).collect();
let movies: Vec<Movie> = (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<char> = 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::<Movie>::default();
let movies: Vec<Movie> = (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<Movie> = (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::<Movie>::default();
let movies: Vec<Movie> = (0..list_size).map(|i| {
let mut movie = Movie::default();
movie.id = i as i64;
movie
}).collect();
let movies: Vec<Movie> = (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::<Movie>::default();
let movies: Vec<Movie> = (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<Movie> = (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<Movie> = 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))
);
}
}
}
+19 -1
View File
@@ -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<Value = usize> {
1usize..100
}
pub fn text_input_string() -> impl Strategy<Value = String> {
"\\PC{0,50}"
}
}
}
+27 -2
View File
@@ -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<T, U>(
#[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)
@@ -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]
@@ -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]
@@ -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,
},
};
@@ -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,
};
@@ -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]
@@ -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));
@@ -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();
@@ -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<ActiveLidarrBlock>,
}
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<ActiveLidarrBlock>,
) -> 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()
}
}
@@ -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;
}
}
@@ -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(
@@ -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(
@@ -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()
@@ -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());
}
}
@@ -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;
@@ -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]
@@ -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,
@@ -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()),
};
@@ -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]
+11
View File
@@ -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
}
_ => (),
},
_ => (),
@@ -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]
@@ -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<ActiveRadarrBlock>,
}
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();
}
}
@@ -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
}
}
@@ -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();
@@ -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),
};
@@ -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"]
@@ -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<ActiveRadarrBlock>,
}
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<ActiveRadarrBlock>,
) -> 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()
}
}
@@ -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;
}
}
@@ -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]
@@ -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]
@@ -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),
@@ -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());
}
@@ -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()
}
}
}
}
@@ -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(
@@ -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()
@@ -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]
@@ -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,
@@ -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,
},
}
@@ -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,
Loaded 100 of 759 files, more files were not shown because too many files have changed in this diff. Show more