src/goTest: group test explorer settings, fix bugs

- Rename settings and update descriptions to improve clarity
- vscode.URI already encodes the fragment - don't double encode
- Dynamically resolved subtests were not labeled as such

Change-Id: Ifb5c4636f9fa06ef8980df270c3afdb20bec0089
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/347669
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
Reviewed-by: Suzy Mueller <suzmue@golang.org>
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
diff --git a/docs/settings.md b/docs/settings.md
index dcb17a8..62dafd1 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -393,25 +393,25 @@
 ### `go.testEnvVars`
 
 Environment variables that will be passed to the process that runs the Go tests
-### `go.testExplorerConcatenateMessages`
+### `go.testExplorer.alwaysRunBenchmarks`
 
-If true, test log messages associated with a given location will be shown as a single message.
+Run benchmarks when running all tests in a file or folder.
+
+Default: `false`
+### `go.testExplorer.concatenateMessages`
+
+Concatenate all test log messages for a given location into a single message.
 
 Default: `true`
-### `go.testExplorerPackages`
+### `go.testExplorer.packageDisplayMode`
 
-Control whether packages in the test explorer are presented flat or nested.<br/>
+Present packages in the test explorer flat or nested.<br/>
 Allowed Options: `flat`, `nested`
 
 Default: `"flat"`
-### `go.testExplorerRunBenchmarks`
+### `go.testExplorer.showDynamicSubtestsInEditor`
 
-Include benchmarks when running all tests in a group.
-
-Default: `false`
-### `go.testExplorerSetDynamicSubtestRange`
-
-If true, the source location of dynamically discovered subtests will be set to the source location of the containing function
+Set the source location of dynamically discovered subtests to the location of the containing function. As a result, dynamically discovered subtests will be added to the gutter test widget of the containing function.
 
 Default: `false`
 ### `go.testFlags`
diff --git a/package.json b/package.json
index 0404700..d3b6c2f 100644
--- a/package.json
+++ b/package.json
@@ -1302,32 +1302,32 @@
           "description": "Flags to pass to `go test`. If null, then buildFlags will be used. This is not propagated to the language server.",
           "scope": "resource"
         },
