diff --git a/docs/Development.md b/docs/Development.md index d6b4566..f4a0762 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -31,7 +31,7 @@ Two reports point at what to fix next: lint findings and unit-test coverage gaps - Run `just coverage`, or `npm run test:coverage`. It prints a table per `src/*.ts` file: statement, branch, function, and line coverage, plus the uncovered line numbers. - `coverage/index.html` shows each file with its uncovered lines highlighted. `coverage/lcov.info` and `coverage/coverage-summary.json` serve other tools. -- Only unit tests are measured. VS Code adapters such as `testController.ts` and `main.ts` show 0% until their logic moves into tested modules or integration tests cover it. +- Only unit tests are measured. The VS Code adapters, `testController.ts`, `problemMatcher.ts`, `main.ts`, and `logger.ts`, show 0%. Their decisions live in tested pure modules, and what remains is wiring to the editor. The unit tests compile into `out-test/`, apart from the extension bundle in `out/`. The test build compiles every file in `src/`, so a file no test touches still appears in the coverage table. @@ -46,13 +46,13 @@ The extension connects Ceedling’s command line to VS Code’s native Testing A ### Orientation -The code has two layers. Most modules have no `vscode` dependency and are unit-tested in plain Node. A thin layer of VS Code adapters wires them to the editor. +The code has two layers. Most modules have no `vscode` dependency and are unit-tested in plain Node. A thin layer of VS Code adapters wires them to the editor. A decision an adapter makes belongs in a pure module, where a unit test can reach it. | Module | Layer | Responsibility | |---|---|---| | `src/main.ts` | VS Code | Activation, commands, one controller per workspace folder | -| `src/testController.ts` | VS Code | TestItems, run and debug profiles, reporting into a `TestRun` | -| `src/problemMatcher.ts` | VS Code | The Problems panel’s `DiagnosticCollection`, keyed by run scope | +| `src/testController.ts` | VS Code | TestItems, run and debug profiles, watchers, and the `TestRun` sink | +| `src/problemMatcher.ts` | VS Code | Publishes diagnostics to the Problems panel’s `DiagnosticCollection` | | `src/logger.ts` | VS Code | The “Ceedling Explorer” log output channel | | `src/ceedlingEngine.ts` | Pure | Orchestration: discover, run, debug build, clean, clobber | | `src/ceedlingProjectConfig.ts` | Pure | Settings → projects; resolved configuration → `ProjectState` | @@ -60,7 +60,11 @@ The code has two layers. Most modules have no `vscode` dependency and are unit-t | `src/unityTestDiscovery.ts` | Pure | Test functions and cases in source, named as Unity names them | | `src/testResults.ts` | Pure | The CppUnit report, mapped onto discovered ids | | `src/runPlan.ts` | Pure | A run request → the fewest Ceedling invocations | -| `src/testModel.ts` | Pure | The test tree types and the `RunReporter` interface | +| `src/testModel.ts` | Pure | The test tree types, the `RunReporter` interface, and the controller’s label | +| `src/testReporting.ts` | Pure | A `RunReporter` that resolves ids to items and builds plain messages for a `RunSink` | +| `src/debugLaunch.ts` | Pure | The file a debug request debugs, and its launch configuration | +| `src/watchPolicy.ts` | Pure | What settings changes, file events, and edits make the controller do | +| `src/diagnosticsStore.ts` | Pure | Each run scope’s diagnostics, shown by file without duplicates | | `src/singleFlight.ts` | Pure | Coalesces overlapping discovery requests into one rerun | | `src/concurrency.ts` | Pure | Grouped concurrent mapping, a lock per key, and a limit shared by every caller | | `src/ceedlingOutputParsing.ts` | Pure | Ceedling stdout, report names, and pretty labels | @@ -88,9 +92,9 @@ The engine replaces its `projects` map only after every project loads. A failed The controller keeps the tree current without polling. Its refreshes go through a `SingleFlight`, so startup, watchers, and settings changes arriving together cause one discovery plus at most one rerun. - **Configuration files.** `CeedlingEngine.configurationFiles()` lists every file a project’s configuration can come from, existing or not: both default project files, any alternate project file, and file-path mixins. A `FileSystemWatcher` on each triggers discovery, so creating a project file in an empty folder starts one. -- **Test sources.** A watcher on each project directory’s `**/*.c` reparses a changed test file alone, through `rediscoverFile()`, with no Ceedling call. A created file that `mayBeTestFile()` accepts, or a deleted test file, triggers discovery, since only `ceedling files:test` knows the full list. -- **Unsaved edits.** `onDidChangeTextDocument` reparses an open test file from its buffer once typing pauses. -- **Settings.** A change to a setting in `DISCOVERY_SETTINGS` triggers discovery. Others apply on the next run. +- **Test sources.** A watcher on each project directory’s `**/*.c` reparses a changed test file alone, through `rediscoverFile()`, with no Ceedling call. A created file that `mayBeTestFile()` accepts, or a deleted test file, triggers discovery, since only `ceedling files:test` knows the full list. `sourceEventEffect()` makes that choice. +- **Unsaved edits.** `onDidChangeTextDocument` reparses an open test file from its buffer once typing pauses, through a `Debouncer` keyed by file. +- **Settings.** `settingsChangeEffects()` decides. A change to a setting that shapes discovery, such as `projects` or `mixins`, triggers discovery. Turning problem matching off clears its diagnostics. Others apply on the next run. The TestController itself exists only while discovery finds a project, so a folder without one adds nothing to the Testing view. @@ -100,7 +104,7 @@ A run turns the request into a plan, then reports each plan entry’s results. - **Planning.** `buildRunPlan(projects, include, exclude)` returns `RunPlanEntry`s of three kinds. A `project` entry runs `test:all`. A `file` entry runs `test:`. A `function` entry adds `--test-case=`. Excluded items narrow the plan to function granularity at most. The reporter drops their results. - **Running.** `runTests()` marks every id started, then takes its project directory’s lock. `buildAndRun()` deletes stale reports in `artifacts/test` and `artifacts/gcov`, runs Ceedling, appends the transcript to the run’s output, updates diagnostics, and reads the newest report. -- **Reporting.** `testResults(report, leafIds)` yields `passed`, `skipped`, `failed`, and `unreported` results. An `unreported` id gets an error naming the likely cause: discovery and Ceedling disagree about its name. With no report at all, every id errors, and the most severe compiler diagnostic leads the message. +- **Reporting.** `testResults(report, leafIds)` yields `passed`, `skipped`, `failed`, and `unreported` results. An `unreported` id gets an error naming the likely cause: discovery and Ceedling disagree about its name. With no report at all, every id errors, and the most severe compiler diagnostic leads the message. The controller’s reporter, from `createRunReporter()`, maps each id to its TestItem. It drops results for excluded items and, with a warning, for unknown ids, and turns a crash’s gdb log path into a link. - **Edge cases.** A `--test-case` run whose report also names another function, from Ceedling’s substring filter, reruns the whole file. A cancelled run kills only its own process tree, through `CeedlingRunner`, and marks its tests skipped. **One Ceedling command per project directory at a time.** Every Ceedling command that loads a project, `dumpconfig` and `files:test` included, rewrites the vendored Unity and CMock sources under `build/vendor/` in place. A second command in the same directory can leave a build compiling a half-written header. So `dumpconfig`, `files:test`, runs, debug builds, and clean/clobber each hold their directory’s lock (`KeyedLock`, via `inDirectory()`). `ceedling version` loads no project and needs no lock. Different directories proceed independently, so builds in separate projects may overlap. @@ -109,7 +113,7 @@ Diagnostics are keyed by run scope: the project key for a project run, `projectK ### Debugging -`handleDebug()` debugs one test file. `compileForDebug()` runs the file exactly as a run does and reports its real results. It then returns the executable’s path relative to Ceedling’s test output directory, `name/name`. `resolveLaunchConfig()` splices that path into every `${command:ceedlingExplorer.debugTestExecutable}` in the named launch configuration. A unique `__ceedlingDebugRequest` value in that configuration identifies its debug session among any others. +`handleDebug()` debugs one test file. `compileForDebug()` runs the file exactly as a run does and reports its real results. It then returns the executable’s path relative to Ceedling’s test output directory, `name/name`. `resolveLaunchConfig()` in `debugLaunch.ts` splices that path into every `${command:ceedlingExplorer.debugTestExecutable}` in a copy of the named launch configuration. A unique `__ceedlingDebugRequest` value in that configuration identifies its debug session among any others. ### Test seams diff --git a/src/debugLaunch.ts b/src/debugLaunch.ts new file mode 100644 index 0000000..d125080 --- /dev/null +++ b/src/debugLaunch.ts @@ -0,0 +1,46 @@ +// What a Testing view debug request debugs, and the launch configuration that debugs it. It has +// no `vscode` dependency. The controller starts the debug session. + +import { type RunPlanEntry } from './runPlan'; +import { type CeedlingFileNode } from './testModel'; + +// The launch.json token that stands for the built test executable, relative to Ceedling's test +// output directory. +const EXECUTABLE_TOKEN = /\$\{command:ceedlingExplorer\.debugTestExecutable\}/g; + +// Ceedling builds a whole test file into one executable, so a request covering several files +// debugs the first. `severalFiles` says the rest were left out. +export function debugTarget(plan: readonly RunPlanEntry[]): { file: CeedlingFileNode, severalFiles: boolean } | undefined { + const files = plan.flatMap((entry) => entry.kind === 'project' ? entry.files : [entry.file]); + const file = files[0]; + if (!file) { + return undefined; + } + return { file, severalFiles: new Set(files.map((f) => `${f.projectKey}::${f.id}`)).size > 1 }; +} + +// A copy of the named configuration with the executable's path spliced into every occurrence of +// the token. VS Code never resolves the token itself, so debugging doesn't depend on when it +// would. undefined when launch.json has no configuration of that name. +export function resolveLaunchConfig(configurations: readonly Config[], name: string, executableSubpath: string): Config | undefined { + const found = configurations.find((c) => c.name === name); + if (!found) { + return undefined; + } + return splice(found, executableSubpath) as Config; +} + +// A deep copy with the token replaced in every string. Replacing within strings, rather than in +// serialized JSON, keeps a backslash or quote in the path from being read as JSON escaping. +function splice(value: unknown, executableSubpath: string): unknown { + if (typeof value === 'string') { + return value.replace(EXECUTABLE_TOKEN, () => executableSubpath); + } + if (Array.isArray(value)) { + return value.map((element) => splice(element, executableSubpath)); + } + if (typeof value === 'object' && value !== null) { + return Object.fromEntries(Object.entries(value).map(([key, element]) => [key, splice(element, executableSubpath)])); + } + return value; +} diff --git a/src/diagnosticsStore.ts b/src/diagnosticsStore.ts new file mode 100644 index 0000000..3b2b1db --- /dev/null +++ b/src/diagnosticsStore.ts @@ -0,0 +1,50 @@ +// The diagnostics each run reported, kept by id: a project, or one of its files. The Problems +// panel shows their union, by file, without duplicates, since a project run and a file run can +// report the same compiler error. It has no `vscode` dependency. ProblemMatcher publishes +// `byFile()` to a DiagnosticCollection. +export class DiagnosticsStore { + private readonly entries = new Map(); + + constructor(private readonly same: (a: D, b: D) => boolean) { } + + set(id: string, diagnostics: D[]): void { + this.entries.set(id, diagnostics); + } + + // Keeps only the diagnostics under `id` that `keep` accepts. False when `id` has none. + retain(id: string, keep: (diagnostic: D) => boolean): boolean { + const current = this.entries.get(id); + if (!current) { + return false; + } + this.entries.set(id, current.filter(keep)); + return true; + } + + // Keeps only the ids `keep` accepts, e.g. dropping a deleted test file's entry. + retainIds(keep: (id: string) => boolean): void { + for (const id of [...this.entries.keys()]) { + if (!keep(id)) { + this.entries.delete(id); + } + } + } + + clear(): void { + this.entries.clear(); + } + + byFile(): Map { + const byFile = new Map(); + for (const diagnostics of this.entries.values()) { + for (const diagnostic of diagnostics) { + const shown = byFile.get(diagnostic.file) ?? []; + if (!shown.some((existing) => this.same(existing, diagnostic))) { + shown.push(diagnostic); + } + byFile.set(diagnostic.file, shown); + } + } + return byFile; + } +} diff --git a/src/main.ts b/src/main.ts index d10f971..9b9254a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import vscode from 'vscode'; -import { CeedlingTestController } from './testController'; import { Logger } from './logger'; +import { CeedlingTestController } from './testController'; +import { controllerLabel } from './testModel'; const logger = new Logger(); // Keyed by folder URI. VS Code reuses folder objects in practice but doesn't promise to. @@ -8,9 +9,8 @@ const controllers = new Map(); let isDevelopmentMode = false; let extensionPath = ''; -// The Testing view names each folder's tests when there is more than one folder. function labelFor(workspaceFolder: vscode.WorkspaceFolder): string { - return (vscode.workspace.workspaceFolders?.length ?? 0) > 1 ? `Ceedling (${workspaceFolder.name})` : 'Ceedling'; + return controllerLabel(workspaceFolder.name, vscode.workspace.workspaceFolders?.length ?? 0); } function addWorkspaceFolder(workspaceFolder: vscode.WorkspaceFolder): void { diff --git a/src/problemMatcher.ts b/src/problemMatcher.ts index 2ff3bf6..934a402 100644 --- a/src/problemMatcher.ts +++ b/src/problemMatcher.ts @@ -15,6 +15,7 @@ import { type ProblemMatchingPattern, } from './problemMatching'; import util from 'util'; +import { DiagnosticsStore } from './diagnosticsStore'; import { type Logger } from './logger'; // A Diagnostic with the absolute path of the file it belongs to. @@ -39,11 +40,11 @@ function toFileDiagnostic(problem: MatchedProblem): FileDiagnostic { } // The Problems panel side of problem matching. Pattern matching itself lives in the pure -// problemMatching.ts. This class holds the vscode state: the DiagnosticCollection, and each id's -// diagnostics. An id is whatever a run keys its diagnostics under, a project or one of its files. -// The panel shows the union of every id's diagnostics, without duplicates. +// problemMatching.ts, and each id's diagnostics in DiagnosticsStore. An id is whatever a run keys +// its diagnostics under, a project or one of its files. This class publishes the store to the +// DiagnosticCollection after every change. export class ProblemMatcher { - private readonly entries = new Map(); + private readonly store = new DiagnosticsStore((a, b) => sameDiagnostic(a.diagnostic, b.diagnostic)); private readonly diagnosticCollection: DiagnosticCollection; constructor(private readonly logger: Logger) { @@ -76,31 +77,25 @@ export class ProblemMatcher { } set(id: string, diagnostics: FileDiagnostic[]): void { - this.entries.set(id, diagnostics); + this.store.set(id, diagnostics); this.publish(); } // Keeps only the diagnostics under `id` that `keep` accepts. retain(id: string, keep: (diagnostic: FileDiagnostic) => boolean): void { - const current = this.entries.get(id); - if (current) { - this.entries.set(id, current.filter(keep)); + if (this.store.retain(id, keep)) { this.publish(); } } // Keeps only the ids `keep` accepts, e.g. dropping a deleted test file's entry. retainIds(keep: (id: string) => boolean): void { - for (const id of [...this.entries.keys()]) { - if (!keep(id)) { - this.entries.delete(id); - } - } + this.store.retainIds(keep); this.publish(); } clear(): void { - this.entries.clear(); + this.store.clear(); this.diagnosticCollection.clear(); } @@ -110,19 +105,9 @@ export class ProblemMatcher { } private publish(): void { - const byFile = new Map(); - for (const fileDiagnostics of this.entries.values()) { - for (const { file, diagnostic } of fileDiagnostics) { - const diagnostics = byFile.get(file) ?? []; - if (!diagnostics.some((existing) => sameDiagnostic(existing, diagnostic))) { - diagnostics.push(diagnostic); - } - byFile.set(file, diagnostics); - } - } this.diagnosticCollection.clear(); - for (const [file, diagnostics] of byFile) { - this.diagnosticCollection.set(Uri.file(file), diagnostics); + for (const [file, diagnostics] of this.store.byFile()) { + this.diagnosticCollection.set(Uri.file(file), diagnostics.map((d) => d.diagnostic)); } } } diff --git a/src/testController.ts b/src/testController.ts index 2807e65..8f9bb76 100644 --- a/src/testController.ts +++ b/src/testController.ts @@ -3,18 +3,18 @@ import path from 'path'; import util from 'util'; import vscode from 'vscode'; import { CeedlingEngine, CeedlingLoadError } from './ceedlingEngine'; -import { extractGdbLogReference } from './ceedlingOutputParsing'; +import { debugTarget, resolveLaunchConfig } from './debugLaunch'; import { type Logger } from './logger'; import { ProblemMatcher } from './problemMatcher'; import { buildRunPlan, type PlanTarget, type RunPlanEntry } from './runPlan'; import { SingleFlight } from './singleFlight'; import { type CeedlingFileNode, type CeedlingProjectNode, type CeedlingTestFunction, type RunReporter } from './testModel'; +import { createRunReporter, type ReportMessage } from './testReporting'; +import { Debouncer, settingsChangeEffects, sourceEventEffect, watcherChanges } from './watchPolicy'; // Names the launch configuration of one debug request, so its debug session can be told apart // from any other session VS Code starts meanwhile. const DEBUG_REQUEST_KEY = '__ceedlingDebugRequest'; -// Settings whose change changes what discovery finds. The rest apply on the next run. -const DISCOVERY_SETTINGS = ['projects', 'mixins', 'shellPath', 'ceedlingCommand', 'prettyTestLabel', 'prettyTestFileLabel']; // How long typing pauses before an unsaved test file is reparsed. const EDIT_REPARSE_DELAY_MS = 400; @@ -33,7 +33,7 @@ export class CeedlingTestController { // project directory's C sources. private readonly configurationWatchers = new Map(); private readonly sourceWatchers = new Map(); - private readonly pendingReparses = new Map(); + private readonly editReparses = new Debouncer(EDIT_REPARSE_DELAY_MS); private readonly targets = new WeakMap(); // Item ids are unique only within a project, so lookups are scoped by project key. @@ -84,7 +84,7 @@ export class CeedlingTestController { dispose(): void { this.logger.debug(`CeedlingTestController disposed for ${this.workspaceFolder.uri.toString()}`); - this.pendingReparses.forEach((timer) => clearTimeout(timer)); + this.editReparses.dispose(); [...this.configurationWatchers.values(), ...this.sourceWatchers.values(), ...this.disposables].forEach((d) => { d.dispose(); }); this.disposeController(); this.engine.dispose(); @@ -119,19 +119,20 @@ export class CeedlingTestController { } private onConfigurationChanged(event: vscode.ConfigurationChangeEvent): void { - const affects = (setting: string) => event.affectsConfiguration(`ceedlingExplorer.${setting}`, this.workspaceFolder.uri); - if (affects('problemMatching') && this.engine.getConfiguration().get('problemMatching.mode', 'disabled') === 'disabled') { + const { clearDiagnostics, rediscoverFor } = settingsChangeEffects( + (setting) => event.affectsConfiguration(`ceedlingExplorer.${setting}`, this.workspaceFolder.uri), + () => this.engine.getConfiguration().get('problemMatching.mode', 'disabled'), + ); + if (clearDiagnostics) { this.logger.debug(`onConfigurationChanged: problem matching disabled, clearing diagnostics`); this.engine.problemMatcher.clear(); } - const changed = DISCOVERY_SETTINGS.filter(affects); - if (changed.length > 0) { - this.logger.debug(`onConfigurationChanged: ${changed.join(', ')} changed, refreshing`); + if (rediscoverFor.length > 0) { + this.logger.debug(`onConfigurationChanged: ${rediscoverFor.join(', ')} changed, refreshing`); void this.refresh(); } } - private async runTask(name: string, task: (token: vscode.CancellationToken) => Promise): Promise { this.logger.trace(`${name.toLowerCase()}()`); const succeeded = await vscode.window.withProgress( @@ -168,9 +169,8 @@ export class CeedlingTestController { await this.updateWatchers(); } - // Configuration files rediscover everything. A C source change in a project directory - // reparses that test file alone. A test file appearing or disappearing rediscovers, since only - // `ceedling files:test` knows the full list. + // Configuration files rediscover everything. C sources in a project directory follow + // sourceEventEffect. private async updateWatchers(): Promise { const configurationFiles = new Set(await this.engine.configurationFiles()); const projectDirectories = new Set(this.projects.map((p) => this.engine.getProjectAbsPath(p.projectKey))); @@ -185,15 +185,17 @@ export class CeedlingTestController { }); this.syncWatchers(this.sourceWatchers, projectDirectories, (directory) => new vscode.RelativePattern(vscode.Uri.file(directory), '**/*.c'), - (watcher) => [ - watcher.onDidChange((uri) => void this.reparse(uri.fsPath)), - watcher.onDidCreate((uri) => { - if (this.engine.mayBeTestFile(uri.fsPath)) void this.refresh(); - }), - watcher.onDidDelete((uri) => { - if (this.engine.testFileAt(uri.fsPath)) void this.refresh(); - }), - ]); + (watcher) => { + const onSource = (event: 'changed' | 'created' | 'deleted') => (uri: vscode.Uri) => { + const effect = sourceEventEffect(event, { + isTestFile: () => this.engine.testFileAt(uri.fsPath) !== undefined, + mayBeTestFile: () => this.engine.mayBeTestFile(uri.fsPath), + }); + if (effect === 'reparse') void this.reparse(uri.fsPath); + else if (effect === 'rediscover') void this.refresh(); + }; + return [watcher.onDidChange(onSource('changed')), watcher.onDidCreate(onSource('created')), watcher.onDidDelete(onSource('deleted'))]; + }); } private syncWatchers( @@ -201,17 +203,14 @@ export class CeedlingTestController { pattern: (key: string) => vscode.RelativePattern, listen: (watcher: vscode.FileSystemWatcher, key: string) => vscode.Disposable[], ): void { - for (const [key, watcher] of watchers) { - if (!wanted.has(key)) { - watcher.dispose(); - watchers.delete(key); - } + const { stale, added } = watcherChanges(watchers.keys(), wanted); + for (const key of stale) { + watchers.get(key)?.dispose(); + watchers.delete(key); } - for (const key of wanted) { - if (!watchers.has(key)) { - const watcher = vscode.workspace.createFileSystemWatcher(pattern(key)); - watchers.set(key, vscode.Disposable.from(watcher, ...listen(watcher, key))); - } + for (const key of added) { + const watcher = vscode.workspace.createFileSystemWatcher(pattern(key)); + watchers.set(key, vscode.Disposable.from(watcher, ...listen(watcher, key))); } } @@ -221,11 +220,7 @@ export class CeedlingTestController { if (document.uri.scheme !== 'file' || !this.engine.testFileAt(file)) { return; } - clearTimeout(this.pendingReparses.get(file)); - this.pendingReparses.set(file, setTimeout(() => { - this.pendingReparses.delete(file); - void this.reparse(file, document.getText()); - }, EDIT_REPARSE_DELAY_MS)); + this.editReparses.schedule(file, () => void this.reparse(file, document.getText())); } // Replaces one test file's functions and cases in the tree, without running Ceedling. Watchers @@ -355,54 +350,21 @@ export class CeedlingTestController { private makeReporter(run: vscode.TestRun, projectKey: string, excluded: Set): RunReporter { const items = this.items.get(projectKey); - const lookup = (callback: string, id: string): vscode.TestItem | undefined => { - const item = items?.get(id); - if (!item) { - this.logger.warn(`makeReporter.${callback}: no TestItem for '${id}' in project '${projectKey}', dropping this result`); - } - return item && !excluded.has(item) ? item : undefined; - }; - return { - started: (id) => { - const item = lookup('started', id); - if (item) run.started(item); - }, - passed: (id) => { - const item = lookup('passed', id); - if (item) run.passed(item); - }, - failed: (id, message, line) => { - const item = lookup('failed', id); - if (!item) return; - // Ceedling 1.1.0+'s :use_backtrace: :gdb ends a crash message with a gdb log - // path, relative to the project directory. It becomes a clickable link. - const { text, logPath } = extractGdbLogReference(message); - const testMessage = logPath !== undefined - ? new vscode.TestMessage(new vscode.MarkdownString( - `${text}\n\n[${logPath}](${vscode.Uri.file(path.resolve(this.engine.getProjectAbsPath(projectKey), logPath)).toString()})` - )) - : new vscode.TestMessage(message); - if (line !== undefined && item.uri) { - testMessage.location = new vscode.Location(item.uri, new vscode.Position(line, 0)); - } - run.failed(item, testMessage); + return createRunReporter({ + sink: { + started: (item) => { run.started(item); }, + passed: (item) => { run.passed(item); }, + failed: (item, message) => { run.failed(item, testMessage(message, item)); }, + skipped: (item) => { run.skipped(item); }, + errored: (item, message) => { run.errored(item, testMessage(message, item)); }, + appendOutput: (text) => { run.appendOutput(text); }, }, - skipped: (id) => { - const item = lookup('skipped', id); - if (item) run.skipped(item); - }, - errored: (id, message, location) => { - const item = lookup('errored', id); - if (!item) return; - const testMessage = new vscode.TestMessage(message); - if (location) { - // The diagnostic's own file, which may be a header rather than the test file. - testMessage.location = new vscode.Location(vscode.Uri.file(location.file), new vscode.Position(location.line, 0)); - } - run.errored(item, testMessage); - }, - appendOutput: (text) => run.appendOutput(text), - }; + projectKey, + find: (id) => items?.get(id), + excluded: (item) => excluded.has(item), + projectAbsPath: () => this.engine.getProjectAbsPath(projectKey), + warn: (message) => { this.logger.warn(message); }, + }); } // Cancellation reaches Ceedling through `token`, which stops only this run's processes. @@ -443,14 +405,13 @@ export class CeedlingTestController { const run = this.ensureController().createTestRun(request); const listeners: vscode.Disposable[] = []; try { - const plan = this.planFor(request); - const files = plan.flatMap((entry) => entry.kind === 'project' ? entry.files : [entry.file]); - const file = files[0]; - if (!file) { + const target = debugTarget(this.planFor(request)); + if (!target) { this.logger.debug(`handleDebug: nothing to debug`); return; } - if (new Set(files.map((f) => `${f.projectKey}::${f.id}`)).size > 1) { + const { file } = target; + if (target.severalFiles) { vscode.window.showInformationMessage(`Debugging ${file.id} only. Ceedling debugs one test file at a time.`); } @@ -501,21 +462,28 @@ export class CeedlingTestController { } } - // Splices the built test executable's relative path into every occurrence of - // ${command:ceedlingExplorer.debugTestExecutable} in the named launch.json configuration. - // VS Code never resolves the token itself, so debugging doesn't depend on when it would. + // The named launch.json configuration, debugging the built test executable. private resolveLaunchConfig(debugLaunchConfig: string, executableSubpath: string): vscode.DebugConfiguration | undefined { const configurations = vscode.workspace .getConfiguration('launch', this.workspaceFolder.uri) .get('configurations', []); - const found = configurations.find((c) => c.name === debugLaunchConfig); - if (!found) { - this.logger.debug(`resolveLaunchConfig: '${debugLaunchConfig}' not found among ${configurations.length} launch.json configuration(s)`); - return undefined; - } - this.logger.debug(`resolveLaunchConfig: resolved '${debugLaunchConfig}' with executableSubpath='${executableSubpath}'`); - return JSON.parse( - JSON.stringify(found).replace(/\$\{command:ceedlingExplorer\.debugTestExecutable\}/g, () => executableSubpath) - ) as vscode.DebugConfiguration; + const config = resolveLaunchConfig(configurations, debugLaunchConfig, executableSubpath); + this.logger.debug(config + ? `resolveLaunchConfig: resolved '${debugLaunchConfig}' with executableSubpath='${executableSubpath}'` + : `resolveLaunchConfig: '${debugLaunchConfig}' not found among ${configurations.length} launch.json configuration(s)`); + return config; + } +} + +// A reported message as the Testing view shows it: a gdb log as a link, a location as a place in +// the editor, by default in the test's own file. +function testMessage(message: ReportMessage, item: vscode.TestItem): vscode.TestMessage { + const shown = message.log + ? new vscode.TestMessage(new vscode.MarkdownString(`${message.text}\n\n[${message.log.label}](${vscode.Uri.file(message.log.absPath).toString()})`)) + : new vscode.TestMessage(message.text); + const uri = message.location?.file !== undefined ? vscode.Uri.file(message.location.file) : item.uri; + if (message.location && uri) { + shown.location = new vscode.Location(uri, new vscode.Position(message.location.line, 0)); } + return shown; } diff --git a/src/testModel.ts b/src/testModel.ts index b76c410..a0441c0 100644 --- a/src/testModel.ts +++ b/src/testModel.ts @@ -46,3 +46,8 @@ export function leafIds(functions: readonly CeedlingTestFunction[]): string[] { export function allIds(file: CeedlingFileNode): string[] { return [file.id, ...file.functions.flatMap((fn) => [fn.id, ...fn.cases.map((c) => c.id)])]; } + +// The Testing view names each folder's tests when there is more than one folder. +export function controllerLabel(folderName: string, folderCount: number): string { + return folderCount > 1 ? `Ceedling (${folderName})` : 'Ceedling'; +} diff --git a/src/testReporting.ts b/src/testReporting.ts new file mode 100644 index 0000000..a2fb4fd --- /dev/null +++ b/src/testReporting.ts @@ -0,0 +1,71 @@ +// Turns the engine's results, reported by id, into calls on a Testing view run. Results for ids +// with no test item are dropped with a warning, and excluded items get nothing. Messages are +// plain data here, so this has no `vscode` dependency. The controller's RunSink builds the +// TestMessages, links, and locations from them. + +import path from 'path'; +import { extractGdbLogReference } from './ceedlingOutputParsing'; +import { type RunReporter } from './testModel'; + +export type ReportMessage = { + text: string, + // A crash's gdb log, shown as a link to the file. + log?: { label: string, absPath: string }, + // A 0-based line in `file`, or in the test's own file when `file` is absent. + location?: { line: number, file?: string }, +}; + +// A test run, as the reporter calls it, with each id already resolved to its item. +export interface RunSink { + started(item: Item): void; + passed(item: Item): void; + failed(item: Item, message: ReportMessage): void; + skipped(item: Item): void; + errored(item: Item, message: ReportMessage): void; + appendOutput(text: string): void; +} + +export type RunReporterOptions = { + sink: RunSink, + projectKey: string, + find: (id: string) => Item | undefined, + // An excluded item can still be run, e.g. one case of a function, but reports nothing. + excluded: (item: Item) => boolean, + // Looked up only for a gdb log link, whose path is relative to the project directory. + projectAbsPath: () => string, + warn: (message: string) => void, +}; + +export function createRunReporter(options: RunReporterOptions): RunReporter { + const { sink, projectKey, find, excluded, projectAbsPath, warn } = options; + // Each reporting call runs `report` with the id's item, unless there is none to report to. + const withItem = (callback: string, id: string, report: (item: Item) => void) => { + const item = find(id); + if (item === undefined) { + warn(`makeReporter.${callback}: no TestItem for '${id}' in project '${projectKey}', dropping this result`); + } else if (!excluded(item)) { + report(item); + } + }; + return { + started: (id) => { withItem('started', id, (item) => { sink.started(item); }); }, + passed: (id) => { withItem('passed', id, (item) => { sink.passed(item); }); }, + failed: (id, message, line) => { + withItem('failed', id, (item) => { + // Ceedling 1.1.0+'s :use_backtrace: :gdb ends a crash message with a gdb log path, + // relative to the project directory. + const { text, logPath } = extractGdbLogReference(message); + sink.failed(item, { + text, + ...(logPath !== undefined && { log: { label: logPath, absPath: path.resolve(projectAbsPath(), logPath) } }), + ...(line !== undefined && { location: { line } }), + }); + }); + }, + skipped: (id) => { withItem('skipped', id, (item) => { sink.skipped(item); }); }, + errored: (id, message, location) => { + withItem('errored', id, (item) => { sink.errored(item, { text: message, ...(location && { location }) }); }); + }, + appendOutput: (text) => { sink.appendOutput(text); }, + }; +} diff --git a/src/watchPolicy.ts b/src/watchPolicy.ts new file mode 100644 index 0000000..155afaa --- /dev/null +++ b/src/watchPolicy.ts @@ -0,0 +1,59 @@ +// What a settings change, a watched file's event, or an edit makes the controller do. It has no +// `vscode` dependency. The controller owns the watchers and listeners themselves. + +// Settings whose change changes what discovery finds. The rest apply on the next run. +const DISCOVERY_SETTINGS = ['projects', 'mixins', 'shellPath', 'ceedlingCommand', 'prettyTestLabel', 'prettyTestFileLabel']; + +// `affects` says whether a ceedlingExplorer setting changed. Turning problem matching off clears +// its diagnostics at once rather than at the next run. `problemMatchingMode` is read only then. +export function settingsChangeEffects(affects: (setting: string) => boolean, problemMatchingMode: () => string): { clearDiagnostics: boolean, rediscoverFor: string[] } { + return { + clearDiagnostics: affects('problemMatching') && problemMatchingMode() === 'disabled', + rediscoverFor: DISCOVERY_SETTINGS.filter(affects), + }; +} + +export type SourceEvent = 'changed' | 'created' | 'deleted'; + +// A C source event in a project directory. A change reparses that file, if it is a discovered +// test file. A test file appearing or disappearing rediscovers, since only `ceedling files:test` +// knows the full list. +export function sourceEventEffect(event: SourceEvent, file: { isTestFile: () => boolean, mayBeTestFile: () => boolean }): 'reparse' | 'rediscover' | 'none' { + switch (event) { + case 'changed': return 'reparse'; + case 'created': return file.mayBeTestFile() ? 'rediscover' : 'none'; + case 'deleted': return file.isTestFile() ? 'rediscover' : 'none'; + } +} + +// The watchers to dispose and to create, keyed by what each watches, so that exactly `wanted` is watched. +export function watcherChanges(current: Iterable, wanted: ReadonlySet): { stale: string[], added: string[] } { + const existing = new Set(current); + return { stale: [...existing].filter((key) => !wanted.has(key)), added: [...wanted].filter((key) => !existing.has(key)) }; +} + +export type Timers = { + setTimeout(action: () => void, ms: number): Handle, + clearTimeout(handle: Handle | undefined): void, +}; + +// Runs the latest action scheduled for a key once `delayMs` pass without another, e.g. reparsing +// an edited file once typing pauses. Tests substitute `timers`. +export class Debouncer { + private readonly pending = new Map(); + + constructor(private readonly delayMs: number, private readonly timers: Timers = globalThis as unknown as Timers) { } + + schedule(key: string, action: () => void): void { + this.timers.clearTimeout(this.pending.get(key)); + this.pending.set(key, this.timers.setTimeout(() => { + this.pending.delete(key); + action(); + }, this.delayMs)); + } + + dispose(): void { + this.pending.forEach((handle) => { this.timers.clearTimeout(handle); }); + this.pending.clear(); + } +} diff --git a/tests/unit/debugLaunch.test.ts b/tests/unit/debugLaunch.test.ts new file mode 100644 index 0000000..a221a1d --- /dev/null +++ b/tests/unit/debugLaunch.test.ts @@ -0,0 +1,70 @@ +import * as assert from 'assert'; +import { debugTarget, resolveLaunchConfig } from '../../src/debugLaunch'; +import { type RunPlanEntry } from '../../src/runPlan'; +import { type CeedlingFileNode } from '../../src/testModel'; + +function file(projectKey: string, id: string): CeedlingFileNode { + return { id, label: id, absPath: `/ws/${id}`, projectKey, functions: [{ id: `${id}::test_a`, label: 'a', line: 0, cases: [] }, { id: `${id}::test_b`, label: 'b', line: 1, cases: [] }] }; +} + +const fileA = file('p', 'test/test_a.c'); +const fileB = file('p', 'test/test_b.c'); +const otherA = file('q', 'test/test_a.c'); + +suite('debugTarget', () => { + test('nothing to debug in an empty plan', () => { + assert.strictEqual(debugTarget([]), undefined); + }); + + test('a file entry debugs that file alone', () => { + assert.deepStrictEqual(debugTarget([{ kind: 'file', projectKey: 'p', file: fileA }]), { file: fileA, severalFiles: false }); + }); + + test('several functions of one file are still one file', () => { + const plan: RunPlanEntry[] = [ + { kind: 'function', projectKey: 'p', file: fileA, fn: fileA.functions[0] }, + { kind: 'function', projectKey: 'p', file: fileA, fn: fileA.functions[1] }, + ]; + assert.deepStrictEqual(debugTarget(plan), { file: fileA, severalFiles: false }); + }); + + // Ceedling builds one executable per test file, so only one file can be debugged at a time. + test('a plan naming several files debugs the first and says there were several', () => { + assert.deepStrictEqual(debugTarget([{ kind: 'project', projectKey: 'p', files: [fileA, fileB] }]), { file: fileA, severalFiles: true }); + }); + + test('files with the same path in different projects are different files', () => { + const plan: RunPlanEntry[] = [{ kind: 'file', projectKey: 'p', file: fileA }, { kind: 'file', projectKey: 'q', file: otherA }]; + assert.deepStrictEqual(debugTarget(plan), { file: fileA, severalFiles: true }); + }); +}); + +suite('resolveLaunchConfig', () => { + const TOKEN = '${command:ceedlingExplorer.debugTestExecutable}'; + const gdb = { name: 'gdb', type: 'cppdbg', program: `\${workspaceFolder}/build/test/out/${TOKEN}`, args: [TOKEN, '--flag'], env: { EXE: TOKEN }, stopAtEntry: false, port: 3333, miDebuggerPath: null }; + const configurations: Array & { name: string }> = [{ name: 'other', type: 'cppdbg', program: 'x' }, gdb]; + + test('a name not in launch.json resolves to nothing', () => { + assert.strictEqual(resolveLaunchConfig(configurations, 'missing', 'x/x.out'), undefined); + }); + + test("every occurrence of the token becomes the executable's path, everything else untouched", () => { + assert.deepStrictEqual(resolveLaunchConfig(configurations, 'gdb', 'test_calc/test_calc.out'), { + name: 'gdb', type: 'cppdbg', + program: '${workspaceFolder}/build/test/out/test_calc/test_calc.out', + args: ['test_calc/test_calc.out', '--flag'], + env: { EXE: 'test_calc/test_calc.out' }, + stopAtEntry: false, port: 3333, miDebuggerPath: null, + }); + }); + + test('the configuration from launch.json is left unchanged', () => { + resolveLaunchConfig(configurations, 'gdb', 'test_calc/test_calc.out'); + assert.strictEqual(gdb.args[0], TOKEN); + }); + + test('a path with backslashes, quotes, or replacement patterns arrives intact', () => { + const subpath = 'unit\\test "odd" $& $1/test.exe'; + assert.deepStrictEqual(resolveLaunchConfig(configurations, 'gdb', subpath)?.args, [subpath, '--flag']); + }); +}); diff --git a/tests/unit/diagnosticsStore.test.ts b/tests/unit/diagnosticsStore.test.ts new file mode 100644 index 0000000..d235f61 --- /dev/null +++ b/tests/unit/diagnosticsStore.test.ts @@ -0,0 +1,55 @@ +import * as assert from 'assert'; +import { DiagnosticsStore } from '../../src/diagnosticsStore'; + +type D = { file: string, message: string }; +const d = (file: string, message: string): D => ({ file, message }); +const store = () => new DiagnosticsStore((a, b) => a.message === b.message); +const view = (byFile: Map) => Object.fromEntries([...byFile].map(([file, ds]) => [file, ds.map((x) => x.message)])); + +suite('DiagnosticsStore', () => { + test('shows every id\'s diagnostics, grouped by file', () => { + const s = store(); + s.set('p', [d('/a.c', 'one'), d('/b.h', 'two')]); + s.set('p::test/t.c', [d('/a.c', 'three')]); + assert.deepStrictEqual(view(s.byFile()), { '/a.c': ['one', 'three'], '/b.h': ['two'] }); + }); + + // A project run and a file run can both report the same compiler error. + test('the same diagnostic under two ids shows once', () => { + const s = store(); + s.set('p', [d('/a.c', 'same')]); + s.set('p::test/t.c', [d('/a.c', 'same'), d('/a.c', 'other')]); + assert.deepStrictEqual(view(s.byFile()), { '/a.c': ['same', 'other'] }); + }); + + test('setting an id replaces only its own diagnostics', () => { + const s = store(); + s.set('p', [d('/a.c', 'project')]); + s.set('q', [d('/a.c', 'old')]); + s.set('q', [d('/a.c', 'new')]); + assert.deepStrictEqual(view(s.byFile()), { '/a.c': ['project', 'new'] }); + }); + + test('retain filters one id, and reports whether there was anything to filter', () => { + const s = store(); + s.set('p', [d('/a.c', 'keep'), d('/t.c', 'drop')]); + assert.strictEqual(s.retain('p', (x) => x.file !== '/t.c'), true); + assert.strictEqual(s.retain('missing', () => false), false); + assert.deepStrictEqual(view(s.byFile()), { '/a.c': ['keep'] }); + }); + + test('retainIds drops whole ids, e.g. a deleted test file\'s', () => { + const s = store(); + s.set('p', [d('/a.c', 'p')]); + s.set('p::gone.c', [d('/gone.c', 'gone')]); + s.retainIds((id) => id !== 'p::gone.c'); + assert.deepStrictEqual(view(s.byFile()), { '/a.c': ['p'] }); + }); + + test('clear drops everything', () => { + const s = store(); + s.set('p', [d('/a.c', 'p')]); + s.clear(); + assert.deepStrictEqual(view(s.byFile()), {}); + }); +}); diff --git a/tests/unit/testModel.test.ts b/tests/unit/testModel.test.ts new file mode 100644 index 0000000..9bf5082 --- /dev/null +++ b/tests/unit/testModel.test.ts @@ -0,0 +1,12 @@ +import * as assert from 'assert'; +import { controllerLabel } from '../../src/testModel'; + +suite('controllerLabel', () => { + test('a single folder\'s tests are simply Ceedling', () => { + assert.strictEqual(controllerLabel('firmware', 1), 'Ceedling'); + }); + + test('with several folders, each folder\'s tests are named by the folder', () => { + assert.strictEqual(controllerLabel('firmware', 2), 'Ceedling (firmware)'); + }); +}); diff --git a/tests/unit/testReporting.test.ts b/tests/unit/testReporting.test.ts new file mode 100644 index 0000000..b522db5 --- /dev/null +++ b/tests/unit/testReporting.test.ts @@ -0,0 +1,106 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import { createRunReporter, type ReportMessage, type RunSink } from '../../src/testReporting'; + +type Item = { id: string }; +type SinkCall = [string, ...unknown[]]; + +function recordingSink(): RunSink & { calls: SinkCall[] } { + const calls: SinkCall[] = []; + return { + calls, + started: (item) => calls.push(['started', item.id]), + passed: (item) => calls.push(['passed', item.id]), + failed: (item, message) => calls.push(['failed', item.id, message]), + skipped: (item) => calls.push(['skipped', item.id]), + errored: (item, message) => calls.push(['errored', item.id, message]), + appendOutput: (text) => calls.push(['appendOutput', text]), + }; +} + +function reporterFor(ids: string[], options: { excluded?: string[], projectAbsPath?: () => string } = {}) { + const sink = recordingSink(); + const warnings: string[] = []; + const items = new Map(ids.map((id) => [id, { id }])); + const reporter = createRunReporter({ + sink, + projectKey: 'fw', + find: (id) => items.get(id), + excluded: (item) => (options.excluded ?? []).includes(item.id), + projectAbsPath: options.projectAbsPath ?? (() => '/ws/fw'), + warn: (message) => warnings.push(message), + }); + return { reporter, sink, warnings }; +} + +suite('createRunReporter', () => { + test('each result reaches the test run against its item', () => { + const { reporter, sink } = reporterFor(['a', 'b', 'c']); + reporter.started('a'); + reporter.passed('a'); + reporter.skipped('b'); + reporter.appendOutput('text\r\n'); + assert.deepStrictEqual(sink.calls, [['started', 'a'], ['passed', 'a'], ['skipped', 'b'], ['appendOutput', 'text\r\n']]); + }); + + test('a result for an id with no item is dropped, with a warning naming the id and project', () => { + const { reporter, sink, warnings } = reporterFor(['a']); + reporter.passed('gone'); + reporter.failed('gone', 'message', 3); + assert.deepStrictEqual(sink.calls, []); + assert.strictEqual(warnings.length, 2); + assert.match(warnings[0], /passed.*'gone'.*'fw'/); + assert.match(warnings[1], /failed.*'gone'.*'fw'/); + }); + + // A run can't always leave an excluded test out of what Ceedling runs, e.g. one case of a function. + test('an excluded item gets no result, and no warning', () => { + const { reporter, sink, warnings } = reporterFor(['a', 'b'], { excluded: ['b'] }); + reporter.started('b'); + reporter.failed('b', 'message'); + reporter.errored('b', 'message'); + reporter.passed('a'); + assert.deepStrictEqual(sink.calls, [['passed', 'a']]); + assert.deepStrictEqual(warnings, []); + }); + + test('a failure points at its line in the test file, when it has one', () => { + const { reporter, sink } = reporterFor(['a', 'b']); + reporter.failed('a', 'Expected 1 Was 2', 4); + reporter.failed('b', 'no location'); + assert.deepStrictEqual(sink.calls, [ + ['failed', 'a', { text: 'Expected 1 Was 2', location: { line: 4 } } satisfies ReportMessage], + ['failed', 'b', { text: 'no location' } satisfies ReportMessage], + ]); + }); + + // Ceedling 1.1.0+'s :use_backtrace: :gdb ends a crash message with the gdb log's path. + test("a crash's gdb log becomes a link to it, resolved against the project directory", () => { + const { reporter, sink } = reporterFor(['a']); + reporter.failed('a', 'Test crashed\n(build/test/test_calc.gdb.log)', 7); + assert.deepStrictEqual(sink.calls, [['failed', 'a', { + text: 'Test crashed', + log: { label: 'build/test/test_calc.gdb.log', absPath: path.resolve('/ws/fw', 'build/test/test_calc.gdb.log') }, + location: { line: 7 }, + } satisfies ReportMessage]]); + }); + + test('the project directory is looked up only for a gdb log link', () => { + let lookups = 0; + const { reporter } = reporterFor(['a'], { projectAbsPath: () => { lookups++; return '/ws/fw'; } }); + reporter.failed('a', 'plain failure', 1); + assert.strictEqual(lookups, 0); + reporter.failed('a', 'crash\n(x.gdb.log)', 1); + assert.strictEqual(lookups, 1); + }); + + test("an error points at its diagnostic's own file, which may be a header", () => { + const { reporter, sink } = reporterFor(['a', 'b']); + reporter.errored('a', 'compile failed', { file: '/ws/fw/src/calc.h', line: 2 }); + reporter.errored('b', 'no report'); + assert.deepStrictEqual(sink.calls, [ + ['errored', 'a', { text: 'compile failed', location: { file: '/ws/fw/src/calc.h', line: 2 } } satisfies ReportMessage], + ['errored', 'b', { text: 'no report' } satisfies ReportMessage], + ]); + }); +}); diff --git a/tests/unit/watchPolicy.test.ts b/tests/unit/watchPolicy.test.ts new file mode 100644 index 0000000..f8f5864 --- /dev/null +++ b/tests/unit/watchPolicy.test.ts @@ -0,0 +1,119 @@ +import * as assert from 'assert'; +import { Debouncer, settingsChangeEffects, sourceEventEffect, watcherChanges } from '../../src/watchPolicy'; + +suite('settingsChangeEffects', () => { + const affecting = (...settings: string[]) => (setting: string) => settings.includes(setting); + + test('a setting that changes what discovery finds rediscovers, naming it', () => { + for (const setting of ['projects', 'mixins', 'shellPath', 'ceedlingCommand', 'prettyTestLabel', 'prettyTestFileLabel']) { + assert.deepStrictEqual(settingsChangeEffects(affecting(setting), () => 'gcc'), { clearDiagnostics: false, rediscoverFor: [setting] }); + } + }); + + // These apply on the next run, from the settings read then. + test('a setting a run reads for itself changes nothing now', () => { + assert.deepStrictEqual(settingsChangeEffects(affecting('testCommandArgs', 'ansiEscapeSequencesRemoved'), () => 'gcc'), + { clearDiagnostics: false, rediscoverFor: [] }); + }); + + test('turning problem matching off clears its diagnostics, without rediscovering', () => { + assert.deepStrictEqual(settingsChangeEffects(affecting('problemMatching'), () => 'disabled'), { clearDiagnostics: true, rediscoverFor: [] }); + assert.deepStrictEqual(settingsChangeEffects(affecting('problemMatching'), () => 'gcc'), { clearDiagnostics: false, rediscoverFor: [] }); + }); + + test('the problem matching mode is read only when problem matching changed', () => { + let reads = 0; + settingsChangeEffects(affecting('projects'), () => { reads++; return 'disabled'; }); + assert.strictEqual(reads, 0); + }); +}); + +suite('sourceEventEffect', () => { + const file = (isTestFile: boolean, mayBeTestFile: boolean) => ({ isTestFile: () => isTestFile, mayBeTestFile: () => mayBeTestFile }); + + test('a changed file is reparsed, which ignores anything not a discovered test file', () => { + assert.strictEqual(sourceEventEffect('changed', file(true, true)), 'reparse'); + assert.strictEqual(sourceEventEffect('changed', file(false, false)), 'reparse'); + }); + + // Only `ceedling files:test` knows the full list of test files. + test('a new file that may be a test file rediscovers; any other new file does nothing', () => { + assert.strictEqual(sourceEventEffect('created', file(false, true)), 'rediscover'); + assert.strictEqual(sourceEventEffect('created', file(false, false)), 'none'); + }); + + test('a deleted test file rediscovers; any other deleted file does nothing', () => { + assert.strictEqual(sourceEventEffect('deleted', file(true, true)), 'rediscover'); + assert.strictEqual(sourceEventEffect('deleted', file(false, true)), 'none'); + }); +}); + +suite('watcherChanges', () => { + test('watchers no longer wanted go, and newly wanted ones come, the rest untouched', () => { + assert.deepStrictEqual(watcherChanges(['/a', '/b'], new Set(['/b', '/c'])), { stale: ['/a'], added: ['/c'] }); + }); + + test('nothing changes when the wanted set is already watched', () => { + assert.deepStrictEqual(watcherChanges(['/a'], new Set(['/a'])), { stale: [], added: [] }); + }); +}); + +// A manual clock, so tests control when timers fire. +function manualTimers() { + let now = 0; + let nextId = 1; + const pending = new Map void }>(); + return { + timers: { + setTimeout: (action: () => void, ms: number) => { const id = nextId++; pending.set(id, { at: now + ms, action }); return id; }, + clearTimeout: (id: number | undefined) => { if (id !== undefined) pending.delete(id); }, + }, + advance(ms: number) { + now += ms; + for (const [id, timer] of [...pending].sort((a, b) => a[1].at - b[1].at)) { + if (timer.at <= now) { pending.delete(id); timer.action(); } + } + }, + get pending() { return pending.size; }, + }; +} + +suite('Debouncer', () => { + test('runs the latest action for a key once its calls pause', () => { + const clock = manualTimers(); + const debouncer = new Debouncer(400, clock.timers); + const ran: string[] = []; + debouncer.schedule('a.c', () => ran.push('first')); + clock.advance(300); + debouncer.schedule('a.c', () => ran.push('second')); + clock.advance(300); + assert.deepStrictEqual(ran, []); + clock.advance(100); + assert.deepStrictEqual(ran, ['second']); + }); + + test('keys wait independently', () => { + const clock = manualTimers(); + const debouncer = new Debouncer(400, clock.timers); + const ran: string[] = []; + debouncer.schedule('a.c', () => ran.push('a')); + clock.advance(200); + debouncer.schedule('b.c', () => ran.push('b')); + clock.advance(200); + assert.deepStrictEqual(ran, ['a']); + clock.advance(200); + assert.deepStrictEqual(ran, ['a', 'b']); + }); + + test('dispose cancels everything pending', () => { + const clock = manualTimers(); + const debouncer = new Debouncer(400, clock.timers); + const ran: string[] = []; + debouncer.schedule('a.c', () => ran.push('a')); + debouncer.schedule('b.c', () => ran.push('b')); + debouncer.dispose(); + clock.advance(1000); + assert.deepStrictEqual(ran, []); + assert.strictEqual(clock.pending, 0); + }); +});