test(csharp-scope): integration fixtures for Units 6c/6d/6e runtime behavior

Close the integration-coverage gap flagged in the production-readiness
review. Units 6c (collection-accessor unwrap), 6d (using-static member
injection), and 6e (overload disambig + interface dispatch) previously
had only hook-level unit tests; the end-to-end wiring was exercised
only by the parity harness.

Three minimal fixtures + four new it() blocks:

  * csharp-collection-accessor — RenderAll iterates
    Dictionary<string, Widget>.Values and calls .Render(); asserts the
    CALLS edge lands on Widget.Render.
  * csharp-using-static — `using static Helpers.MathUtils;` makes
    Square(int) a free-callable in the consumer; asserts the CALLS
    edge lands on MathUtils.Square.
  * csharp-overload-interface — three assertions:
      1. Run → Log binds to the 2-arg overload only (arity narrowing);
         verified via target Method node's parameterTypes.length === 2.
      2. Run → Greet emits one primary edge to IGreeter.Greet plus two
         reason='interface-dispatch' siblings to En/FrGreeter.Greet.
      3. Interface-dispatch fan-out excludes the primary target.

Verified:
  - csharp integration        189/189 passing
This commit is contained in:
Gergo Magyar 2026-04-22 08:58:12 +01:00
parent 952350801c
commit fed95a9ef4
13 changed files with 204 additions and 0 deletions

View file

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,8 @@
namespace Models;
public class Widget
{
public void Render()
{
}
}

View file

@ -0,0 +1,17 @@
using System.Collections.Generic;
using Models;
namespace Services;
public class Renderer
{
private Dictionary<string, Widget> _widgets = new();
public void RenderAll()
{
foreach (var w in _widgets.Values)
{
w.Render();
}
}
}

View file

@ -0,0 +1,14 @@
using Greeting;
namespace App;
public class Caller
{
private Logger _logger = new();
public string Run(IGreeter greeter)
{
_logger.Log("starting", 1);
return greeter.Greet();
}
}

View file

@ -0,0 +1,12 @@
namespace App;
public class Logger
{
public void Log(string message)
{
}
public void Log(string message, int level)
{
}
}

View file

@ -0,0 +1,9 @@
namespace Greeting;
public class EnGreeter : IGreeter
{
public string Greet()
{
return "hello";
}
}

View file

@ -0,0 +1,9 @@
namespace Greeting;
public class FrGreeter : IGreeter
{
public string Greet()
{
return "bonjour";
}
}

View file

@ -0,0 +1,6 @@
namespace Greeting;
public interface IGreeter
{
string Greet();
}

View file

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,11 @@
using static Helpers.MathUtils;
namespace App;
public class Calculator
{
public int Compute(int value)
{
return Square(value);
}
}

View file

@ -0,0 +1,9 @@
namespace Helpers;
public static class MathUtils
{
public static int Square(int x)
{
return x * x;
}
}

View file

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

View file

@ -197,6 +197,100 @@ describe('C# member-call resolution', () => {
});
});
// ---------------------------------------------------------------------------
// Collection-accessor unwrap (Unit 6c): data.Values on Dictionary<K,V>
// resolves to the value type's class.
// ---------------------------------------------------------------------------
describe('C# collection-accessor unwrap', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-collection-accessor'), () => {});
}, 60000);
it('resolves RenderAll → Render through Dictionary<string, Widget>.Values', () => {
const calls = getRelationships(result, 'CALLS');
const renderCall = calls.find((c) => c.source === 'RenderAll' && c.target === 'Render');
expect(renderCall).toBeDefined();
expect(renderCall!.targetFilePath).toBe('Models/Widget.cs');
expect(['import-resolved', 'global']).toContain(renderCall!.rel.reason);
});
});
// ---------------------------------------------------------------------------
// using-static member injection (Unit 6d): `using static X.Y;` exposes Y's
// static methods as free-callables in the consumer.
// ---------------------------------------------------------------------------
describe('C# using static member injection', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-using-static'), () => {});
}, 60000);
it('resolves Compute → Square via `using static Helpers.MathUtils;`', () => {
const calls = getRelationships(result, 'CALLS');
const sqCall = calls.find((c) => c.source === 'Compute' && c.target === 'Square');
expect(sqCall).toBeDefined();
expect(sqCall!.targetFilePath).toBe('Helpers/MathUtils.cs');
expect(['import-resolved', 'global']).toContain(sqCall!.rel.reason);
});
});
// ---------------------------------------------------------------------------
// Overload disambiguation + interface dispatch (Unit 6e).
// ---------------------------------------------------------------------------
describe('C# overload disambiguation and interface dispatch', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-overload-interface'), () => {});
}, 60000);
it('Run → Log resolves to the 2-arg overload (arity narrowing)', () => {
const calls = getRelationships(result, 'CALLS');
const logCalls = calls.filter((c) => c.source === 'Run' && c.target === 'Log');
// With collapse-by-caller-target enabled and arity narrowing, Run
// should bind to the 2-arg overload only — not the 1-arg sibling.
expect(logCalls.length).toBe(1);
// Verify targetId points to the 2-arg overload by checking the
// target Method node's parameterTypes length.
const target = result.graph.getNode(logCalls[0].rel.targetId);
expect(target).toBeDefined();
const parameterTypes = (target!.properties as { parameterTypes?: string[] }).parameterTypes;
expect(parameterTypes).toBeDefined();
expect(parameterTypes!.length).toBe(2);
});
it('Run → Greet emits primary edge to IGreeter.Greet plus interface-dispatch siblings', () => {
const calls = getRelationships(result, 'CALLS');
const greetCalls = calls.filter((c) => c.source === 'Run' && c.target === 'Greet');
// One primary edge (IGreeter.Greet) + two interface-dispatch edges
// (EnGreeter.Greet, FrGreeter.Greet).
expect(greetCalls.length).toBe(3);
const primaries = greetCalls.filter((c) => c.rel.reason !== 'interface-dispatch');
expect(primaries.length).toBe(1);
expect(primaries[0].targetFilePath).toBe('Greeting/IGreeter.cs');
const fanout = greetCalls.filter((c) => c.rel.reason === 'interface-dispatch');
expect(fanout.length).toBe(2);
const fanoutPaths = fanout.map((c) => c.targetFilePath).sort();
expect(fanoutPaths).toEqual(['Greeting/EnGreeter.cs', 'Greeting/FrGreeter.cs']);
});
it('interface-dispatch fan-out excludes the primary target (no self-edge)', () => {
const calls = getRelationships(result, 'CALLS');
const fanout = calls.filter((c) => c.source === 'Run' && c.rel.reason === 'interface-dispatch');
for (const edge of fanout) {
expect(edge.targetFilePath).not.toBe('Greeting/IGreeter.cs');
}
});
});
// ---------------------------------------------------------------------------
// Primary constructor resolution: class User(string name, int age) { }
// ---------------------------------------------------------------------------