Skip to content

Commit 08b6cdc

Browse files
authored
Merge pull request #399 from recca0120/fix/test-duplicates-390
fix: resolve test duplicates and unify dataset logic (#390)
2 parents fd6f1aa + 2b8c04b commit 08b6cdc

23 files changed

Lines changed: 257 additions & 147 deletions

packages/extension/src/TestCollection/TestCollection.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1763,6 +1763,28 @@ class NoDatasetTest extends TestCase
17631763
}
17641764
});
17651765

1766+
it('reset should clear testsuite and namespace nodes from tree (#390)', async () => {
1767+
const collection = givenTestCollection(`
1768+
<testsuites>
1769+
<testsuite name="Unit">
1770+
<directory>tests/Unit</directory>
1771+
</testsuite>
1772+
<testsuite name="Feature">
1773+
<directory>tests/Feature</directory>
1774+
</testsuite>
1775+
</testsuites>`);
1776+
1777+
await collection.add(URI.file(phpUnitProject('tests/Unit/ExampleTest.php')));
1778+
await collection.add(URI.file(phpUnitProject('tests/Feature/ExampleTest.php')));
1779+
1780+
expect(ctrl.items.size).toBeGreaterThan(0);
1781+
1782+
collection.reset();
1783+
1784+
// After reset, the VS Code tree should be completely empty
1785+
expect(ctrl.items.size).toBe(0);
1786+
});
1787+
17661788
it('add test — all fixture files discovered', async () => {
17671789
const collection = givenTestCollection(`
17681790
<testsuites>

packages/extension/src/TestCollection/TestCollection.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -195,13 +195,11 @@ export class TestCollection extends BaseTestCollection {
195195
private handleReset() {
196196
const workspaceDefs = this.index.getDefinitionsByType(TestType.workspace);
197197

198-
for (const [testItem] of this.index.getDefinitionsByType(TestType.class)) {
199-
if (!testItem.parent) {
200-
this.rootItems.delete(testItem.id);
201-
continue;
198+
const keepIds = new Set(workspaceDefs.map(([item]) => item.id));
199+
for (const [id] of this.rootItems) {
200+
if (!keepIds.has(id)) {
201+
this.rootItems.delete(id);
202202
}
203-
204-
testItem.parent.children.delete(testItem.id);
205203
}
206204
this.index.clear();
207205

packages/phpunit/docs/data-provider-guide.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,10 @@ Teamcity fallback components:
6767

6868
| Component | Description |
6969
|-----------|------------|
70-
| `resolveDatasetDefinition` | Parses `#N` and `"name"` formats from Teamcity output |
70+
| `DatasetExpander.fromTestOutput` | Parses `#N` and `"name"` formats from Teamcity output |
71+
| `DatasetExpander.fromAnnotations` | Creates dataset children from static analysis labels |
7172
| `isDatasetResult` | Used by TestResultObserver to distinguish dataset vs non-dataset results |
7273
| `DatasetObserver` | Dynamically creates dataset child TestItem nodes from Teamcity output after execution |
73-
| `createDatasetDefinition` | Unified constructor for dataset TestDefinition |
7474

7575
---
7676

@@ -635,10 +635,10 @@ All patterns that AST cannot statically resolve are dynamically populated with d
635635

636636
| Component | Status | Description |
637637
|-----------|:------:|------------|
638-
| `resolveDatasetDefinition` || Parses `with data set #N` and `with data set "name"` formats from Teamcity output |
638+
| `DatasetExpander.fromTestOutput` || Parses `with data set #N` and `with data set "name"` formats from Teamcity output |
639+
| `DatasetExpander.fromAnnotations` || Creates dataset children from static analysis labels, ensures consistent format |
639640
| `isDatasetResult` || Used by TestResultObserver to distinguish dataset vs non-dataset results |
640641
| `DatasetObserver` || Dynamically creates dataset child TestItem nodes from Teamcity output after execution |
641-
| `createDatasetDefinition` || Unified constructor for dataset TestDefinition, ensures consistent format |
642642

643643
### Fallback Flow
644644

packages/phpunit/docs/data-provider-guide.zh-TW.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,10 @@ Teamcity 補漏元件:
6767

6868
| 元件 | 說明 |
6969
|------|------|
70-
| `resolveDatasetDefinition` | 解析 Teamcity 輸出中的 `#N``"name"` 格式 |
70+
| `DatasetExpander.fromTestOutput` | 解析 Teamcity 輸出中的 `#N``"name"` 格式 |
71+
| `DatasetExpander.fromAnnotations` | 從靜態分析 labels 建立 dataset children |
7172
| `isDatasetResult` | TestResultObserver 用來分流 dataset vs 非 dataset 結果 |
7273
| `DatasetObserver` | 執行後從 Teamcity 輸出動態建立 dataset 子 TestItem |
73-
| `createDatasetDefinition` | 統一建構 dataset TestDefinition |
7474

7575
---
7676

@@ -635,10 +635,10 @@ testStarted name='it business closed with data set "(|'School|', |'Sunday|')"'
635635

636636
| 元件 | 狀態 | 說明 |
637637
|------|:----:|------|
638-
| `resolveDatasetDefinition` || 解析 Teamcity 輸出中的 `with data set #N``with data set "name"` 格式 |
638+
| `DatasetExpander.fromTestOutput` || 解析 Teamcity 輸出中的 `with data set #N``with data set "name"` 格式 |
639+
| `DatasetExpander.fromAnnotations` || 從靜態分析 labels 建立 dataset children,確保格式一致 |
639640
| `isDatasetResult` || TestResultObserver 用來分流 dataset vs 非 dataset 結果 |
640641
| `DatasetObserver` || 執行後從 Teamcity 輸出動態建立 dataset 子 TestItem 節點 |
641-
| `createDatasetDefinition` || 統一建構 dataset TestDefinition,確保格式一致 |
642642

643643
### 補漏流程
644644

packages/phpunit/src/Interpreter/Expressions/Functions/Array/ArrayCombineExpression.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { datasetIndexed, datasetNamed } from '../../../../utils';
1+
import { datasetExpander } from '../../../../TestParser/DatasetExpander';
22
import type { AstNode, CallNode } from '../../../AstParser/AstNode';
33
import type { Context, Expression } from '../../Expression';
44

@@ -19,7 +19,7 @@ class ArrayCombineExpression implements Expression<string[]> {
1919
return undefined;
2020
}
2121
return [...resolved.values()].map((item, i) =>
22-
typeof item === 'string' ? datasetNamed(item) : datasetIndexed(i),
22+
typeof item === 'string' ? datasetExpander.named(item) : datasetExpander.indexed(i),
2323
);
2424
}
2525
}

packages/phpunit/src/Interpreter/Expressions/PhpExpression.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { datasetIndexed, datasetNamed } from '../../utils';
1+
import { datasetExpander } from '../../TestParser/DatasetExpander';
22
import type { AstNode } from '../AstParser/AstNode';
33
import type { Bindings, Context, Expression } from './Expression';
44
import { arrayCombineExpression } from './Functions/Array/ArrayCombineExpression';
@@ -106,7 +106,7 @@ export function extractLabels(resolved: unknown): string[] | undefined {
106106
return undefined;
107107
}
108108
return [...resolved.keys()].map((key: string) =>
109-
/^\d+$/.test(key) ? datasetIndexed(key) : datasetNamed(key),
109+
/^\d+$/.test(key) ? datasetExpander.indexed(key) : datasetExpander.named(key),
110110
);
111111
}
112112

