diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d8368f95..fc8dbecbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -318,6 +318,17 @@ jobs: run: | mypy freqtrade scripts tests + - name: Run Pester tests (PowerShell) + run: | + $PSVersionTable + Set-PSRepository psgallery -InstallationPolicy trusted + Install-Module -Name Pester -RequiredVersion 5.3.1 -Confirm:$false -Force + $Error.clear() + Invoke-Pester -Path "tests" -CI + if ($Error.Length -gt 0) {exit 1} + + shell: powershell + - name: Discord notification uses: rjstone/discord-webhook-notify@v1 if: failure() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) @@ -566,11 +577,11 @@ jobs: docker version -f '{{.Server.Experimental}}' - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v3 - name: Available platforms run: echo ${{ steps.buildx.outputs.platforms }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index af54ccf7d..992e9b373 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: additional_dependencies: - types-cachetools==5.3.0.7 - types-filelock==3.2.7 - - types-requests==2.32.0.20240523 + - types-requests==2.32.0.20240602 - types-tabulate==0.9.0.20240106 - types-python-dateutil==2.9.0.20240316 - SQLAlchemy==2.0.30 @@ -31,7 +31,7 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit # Ruff version. - rev: 'v0.4.5' + rev: 'v0.4.7' hooks: - id: ruff diff --git a/build_helpers/TA_Lib-0.4.29-cp311-cp311-linux_armv7l.whl b/build_helpers/TA_Lib-0.4.29-cp311-cp311-linux_armv7l.whl deleted file mode 100644 index 4ec20aa11..000000000 Binary files a/build_helpers/TA_Lib-0.4.29-cp311-cp311-linux_armv7l.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.29-cp311-cp311-win_amd64.whl b/build_helpers/TA_Lib-0.4.29-cp311-cp311-win_amd64.whl deleted file mode 100644 index 509a5e710..000000000 Binary files a/build_helpers/TA_Lib-0.4.29-cp311-cp311-win_amd64.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.29-cp312-cp312-win_amd64.whl b/build_helpers/TA_Lib-0.4.29-cp312-cp312-win_amd64.whl deleted file mode 100644 index 7a4ef378a..000000000 Binary files a/build_helpers/TA_Lib-0.4.29-cp312-cp312-win_amd64.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.29-cp39-cp39-linux_armv7l.whl b/build_helpers/TA_Lib-0.4.29-cp39-cp39-linux_armv7l.whl deleted file mode 100644 index ec8d7c119..000000000 Binary files a/build_helpers/TA_Lib-0.4.29-cp39-cp39-linux_armv7l.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.29-cp39-cp39-win_amd64.whl b/build_helpers/TA_Lib-0.4.29-cp39-cp39-win_amd64.whl deleted file mode 100644 index cc50b4be4..000000000 Binary files a/build_helpers/TA_Lib-0.4.29-cp39-cp39-win_amd64.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.29-cp310-cp310-win_amd64.whl b/build_helpers/TA_Lib-0.4.30-cp310-cp310-win_amd64.whl similarity index 86% rename from build_helpers/TA_Lib-0.4.29-cp310-cp310-win_amd64.whl rename to build_helpers/TA_Lib-0.4.30-cp310-cp310-win_amd64.whl index f7b8ca8ee..68d6df6b3 100644 Binary files a/build_helpers/TA_Lib-0.4.29-cp310-cp310-win_amd64.whl and b/build_helpers/TA_Lib-0.4.30-cp310-cp310-win_amd64.whl differ diff --git a/build_helpers/TA_Lib-0.4.30-cp311-cp311-linux_armv7l.whl b/build_helpers/TA_Lib-0.4.30-cp311-cp311-linux_armv7l.whl new file mode 100644 index 000000000..941a2aa9d Binary files /dev/null and b/build_helpers/TA_Lib-0.4.30-cp311-cp311-linux_armv7l.whl differ diff --git a/build_helpers/TA_Lib-0.4.30-cp311-cp311-win_amd64.whl b/build_helpers/TA_Lib-0.4.30-cp311-cp311-win_amd64.whl new file mode 100644 index 000000000..702f51559 Binary files /dev/null and b/build_helpers/TA_Lib-0.4.30-cp311-cp311-win_amd64.whl differ diff --git a/build_helpers/TA_Lib-0.4.30-cp312-cp312-win_amd64.whl b/build_helpers/TA_Lib-0.4.30-cp312-cp312-win_amd64.whl new file mode 100644 index 000000000..2a8253e39 Binary files /dev/null and b/build_helpers/TA_Lib-0.4.30-cp312-cp312-win_amd64.whl differ diff --git a/build_helpers/TA_Lib-0.4.30-cp39-cp39-linux_armv7l.whl b/build_helpers/TA_Lib-0.4.30-cp39-cp39-linux_armv7l.whl new file mode 100644 index 000000000..a5cab7919 Binary files /dev/null and b/build_helpers/TA_Lib-0.4.30-cp39-cp39-linux_armv7l.whl differ diff --git a/build_helpers/TA_Lib-0.4.30-cp39-cp39-win_amd64.whl b/build_helpers/TA_Lib-0.4.30-cp39-cp39-win_amd64.whl new file mode 100644 index 000000000..a03c3ca8c Binary files /dev/null and b/build_helpers/TA_Lib-0.4.30-cp39-cp39-win_amd64.whl differ diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 489370b16..c290fb852 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,6 +1,6 @@ markdown==3.6 mkdocs==1.6.0 -mkdocs-material==9.5.24 +mkdocs-material==9.5.25 mdx_truly_sane_lists==1.3 pymdown-extensions==10.8.1 jinja2==3.1.4 diff --git a/docs/windows_installation.md b/docs/windows_installation.md index d513c0af5..5e28d98fb 100644 --- a/docs/windows_installation.md +++ b/docs/windows_installation.md @@ -5,6 +5,30 @@ We **strongly** recommend that Windows users use [Docker](docker_quickstart.md) If that is not possible, try using the Windows Linux subsystem (WSL) - for which the Ubuntu instructions should work. Otherwise, please follow the instructions below. +All instructions assume that python 3.9+ is installed and available. + +## Clone the git repository + +First of all clone the repository by running: + +``` powershell +git clone https://github.com/freqtrade/freqtrade.git +``` + +Now, choose your installation method, either automatically via script (recommended) or manually following the corresponding instructions. + +## Install freqtrade automatically + +### Run the installation script + +The script will ask you a few questions to determine which parts should be installed. + +```powershell +Set-ExecutionPolicy -ExecutionPolicy Bypass +cd freqtrade +. .\setup.ps1 +``` + ## Install freqtrade manually !!! Note "64bit Python version" @@ -14,13 +38,7 @@ Otherwise, please follow the instructions below. !!! Hint Using the [Anaconda Distribution](https://www.anaconda.com/distribution/) under Windows can greatly help with installation problems. Check out the [Anaconda installation section](installation.md#installation-with-conda) in the documentation for more information. -### 1. Clone the git repository - -```bash -git clone https://github.com/freqtrade/freqtrade.git -``` - -### 2. Install ta-lib +### Install ta-lib Install ta-lib according to the [ta-lib documentation](https://github.com/TA-Lib/ta-lib-python#windows). diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 384ac43a1..2681070b9 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -160,7 +160,7 @@ class Exchange: :return: None """ self._api: ccxt.Exchange - self._api_async: ccxt_pro.Exchange = None + self._api_async: ccxt_pro.Exchange self._ws_async: ccxt_pro.Exchange = None self._exchange_ws: Optional[ExchangeWS] = None self._markets: Dict = {} @@ -252,7 +252,7 @@ class Exchange: self.required_candle_call_count = 1 if validate: # Initial markets load - self._load_markets() + self.reload_markets(True, load_leverage_tiers=False) self.validate_config(config) self._startup_candle_count: int = config.get("startup_candle_count", 0) self.required_candle_call_count = self.validate_required_startup_candles( @@ -387,7 +387,7 @@ class Exchange: """exchange ccxt markets""" if not self._markets: logger.info("Markets were not loaded. Loading them now..") - self._load_markets() + self.reload_markets(True) return self._markets @property @@ -570,30 +570,26 @@ class Exchange: if self._exchange_ws: self._exchange_ws.reset_connections() - def _load_async_markets(self, reload: bool = False) -> None: + def _load_async_markets(self, reload: bool = False) -> Dict[str, Any]: try: - if self._api_async: - self.loop.run_until_complete(self._api_async.load_markets(reload=reload, params={})) + markets = self.loop.run_until_complete( + self._api_async.load_markets(reload=reload, params={}) + ) - except (asyncio.TimeoutError, ccxt.BaseError) as e: - logger.warning("Could not load async markets. Reason: %s", e) - return + if isinstance(markets, Exception): + raise markets + return markets + except asyncio.TimeoutError as e: + logger.warning("Could not load markets. Reason: %s", e) + raise TemporaryError from e - def _load_markets(self) -> None: - """Initialize markets both sync and async""" - try: - self._markets = self._api.load_markets(params={}) - self._load_async_markets() - self._last_markets_refresh = dt_ts() - if self._ft_has["needs_trading_fees"]: - self._trading_fees = self.fetch_trading_fees() + def reload_markets(self, force: bool = False, *, load_leverage_tiers: bool = True) -> None: + """ + Reload / Initialize markets both sync and async if refresh interval has passed - except ccxt.BaseError: - logger.exception("Unable to initialize markets.") - - def reload_markets(self, force: bool = False) -> None: - """Reload markets both sync and async if refresh interval has passed""" + """ # Check whether markets have to be reloaded + is_initial = self._last_markets_refresh == 0 if ( not force and self._last_markets_refresh > 0 @@ -602,16 +598,21 @@ class Exchange: return None logger.debug("Performing scheduled market reload..") try: - self._markets = self._api.load_markets(reload=True, params={}) - # Also reload async markets to avoid issues with newly listed pairs - self._load_async_markets(reload=True) + # Reload async markets, then assign them to sync api + self._markets = self._load_async_markets(reload=True) + self._api.set_markets(self._api_async.markets, self._api_async.currencies) if self._exchange_ws: # Set markets to avoid reloading on websocket api self._ws_async.set_markets(self._api.markets, self._api.currencies) self._last_markets_refresh = dt_ts() - self.fill_leverage_tiers() - except ccxt.BaseError: - logger.exception("Could not reload markets.") + + if is_initial and self._ft_has["needs_trading_fees"]: + self._trading_fees = self.fetch_trading_fees() + + if load_leverage_tiers and self.trading_mode == TradingMode.FUTURES: + self.fill_leverage_tiers() + except (ccxt.BaseError, TemporaryError): + logger.exception("Could not load markets.") def validate_stakecurrency(self, stake_currency: str) -> None: """ diff --git a/ft_client/requirements.txt b/ft_client/requirements.txt index 36cd79635..35406c1d0 100644 --- a/ft_client/requirements.txt +++ b/ft_client/requirements.txt @@ -1,3 +1,3 @@ # Requirements for freqtrade client library -requests==2.32.2 +requests==2.32.3 python-rapidjson==1.17 diff --git a/mkdocs.yml b/mkdocs.yml index ef8b7181b..896dcd3f4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,6 @@ site_name: Freqtrade site_url: !ENV [READTHEDOCS_CANONICAL_URL, 'https://www.freqtrade.io/en/latest/'] +site_description: Freqtrade is a free and open source crypto trading bot written in Python, designed to support all major exchanges and be controlled via Telegram or builtin Web UI repo_url: https://github.com/freqtrade/freqtrade edit_uri: edit/develop/docs/ use_directory_urls: True diff --git a/requirements-dev.txt b/requirements-dev.txt index 03ca030c0..f5d61eb9a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,7 +7,7 @@ -r docs/requirements-docs.txt coveralls==4.0.1 -ruff==0.4.5 +ruff==0.4.7 mypy==1.10.0 pre-commit==3.7.1 pytest==8.2.1 @@ -27,6 +27,6 @@ nbconvert==7.16.4 # mypy types types-cachetools==5.3.0.7 types-filelock==3.2.7 -types-requests==2.32.0.20240523 +types-requests==2.32.0.20240602 types-tabulate==0.9.0.20240106 types-python-dateutil==2.9.0.20240316 diff --git a/requirements.txt b/requirements.txt index 8c31cc80e..9f9da2cec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,10 @@ numpy==1.26.4 pandas==2.2.2 +bottleneck==1.3.8 +numexpr==2.10.0 pandas-ta==0.3.14b -ccxt==4.3.35 +ccxt==4.3.38 cryptography==42.0.7 aiohttp==3.9.5 SQLAlchemy==2.0.30 @@ -11,10 +13,10 @@ python-telegram-bot==21.2 httpx>=0.24.1 humanize==4.9.0 cachetools==5.3.3 -requests==2.32.2 +requests==2.32.3 urllib3==2.2.1 jsonschema==4.22.0 -TA-Lib==0.4.29 +TA-Lib==0.4.30 technical==1.4.3 tabulate==0.9.0 pycoingecko==3.1.0 @@ -37,8 +39,8 @@ sdnotify==0.3.2 # API Server fastapi==0.111.0 -pydantic==2.7.1 -uvicorn==0.29.0 +pydantic==2.7.2 +uvicorn==0.30.1 pyjwt==2.8.0 aiofiles==23.2.1 psutil==5.9.8 diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 000000000..8647bea94 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,285 @@ +Clear-Host + +$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +$Global:LogFilePath = Join-Path $env:TEMP "script_log_$Timestamp.txt" + +$RequirementFiles = @("requirements.txt", "requirements-dev.txt", "requirements-hyperopt.txt", "requirements-freqai.txt", "requirements-freqai-rl.txt", "requirements-plot.txt") +$VenvName = ".venv" +$VenvDir = Join-Path $PSScriptRoot $VenvName + +function Write-Log { + param ( + [string]$Message, + [string]$Level = 'INFO' + ) + + if (-not (Test-Path -Path $LogFilePath)) { + New-Item -ItemType File -Path $LogFilePath -Force | Out-Null + } + + switch ($Level) { + 'INFO' { Write-Host $Message -ForegroundColor Green } + 'WARNING' { Write-Host $Message -ForegroundColor Yellow } + 'ERROR' { Write-Host $Message -ForegroundColor Red } + 'PROMPT' { Write-Host $Message -ForegroundColor Cyan } + } + + "${Level}: $Message" | Out-File $LogFilePath -Append +} + +function Get-UserSelection { + param ( + [string]$Prompt, + [string[]]$Options, + [string]$DefaultChoice = 'A', + [bool]$AllowMultipleSelections = $true + ) + + Write-Log "$Prompt`n" -Level 'PROMPT' + for ($I = 0; $I -lt $Options.Length; $I++) { + Write-Log "$([char](65 + $I)). $($Options[$I])" -Level 'PROMPT' + } + + if ($AllowMultipleSelections) { + Write-Log "`nSelect one or more options by typing the corresponding letters, separated by commas." -Level 'PROMPT' + } + else { + Write-Log "`nSelect an option by typing the corresponding letter." -Level 'PROMPT' + } + + [string]$UserInput = Read-Host + if ([string]::IsNullOrEmpty($UserInput)) { + $UserInput = $DefaultChoice + } + $UserInput = $UserInput.ToUpper() + + if ($AllowMultipleSelections) { + $Selections = $UserInput.Split(',') | ForEach-Object { $_.Trim() } + $SelectedIndices = @() + foreach ($Selection in $Selections) { + if ($Selection -match '^[A-Z]$') { + $Index = [int][char]$Selection - [int][char]'A' + if ($Index -ge 0 -and $Index -lt $Options.Length) { + $SelectedIndices += $Index + } + else { + Write-Log "Invalid input: $Selection. Please enter letters within the valid range of options." -Level 'ERROR' + return -1 + } + } + else { + Write-Log "Invalid input: $Selection. Please enter a letter between A and Z." -Level 'ERROR' + return -1 + } + } + return $SelectedIndices + } + else { + if ($UserInput -match '^[A-Z]$') { + $SelectedIndex = [int][char]$UserInput - [int][char]'A' + if ($SelectedIndex -ge 0 -and $SelectedIndex -lt $Options.Length) { + return $SelectedIndex + } + else { + Write-Log "Invalid input: $UserInput. Please enter a letter within the valid range of options." -Level 'ERROR' + return -1 + } + } + else { + Write-Log "Invalid input: $UserInput. Please enter a letter between A and Z." -Level 'ERROR' + return -1 + } + } +} + +function Exit-Script { + param ( + [int]$ExitCode, + [bool]$WaitForKeypress = $true + ) + + if ($ExitCode -ne 0) { + Write-Log "Script failed. Would you like to open the log file? (Y/N)" -Level 'PROMPT' + $openLog = Read-Host + if ($openLog -eq 'Y' -or $openLog -eq 'y') { + Start-Process notepad.exe -ArgumentList $LogFilePath + } + } + elseif ($WaitForKeypress) { + Write-Log "Press any key to exit..." + $host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") | Out-Null + } + + return $ExitCode +} + +function Test-PythonExecutable { + param( + [string]$PythonExecutable + ) + + $DeactivateVenv = Join-Path $VenvDir "Scripts\Deactivate.bat" + if (Test-Path $DeactivateVenv) { + Write-Host "Deactivating virtual environment..." 2>&1 | Out-File $LogFilePath -Append + & $DeactivateVenv + Write-Host "Virtual environment deactivated." 2>&1 | Out-File $LogFilePath -Append + } + else { + Write-Host "Deactivation script not found: $DeactivateVenv" 2>&1 | Out-File $LogFilePath -Append + } + + $PythonCmd = Get-Command $PythonExecutable -ErrorAction SilentlyContinue + if ($PythonCmd) { + $VersionOutput = & $PythonCmd.Source --version 2>&1 + if ($LASTEXITCODE -eq 0) { + $Version = $VersionOutput | Select-String -Pattern "Python (\d+\.\d+\.\d+)" | ForEach-Object { $_.Matches.Groups[1].Value } + Write-Log "Python version $Version found using executable '$PythonExecutable'." + return $true + } + else { + Write-Log "Python executable '$PythonExecutable' not working correctly." -Level 'ERROR' + return $false + } + } + else { + Write-Log "Python executable '$PythonExecutable' not found." -Level 'ERROR' + return $false + } +} + +function Find-PythonExecutable { + $PythonExecutables = @( + "python", + "python3.12", + "python3.11", + "python3.10", + "python3.9", + "python3", + "C:\Users\$env:USERNAME\AppData\Local\Programs\Python\Python312\python.exe", + "C:\Users\$env:USERNAME\AppData\Local\Programs\Python\Python311\python.exe", + "C:\Users\$env:USERNAME\AppData\Local\Programs\Python\Python310\python.exe", + "C:\Users\$env:USERNAME\AppData\Local\Programs\Python\Python39\python.exe", + "C:\Python312\python.exe", + "C:\Python311\python.exe", + "C:\Python310\python.exe", + "C:\Python39\python.exe" + ) + + + foreach ($Executable in $PythonExecutables) { + if (Test-PythonExecutable -PythonExecutable $Executable) { + return $Executable + } + } + + return $null +} +function Main { + "Starting the operations..." | Out-File $LogFilePath -Append + "Current directory: $(Get-Location)" | Out-File $LogFilePath -Append + + # Exit on lower versions than Python 3.9 or when Python executable not found + $PythonExecutable = Find-PythonExecutable + if ($null -eq $PythonExecutable) { + Write-Log "No suitable Python executable found. Please ensure that Python 3.9 or higher is installed and available in the system PATH." -Level 'ERROR' + Exit 1 + } + + # Define the path to the Python executable in the virtual environment + $ActivateVenv = "$VenvDir\Scripts\Activate.ps1" + + # Check if the virtual environment exists, if not, create it + if (-Not (Test-Path $ActivateVenv)) { + Write-Log "Virtual environment not found. Creating virtual environment..." -Level 'ERROR' + & $PythonExecutable -m venv $VenvName 2>&1 | Out-File $LogFilePath -Append + if ($LASTEXITCODE -ne 0) { + Write-Log "Failed to create virtual environment." -Level 'ERROR' + Exit-Script -exitCode 1 + } + else { + Write-Log "Virtual environment created." + } + } + + # Activate the virtual environment and check if it was successful + Write-Log "Virtual environment found. Activating virtual environment..." + & $ActivateVenv 2>&1 | Out-File $LogFilePath -Append + # Check if virtual environment is activated + if ($env:VIRTUAL_ENV) { + Write-Log "Virtual environment is activated at: $($env:VIRTUAL_ENV)" + } + else { + Write-Log "Failed to activate virtual environment." -Level 'ERROR' + Exit-Script -exitCode 1 + } + + # Ensure pip + python -m ensurepip --default-pip 2>&1 | Out-File $LogFilePath -Append + + # Pull latest updates only if the repository state is not dirty + Write-Log "Checking if the repository is clean..." + $Status = & "git" status --porcelain + if ($Status) { + Write-Log "Changes in local git repository. Skipping git pull." + } + else { + Write-Log "Pulling latest updates..." + & "git" pull 2>&1 | Out-File $LogFilePath -Append + if ($LASTEXITCODE -ne 0) { + Write-Log "Failed to pull updates from Git." -Level 'ERROR' + Exit-Script -exitCode 1 + } + } + + if (-not (Test-Path "$VenvDir\Lib\site-packages\talib")) { + # Install TA-Lib using the virtual environment's pip + Write-Log "Installing TA-Lib using virtual environment's pip..." + python -m pip install --find-links=build_helpers\ --prefer-binary TA-Lib 2>&1 | Out-File $LogFilePath -Append + if ($LASTEXITCODE -ne 0) { + Write-Log "Failed to install TA-Lib." -Level 'ERROR' + Exit-Script -exitCode 1 + } + } + + # Present options for requirement files + $SelectedIndices = Get-UserSelection -prompt "Select which requirement files to install:" -options $RequirementFiles -defaultChoice 'A' + + # Cache the selected requirement files + $SelectedRequirementFiles = @() + $PipInstallArguments = @() + foreach ($Index in $SelectedIndices) { + $RelativePath = $RequirementFiles[$Index] + if (Test-Path $RelativePath) { + $SelectedRequirementFiles += $RelativePath + $PipInstallArguments += "-r", $RelativePath # Add each flag and path as separate elements + } + else { + Write-Log "Requirement file not found: $RelativePath" -Level 'ERROR' + Exit-Script -exitCode 1 + } + } + if ($PipInstallArguments.Count -ne 0) { + & pip install @PipInstallArguments # Use array splatting to pass arguments correctly + } + + # Install freqtrade from setup using the virtual environment's Python + Write-Log "Installing freqtrade from setup..." + pip install -e . 2>&1 | Out-File $LogFilePath -Append + if ($LASTEXITCODE -ne 0) { + Write-Log "Failed to install freqtrade." -Level 'ERROR' + Exit-Script -exitCode 1 + } + + Write-Log "Installing freqUI..." + python freqtrade install-ui 2>&1 | Out-File $LogFilePath -Append + if ($LASTEXITCODE -ne 0) { + Write-Log "Failed to install freqUI." -Level 'ERROR' + Exit-Script -exitCode 1 + } + + Write-Log "Installation/Update complete!" + Exit-Script -exitCode 0 +} + +# Call the Main function +Main diff --git a/tests/conftest.py b/tests/conftest.py index 0dfb859c0..c4964c414 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -238,7 +238,6 @@ def patched_configuration_load_config_file(mocker, config) -> None: def patch_exchange( mocker, api_mock=None, id="binance", mock_markets=True, mock_supported_modes=True ) -> None: - mocker.patch(f"{EXMS}._load_async_markets", return_value={}) mocker.patch(f"{EXMS}.validate_config", MagicMock()) mocker.patch(f"{EXMS}.validate_timeframes", MagicMock()) mocker.patch(f"{EXMS}.id", PropertyMock(return_value=id)) @@ -248,6 +247,7 @@ def patch_exchange( mocker.patch("freqtrade.exchange.bybit.Bybit.cache_leverage_tiers") if mock_markets: + mocker.patch(f"{EXMS}._load_async_markets", return_value={}) if isinstance(mock_markets, bool): mock_markets = get_markets() mocker.patch(f"{EXMS}.markets", PropertyMock(return_value=mock_markets)) diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 520de1c0f..fe47f821c 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -181,7 +181,7 @@ def test_remove_exchange_credentials(default_conf) -> None: def test_init_ccxt_kwargs(default_conf, mocker, caplog): - mocker.patch(f"{EXMS}._load_markets", MagicMock(return_value={})) + mocker.patch(f"{EXMS}.reload_markets") mocker.patch(f"{EXMS}.validate_stakecurrency") aei_mock = mocker.patch(f"{EXMS}.additional_exchange_init") @@ -518,7 +518,7 @@ def test__load_async_markets(default_conf, mocker, caplog): mocker.patch(f"{EXMS}._init_ccxt") mocker.patch(f"{EXMS}.validate_pairs") mocker.patch(f"{EXMS}.validate_timeframes") - mocker.patch(f"{EXMS}._load_markets") + mocker.patch(f"{EXMS}.reload_markets") mocker.patch(f"{EXMS}.validate_stakecurrency") mocker.patch(f"{EXMS}.validate_pricing") exchange = Exchange(default_conf) @@ -527,28 +527,26 @@ def test__load_async_markets(default_conf, mocker, caplog): assert exchange._api_async.load_markets.call_count == 1 caplog.set_level(logging.DEBUG) - exchange._api_async.load_markets = Mock(side_effect=ccxt.BaseError("deadbeef")) - exchange._load_async_markets() - - assert log_has("Could not load async markets. Reason: deadbeef", caplog) + exchange._api_async.load_markets = get_mock_coro(side_effect=ccxt.BaseError("deadbeef")) + with pytest.raises(ccxt.BaseError, match="deadbeef"): + exchange._load_async_markets() def test__load_markets(default_conf, mocker, caplog): caplog.set_level(logging.INFO) api_mock = MagicMock() - api_mock.load_markets = MagicMock(side_effect=ccxt.BaseError("SomeError")) + api_mock.load_markets = get_mock_coro(side_effect=ccxt.BaseError("SomeError")) mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) mocker.patch(f"{EXMS}.validate_pairs") mocker.patch(f"{EXMS}.validate_timeframes") - mocker.patch(f"{EXMS}._load_async_markets") mocker.patch(f"{EXMS}.validate_stakecurrency") mocker.patch(f"{EXMS}.validate_pricing") Exchange(default_conf) - assert log_has("Unable to initialize markets.", caplog) + assert log_has("Could not load markets.", caplog) expected_return = {"ETH/BTC": "available"} api_mock = MagicMock() - api_mock.load_markets = MagicMock(return_value=expected_return) + api_mock.load_markets = get_mock_coro(return_value=expected_return) mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) default_conf["exchange"]["pair_whitelist"] = ["ETH/BTC"] ex = Exchange(default_conf) @@ -563,12 +561,12 @@ def test_reload_markets(default_conf, mocker, caplog, time_machine): start_dt = dt_now() time_machine.move_to(start_dt, tick=False) api_mock = MagicMock() - api_mock.load_markets = MagicMock(return_value=initial_markets) + api_mock.load_markets = get_mock_coro(return_value=initial_markets) default_conf["exchange"]["markets_refresh_interval"] = 10 exchange = get_patched_exchange( mocker, default_conf, api_mock, id="binance", mock_markets=False ) - exchange._load_async_markets = MagicMock() + lam_spy = mocker.spy(exchange, "_load_async_markets") assert exchange._last_markets_refresh == dt_ts() assert exchange.markets == initial_markets @@ -577,42 +575,45 @@ def test_reload_markets(default_conf, mocker, caplog, time_machine): # less than 10 minutes have passed, no reload exchange.reload_markets() assert exchange.markets == initial_markets - assert exchange._load_async_markets.call_count == 0 + assert lam_spy.call_count == 0 - api_mock.load_markets = MagicMock(return_value=updated_markets) + api_mock.load_markets = get_mock_coro(return_value=updated_markets) # more than 10 minutes have passed, reload is executed time_machine.move_to(start_dt + timedelta(minutes=11), tick=False) exchange.reload_markets() assert exchange.markets == updated_markets - assert exchange._load_async_markets.call_count == 1 + assert lam_spy.call_count == 1 assert log_has("Performing scheduled market reload..", caplog) # Not called again - exchange._load_async_markets.reset_mock() + lam_spy.reset_mock() exchange.reload_markets() - assert exchange._load_async_markets.call_count == 0 + assert lam_spy.call_count == 0 def test_reload_markets_exception(default_conf, mocker, caplog): caplog.set_level(logging.DEBUG) api_mock = MagicMock() - api_mock.load_markets = MagicMock(side_effect=ccxt.NetworkError("LoadError")) + api_mock.load_markets = get_mock_coro(side_effect=ccxt.NetworkError("LoadError")) default_conf["exchange"]["markets_refresh_interval"] = 10 - exchange = get_patched_exchange(mocker, default_conf, api_mock, id="binance") + exchange = get_patched_exchange( + mocker, default_conf, api_mock, id="binance", mock_markets=False + ) + exchange._last_markets_refresh = 2 # less than 10 minutes have passed, no reload exchange.reload_markets() - assert exchange._last_markets_refresh == 0 - assert log_has_re(r"Could not reload markets.*", caplog) + assert exchange._last_markets_refresh == 2 + assert log_has_re(r"Could not load markets\..*", caplog) @pytest.mark.parametrize("stake_currency", ["ETH", "BTC", "USDT"]) def test_validate_stakecurrency(default_conf, stake_currency, mocker, caplog): default_conf["stake_currency"] = stake_currency api_mock = MagicMock() - type(api_mock).load_markets = MagicMock( + type(api_mock).load_markets = get_mock_coro( return_value={ "ETH/BTC": {"quote": "BTC"}, "LTC/BTC": {"quote": "BTC"}, @@ -623,7 +624,6 @@ def test_validate_stakecurrency(default_conf, stake_currency, mocker, caplog): mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) mocker.patch(f"{EXMS}.validate_pairs") mocker.patch(f"{EXMS}.validate_timeframes") - mocker.patch(f"{EXMS}._load_async_markets") mocker.patch(f"{EXMS}.validate_pricing") Exchange(default_conf) @@ -631,7 +631,7 @@ def test_validate_stakecurrency(default_conf, stake_currency, mocker, caplog): def test_validate_stakecurrency_error(default_conf, mocker, caplog): default_conf["stake_currency"] = "XRP" api_mock = MagicMock() - type(api_mock).load_markets = MagicMock( + type(api_mock).load_markets = get_mock_coro( return_value={ "ETH/BTC": {"quote": "BTC"}, "LTC/BTC": {"quote": "BTC"}, @@ -642,14 +642,13 @@ def test_validate_stakecurrency_error(default_conf, mocker, caplog): mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) mocker.patch(f"{EXMS}.validate_pairs") mocker.patch(f"{EXMS}.validate_timeframes") - mocker.patch(f"{EXMS}._load_async_markets") with pytest.raises( ConfigurationError, match=r"XRP is not available as stake on .*Available currencies are: BTC, ETH, USDT", ): Exchange(default_conf) - type(api_mock).load_markets = MagicMock(side_effect=ccxt.NetworkError("No connection.")) + type(api_mock).load_markets = get_mock_coro(side_effect=ccxt.NetworkError("No connection.")) mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) with pytest.raises( @@ -694,24 +693,26 @@ def test_get_pair_base_currency(default_conf, mocker, pair, expected): assert ex.get_pair_base_currency(pair) == expected -def test_validate_pairs(default_conf, mocker): # test exchange.validate_pairs directly +def test_validate_pairs(default_conf, mocker): api_mock = MagicMock() - type(api_mock).load_markets = MagicMock( - return_value={ - "ETH/BTC": {"quote": "BTC"}, - "LTC/BTC": {"quote": "BTC"}, - "XRP/BTC": {"quote": "BTC"}, - "NEO/BTC": {"quote": "BTC"}, - } - ) id_mock = PropertyMock(return_value="test_exchange") type(api_mock).id = id_mock mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) mocker.patch(f"{EXMS}.validate_timeframes") - mocker.patch(f"{EXMS}._load_async_markets") + mocker.patch( + f"{EXMS}._load_async_markets", + return_value={ + "ETH/BTC": {"quote": "BTC"}, + "LTC/BTC": {"quote": "BTC"}, + "XRP/BTC": {"quote": "BTC"}, + "NEO/BTC": {"quote": "BTC"}, + }, + ) mocker.patch(f"{EXMS}.validate_stakecurrency") mocker.patch(f"{EXMS}.validate_pricing") + # test exchange.validate_pairs directly + # No assert - but this should not fail (!) Exchange(default_conf) @@ -751,7 +752,7 @@ def test_validate_pairs_exception(default_conf, mocker, caplog): def test_validate_pairs_restricted(default_conf, mocker, caplog): api_mock = MagicMock() - type(api_mock).load_markets = MagicMock( + type(api_mock).load_markets = get_mock_coro( return_value={ "ETH/BTC": {"quote": "BTC"}, "LTC/BTC": {"quote": "BTC"}, @@ -761,7 +762,6 @@ def test_validate_pairs_restricted(default_conf, mocker, caplog): ) mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) mocker.patch(f"{EXMS}.validate_timeframes") - mocker.patch(f"{EXMS}._load_async_markets") mocker.patch(f"{EXMS}.validate_pricing") mocker.patch(f"{EXMS}.validate_stakecurrency") @@ -774,9 +774,9 @@ def test_validate_pairs_restricted(default_conf, mocker, caplog): ) -def test_validate_pairs_stakecompatibility(default_conf, mocker, caplog): +def test_validate_pairs_stakecompatibility(default_conf, mocker): api_mock = MagicMock() - type(api_mock).load_markets = MagicMock( + type(api_mock).load_markets = get_mock_coro( return_value={ "ETH/BTC": {"quote": "BTC"}, "LTC/BTC": {"quote": "BTC"}, @@ -787,17 +787,16 @@ def test_validate_pairs_stakecompatibility(default_conf, mocker, caplog): ) mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) mocker.patch(f"{EXMS}.validate_timeframes") - mocker.patch(f"{EXMS}._load_async_markets") mocker.patch(f"{EXMS}.validate_stakecurrency") mocker.patch(f"{EXMS}.validate_pricing") Exchange(default_conf) -def test_validate_pairs_stakecompatibility_downloaddata(default_conf, mocker, caplog): +def test_validate_pairs_stakecompatibility_downloaddata(default_conf, mocker): api_mock = MagicMock() default_conf["stake_currency"] = "" - type(api_mock).load_markets = MagicMock( + type(api_mock).load_markets = get_mock_coro( return_value={ "ETH/BTC": {"quote": "BTC"}, "LTC/BTC": {"quote": "BTC"}, @@ -808,7 +807,6 @@ def test_validate_pairs_stakecompatibility_downloaddata(default_conf, mocker, ca ) mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) mocker.patch(f"{EXMS}.validate_timeframes") - mocker.patch(f"{EXMS}._load_async_markets") mocker.patch(f"{EXMS}.validate_stakecurrency") mocker.patch(f"{EXMS}.validate_pricing") @@ -816,10 +814,10 @@ def test_validate_pairs_stakecompatibility_downloaddata(default_conf, mocker, ca assert type(api_mock).load_markets.call_count == 1 -def test_validate_pairs_stakecompatibility_fail(default_conf, mocker, caplog): +def test_validate_pairs_stakecompatibility_fail(default_conf, mocker): default_conf["exchange"]["pair_whitelist"].append("HELLO-WORLD") api_mock = MagicMock() - type(api_mock).load_markets = MagicMock( + type(api_mock).load_markets = get_mock_coro( return_value={ "ETH/BTC": {"quote": "BTC"}, "LTC/BTC": {"quote": "BTC"}, @@ -830,7 +828,6 @@ def test_validate_pairs_stakecompatibility_fail(default_conf, mocker, caplog): ) mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) mocker.patch(f"{EXMS}.validate_timeframes") - mocker.patch(f"{EXMS}._load_async_markets") mocker.patch(f"{EXMS}.validate_stakecurrency") with pytest.raises(OperationalException, match=r"Stake-currency 'BTC' not compatible with.*"): @@ -847,7 +844,7 @@ def test_validate_timeframes(default_conf, mocker, timeframe): type(api_mock).timeframes = timeframes mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) - mocker.patch(f"{EXMS}._load_markets", MagicMock(return_value={})) + mocker.patch(f"{EXMS}.reload_markets") mocker.patch(f"{EXMS}.validate_pairs") mocker.patch(f"{EXMS}.validate_stakecurrency") mocker.patch(f"{EXMS}.validate_pricing") @@ -865,7 +862,7 @@ def test_validate_timeframes_failed(default_conf, mocker): type(api_mock).timeframes = timeframes mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) - mocker.patch(f"{EXMS}._load_markets", MagicMock(return_value={})) + mocker.patch(f"{EXMS}.reload_markets") mocker.patch(f"{EXMS}.validate_pairs") mocker.patch(f"{EXMS}.validate_stakecurrency") mocker.patch(f"{EXMS}.validate_pricing") @@ -895,7 +892,7 @@ def test_validate_timeframes_emulated_ohlcv_1(default_conf, mocker): del api_mock.timeframes mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) - mocker.patch(f"{EXMS}._load_markets", MagicMock(return_value={})) + mocker.patch(f"{EXMS}.reload_markets") mocker.patch(f"{EXMS}.validate_pairs") mocker.patch(f"{EXMS}.validate_stakecurrency") with pytest.raises( @@ -917,7 +914,7 @@ def test_validate_timeframes_emulated_ohlcvi_2(default_conf, mocker): del api_mock.timeframes mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) - mocker.patch(f"{EXMS}._load_markets", MagicMock(return_value={"timeframes": None})) + mocker.patch(f"{EXMS}.reload_markets") mocker.patch(f"{EXMS}.validate_pairs", MagicMock()) mocker.patch(f"{EXMS}.validate_stakecurrency") with pytest.raises( @@ -939,7 +936,7 @@ def test_validate_timeframes_not_in_config(default_conf, mocker): type(api_mock).timeframes = timeframes mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) - mocker.patch(f"{EXMS}._load_markets", MagicMock(return_value={})) + mocker.patch(f"{EXMS}.reload_markets") mocker.patch(f"{EXMS}.validate_pairs") mocker.patch(f"{EXMS}.validate_stakecurrency") mocker.patch(f"{EXMS}.validate_pricing") @@ -955,7 +952,7 @@ def test_validate_pricing(default_conf, mocker): } type(api_mock).has = PropertyMock(return_value=has) mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) - mocker.patch(f"{EXMS}._load_markets", MagicMock(return_value={})) + mocker.patch(f"{EXMS}.reload_markets") mocker.patch(f"{EXMS}.validate_trading_mode_and_margin_mode") mocker.patch(f"{EXMS}.validate_pairs") mocker.patch(f"{EXMS}.validate_timeframes") @@ -991,7 +988,7 @@ def test_validate_ordertypes(default_conf, mocker): type(api_mock).has = PropertyMock(return_value={"createMarketOrder": True}) mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) - mocker.patch(f"{EXMS}._load_markets", MagicMock(return_value={})) + mocker.patch(f"{EXMS}.reload_markets") mocker.patch(f"{EXMS}.validate_pairs") mocker.patch(f"{EXMS}.validate_timeframes") mocker.patch(f"{EXMS}.validate_stakecurrency") @@ -1050,7 +1047,7 @@ def test_validate_ordertypes_stop_advanced(default_conf, mocker, exchange_name, default_conf["margin_mode"] = MarginMode.ISOLATED type(api_mock).has = PropertyMock(return_value={"createMarketOrder": True}) mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) - mocker.patch(f"{EXMS}._load_markets", MagicMock(return_value={})) + mocker.patch(f"{EXMS}.reload_markets") mocker.patch(f"{EXMS}.validate_pairs") mocker.patch(f"{EXMS}.validate_timeframes") mocker.patch(f"{EXMS}.validate_stakecurrency") @@ -1075,7 +1072,7 @@ def test_validate_ordertypes_stop_advanced(default_conf, mocker, exchange_name, def test_validate_order_types_not_in_config(default_conf, mocker): api_mock = MagicMock() mocker.patch(f"{EXMS}._init_ccxt", MagicMock(return_value=api_mock)) - mocker.patch(f"{EXMS}._load_markets", MagicMock(return_value={})) + mocker.patch(f"{EXMS}.reload_markets") mocker.patch(f"{EXMS}.validate_pairs") mocker.patch(f"{EXMS}.validate_timeframes") mocker.patch(f"{EXMS}.validate_pricing") @@ -1947,7 +1944,9 @@ def test_fetch_trading_fees(default_conf, mocker): assert api_mock.fetch_trading_fees.call_count == 1 api_mock.fetch_trading_fees.reset_mock() - + # Reload-markets calls fetch_trading_fees, too - so the explicit calls in the below + # exception test would be called twice. + mocker.patch(f"{EXMS}.reload_markets") ccxt_exceptionhandlers( mocker, default_conf, api_mock, exchange_name, "fetch_trading_fees", "fetch_trading_fees" ) diff --git a/tests/freqtradebot/test_freqtradebot.py b/tests/freqtradebot/test_freqtradebot.py index e793e3c50..db2e96b37 100644 --- a/tests/freqtradebot/test_freqtradebot.py +++ b/tests/freqtradebot/test_freqtradebot.py @@ -699,18 +699,20 @@ def test_process_trade_creation( def test_process_exchange_failures(default_conf_usdt, ticker_usdt, mocker) -> None: + # TODO: Move this test to test_worker patch_RPCManager(mocker) patch_exchange(mocker) mocker.patch.multiple( EXMS, fetch_ticker=ticker_usdt, - reload_markets=MagicMock(side_effect=TemporaryError), + reload_markets=MagicMock(), create_order=MagicMock(side_effect=TemporaryError), ) sleep_mock = mocker.patch("time.sleep") worker = Worker(args=None, config=default_conf_usdt) patch_get_signal(worker.freqtrade) + mocker.patch(f"{EXMS}.reload_markets", MagicMock(side_effect=TemporaryError)) worker._process_running() assert sleep_mock.called is True diff --git a/tests/optimize/test_hyperopt.py b/tests/optimize/test_hyperopt.py index 4d2f9ae36..d2ff5b8d3 100644 --- a/tests/optimize/test_hyperopt.py +++ b/tests/optimize/test_hyperopt.py @@ -1063,7 +1063,7 @@ def test_in_strategy_auto_hyperopt(mocker, hyperopt_conf, tmp_path, fee) -> None def test_in_strategy_auto_hyperopt_with_parallel(mocker, hyperopt_conf, tmp_path, fee) -> None: mocker.patch(f"{EXMS}.validate_config", MagicMock()) mocker.patch(f"{EXMS}.get_fee", fee) - mocker.patch(f"{EXMS}._load_markets") + mocker.patch(f"{EXMS}.reload_markets") mocker.patch(f"{EXMS}.markets", PropertyMock(return_value=get_markets())) (tmp_path / "hyperopt_results").mkdir(parents=True) # Dummy-reduce points to ensure scikit-learn is forced to generate new values diff --git a/tests/setup.Tests.ps1 b/tests/setup.Tests.ps1 new file mode 100644 index 000000000..e58a4729d --- /dev/null +++ b/tests/setup.Tests.ps1 @@ -0,0 +1,177 @@ + +Describe "Setup and Tests" { + BeforeAll { + # Setup variables + $SetupScriptPath = Join-Path $PSScriptRoot "..\setup.ps1" + $Global:LogFilePath = Join-Path $env:TEMP "script_log.txt" + + # Check if the setup script exists + if (-Not (Test-Path -Path $SetupScriptPath)) { + Write-Host "Error: setup.ps1 script not found at path: $SetupScriptPath" + exit 1 + } + + # Mock main to prevent it from running + Mock Main {} + + . $SetupScriptPath + } + + Context "Write-Log Tests" -Tag "Unit" { + It "should write INFO level log" { + if (Test-Path $Global:LogFilePath){ + Remove-Item $Global:LogFilePath -ErrorAction SilentlyContinue + } + + Write-Log -Message "Test Info Message" -Level "INFO" + $Global:LogFilePath | Should -Exist + + $LogContent = Get-Content $Global:LogFilePath + $LogContent | Should -Contain "INFO: Test Info Message" + } + + It "should write ERROR level log" { + if (Test-Path $Global:LogFilePath){ + Remove-Item $Global:LogFilePath -ErrorAction SilentlyContinue + } + + Write-Log -Message "Test Error Message" -Level "ERROR" + $Global:LogFilePath | Should -Exist + + $LogContent = Get-Content $Global:LogFilePath + $LogContent | Should -Contain "ERROR: Test Error Message" + } + } + + Describe "Get-UserSelection Tests" { + Context "Valid input" { + It "Should return the correct index for a valid single selection" { + $Options = @("Option1", "Option2", "Option3") + Mock Read-Host { return "B" } + $Result = Get-UserSelection -prompt "Select an option" -options $Options + $Result | Should -Be 1 + } + + It "Should return the correct index for a valid single selection" { + $Options = @("Option1", "Option2", "Option3") + Mock Read-Host { return "b" } + $Result = Get-UserSelection -prompt "Select an option" -options $Options + $Result | Should -Be 1 + } + + It "Should return the default choice when no input is provided" { + $Options = @("Option1", "Option2", "Option3") + Mock Read-Host { return "" } + $Result = Get-UserSelection -prompt "Select an option" -options $Options -defaultChoice "C" + $Result | Should -Be 2 + } + } + + Context "Invalid input" { + It "Should return -1 for an invalid letter selection" { + $Options = @("Option1", "Option2", "Option3") + Mock Read-Host { return "X" } + $Result = Get-UserSelection -prompt "Select an option" -options $Options + $Result | Should -Be -1 + } + + It "Should return -1 for a selection outside the valid range" { + $Options = @("Option1", "Option2", "Option3") + Mock Read-Host { return "D" } + $Result = Get-UserSelection -prompt "Select an option" -options $Options + $Result | Should -Be -1 + } + + It "Should return -1 for a non-letter input" { + $Options = @("Option1", "Option2", "Option3") + Mock Read-Host { return "1" } + $Result = Get-UserSelection -prompt "Select an option" -options $Options + $Result | Should -Be -1 + } + + It "Should return -1 for mixed valid and invalid input" { + Mock Read-Host { return "A,X,B,Y,C,Z" } + $Options = @("Option1", "Option2", "Option3") + $Indices = Get-UserSelection -prompt "Select options" -options $Options -defaultChoice "A" + $Indices | Should -Be -1 + } + } + + Context "Multiple selections" { + It "Should handle valid input correctly" { + Mock Read-Host { return "A, B, C" } + $Options = @("Option1", "Option2", "Option3") + $Indices = Get-UserSelection -prompt "Select options" -options $Options -defaultChoice "A" + $Indices | Should -Be @(0, 1, 2) + } + + It "Should handle valid input without whitespace correctly" { + Mock Read-Host { return "A,B,C" } + $Options = @("Option1", "Option2", "Option3") + $Indices = Get-UserSelection -prompt "Select options" -options $Options -defaultChoice "A" + $Indices | Should -Be @(0, 1, 2) + } + + It "Should return indices for selected options" { + Mock Read-Host { return "a,b" } + $Options = @("Option1", "Option2", "Option3") + $Indices = Get-UserSelection -prompt "Select options" -options $Options + $Indices | Should -Be @(0, 1) + } + + It "Should return default choice if no input" { + Mock Read-Host { return "" } + $Options = @("Option1", "Option2", "Option3") + $Indices = Get-UserSelection -prompt "Select options" -options $Options -defaultChoice "C" + $Indices | Should -Be @(2) + } + + It "Should handle invalid input gracefully" { + Mock Read-Host { return "x,y,z" } + $Options = @("Option1", "Option2", "Option3") + $Indices = Get-UserSelection -prompt "Select options" -options $Options -defaultChoice "A" + $Indices | Should -Be -1 + } + + It "Should handle input without whitespace" { + Mock Read-Host { return "a,b,c" } + $Options = @("Option1", "Option2", "Option3") + $Indices = Get-UserSelection -prompt "Select options" -options $Options + $Indices | Should -Be @(0, 1, 2) + } + } + } + + Describe "Exit-Script Tests" -Tag "Unit" { + BeforeEach { + Mock Write-Log {} + Mock Start-Process {} + Mock Read-Host { return "Y" } + } + + It "should exit with the given exit code without waiting for key press" { + $ExitCode = Exit-Script -ExitCode 0 -isSubShell $true -waitForKeypress $false + $ExitCode | Should -Be 0 + } + + It "should prompt to open log file on error" { + Exit-Script -ExitCode 1 -isSubShell $true -waitForKeypress $false + Assert-MockCalled Read-Host -Exactly 1 + Assert-MockCalled Start-Process -Exactly 1 + } + } + + Context 'Find-PythonExecutable' { + It 'Returns the first valid Python executable' { + Mock Test-PythonExecutable { $true } -ParameterFilter { $PythonExecutable -eq 'python' } + $Result = Find-PythonExecutable + $Result | Should -Be 'python' + } + + It 'Returns null if no valid Python executable is found' { + Mock Test-PythonExecutable { $false } + $Result = Find-PythonExecutable + $Result | Should -Be $null + } + } +}