-        "go.testExplorerPackages": {
+        "go.testExplorer.packageDisplayMode": {
           "type": "string",
           "enum": [
             "flat",
             "nested"
           ],
           "default": "flat",
-          "description": "Control whether packages in the test explorer are presented flat or nested.",
+          "description": "Present packages in the test explorer flat or nested.",
           "scope": "resource"
         },
-        "go.testExplorerRunBenchmarks": {
+        "go.testExplorer.alwaysRunBenchmarks": {
           "type": "boolean",
           "default": false,
-          "description": "Include benchmarks when running all tests in a group.",
+          "description": "Run benchmarks when running all tests in a file or folder.",
           "scope": "resource"
         },
-        "go.testExplorerConcatenateMessages": {
+        "go.testExplorer.concatenateMessages": {
           "type": "boolean",
           "default": true,
-          "description": "If true, test log messages associated with a given location will be shown as a single message.",
+          "description": "Concatenate all test log messages for a given location into a single message.",
           "scope": "resource"
         },
-        "go.testExplorerSetDynamicSubtestRange": {
+        "go.testExplorer.showDynamicSubtestsInEditor": {
           "type": "boolean",
           "default": false,
-          "description": "If true, the source location of dynamically discovered subtests will be set to the source location of the containing function",
+          "description": "Set the source location of dynamically discovered subtests to the location of the containing function. As a result, dynamically discovered subtests will be added to the gutter test widget of the containing function.",
           "scope": "resource"
         },
         "go.generateTestsFlags": {
diff --git a/src/goTest/resolve.ts b/src/goTest/resolve.ts
index 277a8e5..b5e342a 100644
--- a/src/goTest/resolve.ts
+++ b/src/goTest/resolve.ts
@@ -228,7 +228,7 @@
 
 	private shouldSetRange(item: TestItem): boolean {
 		const config = getGoConfig(item.uri);
-		return config.get<boolean>('testExplorerSetDynamicSubtestRange');
+		return config.get<boolean>('testExplorer.showDynamicSubtestsInEditor');
 	}
 
 	// Create an item.
@@ -303,7 +303,7 @@
 	private async getPackage(uri: Uri): Promise<TestItem> {
 		let item: TestItem;
 
-		const nested = getGoConfig(uri).get('testExplorerPackages') === 'nested';
+		const nested = getGoConfig(uri).get('testExplorer.packageDisplayMode') === 'nested';
 		const modDir = await getModFolderPath(uri, true);
 		const wsfolder = workspace.getWorkspaceFolder(uri);
 		if (modDir) {
diff --git a/src/goTest/run.ts b/src/goTest/run.ts
index d5da2d8..e9c2178 100644
--- a/src/goTest/run.ts
+++ b/src/goTest/run.ts
@@ -158,7 +158,7 @@
 			const isMod = isInMod(pkg) || (await isModSupported(pkg.uri, true));
 			const goConfig = getGoConfig(pkg.uri);
 			const flags = getTestFlags(goConfig);
-			const includeBench = getGoConfig(pkg.uri).get('testExplorerRunBenchmarks');
+			const includeBench = getGoConfig(pkg.uri).get('testExplorer.alwaysRunBenchmarks');
 
 			// If any of the tests are test suite methods, add all test functions that call `suite.Run`
 			const hasTestMethod = items.some(({ item }) => this.resolver.isTestMethod.has(item));
@@ -212,7 +212,7 @@
 			}
 
 			const record = new Map<TestItem, string[]>();
-			const concat = goConfig.get<boolean>('testExplorerConcatenateMessages');
+			const concat = goConfig.get<boolean>('testExplorer.concatenateMessages');
 
 			// https://github.com/golang/go/issues/39904
 			if (subItems.length > 0 && Object.keys(tests).length + Object.keys(benchmarks).length > 1) {
@@ -376,12 +376,12 @@
 			const m = name.substring(pos).match(re);
 			if (!m) {
 				if (!parent) return tests[name];
-				return this.resolver.getOrCreateSubTest(parent, name.substring(pos), name);
+				return this.resolver.getOrCreateSubTest(parent, name.substring(pos), name, true);
 			}
 
 			const subName = name.substring(0, pos + m.index);
 			const test = parent
-				? this.resolver.getOrCreateSubTest(parent, name.substring(pos, pos + m.index), subName)
+				? this.resolver.getOrCreateSubTest(parent, name.substring(pos, pos + m.index), subName, true)
 				: tests[subName];
 			return resolve(test, pos + m.index, m[0].length);
 		};
diff --git a/src/goTest/utils.ts b/src/goTest/utils.ts
index 8610a2b..4d81422 100644
--- a/src/goTest/utils.ts
+++ b/src/goTest/utils.ts
@@ -36,7 +36,7 @@
 	// - Example:   file:///path/to/mod/file.go?example#ExampleXxx
 	static id(uri: vscode.Uri, kind: GoTestKind, name?: string): string {
 		uri = uri.with({ query: kind });
-		if (name) uri = uri.with({ fragment: encodeURIComponent(name) });
+		if (name) uri = uri.with({ fragment: name });
 		return uri.toString();
 	}
 
@@ -47,7 +47,7 @@
 	static parseId(id: string): { kind: GoTestKind; name?: string } {
 		const u = vscode.Uri.parse(id);
 		const kind = u.query as GoTestKind;
-		const name = decodeURIComponent(u.fragment);
+		const name = u.fragment;
 		return { name, kind };
 	}
 }
diff --git a/test/integration/goTest.explore.test.ts b/test/integration/goTest.explore.test.ts
index a3e6b39..31a581f 100644
--- a/test/integration/goTest.explore.test.ts
+++ b/test/integration/goTest.explore.test.ts
@@ -4,12 +4,17 @@
  *--------------------------------------------------------*/
 import assert = require('assert');
 import path = require('path');
-import { TextDocument, TestItemCollection, TextDocumentChangeEvent, workspace, Uri } from 'vscode';
+import sinon = require('sinon');
+import { TextDocument, TestItem, TestItemCollection, TextDocumentChangeEvent, workspace, Uri } from 'vscode';
 import { GoTestExplorer } from '../../src/goTest/explore';
 import { MockTestController, MockTestWorkspace } from '../mocks/MockTest';
 import { forceDidOpenTextDocument, getSymbols_Regex, populateModulePathCache } from './goTest.utils';
 import { MockExtensionContext } from '../mocks/MockContext';
 import { MockMemento } from '../mocks/MockMemento';
+import * as config from '../../src/config';
+import { GoTestResolver } from '../../src/goTest/resolve';
+import * as testUtils from '../../src/testUtils';
+import { GoTest } from '../../src/goTest/utils';
 
 type Files = Record<string, string | { contents: string; language: string }>;
 
@@ -18,7 +23,7 @@
 	files: Files;
 }
 
-function setupCtor<T extends GoTestExplorer>(
+function newExplorer<T extends GoTestExplorer>(
 	folders: string[],
 	files: Files,
 	ctor: new (...args: ConstructorParameters<typeof GoTestExplorer>) => T
@@ -42,6 +47,13 @@
 	assert.deepStrictEqual(actual, expect);
 }
 
+async function forceResolve(resolver: GoTestResolver, item?: TestItem) {
+	await resolver.resolve(item);
+	const items: TestItem[] = [];
+	(item?.children || resolver.items).forEach((x) => items.push(x));
+	await Promise.all(items.map((x) => forceResolve(resolver, x)));
+}
+
 suite('Go Test Explorer', () => {
 	suite('Document opened', () => {
 		class DUT extends GoTestExplorer {
@@ -89,7 +101,7 @@
 		for (const name in cases) {
 			test(name, async () => {
 				const { workspace, files, open, expect } = cases[name];
-				const { ctrl, expl, ws } = setupCtor(workspace, files, DUT);
+				const { ctrl, expl, ws } = newExplorer(workspace, files, DUT);
 
 				await expl._didOpen(ws.fs.files.get(open));
 
@@ -158,7 +170,7 @@
 		for (const name in cases) {
 			test(name, async () => {
 				const { workspace, files, open, changes, expect } = cases[name];
-				const { ctrl, expl, ws } = setupCtor(workspace, files, DUT);
+				const { ctrl, expl, ws } = newExplorer(workspace, files, DUT);
 
 				await expl._didOpen(ws.fs.files.get(open));
 
@@ -205,4 +217,206 @@
 			]);
 		});
 	});
+
+	suite('settings', () => {
+		const sandbox = sinon.createSandbox();
+
+		// eslint-disable-next-line @typescript-eslint/no-explicit-any
+		let goConfig: any;
+
+		setup(() => {
+			goConfig = Object.create(config.getGoConfig());
+			sandbox.stub(config, 'getGoConfig').returns(goConfig);
+		});
+
+		teardown(() => {
+			sandbox.restore();
+		});
+
+		suite('packageDisplayMode', () => {
+			let resolver: GoTestResolver;
+
+			setup(() => {
+				const { expl } = newExplorer(
+					['/src/proj'],
+					{
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/pkg/main_test.go': 'package main\nfunc TestFoo(t *testing.T) {}',
+						'/src/proj/pkg/sub/main_test.go': 'package main\nfunc TestBar(t *testing.T) {}'
+					},
+					GoTestExplorer
+				);
+				resolver = expl.resolver;
+			});
+
+			test('flat', async () => {
+				// Expect:
+				// - module
+				//   - package pkg
+				//   - package pkg/sub
+
+				sinon.stub(goConfig, 'get').withArgs('testExplorer.packageDisplayMode').returns('flat');
+				await forceResolve(resolver);
+
+				const mod = resolver.items.get('file:///src/proj?module');
+				assert(mod, 'Module is missing or is not at the root');
+
+				const pkg = mod.children.get('file:///src/proj/pkg?package');
+				assert(pkg, 'Package pkg is missing or not a child of the module');
+
+				const sub = mod.children.get('file:///src/proj/pkg/sub?package');
+				assert(sub, 'Package pkg/sub is missing or not a child of the module');
+			});
+
+			test('nested', async () => {
+				// Expect:
+				// - module
+				//   - package pkg
+				//     - package pkg/sub
+
+				sinon.stub(goConfig, 'get').withArgs('testExplorer.packageDisplayMode').returns('nested');
+				await forceResolve(resolver);
+
+				const mod = resolver.items.get('file:///src/proj?module');
+				assert(mod, 'Module is missing or is not at the root');
+
+				const pkg = mod.children.get('file:///src/proj/pkg?package');
+				assert(pkg, 'Package pkg is missing or not a child of the module');
+
+				const sub = pkg.children.get('file:///src/proj/pkg/sub?package');
+				assert(sub, 'Package pkg/sub is missing or not a child of package pkg');
+			});
+		});
+
+		suite('alwaysRunBenchmarks', () => {
+			let explorer: GoTestExplorer;
+			let runStub: sinon.SinonStub<[testUtils.TestConfig], Promise<boolean>>;
+
+			const expectedTests = [
+				'file:///src/proj/pkg/main_test.go?file',
+				'file:///src/proj/pkg/main_test.go?test#TestFoo',
+				'file:///src/proj/pkg/main_test.go?benchmark#BenchmarkBar'
+			];
+
+			setup(() => {
+				runStub = sandbox.stub(testUtils, 'goTest');
+				runStub.callsFake(() => Promise.resolve(true));
+
+				explorer = newExplorer(
+					['/src/proj'],
+					{
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/pkg/main_test.go': `
+							package main
+
+							func TestFoo(t *testing.T) {}
+							func BenchmarkBar(b *testing.B) {}
+						`
+					},
+					GoTestExplorer
+				).expl;
+			});
+
+			test('false', async () => {
+				// Running the file should only run the test
+
+				sinon.stub(goConfig, 'get').withArgs('testExplorer.alwaysRunBenchmarks').returns(false);
+				await forceResolve(explorer.resolver);
+
+				const tests = explorer.resolver.find(Uri.parse('file:///src/proj/pkg/main_test.go'));
+				assert.deepStrictEqual(
+					tests.map((x) => x.id),
+					expectedTests
+				);
+
+				await explorer.runner.run({ include: [tests[0]] });
+				assert.strictEqual(runStub.callCount, 1, 'Expected goTest to be called once');
+				assert.deepStrictEqual(runStub.lastCall.args[0].functions, ['TestFoo']);
+			});
+
+			test('true', async () => {
+				// Running the file should run the test and the benchmark
+
+				sinon.stub(goConfig, 'get').withArgs('testExplorer.alwaysRunBenchmarks').returns(true);
+				await forceResolve(explorer.resolver);
+
+				const tests = explorer.resolver.find(Uri.parse('file:///src/proj/pkg/main_test.go'));
+				assert.deepStrictEqual(
+					tests.map((x) => x.id),
+					expectedTests
+				);
+
+				await explorer.runner.run({ include: [tests[0]] });
+				assert.strictEqual(runStub.callCount, 2, 'Expected goTest to be called twice');
+				assert.deepStrictEqual(runStub.firstCall.args[0].functions, ['TestFoo']);
+				assert.deepStrictEqual(runStub.secondCall.args[0].functions, ['BenchmarkBar']);
+			});
+		});
+
+		suite('showDynamicSubtestsInEditor', () => {
+			let explorer: GoTestExplorer;
+			let runStub: sinon.SinonStub<[testUtils.TestConfig], Promise<boolean>>;
+
+			setup(() => {
+				runStub = sandbox.stub(testUtils, 'goTest');
+				runStub.callsFake((cfg) => {
+					// Trigger creation of dynamic subtest
+					cfg.goTestOutputConsumer({
+						Test: 'TestFoo/Bar',
+						Action: 'run'
+					});
+					return Promise.resolve(true);
+				});
+
+				explorer = newExplorer(
+					['/src/proj'],
+					{
+						'/src/proj/go.mod': 'module test',
+						'/src/proj/main_test.go': 'package main\nfunc TestFoo(t *testing.T) {}'
+					},
+					GoTestExplorer
+				).expl;
+			});
+
+			test('false', async () => {
+				// Dynamic subtests should have no location
+
+				sinon.stub(goConfig, 'get').withArgs('testExplorer.showDynamicSubtestsInEditor').returns(false);
+				await forceResolve(explorer.resolver);
+
+				const test = explorer.resolver
+					.find(Uri.parse('file:///src/proj/main_test.go'))
+					.filter((x) => GoTest.parseId(x.id).name)[0];
+				assert(test, 'Could not find test');
+
+				await explorer.runner.run({ include: [test] });
+				assert.strictEqual(runStub.callCount, 1, 'Expected goTest to be called once');
+
+				const subTest = test.children.get('file:///src/proj/main_test.go?test#TestFoo%2FBar');
+				assert(subTest, 'Could not find subtest');
+
+				assert(!subTest.range, 'Subtest should not have a range');
+			});
+
+			test('true', async () => {
+				// Dynamic subtests should have the same location as their parents
+
+				sinon.stub(goConfig, 'get').withArgs('testExplorer.showDynamicSubtestsInEditor').returns(true);
+				await forceResolve(explorer.resolver);
+
+				const test = explorer.resolver
+					.find(Uri.parse('file:///src/proj/main_test.go'))
+					.filter((x) => GoTest.parseId(x.id).name)[0];
+				assert(test, 'Could not find test');
+
+				await explorer.runner.run({ include: [test] });
+				assert.strictEqual(runStub.callCount, 1, 'Expected goTest to be called once');
+
+				const subTest = test.children.get('file:///src/proj/main_test.go?test#TestFoo%2FBar');
+				assert(subTest, 'Could not find subtest');
+
+				assert.deepStrictEqual(subTest.range, test.range, 'Subtest range should match parent range');
+			});
+		});
+	});
 });
diff --git a/test/mocks/MockTest.ts b/test/mocks/MockTest.ts
index 41bc6aa..8372de2 100644
--- a/test/mocks/MockTest.ts
+++ b/test/mocks/MockTest.ts
@@ -15,6 +15,7 @@
 	TestController,
 	TestItem,
 	TestItemCollection,
+	TestMessage,
 	TestRun,
 	TestRunProfile,
 	TestRunProfileKind,
@@ -109,6 +110,24 @@
 	dispose(): void {}
 }
 
+class MockTestRun implements TestRun {
+	name = 'test run';
+	isPersisted = false;
+
+	get token(): CancellationToken {
+		throw new Error('Method not implemented.');
+	}
+
+	enqueued(test: TestItem): void {}
+	started(test: TestItem): void {}
+	skipped(test: TestItem): void {}
+	failed(test: TestItem, message: TestMessage | readonly TestMessage[], duration?: number): void {}
+	errored(test: TestItem, message: TestMessage | readonly TestMessage[], duration?: number): void {}
+	passed(test: TestItem, duration?: number): void {}
+	appendOutput(output: string): void {}
+	end(): void {}
+}
+
 export class MockTestController implements TestController {
 	id = 'go';
 	label = 'Go';
@@ -117,7 +136,7 @@
 	resolveHandler?: (item: TestItem) => void | Thenable<void>;
 
 	createTestRun(request: TestRunRequest, name?: string, persist?: boolean): TestRun {
-		throw new Error('Method not implemented.');
+		return new MockTestRun();
 	}
 
 	createRunProfile(