Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/features/pythonApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from '../api';
import { traceError, traceInfo } from '../common/logging';
import { pickEnvironmentManager } from '../common/pickers/managers';
import { timeout } from '../common/utils/asyncUtils';
import { createDeferred } from '../common/utils/deferred';
import { checkUri } from '../common/utils/pathUtils';
import { handlePythonPath } from '../common/utils/pythonPath';
Expand All @@ -44,7 +45,6 @@ import {
PythonPackageImpl,
PythonProjectManager,
} from '../internal.api';
import { timeout } from '../common/utils/asyncUtils';
import { waitForAllEnvManagers, waitForEnvManager, waitForEnvManagerId } from './common/managerReady';
import { EnvVarManager } from './execution/envVariableManager';
import { runAsTask } from './execution/runAsTask';
Expand All @@ -58,12 +58,15 @@ import { TerminalManager } from './terminal/terminalManager';
const GET_ENVIRONMENT_TIMEOUT_MS = 1000;
const GET_ENVIRONMENT_TIMED_OUT = Symbol('getEnvironmentTimedOut');

class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we are exporting this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @edvilme! I exported PythonEnvironmentApiImpl so I could import and instantiate it directly in the new unit test file (src/test/features/pythonApi.unit.test.ts) to verify onDidChangePythonProjects. If you prefer a different factory/internal access pattern for unit testing this class, let me know and I'd be happy to adjust!

private readonly _onDidChangeEnvironments = new EventEmitter<DidChangeEnvironmentsEventArgs>();
private readonly _onDidChangeEnvironment = new EventEmitter<DidChangeEnvironmentEventArgs>();
private readonly _onDidChangePythonProjects = new EventEmitter<DidChangePythonProjectsEventArgs>();
private readonly _onDidChangePackages = new EventEmitter<DidChangePackagesEventArgs>();
private readonly _onDidChangeEnvironmentVariables = new EventEmitter<DidChangeEnvironmentVariablesEventArgs>();
// Tracks the last-known project set so we can compute an added/removed delta
// whenever the underlying project manager reports a change (fix for #1599).
private previousProjects: readonly PythonProject[] = [];

constructor(
private readonly envManagers: EnvironmentManagers,
Expand All @@ -73,6 +76,8 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
private readonly envVarManager: EnvVarManager,
private readonly disposables: Disposable[] = [],
) {
this.previousProjects = this.projectManager.getProjects();

this.disposables.push(
this._onDidChangeEnvironment,
this._onDidChangeEnvironments,
Expand All @@ -87,6 +92,26 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
);
}),
this.envVarManager.onDidChangeEnvironmentVariables((e) => this._onDidChangeEnvironmentVariables.fire(e)),
this.projectManager.onDidChangeProjects(() => {
const current = this.projectManager.getProjects();
const currentByUri = new Map(current.map((p) => [p.uri.toString(), p] as const));
const previousByUri = new Map(this.previousProjects.map((p) => [p.uri.toString(), p] as const));

const added = [...currentByUri.entries()]
.filter(([uri]) => !previousByUri.has(uri))
.map(([, project]) => project);
const removed = [...previousByUri.entries()]
.filter(([uri]) => !currentByUri.has(uri))
.map(([, project]) => project);

this.previousProjects = current;
if (added.length > 0 || removed.length > 0) {
traceInfo(
`Python API: Projects changed. Added: ${added.length}, Removed: ${removed.length}`,
);
this._onDidChangePythonProjects.fire({ added, removed });
}
}),
);
}

Expand Down
65 changes: 65 additions & 0 deletions src/test/features/pythonApi.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as assert from 'assert';
import { EventEmitter, Uri } from 'vscode';
import { PythonProject } from '../../api';
import { PythonEnvironmentApiImpl } from '../../features/pythonApi';
import { PythonProjectManager } from '../../internal.api';

suite('PythonEnvironmentApiImpl - onDidChangePythonProjects', () => {
test('Fires event with correct added and removed projects', async () => {
// 1. Create a mock EventEmitter to simulate the internal project manager
const onDidChangeProjectsEmitter = new EventEmitter<void>();

// 2. Mock the PythonProjectManager
let currentProjects: PythonProject[] = [];
const mockProjectManager = {
getProjects: () => currentProjects,
onDidChangeProjects: onDidChangeProjectsEmitter.event,
} as unknown as PythonProjectManager;

// 3. Mock the other required constructor arguments using ConstructorParameters
type ApiArgs = ConstructorParameters<typeof PythonEnvironmentApiImpl>;

const mockEnvManagers = { onDidChangeActiveEnvironment: new EventEmitter().event } as unknown as ApiArgs[0];
const mockProjectCreators = {} as unknown as ApiArgs[2];
const mockTerminalManager = {} as unknown as ApiArgs[3];
const mockEnvVarManager = { onDidChangeEnvironmentVariables: new EventEmitter().event } as unknown as ApiArgs[4];

// 4. Initialize the API instance
const api = new PythonEnvironmentApiImpl(
mockEnvManagers,
mockProjectManager,
mockProjectCreators,
mockTerminalManager,
mockEnvVarManager
);

// 5. Listen to the public event we are testing
let firedEventPayload: unknown = null;
api.onDidChangePythonProjects((e: unknown) => {
firedEventPayload = e;
});

// 6. Simulate adding a project
const newProject = { uri: Uri.joinPath(Uri.file(process.cwd()), 'fake', 'path') } as unknown as PythonProject;
currentProjects = [newProject]; // Update the mock's state

// Fire the internal event
onDidChangeProjectsEmitter.fire();

// 7. Assert the public event fired with the correct delta
assert.ok(firedEventPayload, 'Event should have fired');
assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added.length, 1, 'Should have 1 added project');
assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added[0].uri.fsPath, newProject.uri.fsPath);
assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed.length, 0, 'Should have 0 removed projects');

// 8. Simulate removing the project
firedEventPayload = null;
currentProjects = [];
onDidChangeProjectsEmitter.fire();

assert.ok(firedEventPayload, 'Event should have fired');
assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added.length, 0, 'Should have 0 added projects');
assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed.length, 1, 'Should have 1 removed project');
assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed[0].uri.fsPath, newProject.uri.fsPath);
});
});
Loading