packages/phpunit/src/Interpreter/Expressions/Statements/LoopBodyExecutor.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { datasetIndexed, datasetNamed } from '../../../utils';
1+
import { datasetExpander } from '../../../TestParser/DatasetExpander';
22
import type { AstNode, IfStatementNode } from '../../AstParser/AstNode';
33
import type { Context } from '../Expression';
44
export const MAX_ITERATIONS = 1000;
@@ -47,9 +47,9 @@ const bodyStatementHandlers: Record<string, (stmt: AstNode, ctx: LoopContext) =>
4747
yield_expression: (stmt, ctx) => {
4848
const resolvedKey = ctx.context.resolve(stmt);
4949
if (resolvedKey !== undefined) {
50-
ctx.labels.push(datasetNamed(String(resolvedKey)));
50+
ctx.labels.push(datasetExpander.named(String(resolvedKey)));
5151
} else {
52-
ctx.labels.push(datasetIndexed(ctx.numericIndex++));
52+
ctx.labels.push(datasetExpander.indexed(ctx.numericIndex++));
5353
}
5454
return undefined;
5555
},

packages/phpunit/src/Interpreter/Resolvers/PestResolver.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { datasetNamed } from '../../utils';
1+
import { datasetExpander } from '../../TestParser/DatasetExpander';
22
import type { AstNode, CallNode, ExpressionStatementNode } from '../AstParser/AstNode';
33
import { evaluate } from '../Expressions/PhpExpression';
44
import type { PHP } from '../PHP';
@@ -134,6 +134,7 @@ function resolveDatasets(sources: AstNode[]): string[] {
134134
return cartesianProduct(datasets);
135135
}
136136

137+
// Cartesian failed (e.g. closure/generator can't be statically resolved); fall back to independent resolution
137138
return sources.flatMap((source) => resolvePestLabels(source));
138139
}
139140

