diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index cd735ed..510730b 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -29,7 +29,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: zulu - java-version: 17 + java-version: 21 - name: Set up Gradle uses: gradle/actions/setup-gradle@v5 - name: Build plugin @@ -68,7 +68,7 @@ jobs: strategy: fail-fast: false matrix: - ide: [PC, PY, IC, IU, GO, CL, RR] + ide: [PC, PY] steps: - name: Free disk space uses: jlumbroso/free-disk-space@main @@ -83,19 +83,29 @@ jobs: uses: actions/setup-java@v5 with: distribution: zulu - java-version: 17 + java-version: 21 - name: Set up Gradle uses: gradle/actions/setup-gradle@v5 with: validate-wrappers: false - cache-read-only: true - - name: Set up verifier cache + cache-read-only: false + - name: Cache plugin verifier IDEs uses: actions/cache@v5 with: - path: ${{ needs.build.outputs.pluginVerifierHomeDir }}/ides - key: plugin-verifier-${{ matrix.ide }}-${{ needs.build.outputs.platformVersion }} + path: ~/.pluginVerifier/ides + key: plugin-verifier-ides-${{ matrix.ide }}-${{ needs.build.outputs.platformVersion }} + - name: Clean corrupted Gradle transforms + run: | + find ~/.gradle/caches -type d -name "transforms" -exec sh -c ' + for dir in "$1"/*/; do + if [ -d "$dir" ] && [ ! -f "${dir}metadata.bin" ]; then + echo "Removing corrupted transform: $dir" + rm -rf "$dir" + fi + done + ' _ {} \; 2>/dev/null || true - name: Run verification - run: ./gradlew verifyPlugin -PverifyIde=${{ matrix.ide }} -Dplugin.verifier.home.dir=${{ needs.build.outputs.pluginVerifierHomeDir }} + run: ./gradlew verifyPlugin -PverifyIde=${{ matrix.ide }} - name: Collect verification result if: ${{ always() }} uses: actions/upload-artifact@v6 @@ -103,6 +113,25 @@ jobs: name: pluginVerifier-result-${{ matrix.ide }} path: ${{ github.workspace }}/build/reports/pluginVerifier + lint: + name: Lint + needs: [build] + runs-on: ubuntu-latest + steps: + - name: Checkout git repository + uses: actions/checkout@v6 + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: zulu + java-version: 21 + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-read-only: true + - name: Run linter + run: ./gradlew ktlintCheck + test: name: Run tests needs: [build] @@ -114,19 +143,31 @@ jobs: uses: actions/setup-java@v5 with: distribution: zulu - java-version: 17 + java-version: 21 - name: Set up Gradle uses: gradle/actions/setup-gradle@v5 with: validate-wrappers: false cache-read-only: true - - name: Run linter - run: ./gradlew ktlintCheck + - name: Run unit tests + run: ./gradlew test + - name: Verify 100% coverage + run: ./gradlew koverVerify + - name: Run UI tests + run: | + export DISPLAY=:99.0 + Xvfb :99 -screen 0 1920x1080x24 & + ./gradlew runIdeForUiTests & + echo "Waiting for IDE to start..." + timeout 180 bash -c 'until curl -s http://127.0.0.1:8082 > /dev/null 2>&1; do sleep 2; done' || { echo "IDE failed to start"; exit 1; } + echo "IDE is ready" + ./gradlew uiTest + kill %1 %2 || true releaseDraft: name: Create release draft if: github.event_name != 'pull_request' - needs: [build, test, verify] + needs: [build, lint, test, verify] runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ff0ada5..d3f981b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -27,7 +27,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: zulu - java-version: 17 + java-version: 21 - name: Set up Gradle uses: gradle/actions/setup-gradle@v5 - name: Extract plugin properties diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a629496 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,107 @@ +# Contributing to PyVenvManage + +## Development Setup + +You'll need JDK 21 and Python 3.10+ (for creating test virtual environments). Build the plugin with: + +```bash +./gradlew buildPlugin +``` + +## Testing + +The project uses two complementary testing strategies: fast unit tests that mock IntelliJ platform dependencies, and +end-to-end UI tests that interact with a running IDE. + +### Unit Tests + +Unit tests cover business logic, action update logic, and error paths. They run quickly and don't require a running +IDE: + +```bash +./gradlew test +``` + +### UI Tests + +UI tests validate full user workflows by interacting with a running PyCharm instance via RemoteRobot. Start the IDE +with robot-server in one terminal: + +```bash +./gradlew runIdeForUiTests +``` + +Wait for the IDE to fully start and the robot-server to be ready at http://localhost:8082, then run the tests in +another terminal: + +```bash +./gradlew uiTest +``` + +### Coverage + +Unit tests achieve full line coverage. The CI enforces this with `./gradlew test koverVerify`. UI tests are excluded +from coverage collection since they test end-to-end workflows already covered by unit tests. + +To generate an HTML coverage report showing overall percentage, package breakdown, and line-by-line highlighting: + +```bash +./gradlew test koverHtmlReport +open build/reports/kover/html/index.html +``` + +For per-test coverage analysis (which test covered which line), generate a binary report with +`./gradlew test koverBinaryReport`, then in IntelliJ IDEA go to **Run → Show Coverage Data**, click **+**, select +`build/kover/bin-reports/test.ic`, and click **Show selected**. Right-click any covered line and choose **Show Covering +Tests** to see which tests hit it. + +## Code Quality + +Check code style with `./gradlew ktlintCheck` or auto-fix issues with `./gradlew ktlintFormat`. Run all checks together +(lint, unit tests, coverage verification) with `./gradlew check`. + +## Continuous Integration + +The CI pipeline in `.github/workflows/check.yaml` builds the plugin, runs linting, executes unit tests with coverage +verification followed by UI tests, verifies the plugin against PyCharm Community and PyCharm Professional, and creates +a release draft on the main branch. + +The test job runs unit tests with `koverVerify`, then starts Xvfb and the IDE with robot-server, and finally runs UI +tests for end-to-end validation. + +## Making Code Changes + +Before committing, run `./gradlew ktlintFormat` to fix style issues, then `./gradlew test koverVerify` to ensure tests +pass with full coverage. If you modified action classes, run UI tests for end-to-end validation by starting +`./gradlew runIdeForUiTests` in one terminal and `./gradlew uiTest` in another. + +Follow conventional commit style: use `feat:` for new features, `fix:` for bug fixes, `refactor:` for code +refactoring, `test:` for test changes, `docs:` for documentation, and `chore:` for maintenance tasks. + +## Troubleshooting + +If UI tests timeout or fail to connect, ensure no other IDE instance is using port 8082. Kill any running IDE processes +with `pkill -f runIdeForUiTests`, delete old test projects with `rm -rf ~/projects/ui-test*`, then restart the IDE and +wait for full initialization before running tests. + +If `koverVerify` fails due to coverage below 100%, generate the HTML report with `./gradlew koverHtmlReport` and open +`build/reports/kover/html/index.html` to see which lines are uncovered. Add unit tests for those code paths, or if the +code requires IntelliJ platform services that can't be mocked, add UI test coverage instead. + +## Releasing + +The plugin version is defined in `gradle.properties` as `pluginVersion`. To release, update the version in that file +and merge your PR to main. The CI automatically creates a draft release on GitHub with the version from +`gradle.properties`. + +Review the draft release on the [Releases page](https://github.com/pyvenvmanage/PyVenvManage/releases) and edit the +release notes if needed. Click "Publish release" (not pre-release) to trigger the release workflow, which builds and +signs the plugin, publishes to [JetBrains Marketplace](https://plugins.jetbrains.com/plugin/20536-pyvenv-manage-2), +uploads the plugin ZIP to the GitHub release, and creates a PR to update CHANGELOG.md. Merge that changelog PR after +the release workflow completes. + +The release workflow requires repository secrets configured by maintainers: `PUBLISH_TOKEN` for JetBrains Marketplace +upload, and `CERTIFICATE_CHAIN`, `PRIVATE_KEY`, and `PRIVATE_KEY_PASSWORD` for plugin signing. + +Follow [semantic versioning](https://semver.org/): increment MAJOR for breaking changes, MINOR for new backward +compatible features, and PATCH for backward compatible bug fixes. diff --git a/README.md b/README.md index bd10376..4936a78 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,10 @@ streamlines this by enabling quick interpreter selection directly from the proje - **Quick interpreter switching**: Right-click any virtual environment folder to set it as your project or module interpreter instantly -- **Visual identification**: Virtual environment folders display with a distinctive icon and Python version badge - (e.g., `venv [3.11.5]`) in the project view +- **Visual identification**: Virtual environment folders display with a distinctive icon and customizable decoration + (e.g., `.venv [3.11.5 - CPython]`) in the project view +- **Customizable decorations**: Configure which fields to show (Python version, implementation, system site-packages, + creator tool), their order, and the format via Settings - **Multi-IDE support**: Works with PyCharm (Community and Professional), IntelliJ IDEA, GoLand, CLion, and RustRover - **Smart detection**: Automatically detects Python virtual environments by recognizing `pyvenv.cfg` files - **Cached version display**: Python version information is cached for performance and automatically refreshed when @@ -30,6 +32,8 @@ streamlines this by enabling quick interpreter selection directly from the proje ## Supported IDEs +Version 2025.1 or later of: + - PyCharm (Community and Professional) - IntelliJ IDEA (Community and Ultimate) - GoLand @@ -51,6 +55,20 @@ The official plugin page is at https://plugins.jetbrains.com/plugin/20536-pyvenv 3. Select **Set as Project Interpreter** or **Set as Module Interpreter** 4. The interpreter is configured instantly with a confirmation notification +## Settings + +Open **Settings** -> **PyVenv Manage** to customize the virtual environment decoration display: + +- **Prefix/Suffix**: Characters surrounding the decoration (default: ` [` and `]`) +- **Separator**: Text between fields (default: `-`) +- **Fields**: Enable, disable, and reorder which information to display: + - Python version (e.g., `3.14.2`) + - Python implementation (e.g., `CPython`) + - System site-packages indicator (`SYSTEM`) + - Virtual environment creator (e.g., `uv@0.9.21`) + +A live preview updates as you modify settings. + ## License This project is licensed under the BSD-3-Clause license - see the diff --git a/build.gradle.kts b/build.gradle.kts index 929834b..1979def 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,7 @@ val platformVersion = providers.gradleProperty("platformVersion").get() kotlin { jvmToolchain(17) } + repositories { mavenCentral() intellijPlatform { @@ -108,11 +109,6 @@ intellijPlatform { listOf( IntelliJPlatformType.PyCharmCommunity, IntelliJPlatformType.PyCharmProfessional, - IntelliJPlatformType.IntellijIdeaCommunity, - IntelliJPlatformType.IntellijIdeaUltimate, - IntelliJPlatformType.GoLand, - IntelliJPlatformType.CLion, - IntelliJPlatformType.RustRover, ) } ideTypes.forEach { create(it, platformVersion) } @@ -125,12 +121,25 @@ changelog { repositoryUrl = providers.gradleProperty("pluginRepositoryUrl") } kover { + currentProject { + sources { + excludeJava = true + } + instrumentation { + disabledForTestTasks.add("uiTest") + } + } reports { total { xml { onCheck = true } } + verify { + rule { + minBound(100) + } + } } } @@ -144,8 +153,45 @@ tasks { buildSearchableOptions { enabled = false } + prepareJarSearchableOptions { + enabled = false + } + verifyPlugin { + System.getProperty("http.proxyHost")?.let { host -> + jvmArgs("-Dhttp.proxyHost=$host") + System.getProperty("http.proxyPort")?.let { jvmArgs("-Dhttp.proxyPort=$it") } + } + System.getProperty("https.proxyHost")?.let { host -> + jvmArgs("-Dhttps.proxyHost=$host") + System.getProperty("https.proxyPort")?.let { jvmArgs("-Dhttps.proxyPort=$it") } + } + System.getProperty("javax.net.ssl.trustStore")?.let { jvmArgs("-Djavax.net.ssl.trustStore=$it") } + System + .getProperty( + "javax.net.ssl.trustStorePassword", + )?.let { jvmArgs("-Djavax.net.ssl.trustStorePassword=$it") } + } test { useJUnitPlatform() + exclude("**/UITest.class") + } + val uiTest = + register("uiTest") { + description = "Runs UI tests (requires runIdeForUiTests to be running)" + group = "verification" + useJUnitPlatform() + include("**/UITest.class") + testClassesDirs = sourceSets["test"].output.classesDirs + classpath = sourceSets["test"].runtimeClasspath + shouldRunAfter(test) + jvmArgs( + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.lang.reflect=ALL-UNNAMED", + ) + } + + runIde { + jvmArgs("-XX:+UnlockDiagnosticVMOptions") } } @@ -153,15 +199,23 @@ val runIdeForUiTests by intellijPlatformTesting.runIde.registering { task { jvmArgumentProviders += CommandLineArgumentProvider { - listOf( - "-Drobot-server.port=8082", - "-Dide.mac.message.dialogs.as.sheets=false", - "-Djb.privacy.policy.text=", - "-Djb.consents.confirmation.enabled=false", - "-Didea.trust.all.projects=true", - "-Dide.mac.file.chooser.native=false", - "-Dide.show.tips.on.startup.default.value=false", - ) + buildList { + add("-Drobot-server.port=8082") + add("-Djb.privacy.policy.text=") + add("-Djb.consents.confirmation.enabled=false") + add("-Didea.trust.all.projects=true") + add("-Dide.show.tips.on.startup.default.value=false") + val isMac = + org.gradle.internal.os.OperatingSystem + .current() + .isMacOsX + if (isMac) { + add("-Dide.mac.message.dialogs.as.sheets=false") + add("-Dide.mac.file.chooser.native=false") + add("-DjbScreenMenuBar.enabled=false") + add("-Dapple.laf.useScreenMenuBar=false") + } + } } } diff --git a/gradle.properties b/gradle.properties index a46f599..0ab8920 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,12 +1,12 @@ -gradleVersion=8.9 +gradleVersion=9.2.1 kotlin.stdlib.default.dependency=false nl.littlerobots.vcu.resolver=true org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.welcome=never -platformVersion=2024.1 +platformVersion=2025.1 pluginGroup=com.github.pyvenvmanage pluginName=PyVenv Manage 2 pluginRepositoryUrl=https://github.com/pyvenvmanage/PyVenvManage -pluginSinceBuild=241 -pluginVersion=2.1.2 +pluginSinceBuild=251 +pluginVersion=2.2.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a21e660..974c584 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ changelog = "2.5.0" intelliJPlatform = "2.10.5" junitPlatformLauncher= "6.0.0" jupiter = "6.0.1" -kotlin = "2.3.0" +kotlin = "2.2.21" mockk = "1.14.7" kover = "0.9.4" ktlint = "14.0.1" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 2c35211..8bdaf60 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2e11132..4eac4a8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f5feea6..adff685 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -115,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -173,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -206,15 +203,14 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9b42019..e509b2d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt b/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt index 2fa3307..fd33733 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt @@ -3,6 +3,7 @@ package com.github.pyvenvmanage import com.intellij.ide.projectView.PresentationData import com.intellij.ide.projectView.ProjectViewNode import com.intellij.ide.projectView.ProjectViewNodeDecorator +import com.intellij.openapi.diagnostic.thisLogger import com.intellij.ui.SimpleTextAttributes import com.jetbrains.python.icons.PythonIcons.Python.Virtualenv @@ -14,18 +15,23 @@ class VenvProjectViewNodeDecorator : ProjectViewNodeDecorator { node: ProjectViewNode<*>, data: PresentationData, ) { + node.virtualFile?.let { vf -> + thisLogger().debug("Checking node: ${vf.path}, isDir: ${vf.isDirectory}") + } VenvUtils.getPyVenvCfg(node.virtualFile)?.let { pyVenvCfgPath -> + thisLogger().debug("Found pyvenv.cfg at: $pyVenvCfgPath") val settings = PyVenvManageSettings.getInstance() - if (settings.showVersionInProjectView) { - val pythonVersion = VenvVersionCache.getInstance().getVersion(pyVenvCfgPath.toString()) - pythonVersion?.let { version -> - data.presentableText?.let { fileName -> - data.clearText() - data.addText(fileName, SimpleTextAttributes.REGULAR_ATTRIBUTES) - data.addText(settings.formatVersion(version), SimpleTextAttributes.GRAY_ATTRIBUTES) - } - } - } + val venvInfo = VenvVersionCache.getInstance().getInfo(pyVenvCfgPath.toString()) + thisLogger().debug("VenvInfo from cache: $venvInfo") + venvInfo?.let { info -> + data.presentableText?.let { fileName -> + val decoration = settings.formatDecoration(info) + thisLogger().debug("Decorating $fileName with: $decoration") + data.clearText() + data.addText(fileName, SimpleTextAttributes.REGULAR_ATTRIBUTES) + data.addText(decoration, SimpleTextAttributes.GRAY_ATTRIBUTES) + } ?: thisLogger().debug("No presentableText for decoration") + } ?: thisLogger().debug("No venvInfo found for $pyVenvCfgPath") data.setIcon(Virtualenv) } } diff --git a/src/main/kotlin/com/github/pyvenvmanage/VenvUtils.kt b/src/main/kotlin/com/github/pyvenvmanage/VenvUtils.kt index 8e1238f..7db2d2c 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/VenvUtils.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/VenvUtils.kt @@ -9,6 +9,13 @@ import com.intellij.openapi.vfs.VirtualFile import com.jetbrains.python.sdk.PythonSdkUtil +data class VenvInfo( + val version: String, + val implementation: String? = null, + val includeSystemSitePackages: Boolean = false, + val creator: String? = null, +) + object VenvUtils { fun getPyVenvCfg(file: VirtualFile?): Path? = file @@ -25,4 +32,31 @@ object VenvUtils { }.getProperty("version") ?.trim() }.getOrNull() + + fun getVenvInfo(pyvenvCfgPath: Path): VenvInfo? = + runCatching { + val props = + Properties().apply { + Files.newBufferedReader(pyvenvCfgPath, StandardCharsets.UTF_8).use { load(it) } + } + val version = props.getProperty("version") ?: props.getProperty("version_info") + if (version == null) { + com.intellij.openapi.diagnostic.Logger + .getInstance(VenvUtils::class.java) + .debug("No version found in $pyvenvCfgPath") + return@runCatching null + } + val implementation = props.getProperty("implementation") + val includeSystemSitePackages = + props.getProperty("include-system-site-packages")?.toBoolean() ?: false + val creator = + props.getProperty("uv")?.let { " - uv@$it" } + ?: props.getProperty("virtualenv")?.let { " - virtualenv@$it" } + com.intellij.openapi.diagnostic.Logger + .getInstance(VenvUtils::class.java) + .debug( + "Found venv info: version=$version, implementation=$implementation, systemSitePackages=$includeSystemSitePackages, creator=$creator from $pyvenvCfgPath", + ) + VenvInfo(version, implementation, includeSystemSitePackages, creator) + }.getOrNull() } diff --git a/src/main/kotlin/com/github/pyvenvmanage/VenvVersionCache.kt b/src/main/kotlin/com/github/pyvenvmanage/VenvVersionCache.kt index 333aa25..4c3b303 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/VenvVersionCache.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/VenvVersionCache.kt @@ -1,5 +1,6 @@ package com.github.pyvenvmanage +import java.util.Optional import java.util.concurrent.ConcurrentHashMap import com.intellij.openapi.Disposable @@ -12,7 +13,7 @@ import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent @Service(Service.Level.APP) class VenvVersionCache : Disposable { - private val cache = ConcurrentHashMap() + private val cache = ConcurrentHashMap>() init { VirtualFileManager.getInstance().addAsyncFileListener( @@ -39,13 +40,18 @@ class VenvVersionCache : Disposable { ) } - fun getVersion(pyvenvCfgPath: String): String? = - cache.computeIfAbsent(pyvenvCfgPath) { - VenvUtils.getPythonVersionFromPyVenv( - java.nio.file.Path - .of(it), - ) - } + fun getInfo(pyvenvCfgPath: String): VenvInfo? = + cache + .computeIfAbsent(pyvenvCfgPath) { + Optional.ofNullable( + VenvUtils.getVenvInfo( + java.nio.file.Path + .of(it), + ), + ) + }.orElse(null) + + fun getVersion(pyvenvCfgPath: String): String? = getInfo(pyvenvCfgPath)?.version fun invalidate(path: String) { cache.remove(path) diff --git a/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModule.kt b/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModule.kt index 394885d..bd666b1 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModule.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModule.kt @@ -6,7 +6,7 @@ import com.intellij.openapi.roots.ModuleRootModificationUtil import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.vfs.VirtualFile -class ConfigurePythonActionModule : ConfigurePythonActionAbstract() { +open class ConfigurePythonActionModule : ConfigurePythonActionAbstract() { override fun setSdk( project: Project, selectedPath: VirtualFile, diff --git a/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProject.kt b/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProject.kt index 06eb39c..9a9de35 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProject.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProject.kt @@ -5,7 +5,7 @@ import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil import com.intellij.openapi.vfs.VirtualFile -class ConfigurePythonActionProject : ConfigurePythonActionAbstract() { +open class ConfigurePythonActionProject : ConfigurePythonActionAbstract() { override fun setSdk( project: Project, selectedPath: VirtualFile, diff --git a/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurable.kt b/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurable.kt index 85c70fc..6b70747 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurable.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurable.kt @@ -1,55 +1,241 @@ package com.github.pyvenvmanage.settings -import javax.swing.JCheckBox +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.FlowLayout +import javax.swing.JButton import javax.swing.JComponent import javax.swing.JPanel import javax.swing.JTextField +import javax.swing.ListSelectionModel +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener import com.intellij.openapi.options.Configurable +import com.intellij.ui.CheckBoxList import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextField import com.intellij.util.ui.FormBuilder +import com.github.pyvenvmanage.VenvInfo + class PyVenvManageConfigurable : Configurable { - private var showVersionCheckBox: JCheckBox? = null - private var versionFormatField: JTextField? = null + internal var prefixField: JTextField? = null + internal var suffixField: JTextField? = null + internal var separatorField: JTextField? = null + internal var fieldsList: CheckBoxList? = null + internal var previewField: JBTextField? = null override fun getDisplayName(): String = "PyVenv Manage" override fun createComponent(): JComponent { - showVersionCheckBox = JCheckBox("Show Python version in project view") - versionFormatField = - JTextField().apply { - toolTipText = "Use \$version as placeholder for the version number" + prefixField = JTextField().apply { toolTipText = "Text before the decoration (e.g., ' [')" } + suffixField = JTextField().apply { toolTipText = "Text after the decoration (e.g., ']')" } + separatorField = JTextField().apply { toolTipText = "Text between fields (e.g., ' - ')" } + + fieldsList = + object : CheckBoxList() { + override fun getSecondaryText(index: Int): String? = getFieldExample(getItemAt(index)) + }.apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + setCheckBoxListListener { _, _ -> + updatePreview() + } + } + + previewField = + JBTextField().apply { + isEditable = false + toolTipText = "Preview of what the decoration will look like" + } + + val textChangeListener = + object : DocumentListener { + override fun insertUpdate(e: DocumentEvent?) = updatePreview() + + override fun removeUpdate(e: DocumentEvent?) = updatePreview() + + override fun changedUpdate(e: DocumentEvent?) = updatePreview() + } + + prefixField?.document?.addDocumentListener(textChangeListener) + suffixField?.document?.addDocumentListener(textChangeListener) + separatorField?.document?.addDocumentListener(textChangeListener) + + val moveUpButton = + JButton("↑").apply { + toolTipText = "Move selected field up" + addActionListener { + moveSelectedField(-1) + updatePreview() + } + } + val moveDownButton = + JButton("↓").apply { + toolTipText = "Move selected field down" + addActionListener { + moveSelectedField(1) + updatePreview() + } + } + + val buttonsPanel = + JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + add(moveUpButton) + add(moveDownButton) + } + + val scrollPane = + JBScrollPane(fieldsList).apply { + preferredSize = Dimension(0, 120) + } + + val listPanel = + JPanel(BorderLayout()).apply { + add(scrollPane, BorderLayout.CENTER) + add(buttonsPanel, BorderLayout.SOUTH) + } + + reset() + + val fieldsLabel = + JBLabel("Fields:").apply { + toolTipText = "Check to enable, use arrows to reorder" } return FormBuilder .createFormBuilder() - .addComponent(showVersionCheckBox!!) - .addLabeledComponent(JBLabel("Version format:"), versionFormatField!!) + .addSeparator() + .addComponent(JBLabel("Python Interpreter Decoration")) + .addLabeledComponent(JBLabel("Prefix:"), prefixField!!) + .addLabeledComponent(JBLabel("Suffix:"), suffixField!!) + .addLabeledComponent(JBLabel("Separator:"), separatorField!!) + .addLabeledComponent(fieldsLabel, listPanel) + .addLabeledComponent(JBLabel("Preview:"), previewField!!) .addComponentFillVertically(JPanel(), 0) .panel } + internal fun updatePreview() { + val list = fieldsList ?: return + val preview = previewField ?: return + + val enabledFields = + (0 until list.itemsCount) + .filter { list.isItemSelected(it) } + .mapNotNull { list.getItemAt(it) } + + val sampleInfo = VenvInfo("3.14.2", "CPython", true, " - uv@0.9.21") + val values = + enabledFields.mapNotNull { field -> + when (field) { + DecorationField.VERSION -> sampleInfo.version + DecorationField.IMPLEMENTATION -> sampleInfo.implementation + DecorationField.SYSTEM -> if (sampleInfo.includeSystemSitePackages) "SYSTEM" else null + DecorationField.CREATOR -> sampleInfo.creator?.removePrefix(" - ") + } + } + + val prefix = prefixField?.text ?: "" + val suffix = suffixField?.text ?: "" + val separator = separatorField?.text ?: "" + + val decoration = if (values.isEmpty()) "" else prefix + values.joinToString(separator) + suffix + preview.text = ".venv$decoration" + } + + internal fun moveSelectedField(direction: Int) { + val list = fieldsList ?: return + val selectedIndex = list.selectedIndex + if (selectedIndex < 0) return + + val newIndex = selectedIndex + direction + if (newIndex < 0 || newIndex >= list.itemsCount) return + + val currentItem = list.getItemAt(selectedIndex) ?: return + val swapItem = list.getItemAt(newIndex) ?: return + val currentChecked = list.isItemSelected(selectedIndex) + val swapChecked = list.isItemSelected(newIndex) + + val items = getAllFieldsOrdered() + val checkedStates = (0 until list.itemsCount).associate { list.getItemAt(it) to list.isItemSelected(it) } + + items[selectedIndex] = swapItem + items[newIndex] = currentItem + + list.clear() + items.forEach { field -> + val wasChecked = + when (field) { + currentItem -> currentChecked + swapItem -> swapChecked + else -> checkedStates[field] ?: false + } + list.addItem(field, field.displayName, wasChecked) + } + list.selectedIndex = newIndex + } + + private fun getAllFieldsOrdered(): MutableList { + val list = fieldsList ?: return mutableListOf() + return (0 until list.itemsCount).mapNotNull { list.getItemAt(it) }.toMutableList() + } + override fun isModified(): Boolean { val settings = PyVenvManageSettings.getInstance() - return showVersionCheckBox?.isSelected != settings.showVersionInProjectView || - versionFormatField?.text != settings.versionFormat + return prefixField?.text != settings.prefix || + suffixField?.text != settings.suffix || + separatorField?.text != settings.separator || + getEnabledFields() != settings.fields + } + + private fun getEnabledFields(): List { + val list = fieldsList ?: return emptyList() + return (0 until list.itemsCount) + .filter { list.isItemSelected(it) } + .mapNotNull { list.getItemAt(it) } } override fun apply() { val settings = PyVenvManageSettings.getInstance() - showVersionCheckBox?.isSelected?.let { settings.showVersionInProjectView = it } - versionFormatField?.text?.let { settings.versionFormat = it } + prefixField?.text?.let { settings.prefix = it } + suffixField?.text?.let { settings.suffix = it } + separatorField?.text?.let { settings.separator = it } + settings.fields = getEnabledFields() } override fun reset() { val settings = PyVenvManageSettings.getInstance() - showVersionCheckBox?.isSelected = settings.showVersionInProjectView - versionFormatField?.text = settings.versionFormat + prefixField?.text = settings.prefix + suffixField?.text = settings.suffix + separatorField?.text = settings.separator + + val list = fieldsList ?: return + list.clear() + + val enabledFields = settings.fields.toSet() + val orderedFields = settings.fields + DecorationField.entries.filter { it !in enabledFields } + orderedFields.forEach { field -> + list.addItem(field, field.displayName, field in enabledFields) + } + updatePreview() } override fun disposeUIResources() { - showVersionCheckBox = null - versionFormatField = null + prefixField = null + suffixField = null + separatorField = null + fieldsList = null + previewField = null } } + +internal fun getFieldExample(field: DecorationField?): String? = + when (field) { + DecorationField.VERSION -> "e.g., 3.14.2" + DecorationField.IMPLEMENTATION -> "e.g., CPython" + DecorationField.SYSTEM -> "shows SYSTEM" + DecorationField.CREATOR -> "e.g., uv@0.9.21" + null -> null + } diff --git a/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettings.kt b/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettings.kt index 15232fe..e1f0845 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettings.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettings.kt @@ -6,38 +6,74 @@ import com.intellij.openapi.components.State import com.intellij.openapi.components.Storage import com.intellij.openapi.components.service +import com.github.pyvenvmanage.VenvInfo + +enum class DecorationField( + val displayName: String, +) { + VERSION("Python version"), + IMPLEMENTATION("Python implementation"), + SYSTEM("Is a system site package"), + CREATOR("Virtual environment creator"), +} + @Service(Service.Level.APP) @State( name = "PyVenvManageSettings", storages = [Storage("PyVenvManageSettings.xml")], ) -class PyVenvManageSettings : PersistentStateComponent { - private var state = State() +class PyVenvManageSettings : PersistentStateComponent { + private var state = SettingsState() - data class State( - var showVersionInProjectView: Boolean = true, - var versionFormat: String = " [\$version]", + data class SettingsState( + var prefix: String = " [", + var suffix: String = "]", + var separator: String = " - ", + var fields: List = DecorationField.entries.map { it.name }, ) - override fun getState(): State = state + override fun getState(): SettingsState = state - override fun loadState(state: State) { + override fun loadState(state: SettingsState) { this.state = state } - var showVersionInProjectView: Boolean - get() = state.showVersionInProjectView + var prefix: String + get() = state.prefix + set(value) { + state.prefix = value + } + + var suffix: String + get() = state.suffix set(value) { - state.showVersionInProjectView = value + state.suffix = value } - var versionFormat: String - get() = state.versionFormat + var separator: String + get() = state.separator set(value) { - state.versionFormat = value + state.separator = value } - fun formatVersion(version: String): String = versionFormat.replace("\$version", version) + var fields: List + get() = state.fields.mapNotNull { name -> DecorationField.entries.find { it.name == name } } + set(value) { + state.fields = value.map { it.name } + } + + fun formatDecoration(info: VenvInfo): String { + val values = + fields.mapNotNull { field -> + when (field) { + DecorationField.VERSION -> info.version + DecorationField.IMPLEMENTATION -> info.implementation + DecorationField.SYSTEM -> if (info.includeSystemSitePackages) "SYSTEM" else null + DecorationField.CREATOR -> info.creator?.removePrefix(" - ") + } + } + return if (values.isEmpty()) "" else prefix + values.joinToString(separator) + suffix + } companion object { fun getInstance(): PyVenvManageSettings = service() diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index a65f18e..4841586 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,4 +1,4 @@ - + com.github.pyvenvmanage.pyvenv PyVenv Manage 2 pyvenvmanage diff --git a/src/main/resources/search/PyVenvManageConfigurable.xml b/src/main/resources/search/PyVenvManageConfigurable.xml new file mode 100644 index 0000000..2a0451e --- /dev/null +++ b/src/main/resources/search/PyVenvManageConfigurable.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/src/test/kotlin/com/github/pyvenvmanage/UITest.kt b/src/test/kotlin/com/github/pyvenvmanage/UITest.kt index 26d01b1..5010223 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/UITest.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/UITest.kt @@ -9,7 +9,6 @@ import java.time.Duration.ofSeconds import java.util.concurrent.TimeUnit import javax.imageio.ImageIO -import kotlin.io.path.name import org.assertj.swing.core.MouseButton import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll @@ -20,15 +19,17 @@ import org.junit.jupiter.api.extension.ExtensionContext import org.junit.jupiter.api.extension.TestWatcher import com.intellij.remoterobot.RemoteRobot -import com.intellij.remoterobot.fixtures.JTreeFixture import com.intellij.remoterobot.search.locators.byXpath import com.intellij.remoterobot.stepsProcessing.StepLogger import com.intellij.remoterobot.stepsProcessing.StepWorker import com.intellij.remoterobot.utils.waitFor +import com.github.pyvenvmanage.pages.IdeaFrame import com.github.pyvenvmanage.pages.actionMenuItem import com.github.pyvenvmanage.pages.dialog +import com.github.pyvenvmanage.pages.hasActionMenuItem import com.github.pyvenvmanage.pages.idea +import com.github.pyvenvmanage.pages.pressEscape import com.github.pyvenvmanage.pages.welcomeFrame @ExtendWith(UITest.IdeTestWatcher::class) @@ -56,6 +57,9 @@ class UITest { fun startIdea() { val base = Path.of(System.getProperty("user.home"), "projects") Files.createDirectories(base) + Files.list(base).filter { it.fileName.toString().startsWith("ui-test") }.forEach { + it.toFile().deleteRecursively() + } tmpDir = Files.createTempDirectory(base, "ui-test") // create test project val demo = Paths.get(tmpDir.toString(), "demo") @@ -67,30 +71,23 @@ class UITest { val process = ProcessBuilder("python", "-m", "venv", venv, "--without-pip") assert(process.start().waitFor() == 0) - // ./gradlew runIdeForUiTests requires already running, so just wait to connect StepWorker.registerProcessor(StepLogger()) - remoteRobot = RemoteRobot("http://localhost:8082") - waitFor(ofSeconds(20), ofSeconds(5)) { - runCatching { - remoteRobot.callJs("true") - }.getOrDefault(false) - } - // open test project + remoteRobot = RemoteRobot("http://127.0.0.1:8082") + Thread.sleep(10000) remoteRobot.welcomeFrame { - openLink.click() + openButton.click() dialog("Open File or Project") { - button(byXpath("//div[@myicon='refresh.svg']")).click() + val pathField = textField(byXpath("//div[@class='BorderlessTextField']")) + pathField.click() + Thread.sleep(500) + pathField.runJs("component.setText('${demo.toString().replace("'", "\\'")}')") Thread.sleep(500) - val tree = find(byXpath("//div[@class='Tree']")) - tree.expand(tree.getValueAtRow(0), *demo.map { it.name }.toTypedArray()) - tree.clickPath(tree.getValueAtRow(0), *demo.map { it.name }.toTypedArray(), fullMatch = true) button("OK").click() } } - Thread.sleep(1000) - // wait for indexing to finish - remoteRobot.idea { - waitFor(ofMinutes(1)) { isDumbMode().not() } + Thread.sleep(5000) + remoteRobot.find(timeout = ofMinutes(2)).apply { + waitFor(ofMinutes(2)) { isDumbMode().not() } } } @@ -102,12 +99,11 @@ class UITest { @Test fun testSetProjectInterpreter() { + remoteRobot.pressEscape() remoteRobot.idea { with(projectViewTree) { findText("ve").click(MouseButton.RIGHT_BUTTON) remoteRobot.actionMenuItem("Set as Project Interpreter").click() - findText("Updated SDK for project demo to:") - // wait for indexing to finish waitFor(ofMinutes(1)) { isDumbMode().not() } } } @@ -115,12 +111,11 @@ class UITest { @Test fun testSetModuleInterpreter() { + remoteRobot.pressEscape() remoteRobot.idea { with(projectViewTree) { findText("ve").click(MouseButton.RIGHT_BUTTON) remoteRobot.actionMenuItem("Set as Module Interpreter").click() - findText("Updated SDK for module demo to:") - // wait for indexing to finish waitFor(ofMinutes(1)) { isDumbMode().not() } } } @@ -152,33 +147,30 @@ class UITest { @Test fun testContextMenuOnNonVenvDirectory() { + remoteRobot.pressEscape() remoteRobot.idea { with(projectViewTree) { - // Right-click on a non-venv directory should not show interpreter options findText("demo").click(MouseButton.RIGHT_BUTTON) - waitFor(ofSeconds(2)) { - // The action menu should be visible but interpreter options should not be enabled - runCatching { - remoteRobot.actionMenuItem("Set as Project Interpreter") - false // If found, test should handle it - }.getOrDefault(true) // If not found, that's expected + Thread.sleep(500) + assert(!remoteRobot.hasActionMenuItem("Set as Project Interpreter")) { + "Non-venv directory should not show 'Set as Project Interpreter'" } + remoteRobot.pressEscape() } } } @Test fun testContextMenuOnPythonFile() { + remoteRobot.pressEscape() remoteRobot.idea { with(projectViewTree) { - // Right-click on a Python file should not show interpreter options findText("main.py").click(MouseButton.RIGHT_BUTTON) - waitFor(ofSeconds(2)) { - runCatching { - remoteRobot.actionMenuItem("Set as Project Interpreter") - false - }.getOrDefault(true) + Thread.sleep(500) + assert(!remoteRobot.hasActionMenuItem("Set as Project Interpreter")) { + "Python file should not show 'Set as Project Interpreter'" } + remoteRobot.pressEscape() } } } diff --git a/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt b/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt index c59e822..8aca198 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt @@ -51,8 +51,15 @@ class VenvProjectViewNodeDecoratorTest { settings = mockk(relaxed = true) mockkObject(PyVenvManageSettings.Companion) every { PyVenvManageSettings.getInstance() } returns settings - every { settings.showVersionInProjectView } returns true - every { settings.formatVersion(any()) } answers { " [${firstArg()}]" } + every { settings.formatDecoration(any()) } answers + { + val info = firstArg() + val parts = mutableListOf(info.version) + info.implementation?.let { parts.add(it) } + if (info.includeSystemSitePackages) parts.add("SYSTEM") + info.creator?.removePrefix(" - ")?.let { parts.add(it) } + " [${parts.joinToString(" - ")}]" + } } @AfterEach @@ -87,10 +94,10 @@ class VenvProjectViewNodeDecoratorTest { @TempDir tempDir: Path, ) { val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") - Files.writeString(pyvenvCfgPath, "version = 3.11.0") + Files.writeString(pyvenvCfgPath, "version = 3.11.0\nimplementation = CPython") every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath - every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns "3.11.0" + every { versionCache.getInfo(pyvenvCfgPath.toString()) } returns VenvInfo("3.11.0", "CPython", false, null) every { data.presentableText } returns "venv" decorator.decorate(node, data) @@ -99,32 +106,32 @@ class VenvProjectViewNodeDecoratorTest { } @Test - fun `adds version text when version available`( + fun `adds version and implementation text when info available`( @TempDir tempDir: Path, ) { val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") - Files.writeString(pyvenvCfgPath, "version = 3.11.0") + Files.writeString(pyvenvCfgPath, "version = 3.11.0\nimplementation = CPython") every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath - every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns "3.11.0" + every { versionCache.getInfo(pyvenvCfgPath.toString()) } returns VenvInfo("3.11.0", "CPython", false, null) every { data.presentableText } returns "venv" decorator.decorate(node, data) verify { data.clearText() } verify { data.addText("venv", SimpleTextAttributes.REGULAR_ATTRIBUTES) } - verify { data.addText(" [3.11.0]", SimpleTextAttributes.GRAY_ATTRIBUTES) } + verify { data.addText(" [3.11.0 - CPython]", SimpleTextAttributes.GRAY_ATTRIBUTES) } } @Test - fun `does not add version text when version unavailable`( + fun `does not add text when info unavailable`( @TempDir tempDir: Path, ) { val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") Files.writeString(pyvenvCfgPath, "home = /usr/bin") every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath - every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns null + every { versionCache.getInfo(pyvenvCfgPath.toString()) } returns null every { data.presentableText } returns "venv" decorator.decorate(node, data) @@ -135,40 +142,38 @@ class VenvProjectViewNodeDecoratorTest { } @Test - fun `uses cache for version lookup`( + fun `uses cache for info lookup`( @TempDir tempDir: Path, ) { val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") - Files.writeString(pyvenvCfgPath, "version = 3.11.0") + Files.writeString(pyvenvCfgPath, "version = 3.11.0\nimplementation = CPython") every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath - every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns "3.11.0" + every { versionCache.getInfo(pyvenvCfgPath.toString()) } returns VenvInfo("3.11.0", "CPython", false, null) every { data.presentableText } returns "venv" - // Call twice decorator.decorate(node, data) decorator.decorate(node, data) - // Version cache should be called twice (caching is handled by the cache service) - verify(exactly = 2) { versionCache.getVersion(pyvenvCfgPath.toString()) } + verify(exactly = 2) { versionCache.getInfo(pyvenvCfgPath.toString()) } } @Test - fun `respects showVersionInProjectView setting`( + fun `does not modify text when presentableText is null`( @TempDir tempDir: Path, ) { val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") - Files.writeString(pyvenvCfgPath, "version = 3.11.0") + Files.writeString(pyvenvCfgPath, "version = 3.11.0\nimplementation = CPython") - every { settings.showVersionInProjectView } returns false every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath - every { data.presentableText } returns "venv" + every { versionCache.getInfo(pyvenvCfgPath.toString()) } returns VenvInfo("3.11.0", "CPython", false, null) + every { data.presentableText } returns null decorator.decorate(node, data) verify(exactly = 0) { data.clearText() } verify(exactly = 0) { data.addText(any(), any()) } - verify { data.setIcon(any()) } // Icon is still set + verify { data.setIcon(any()) } } } } diff --git a/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsTest.kt b/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsTest.kt index 2e9b0b4..1b55c27 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsTest.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsTest.kt @@ -3,14 +3,94 @@ package com.github.pyvenvmanage import java.nio.file.Files import java.nio.file.Path +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir +import com.intellij.openapi.vfs.VirtualFile + +import com.jetbrains.python.sdk.PythonSdkUtil + class VenvUtilsTest { + @Nested + inner class GetPyVenvCfgTest { + private lateinit var virtualFile: VirtualFile + private lateinit var pyvenvCfgFile: VirtualFile + + @BeforeEach + fun setUp() { + mockkStatic(PythonSdkUtil::class) + virtualFile = mockk(relaxed = true) + pyvenvCfgFile = mockk(relaxed = true) + } + + @AfterEach + fun tearDown() { + unmockkStatic(PythonSdkUtil::class) + } + + @Test + fun `returns null when file is null`() { + val result = VenvUtils.getPyVenvCfg(null) + assertNull(result) + } + + @Test + fun `returns null when file is not a directory`() { + every { virtualFile.isDirectory } returns false + + val result = VenvUtils.getPyVenvCfg(virtualFile) + + assertNull(result) + } + + @Test + fun `returns null when no Python executable found`() { + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/path" + every { PythonSdkUtil.getPythonExecutable("/some/path") } returns null + + val result = VenvUtils.getPyVenvCfg(virtualFile) + + assertNull(result) + } + + @Test + fun `returns null when pyvenv cfg not found`() { + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { virtualFile.findChild("pyvenv.cfg") } returns null + + val result = VenvUtils.getPyVenvCfg(virtualFile) + + assertNull(result) + } + + @Test + fun `returns path when venv directory with pyvenv cfg`() { + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { virtualFile.findChild("pyvenv.cfg") } returns pyvenvCfgFile + every { pyvenvCfgFile.path } returns "/some/venv/pyvenv.cfg" + + val result = VenvUtils.getPyVenvCfg(virtualFile) + + assertNotNull(result) + assertEquals(Path.of("/some/venv/pyvenv.cfg"), result) + } + } + @Nested inner class GetPythonVersionFromPyVenvTest { @TempDir @@ -116,4 +196,195 @@ class VenvUtilsTest { assertEquals("3.9.0", result) } } + + @Nested + inner class GetVenvInfoTest { + @TempDir + lateinit var tempDir: Path + + private lateinit var pyvenvCfgPath: Path + + @BeforeEach + fun setUp() { + pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + } + + @Test + fun `returns info when version and implementation present`() { + Files.writeString( + pyvenvCfgPath, + """ + home = /usr/bin + version = 3.11.5 + implementation = CPython + include-system-site-packages = false + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.11.5", result?.version) + assertEquals("CPython", result?.implementation) + assertEquals(false, result?.includeSystemSitePackages) + assertNull(result?.creator) + } + + @Test + fun `returns info with null implementation when implementation missing`() { + Files.writeString( + pyvenvCfgPath, + """ + home = /usr/bin + version = 3.11.5 + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.11.5", result?.version) + assertNull(result?.implementation) + assertEquals(false, result?.includeSystemSitePackages) + assertNull(result?.creator) + } + + @Test + fun `returns info with system site packages enabled`() { + Files.writeString( + pyvenvCfgPath, + """ + home = /usr/bin + version = 3.11.5 + implementation = CPython + include-system-site-packages = true + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.11.5", result?.version) + assertEquals("CPython", result?.implementation) + assertEquals(true, result?.includeSystemSitePackages) + assertNull(result?.creator) + } + + @Test + fun `uses version_info when version missing`() { + Files.writeString( + pyvenvCfgPath, + """ + home = /usr/bin + version_info = 3.13.10.final.0 + implementation = CPython + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.13.10.final.0", result?.version) + assertEquals("CPython", result?.implementation) + assertNull(result?.creator) + } + + @Test + fun `returns null when version missing`() { + Files.writeString( + pyvenvCfgPath, + """ + home = /usr/bin + implementation = CPython + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNull(result) + } + + @Test + fun `returns null when file does not exist`() { + val nonExistentPath = tempDir.resolve("nonexistent.cfg") + + val result = VenvUtils.getVenvInfo(nonExistentPath) + + assertNull(result) + } + + @Test + fun `trims version and implementation`() { + Files.writeString( + pyvenvCfgPath, + """ + version = 3.12.0 + implementation = PyPy + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.12.0", result?.version) + assertEquals("PyPy", result?.implementation) + assertNull(result?.creator) + } + + @Test + fun `returns creator when uv present`() { + Files.writeString( + pyvenvCfgPath, + """ + version = 3.11.5 + implementation = CPython + uv = 0.9.18 + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.11.5", result?.version) + assertEquals("CPython", result?.implementation) + assertEquals(" - uv@0.9.18", result?.creator) + } + + @Test + fun `returns creator when virtualenv present`() { + Files.writeString( + pyvenvCfgPath, + """ + version = 3.11.5 + implementation = CPython + virtualenv = 20.35.4 + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.11.5", result?.version) + assertEquals("CPython", result?.implementation) + assertEquals(" - virtualenv@20.35.4", result?.creator) + } + + @Test + fun `prefers uv over virtualenv when both present`() { + Files.writeString( + pyvenvCfgPath, + """ + version = 3.11.5 + uv = 0.9.18 + virtualenv = 20.35.4 + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.11.5", result?.version) + assertEquals(" - uv@0.9.18", result?.creator) + } + } } diff --git a/src/test/kotlin/com/github/pyvenvmanage/VenvVersionCacheTest.kt b/src/test/kotlin/com/github/pyvenvmanage/VenvVersionCacheTest.kt new file mode 100644 index 0000000..ecc9554 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/VenvVersionCacheTest.kt @@ -0,0 +1,265 @@ +package com.github.pyvenvmanage + +import java.nio.file.Files +import java.nio.file.Path + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.vfs.AsyncFileListener +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent +import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent + +class VenvVersionCacheTest { + @TempDir + lateinit var tempDir: Path + + private lateinit var virtualFileManager: VirtualFileManager + private var capturedListener: AsyncFileListener? = null + + @BeforeEach + fun setUp() { + mockkObject(VenvUtils) + mockkStatic(VirtualFileManager::class) + + virtualFileManager = mockk(relaxed = true) + every { VirtualFileManager.getInstance() } returns virtualFileManager + + val listenerSlot = slot() + every { + virtualFileManager.addAsyncFileListener(capture(listenerSlot), any()) + } answers { + capturedListener = listenerSlot.captured + } + } + + @AfterEach + fun tearDown() { + unmockkObject(VenvUtils) + unmockkStatic(VirtualFileManager::class) + capturedListener = null + } + + @Test + fun `getVersion returns cached version on second call`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) + + val cache = VenvVersionCache() + + val firstResult = cache.getVersion(pyvenvCfgPath.toString()) + val secondResult = cache.getVersion(pyvenvCfgPath.toString()) + + assertEquals("3.11.0", firstResult) + assertEquals("3.11.0", secondResult) + verify(exactly = 1) { VenvUtils.getVenvInfo(pyvenvCfgPath) } + } + + @Test + fun `getVersion returns null when version not found`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "home = /usr/bin") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns null + + val cache = VenvVersionCache() + val result = cache.getVersion(pyvenvCfgPath.toString()) + + assertNull(result) + } + + @Test + fun `getInfo returns cached info on second call`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0\nimplementation = CPython") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) + + val cache = VenvVersionCache() + + val firstResult = cache.getInfo(pyvenvCfgPath.toString()) + val secondResult = cache.getInfo(pyvenvCfgPath.toString()) + + assertEquals(VenvInfo("3.11.0", "CPython"), firstResult) + assertEquals(VenvInfo("3.11.0", "CPython"), secondResult) + verify(exactly = 1) { VenvUtils.getVenvInfo(pyvenvCfgPath) } + } + + @Test + fun `getInfo returns null when info not found`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "home = /usr/bin") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns null + + val cache = VenvVersionCache() + val result = cache.getInfo(pyvenvCfgPath.toString()) + + assertNull(result) + } + + @Test + fun `invalidate removes entry from cache`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) andThen + VenvInfo("3.12.0", "CPython", false) + + val cache = VenvVersionCache() + + cache.getVersion(pyvenvCfgPath.toString()) + cache.invalidate(pyvenvCfgPath.toString()) + val afterInvalidate = cache.getVersion(pyvenvCfgPath.toString()) + + assertEquals("3.12.0", afterInvalidate) + verify(exactly = 2) { VenvUtils.getVenvInfo(pyvenvCfgPath) } + } + + @Test + fun `clear removes all entries from cache`() { + val pyvenvCfgPath1 = tempDir.resolve("venv1/pyvenv.cfg") + val pyvenvCfgPath2 = tempDir.resolve("venv2/pyvenv.cfg") + Files.createDirectories(pyvenvCfgPath1.parent) + Files.createDirectories(pyvenvCfgPath2.parent) + Files.writeString(pyvenvCfgPath1, "version = 3.11.0") + Files.writeString(pyvenvCfgPath2, "version = 3.12.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath1) } returns VenvInfo("3.11.0", "CPython") andThen + VenvInfo("3.11.1", "CPython", false) + every { VenvUtils.getVenvInfo(pyvenvCfgPath2) } returns VenvInfo("3.12.0", "CPython", false) andThen + VenvInfo("3.12.1", "CPython", false) + + val cache = VenvVersionCache() + + cache.getVersion(pyvenvCfgPath1.toString()) + cache.getVersion(pyvenvCfgPath2.toString()) + cache.clear() + val after1 = cache.getVersion(pyvenvCfgPath1.toString()) + val after2 = cache.getVersion(pyvenvCfgPath2.toString()) + + assertEquals("3.11.1", after1) + assertEquals("3.12.1", after2) + } + + @Test + fun `dispose clears the cache`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) andThen + VenvInfo("3.12.0", "CPython", false) + + val cache = VenvVersionCache() + + cache.getVersion(pyvenvCfgPath.toString()) + cache.dispose() + val afterDispose = cache.getVersion(pyvenvCfgPath.toString()) + + assertEquals("3.12.0", afterDispose) + } + + @Test + fun `registers async file listener on init`() { + VenvVersionCache() + + verify { virtualFileManager.addAsyncFileListener(any(), any()) } + } + + @Nested + inner class FileListenerTest { + @Test + fun `file listener invalidates cache on pyvenv cfg content change`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) andThen + VenvInfo("3.12.0", "CPython", false) + + val cache = VenvVersionCache() + cache.getVersion(pyvenvCfgPath.toString()) + + val event: VFileContentChangeEvent = mockk(relaxed = true) + every { event.path } returns pyvenvCfgPath.toString() + + val changeApplier = capturedListener?.prepareChange(listOf(event)) + changeApplier?.afterVfsChange() + + val afterChange = cache.getVersion(pyvenvCfgPath.toString()) + assertEquals("3.12.0", afterChange) + } + + @Test + fun `file listener invalidates cache on pyvenv cfg delete`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) andThen null + + val cache = VenvVersionCache() + cache.getVersion(pyvenvCfgPath.toString()) + + val event: VFileDeleteEvent = mockk(relaxed = true) + every { event.path } returns pyvenvCfgPath.toString() + + val changeApplier = capturedListener?.prepareChange(listOf(event)) + changeApplier?.afterVfsChange() + + val afterDelete = cache.getVersion(pyvenvCfgPath.toString()) + assertNull(afterDelete) + } + + @Test + fun `file listener ignores non-pyvenv cfg files`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) + + val cache = VenvVersionCache() + cache.getVersion(pyvenvCfgPath.toString()) + + val event: VFileContentChangeEvent = mockk(relaxed = true) + every { event.path } returns "/some/other/file.txt" + + val changeApplier = capturedListener?.prepareChange(listOf(event)) + + assertNull(changeApplier) + verify(exactly = 1) { VenvUtils.getVenvInfo(pyvenvCfgPath) } + } + } + + @Test + fun `getInstance returns cache instance`() { + val application: Application = mockk(relaxed = true) + val mockCache: VenvVersionCache = mockk(relaxed = true) + + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns application + every { application.getService(VenvVersionCache::class.java) } returns mockCache + + val instance = VenvVersionCache.getInstance() + + assertEquals(mockCache, instance) + unmockkStatic(ApplicationManager::class) + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstractTest.kt b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstractTest.kt new file mode 100644 index 0000000..c70f8c7 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstractTest.kt @@ -0,0 +1,359 @@ +package com.github.pyvenvmanage.actions + +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationGroup +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager + +import com.jetbrains.python.configuration.PyConfigurableInterpreterList +import com.jetbrains.python.sdk.PythonSdkType +import com.jetbrains.python.sdk.PythonSdkUtil + +class ConfigurePythonActionAbstractTest { + private lateinit var action: TestableConfigurePythonAction + private lateinit var event: AnActionEvent + private lateinit var presentation: Presentation + private lateinit var virtualFile: VirtualFile + private lateinit var project: Project + + @BeforeEach + fun setUp() { + action = TestableConfigurePythonAction() + event = mockk(relaxed = true) + presentation = mockk(relaxed = true) + virtualFile = mockk(relaxed = true) + project = mockk(relaxed = true) + + every { event.presentation } returns presentation + every { event.project } returns project + } + + @Test + fun `getActionUpdateThread returns BGT`() { + assertEquals(ActionUpdateThread.BGT, action.actionUpdateThread) + } + + @Nested + inner class UpdateTest { + @BeforeEach + fun setUpMocks() { + mockkStatic(PythonSdkUtil::class) + } + + @AfterEach + fun tearDown() { + unmockkStatic(PythonSdkUtil::class) + } + + @Test + fun `disables action when no file selected`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns null + + action.update(event) + + verify { presentation.isEnabledAndVisible = false } + } + + @Test + fun `enables action for directory with Python executable`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + + action.update(event) + + verify { presentation.isEnabledAndVisible = true } + } + + @Test + fun `disables action for directory without Python executable`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/dir" + every { PythonSdkUtil.getPythonExecutable("/some/dir") } returns null + + action.update(event) + + verify { presentation.isEnabledAndVisible = false } + } + + @Test + fun `enables action for file in virtual env`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns false + every { virtualFile.path } returns "/some/venv/bin/python" + every { PythonSdkUtil.isVirtualEnv("/some/venv/bin/python") } returns true + + action.update(event) + + verify { presentation.isEnabledAndVisible = true } + } + + @Test + fun `disables action for file not in virtual env`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns false + every { virtualFile.path } returns "/usr/bin/python" + every { PythonSdkUtil.isVirtualEnv("/usr/bin/python") } returns false + + action.update(event) + + verify { presentation.isEnabledAndVisible = false } + } + } + + @Nested + inner class ActionPerformedTest { + private lateinit var parentDir: VirtualFile + private lateinit var interpreterList: PyConfigurableInterpreterList + private lateinit var notificationGroupManager: NotificationGroupManager + private lateinit var notificationGroup: NotificationGroup + private lateinit var notification: Notification + + @BeforeEach + fun setUpMocks() { + parentDir = mockk(relaxed = true) + interpreterList = mockk(relaxed = true) + notificationGroupManager = mockk(relaxed = true) + notificationGroup = mockk(relaxed = true) + notification = mockk(relaxed = true) + + mockkStatic(PythonSdkUtil::class) + mockkStatic(PyConfigurableInterpreterList::class) + mockkStatic(SdkConfigurationUtil::class) + mockkStatic(NotificationGroupManager::class) + mockkStatic(PythonSdkType::class) + + every { NotificationGroupManager.getInstance() } returns notificationGroupManager + every { notificationGroupManager.getNotificationGroup(any()) } returns notificationGroup + every { + notificationGroup.createNotification(any(), any(), any()) + } returns notification + every { notification.notify(any()) } just Runs + } + + @AfterEach + fun tearDown() { + unmockkStatic(PythonSdkUtil::class) + unmockkStatic(PyConfigurableInterpreterList::class) + unmockkStatic(SdkConfigurationUtil::class) + unmockkStatic(NotificationGroupManager::class) + unmockkStatic(PythonSdkType::class) + } + + @Test + fun `returns early when project is null`() { + every { event.project } returns null + + action.actionPerformed(event) + + verify(exactly = 0) { event.getData(CommonDataKeys.VIRTUAL_FILE) } + } + + @Test + fun `returns early when no file selected`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns null + + action.actionPerformed(event) + + verify(exactly = 0) { PythonSdkUtil.getPythonExecutable(any()) } + } + + @Test + fun `uses parent directory when file is not a directory`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns false + every { virtualFile.parent } returns parentDir + every { parentDir.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns null + + action.actionPerformed(event) + + verify { PythonSdkUtil.getPythonExecutable("/some/venv") } + } + + @Test + fun `shows error when no Python executable found`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/dir" + every { virtualFile.name } returns "dir" + every { PythonSdkUtil.getPythonExecutable("/some/dir") } returns null + + action.actionPerformed(event) + + verify { + notificationGroup.createNotification( + "Python SDK Error", + "No Python executable found in dir", + NotificationType.ERROR, + ) + } + } + + @Test + fun `shows error when SDK creation fails`() { + val pythonSdkType: PythonSdkType = mockk(relaxed = true) + + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { PyConfigurableInterpreterList.getInstance(project) } returns interpreterList + every { interpreterList.model.projectSdks.values } returns mutableListOf() + every { PythonSdkType.getInstance() } returns pythonSdkType + every { SdkConfigurationUtil.createAndAddSDK("/some/venv/bin/python", pythonSdkType) } returns null + + action.actionPerformed(event) + + verify { + notificationGroup.createNotification( + "Python SDK Error", + "Failed to create SDK from /some/venv/bin/python", + NotificationType.ERROR, + ) + } + } + + @Test + fun `creates new SDK when not found in existing SDKs`() { + val newSdk: Sdk = mockk(relaxed = true) + val pythonSdkType: PythonSdkType = mockk(relaxed = true) + val application: Application = mockk(relaxed = true) + val virtualFileManager: VirtualFileManager = mockk(relaxed = true) + val messageSlot = slot() + + action.lastSetSdkResult = ConfigurePythonActionAbstract.SetSdkResult.Success("module") + + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns application + every { application.getService(any>()) } returns mockk(relaxed = true) + every { application.getService(VirtualFileManager::class.java) } returns virtualFileManager + + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { PyConfigurableInterpreterList.getInstance(project) } returns interpreterList + every { interpreterList.model.projectSdks.values } returns mutableListOf() + every { PythonSdkType.getInstance() } returns pythonSdkType + every { SdkConfigurationUtil.createAndAddSDK("/some/venv/bin/python", pythonSdkType) } returns newSdk + every { newSdk.name } returns "Python 3.11 (venv)" + + action.actionPerformed(event) + + verify { + SdkConfigurationUtil.createAndAddSDK("/some/venv/bin/python", pythonSdkType) + } + verify { + notificationGroup.createNotification( + eq("Python SDK Updated"), + capture(messageSlot), + eq(NotificationType.INFORMATION), + ) + } + assert(messageSlot.captured.startsWith("Updated SDK for module to:")) + + unmockkStatic(ApplicationManager::class) + } + + @Test + fun `shows error notification on setSdk error`() { + val existingSdk: Sdk = mockk(relaxed = true) + + action.lastSetSdkResult = ConfigurePythonActionAbstract.SetSdkResult.Error("Module not found") + + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { PyConfigurableInterpreterList.getInstance(project) } returns interpreterList + every { interpreterList.model.projectSdks.values } returns mutableListOf(existingSdk) + every { existingSdk.homePath } returns "/some/venv/bin/python" + + action.actionPerformed(event) + + verify { + notificationGroup.createNotification( + "Python SDK Error", + "Module not found", + NotificationType.ERROR, + ) + } + } + + @Test + fun `shows success notification on setSdk success`() { + val existingSdk: Sdk = mockk(relaxed = true) + val application: Application = mockk(relaxed = true) + val virtualFileManager: VirtualFileManager = mockk(relaxed = true) + val messageSlot = slot() + + action.lastSetSdkResult = ConfigurePythonActionAbstract.SetSdkResult.Success("module") + + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns application + every { application.getService(any>()) } returns mockk(relaxed = true) + every { application.getService(VirtualFileManager::class.java) } returns virtualFileManager + + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { PyConfigurableInterpreterList.getInstance(project) } returns interpreterList + every { interpreterList.model.projectSdks.values } returns mutableListOf(existingSdk) + every { existingSdk.homePath } returns "/some/venv/bin/python" + every { existingSdk.name } returns "Python 3.11 (venv)" + + action.actionPerformed(event) + + verify { + notificationGroup.createNotification( + eq("Python SDK Updated"), + capture(messageSlot), + eq(NotificationType.INFORMATION), + ) + } + assert(messageSlot.captured.startsWith("Updated SDK for module to:")) + assert(messageSlot.captured.contains("Python 3.11 (venv)")) + + unmockkStatic(ApplicationManager::class) + } + } + + class TestableConfigurePythonAction : ConfigurePythonActionAbstract() { + var lastSetSdkResult: SetSdkResult = SetSdkResult.Success("test") + + override fun setSdk( + project: Project, + selectedPath: VirtualFile, + sdk: Sdk, + ): SetSdkResult = lastSetSdkResult + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModuleTest.kt b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModuleTest.kt new file mode 100644 index 0000000..9806472 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModuleTest.kt @@ -0,0 +1,86 @@ +package com.github.pyvenvmanage.actions + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +import com.intellij.openapi.module.Module +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.roots.ModuleRootModificationUtil +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.vfs.VirtualFile + +class ConfigurePythonActionModuleTest { + private lateinit var action: TestableConfigurePythonActionModule + private lateinit var project: Project + private lateinit var selectedPath: VirtualFile + private lateinit var sdk: Sdk + private lateinit var projectFileIndex: ProjectFileIndex + private lateinit var module: Module + + @BeforeEach + fun setUp() { + action = TestableConfigurePythonActionModule() + project = mockk(relaxed = true) + selectedPath = mockk(relaxed = true) + sdk = mockk(relaxed = true) + projectFileIndex = mockk(relaxed = true) + module = mockk(relaxed = true) + + mockkStatic(ProjectFileIndex::class) + mockkStatic(ModuleRootModificationUtil::class) + every { ProjectFileIndex.getInstance(project) } returns projectFileIndex + every { selectedPath.name } returns "venv" + } + + @AfterEach + fun tearDown() { + unmockkStatic(ProjectFileIndex::class) + unmockkStatic(ModuleRootModificationUtil::class) + } + + @Test + fun `setSdk returns error when no module found`() { + every { projectFileIndex.getModuleForFile(selectedPath, false) } returns null + + val result = action.testSetSdk(project, selectedPath, sdk) + + assertTrue(result is ConfigurePythonActionAbstract.SetSdkResult.Error) + assertEquals( + "No module found for venv", + (result as ConfigurePythonActionAbstract.SetSdkResult.Error).message, + ) + } + + @Test + fun `setSdk sets module SDK and returns success`() { + every { projectFileIndex.getModuleForFile(selectedPath, false) } returns module + every { module.name } returns "my-module" + every { ModuleRootModificationUtil.setModuleSdk(module, sdk) } returns Unit + + val result = action.testSetSdk(project, selectedPath, sdk) + + assertTrue(result is ConfigurePythonActionAbstract.SetSdkResult.Success) + assertEquals( + "module my-module", + (result as ConfigurePythonActionAbstract.SetSdkResult.Success).target, + ) + verify { ModuleRootModificationUtil.setModuleSdk(module, sdk) } + } + + class TestableConfigurePythonActionModule : ConfigurePythonActionModule() { + fun testSetSdk( + project: Project, + selectedPath: VirtualFile, + sdk: Sdk, + ): SetSdkResult = setSdk(project, selectedPath, sdk) + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProjectTest.kt b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProjectTest.kt new file mode 100644 index 0000000..4b91f6b --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProjectTest.kt @@ -0,0 +1,62 @@ +package com.github.pyvenvmanage.actions + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil +import com.intellij.openapi.vfs.VirtualFile + +class ConfigurePythonActionProjectTest { + private lateinit var action: TestableConfigurePythonActionProject + private lateinit var project: Project + private lateinit var selectedPath: VirtualFile + private lateinit var sdk: Sdk + + @BeforeEach + fun setUp() { + action = TestableConfigurePythonActionProject() + project = mockk(relaxed = true) + selectedPath = mockk(relaxed = true) + sdk = mockk(relaxed = true) + + mockkStatic(SdkConfigurationUtil::class) + every { project.name } returns "my-project" + } + + @AfterEach + fun tearDown() { + unmockkStatic(SdkConfigurationUtil::class) + } + + @Test + fun `setSdk sets project SDK and returns success`() { + every { SdkConfigurationUtil.setDirectoryProjectSdk(project, sdk) } returns Unit + + val result = action.testSetSdk(project, selectedPath, sdk) + + assertTrue(result is ConfigurePythonActionAbstract.SetSdkResult.Success) + assertEquals( + "project my-project", + (result as ConfigurePythonActionAbstract.SetSdkResult.Success).target, + ) + verify { SdkConfigurationUtil.setDirectoryProjectSdk(project, sdk) } + } + + class TestableConfigurePythonActionProject : ConfigurePythonActionProject() { + fun testSetSdk( + project: Project, + selectedPath: VirtualFile, + sdk: Sdk, + ): SetSdkResult = setSdk(project, selectedPath, sdk) + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/pages/ActionMenuFixture.kt b/src/test/kotlin/com/github/pyvenvmanage/pages/ActionMenuFixture.kt index 40f2889..c8f57fb 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/pages/ActionMenuFixture.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/pages/ActionMenuFixture.kt @@ -1,4 +1,8 @@ package com.github.pyvenvmanage.pages + +import java.time.Duration +import java.time.Duration.ofSeconds + import com.intellij.remoterobot.RemoteRobot import com.intellij.remoterobot.data.RemoteComponent import com.intellij.remoterobot.fixtures.ComponentFixture @@ -6,27 +10,25 @@ import com.intellij.remoterobot.fixtures.FixtureName import com.intellij.remoterobot.search.locators.byXpath import com.intellij.remoterobot.utils.waitFor -fun RemoteRobot.actionMenu(text: String): ActionMenuFixture { - val xpath = byXpath("text '$text'", "//div[@class='ActionMenu' and @text='$text']") - waitFor { - findAll(xpath).isNotEmpty() - } - return findAll(xpath).first() -} - -fun RemoteRobot.actionMenuItem(text: String): ActionMenuItemFixture { +fun RemoteRobot.actionMenuItem( + text: String, + timeout: Duration = ofSeconds(30), +): ActionMenuItemFixture { val xpath = byXpath("text '$text'", "//div[@class='ActionMenuItem' and @text='$text']") - waitFor { + waitFor(timeout) { findAll(xpath).isNotEmpty() } return findAll(xpath).first() } -@FixtureName("ActionMenu") -class ActionMenuFixture( - remoteRobot: RemoteRobot, - remoteComponent: RemoteComponent, -) : ComponentFixture(remoteRobot, remoteComponent) +fun RemoteRobot.hasActionMenuItem(text: String): Boolean { + val xpath = byXpath("text '$text'", "//div[@class='ActionMenuItem' and @text='$text']") + return findAll(xpath).isNotEmpty() +} + +fun RemoteRobot.pressEscape() { + runJs("robot.pressAndReleaseKey(java.awt.event.KeyEvent.VK_ESCAPE)") +} @FixtureName("ActionMenuItem") class ActionMenuItemFixture( diff --git a/src/test/kotlin/com/github/pyvenvmanage/pages/IdeaFrame.kt b/src/test/kotlin/com/github/pyvenvmanage/pages/IdeaFrame.kt index 5b4d468..1e6174d 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/pages/IdeaFrame.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/pages/IdeaFrame.kt @@ -8,10 +8,7 @@ import com.intellij.remoterobot.fixtures.CommonContainerFixture import com.intellij.remoterobot.fixtures.ContainerFixture import com.intellij.remoterobot.fixtures.DefaultXpath import com.intellij.remoterobot.fixtures.FixtureName -import com.intellij.remoterobot.fixtures.JMenuBarFixture import com.intellij.remoterobot.search.locators.byXpath -import com.intellij.remoterobot.stepsProcessing.step -import com.intellij.remoterobot.utils.waitFor fun RemoteRobot.idea(function: IdeaFrame.() -> Unit) { find(timeout = Duration.ofSeconds(10)).apply(function) @@ -24,34 +21,11 @@ class IdeaFrame( remoteComponent: RemoteComponent, ) : CommonContainerFixture(remoteRobot, remoteComponent) { val projectViewTree - get() = find(byXpath("ProjectViewTree", "//div[@class='ProjectViewTree']")) - - val projectName - get() = step("Get project name") { return@step callJs("component.getProject().getName()") } - - val menuBar: JMenuBarFixture get() = - step("Menu...") { - return@step remoteRobot.find(JMenuBarFixture::class.java, JMenuBarFixture.byType()) - } - - @JvmOverloads - fun dumbAware( - timeout: Duration = Duration.ofMinutes(5), - function: () -> Unit, - ) { - step("Wait for smart mode") { - waitFor(duration = timeout, interval = Duration.ofSeconds(5)) { - runCatching { isDumbMode().not() }.getOrDefault(false) - } - function() - step("..wait for smart mode again") { - waitFor(duration = timeout, interval = Duration.ofSeconds(5)) { - isDumbMode().not() - } - } - } - } + find( + byXpath("ProjectViewTree", "//div[@class='MyProjectViewTree']"), + Duration.ofSeconds(30), + ) fun isDumbMode(): Boolean = callJs( @@ -66,35 +40,4 @@ class IdeaFrame( """, true, ) - - fun openFile(path: String) { - runJs( - """ - importPackage(com.intellij.openapi.fileEditor) - importPackage(com.intellij.openapi.vfs) - importPackage(com.intellij.openapi.wm.impl) - importClass(com.intellij.openapi.application.ApplicationManager) - - const path = '$path' - const frameHelper = ProjectFrameHelper.getFrameHelper(component) - if (frameHelper) { - const project = frameHelper.getProject() - const projectPath = project.getBasePath() - const file = LocalFileSystem.getInstance().findFileByPath(projectPath + '/' + path) - const openFileFunction = new Runnable({ - run: function() { - FileEditorManager.getInstance(project).openTextEditor( - new OpenFileDescriptor( - project, - file - ), true - ) - } - }) - ApplicationManager.getApplication().invokeLater(openFileFunction) - } - """, - true, - ) - } } diff --git a/src/test/kotlin/com/github/pyvenvmanage/pages/WelcomeFrame.kt b/src/test/kotlin/com/github/pyvenvmanage/pages/WelcomeFrame.kt index 3d1b66a..cb36824 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/pages/WelcomeFrame.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/pages/WelcomeFrame.kt @@ -4,8 +4,8 @@ import java.time.Duration import com.intellij.remoterobot.RemoteRobot import com.intellij.remoterobot.data.RemoteComponent -import com.intellij.remoterobot.fixtures.ActionLinkFixture import com.intellij.remoterobot.fixtures.CommonContainerFixture +import com.intellij.remoterobot.fixtures.ComponentFixture import com.intellij.remoterobot.fixtures.DefaultXpath import com.intellij.remoterobot.fixtures.FixtureName import com.intellij.remoterobot.search.locators.byXpath @@ -20,7 +20,9 @@ class WelcomeFrame( remoteRobot: RemoteRobot, remoteComponent: RemoteComponent, ) : CommonContainerFixture(remoteRobot, remoteComponent) { - private val xpath = "//div[@myiconbutton='createNewProjectTabSelected.svg']" - val createNewProjectLink: ActionLinkFixture get() = actionLink(byXpath("New Project", xpath)) - val openLink: ActionLinkFixture get() = actionLink(byXpath("Open", "//div[@myiconbutton='openSelected.svg']")) + val openButton: ComponentFixture + get() = + find( + byXpath("//div[@class='LargeIconWithTextPanel']//div[@class='JButton' and @accessiblename='Open']"), + ) } diff --git a/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurableTest.kt b/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurableTest.kt new file mode 100644 index 0000000..a278f58 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurableTest.kt @@ -0,0 +1,421 @@ +package com.github.pyvenvmanage.settings + +import java.awt.BorderLayout +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.event.DocumentListener + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.CoroutineSupport +import com.intellij.ui.components.JBScrollPane + +class PyVenvManageConfigurableTest { + private lateinit var configurable: PyVenvManageConfigurable + private lateinit var settings: PyVenvManageSettings + private lateinit var application: Application + + @BeforeEach + fun setUp() { + application = mockk(relaxed = true) + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns application + every { application.getService(CoroutineSupport::class.java) } returns mockk(relaxed = true) + + settings = mockk(relaxed = true) + mockkObject(PyVenvManageSettings.Companion) + every { PyVenvManageSettings.getInstance() } returns settings + every { settings.prefix } returns " [" + every { settings.suffix } returns "]" + every { settings.separator } returns " - " + every { settings.fields } returns DecorationField.entries + + configurable = PyVenvManageConfigurable() + } + + @AfterEach + fun tearDown() { + unmockkObject(PyVenvManageSettings.Companion) + unmockkStatic(ApplicationManager::class) + } + + @Test + fun `getDisplayName returns correct name`() { + assertEquals("PyVenv Manage", configurable.displayName) + } + + @Test + fun `createComponent returns non-null panel`() { + val component = configurable.createComponent() + assertNotNull(component) + } + + @Test + fun `isModified returns false when no changes`() { + configurable.createComponent() + + assertFalse(configurable.isModified) + } + + @Test + fun `isModified returns true when prefix changed`() { + configurable.createComponent() + + every { settings.prefix } returns "(" + + assertTrue(configurable.isModified) + } + + @Test + fun `isModified returns true when suffix changed`() { + configurable.createComponent() + + every { settings.suffix } returns ")" + + assertTrue(configurable.isModified) + } + + @Test + fun `isModified returns true when separator changed`() { + configurable.createComponent() + + every { settings.separator } returns " | " + + assertTrue(configurable.isModified) + } + + @Test + fun `isModified returns true when fields changed`() { + configurable.createComponent() + + every { settings.fields } returns listOf(DecorationField.VERSION) + + assertTrue(configurable.isModified) + } + + @Test + fun `apply updates settings`() { + configurable.createComponent() + + configurable.apply() + + verify { settings.prefix = any() } + verify { settings.suffix = any() } + verify { settings.separator = any() } + verify { settings.fields = any() } + } + + @Test + fun `reset loads settings into UI`() { + every { settings.prefix } returns "(" + every { settings.suffix } returns ")" + every { settings.separator } returns ":" + every { settings.fields } returns listOf(DecorationField.VERSION, DecorationField.IMPLEMENTATION) + + configurable.createComponent() + + assertFalse(configurable.isModified) + } + + @Test + fun `reset with partial fields shows all fields with correct enabled state`() { + every { settings.fields } returns listOf(DecorationField.VERSION) + + configurable.createComponent() + + assertFalse(configurable.isModified) + } + + @Test + fun `disposeUIResources clears references`() { + configurable.createComponent() + configurable.disposeUIResources() + + configurable.apply() + configurable.reset() + } + + @Test + fun `isModified returns false after reset`() { + configurable.createComponent() + configurable.reset() + + assertFalse(configurable.isModified) + } + + @Test + fun `apply then isModified returns false`() { + configurable.createComponent() + configurable.apply() + + assertFalse(configurable.isModified) + } + + @Test + fun `reset with empty fields list`() { + every { settings.fields } returns emptyList() + + configurable.createComponent() + + assertFalse(configurable.isModified) + } + + @Test + fun `multiple createComponent calls work`() { + configurable.createComponent() + configurable.disposeUIResources() + val component = configurable.createComponent() + + assertNotNull(component) + } + + @Test + fun `updatePreview shows sample decoration`() { + configurable.createComponent() + + assertNotNull(configurable.previewField?.text) + assertTrue(configurable.previewField?.text?.contains(".venv") == true) + } + + @Test + fun `updatePreview with no fields shows just folder name`() { + every { settings.fields } returns emptyList() + configurable.createComponent() + + assertEquals(".venv", configurable.previewField?.text) + } + + @Test + fun `moveSelectedField does nothing when no selection`() { + configurable.createComponent() + configurable.fieldsList?.clearSelection() + + configurable.moveSelectedField(-1) + } + + @Test + fun `moveSelectedField does nothing when at boundary`() { + configurable.createComponent() + configurable.fieldsList?.selectedIndex = 0 + + configurable.moveSelectedField(-1) + } + + @Test + fun `moveSelectedField swaps items`() { + configurable.createComponent() + val list = configurable.fieldsList!! + list.selectedIndex = 1 + + configurable.moveSelectedField(-1) + + assertEquals(0, list.selectedIndex) + } + + @Test + fun `moveSelectedField down at last position does nothing`() { + configurable.createComponent() + val list = configurable.fieldsList!! + list.selectedIndex = list.itemsCount - 1 + + configurable.moveSelectedField(1) + + assertEquals(list.itemsCount - 1, list.selectedIndex) + } + + @Test + fun `updatePreview before createComponent does nothing`() { + configurable.updatePreview() + } + + @Test + fun `moveSelectedField before createComponent does nothing`() { + configurable.moveSelectedField(1) + } + + @Test + fun `document insert triggers preview update`() { + configurable.createComponent() + + configurable.prefixField?.document?.insertString(0, "x", null) + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `document remove triggers preview update`() { + configurable.createComponent() + + configurable.prefixField?.document?.remove(0, 1) + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `checkbox toggle triggers preview update`() { + configurable.createComponent() + val list = configurable.fieldsList!! + + list.setItemSelected(list.getItemAt(0), false) + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `checkbox list shows secondary text for each field`() { + configurable.createComponent() + val list = configurable.fieldsList!! + + for (i in 0 until list.itemsCount) { + val item = list.getItemAt(i) + assertNotNull(item) + } + } + + @Test + fun `getFieldExample returns example for VERSION`() { + assertEquals("e.g., 3.14.2", getFieldExample(DecorationField.VERSION)) + } + + @Test + fun `getFieldExample returns example for IMPLEMENTATION`() { + assertEquals("e.g., CPython", getFieldExample(DecorationField.IMPLEMENTATION)) + } + + @Test + fun `getFieldExample returns example for SYSTEM`() { + assertEquals("shows SYSTEM", getFieldExample(DecorationField.SYSTEM)) + } + + @Test + fun `getFieldExample returns example for CREATOR`() { + assertEquals("e.g., uv@0.9.21", getFieldExample(DecorationField.CREATOR)) + } + + @Test + fun `getFieldExample returns null for null`() { + assertEquals(null, getFieldExample(null)) + } + + @Test + fun `move up button click triggers move and preview update`() { + configurable.createComponent() + val list = configurable.fieldsList!! + list.selectedIndex = 1 + + val component = configurable.createComponent() + val listPanel = findListPanel(component) + val buttonsPanel = listPanel.getComponent(1) as JPanel + val moveUpButton = buttonsPanel.getComponent(0) as JButton + + moveUpButton.doClick() + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `move down button click triggers move and preview update`() { + configurable.createComponent() + val list = configurable.fieldsList!! + list.selectedIndex = 0 + + val component = configurable.createComponent() + val listPanel = findListPanel(component) + val buttonsPanel = listPanel.getComponent(1) as JPanel + val moveDownButton = buttonsPanel.getComponent(1) as JButton + + moveDownButton.doClick() + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `checkbox list listener triggers preview update on toggle`() { + configurable.createComponent() + val list = configurable.fieldsList!! + + var listenerFieldName: String? = null + var targetClass: Class<*>? = list.javaClass + while (targetClass != null && listenerFieldName == null) { + for (field in targetClass.declaredFields) { + if (field.type == com.intellij.ui.CheckBoxListListener::class.java) { + listenerFieldName = field.name + break + } + } + targetClass = targetClass.superclass + } + + if (listenerFieldName != null) { + val listenerField = list.javaClass.superclass.getDeclaredField(listenerFieldName) + listenerField.isAccessible = true + val listener = listenerField.get(list) as? com.intellij.ui.CheckBoxListListener + listener?.checkBoxSelectionChanged(0, false) + } + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `changedUpdate triggers preview update`() { + configurable.createComponent() + + configurable.prefixField?.document?.let { doc -> + val listenerList = (doc as javax.swing.text.AbstractDocument).documentListeners + listenerList.filterIsInstance().forEach { + val mockEvent = mockk(relaxed = true) + it.changedUpdate(mockEvent) + } + } + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `suffixField is accessible after createComponent`() { + configurable.createComponent() + + assertNotNull(configurable.suffixField) + configurable.suffixField?.text = "test" + assertEquals("test", configurable.suffixField?.text) + } + + @Test + fun `separatorField is accessible after createComponent`() { + configurable.createComponent() + + assertNotNull(configurable.separatorField) + configurable.separatorField?.text = "test" + assertEquals("test", configurable.separatorField?.text) + } + + private fun findListPanel(component: JComponent): JPanel { + val formPanel = component as JPanel + for (i in 0 until formPanel.componentCount) { + val child = formPanel.getComponent(i) + if (child is JPanel && child.layout is BorderLayout) { + val center = (child.layout as BorderLayout).getLayoutComponent(child, BorderLayout.CENTER) + if (center is JBScrollPane) { + return child + } + } + } + throw IllegalStateException("List panel not found") + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettingsTest.kt b/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettingsTest.kt new file mode 100644 index 0000000..78b7855 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettingsTest.kt @@ -0,0 +1,190 @@ +package com.github.pyvenvmanage.settings + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager + +import com.github.pyvenvmanage.VenvInfo + +class PyVenvManageSettingsTest { + private lateinit var settings: PyVenvManageSettings + + @BeforeEach + fun setUp() { + settings = PyVenvManageSettings() + } + + @Test + fun `default prefix is space bracket`() { + assertEquals(" [", settings.prefix) + } + + @Test + fun `default suffix is close bracket`() { + assertEquals("]", settings.suffix) + } + + @Test + fun `default separator is dash with spaces`() { + assertEquals(" - ", settings.separator) + } + + @Test + fun `default fields include all decoration fields in order`() { + assertEquals(DecorationField.entries, settings.fields) + } + + @Test + fun `prefix can be set`() { + settings.prefix = "(" + assertEquals("(", settings.prefix) + } + + @Test + fun `suffix can be set`() { + settings.suffix = ")" + assertEquals(")", settings.suffix) + } + + @Test + fun `separator can be set`() { + settings.separator = " | " + assertEquals(" | ", settings.separator) + } + + @Test + fun `fields can be set`() { + settings.fields = listOf(DecorationField.VERSION, DecorationField.IMPLEMENTATION) + assertEquals(listOf(DecorationField.VERSION, DecorationField.IMPLEMENTATION), settings.fields) + } + + @Test + fun `formatDecoration with all fields`() { + val info = VenvInfo("3.11.0", "CPython", false, " - uv@0.9.18") + val result = settings.formatDecoration(info) + assertEquals(" [3.11.0 - CPython - uv@0.9.18]", result) + } + + @Test + fun `formatDecoration with version only`() { + settings.fields = listOf(DecorationField.VERSION) + val info = VenvInfo("3.11.0", "CPython", true, " - uv@0.9.18") + val result = settings.formatDecoration(info) + assertEquals(" [3.11.0]", result) + } + + @Test + fun `formatDecoration with null implementation skips it`() { + val info = VenvInfo("3.11.0", null, false, null) + val result = settings.formatDecoration(info) + assertEquals(" [3.11.0]", result) + } + + @Test + fun `formatDecoration with system site packages`() { + val info = VenvInfo("3.11.0", "CPython", true, null) + val result = settings.formatDecoration(info) + assertEquals(" [3.11.0 - CPython - SYSTEM]", result) + } + + @Test + fun `formatDecoration with custom prefix suffix separator`() { + settings.prefix = "(" + settings.suffix = ")" + settings.separator = " | " + val info = VenvInfo("3.12.0", "PyPy", false, null) + val result = settings.formatDecoration(info) + assertEquals("(3.12.0 | PyPy)", result) + } + + @Test + fun `formatDecoration with reordered fields`() { + settings.fields = listOf(DecorationField.IMPLEMENTATION, DecorationField.VERSION) + val info = VenvInfo("3.11.0", "CPython", false, null) + val result = settings.formatDecoration(info) + assertEquals(" [CPython - 3.11.0]", result) + } + + @Test + fun `formatDecoration with empty fields returns empty string`() { + settings.fields = emptyList() + val info = VenvInfo("3.11.0", "CPython", true, " - uv@0.9.18") + val result = settings.formatDecoration(info) + assertEquals("", result) + } + + @Test + fun `formatDecoration strips creator prefix`() { + settings.fields = listOf(DecorationField.CREATOR) + val info = VenvInfo("3.11.0", null, false, " - virtualenv@20.35.4") + val result = settings.formatDecoration(info) + assertEquals(" [virtualenv@20.35.4]", result) + } + + @Test + fun `getState returns current state`() { + settings.prefix = "[" + settings.suffix = "]" + settings.separator = ":" + settings.fields = listOf(DecorationField.VERSION) + + val state = settings.state + + assertEquals("[", state.prefix) + assertEquals("]", state.suffix) + assertEquals(":", state.separator) + assertEquals(listOf("VERSION"), state.fields) + } + + @Test + fun `loadState updates settings`() { + val newState = + PyVenvManageSettings.SettingsState( + prefix = "<<", + suffix = ">>", + separator = " / ", + fields = listOf("IMPLEMENTATION", "VERSION"), + ) + + settings.loadState(newState) + + assertEquals("<<", settings.prefix) + assertEquals(">>", settings.suffix) + assertEquals(" / ", settings.separator) + assertEquals(listOf(DecorationField.IMPLEMENTATION, DecorationField.VERSION), settings.fields) + } + + @Test + fun `fields getter ignores invalid field names`() { + val state = + PyVenvManageSettings.SettingsState( + fields = listOf("VERSION", "INVALID_FIELD", "IMPLEMENTATION"), + ) + settings.loadState(state) + + assertEquals(listOf(DecorationField.VERSION, DecorationField.IMPLEMENTATION), settings.fields) + } + + @Test + fun `getInstance returns settings instance`() { + val application: Application = mockk(relaxed = true) + val mockSettings: PyVenvManageSettings = mockk(relaxed = true) + + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns application + every { application.getService(PyVenvManageSettings::class.java) } returns mockSettings + + val instance = PyVenvManageSettings.getInstance() + + assertNotNull(instance) + unmockkStatic(ApplicationManager::class) + } +}