@@ -154,18 +155,43 @@ function resolvePestLabels(node: AstNode): string[] {
154155
if (!formatted) {
155156
return [];
156157
}
157-
labels.push(datasetNamed(formatted));
158+
labels.push(datasetExpander.named(formatted));
158159
} else {
159-
labels.push(datasetNamed(`dataset "${key}"`));
160+
labels.push(datasetExpander.named(`dataset "${key}"`));
160161
}
161162
}
162-
return labels;
163+
return deduplicateLabels(labels);
163164
}
164165

166+
function deduplicateLabels(labels: string[]): string[] {
167+
const counts = new Map<string, number>();
168+
for (const label of labels) {
169+
counts.set(label, (counts.get(label) ?? 0) + 1);
170+
}
171+
172+
const indices = new Map<string, number>();
173+
return labels.map((label) => {
174+
if ((counts.get(label) ?? 0) <= 1) {
175+
return label;
176+
}
177+
const idx = (indices.get(label) ?? 0) + 1;
178+
indices.set(label, idx);
179+
return `${label} #${idx}`;
180+
});
181+
}
182+
183+
const MAX_DATASET_ITEMS = 3;
184+
165185
function formatPestValue(value: unknown): string | undefined {
166186
if (value instanceof Map || Array.isArray(value)) {
167187
const items = value instanceof Map ? [...value.values()] : value;
168-
return `(${items.map((v) => (typeof v === 'string' ? `'${v}'` : String(v))).join(', ')})`;
188+
const truncated = items.length > MAX_DATASET_ITEMS;
189+
const visible = items.slice(0, MAX_DATASET_ITEMS);
190+
const parts = visible.map((v) => (typeof v === 'string' ? `'${v}'` : String(v)));
191+
if (truncated) {
192+
parts.push('…');
193+
}
194+
return `(${parts.join(', ')})`;
169195
}
170196
if (typeof value === 'string') {
171197
return `('${value}')`;
@@ -178,7 +204,9 @@ function cartesianProduct(datasets: string[][]): string[] {
178204
for (let i = 1; i < datasets.length; i++) {
179205
combinations = combinations.flatMap((combo) => datasets[i].map((v) => [...combo, v]));
180206
}
181-
return combinations.map((combo) => datasetNamed(combo.map((v) => `('${v}')`).join(' / ')));
207+
return combinations.map((combo) =>
208+
datasetExpander.named(combo.map((v) => `('${v}')`).join(' / ')),
209+
);
182210
}
183211

184212
function unwrapClosureBody(node: AstNode): AstNode | undefined {

packages/phpunit/src/Interpreter/Resolvers/TestTagResolver.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { datasetExpander } from '../../TestParser/DatasetExpander';
12
import type { Annotations } from '../../types';
2-
import { datasetIndexed, datasetNamed } from '../../utils';
33
import type { AstNode, MethodNode } from '../AstParser/AstNode';
44
import type { PHP } from '../PHP';
55
import type { Annotatable, Resolver } from '../types';
@@ -98,12 +98,12 @@ export class TestTagResolver implements Resolver {
9898
const name = attr.args[1];
9999
dataset.push(
100100
typeof name === 'string' && name
101-
? datasetNamed(name)
102-
: datasetIndexed(datasetIndex),
101+
? datasetExpander.named(name)
102+
: datasetExpander.indexed(datasetIndex),
103103
);
104104
datasetIndex++;
105105
} else if (attr.name === 'TestWithJson') {
106-
dataset.push(datasetIndexed(datasetIndex));
106+
dataset.push(datasetExpander.indexed(datasetIndex));
107107
datasetIndex++;
108108
}
109109
}

packages/phpunit/src/ProcessBuilder/FilterStrategy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { TestIdentifier } from '../TestIdentifier/TestIdentifier';
2+
import { datasetExpander } from '../TestParser/DatasetExpander';
23
import { type TestDefinition, TestType } from '../types';
3-
import { parseDataset } from '../utils';
44

55
abstract class FilterStrategy {
66
constructor(protected testDefinition: TestDefinition) {}
@@ -93,7 +93,7 @@ class DatasetFilterStrategy extends DescribeFilterStrategy {
9393

9494
protected getMethodNamePattern() {
9595
const methodName = (this.testDefinition.methodName ?? '').trim();
96-
const { dataset } = parseDataset(this.testDefinition.id ?? '');
96+
const { dataset } = datasetExpander.parse(this.testDefinition.id ?? '');
9797

9898
const escapedMethod = TestIdentifier.generateSearchText(methodName).replace(/'/g, "\\'");
9999
return `${escapedMethod}${dataset.replace(/[.*+?^${}()|[\]\\/']/g, '\\$&')}`;

0 commit comments

Comments
 (0)