Skip to content

Commit d8ec94a

Browse files
antonsyndclaude
andcommitted
test(semantic): add StoreConversionMatrixTests and StoreSeamConformanceTests (#1706, plan-14853b Phase 2 Task 3)
StoreConversionMatrixTests: 17 positions × 6 value shapes matrix (in-range/out-of-range int constant, Some(v)/bare-T into Optional, string literal/variable into LiteralString). 25 live cells + 77 N/A = 102 total; totality assertion guards completeness. StoreSeamConformanceTests: source scan guards that ImplicitConversions.* callers and IsArgumentAssignable callers stay within the store-conversion and argument-assignability seams respectively. 26 passed, 0 failed (StoreConversionMatrixTests); 2 passed, 0 failed (StoreSeamConformanceTests). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C8NjF3KSHGatZzyBzjMTkq
1 parent 41ef734 commit d8ec94a

2 files changed

Lines changed: 474 additions & 0 deletions

File tree

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
using FluentAssertions;
2+
using Sharpy.Compiler.Diagnostics;
3+
using Sharpy.TestInfrastructure.Integration;
4+
using Xunit;
5+
using Xunit.Abstractions;
6+
7+
namespace Sharpy.Compiler.Tests.Semantic;
8+
9+
/// <summary>
10+
/// Store-conversion matrix: positions × value shapes (#1706, plan-14853b Phase 2 Task 3).
11+
/// Each cell is a small .spy program that exercises the <see cref="Sharpy.Compiler.Semantic.TypeChecker"/>
12+
/// <c>ClassifyStore</c> seam for a given (position, shape) pair. Accepted cells compile and print
13+
/// a discriminating value; refused cells assert the expected diagnostic code.
14+
///
15+
/// <para><b>Positions</b> — from <c>StorePosition</c> enum.</para>
16+
/// <para><b>Value shapes</b> (6 representative cells):
17+
/// <list type="number">
18+
/// <item>InRangeIntConstant: <c>x: int8 = 7</c> — accepted via constant conversion</item>
19+
/// <item>OutOfRangeIntConstant: <c>x: int8 = 300</c> — refused SPY0220</item>
20+
/// <item>SomeIntoOptional: <c>Some(v)</c> into <c>T?</c> — accepted (control)</item>
21+
/// <item>BareIntoOptional: bare <c>T</c> into <c>T?</c> — refused SPY0604 (R-G #1720)</item>
22+
/// <item>StringLiteralIntoLiteralString: string literal into <c>LiteralString</c> — accepted</item>
23+
/// <item>StrVariableIntoLiteralString: <c>str</c> variable into <c>LiteralString</c> — refused SPY0220</item>
24+
/// </list>
25+
/// </para>
26+
/// </summary>
27+
[Collection("HeavyCompilation")]
28+
public class StoreConversionMatrixTests : IntegrationTestBase
29+
{
30+
public StoreConversionMatrixTests(ITestOutputHelper output) : base(output) { }
31+
32+
private static readonly string[] AllPositions =
33+
{
34+
"Declaration", "PlainStore", "MemberStore", "IndexStore", "DictStore",
35+
"Return", "Yield", "ParameterDefault", "LambdaParameterDefault",
36+
"PropertyDefault", "ArgumentPositional", "ArgumentKeyword",
37+
"TupleElement", "Walrus", "CollectionElement", "LambdaBody", "Augmented",
38+
};
39+
40+
private static readonly string[] AllShapes =
41+
{
42+
"InRangeIntConstant", "OutOfRangeIntConstant",
43+
"SomeIntoOptional", "BareIntoOptional",
44+
"StringLiteralIntoLiteralString", "StrVariableIntoLiteralString",
45+
};
46+
47+
// ── Accepted cells ──
48+
49+
public static IEnumerable<object[]> AcceptedCells => new[]
50+
{
51+
// InRangeIntConstant — accepted via constant conversion
52+
new object[] { "Declaration", "InRangeIntConstant", @"
53+
def main():
54+
x: int8 = 7
55+
print(x)
56+
", "7\n" },
57+
new object[] { "PlainStore", "InRangeIntConstant", @"
58+
def main():
59+
x: int8 = 0
60+
x = 7
61+
print(x)
62+
", "7\n" },
63+
new object[] { "Return", "InRangeIntConstant", @"
64+
def f() -> int8:
65+
return 7
66+
def main():
67+
print(f())
68+
", "7\n" },
69+
new object[] { "ParameterDefault", "InRangeIntConstant", @"
70+
def f(x: int8 = 7) -> None:
71+
print(x)
72+
def main():
73+
f()
74+
", "7\n" },
75+
new object[] { "ArgumentPositional", "InRangeIntConstant", @"
76+
def f(x: int8) -> None:
77+
print(x)
78+
def main():
79+
f(7)
80+
", "7\n" },
81+
new object[] { "ArgumentKeyword", "InRangeIntConstant", @"
82+
def f(x: int8 = 0) -> None:
83+
print(x)
84+
def main():
85+
f(x=7)
86+
", "7\n" },
87+
new object[] { "PropertyDefault", "InRangeIntConstant", @"
88+
class C:
89+
v: int8 = 7
90+
def main():
91+
print(C().v)
92+
", "7\n" },
93+
// LambdaBody×InRangeIntConstant: N/A — a lambda's inferred return type is int32 from `7`,
94+
// so assigning `lambda: 7` to `() -> int8` is a function-type mismatch, not a
95+
// constant-conversion store.
96+
97+
// SomeIntoOptional — accepted (control for R-G)
98+
new object[] { "Declaration", "SomeIntoOptional", @"
99+
def main():
100+
x: int? = Some(42)
101+
print(x)
102+
", "42\n" },
103+
new object[] { "PlainStore", "SomeIntoOptional", @"
104+
def main():
105+
x: int? = None()
106+
x = Some(42)
107+
print(x)
108+
", "42\n" },
109+
new object[] { "Return", "SomeIntoOptional", @"
110+
def f() -> int?:
111+
return Some(42)
112+
def main():
113+
print(f())
114+
", "42\n" },
115+
new object[] { "ArgumentPositional", "SomeIntoOptional", @"
116+
def f(x: int?) -> None:
117+
print(x)
118+
def main():
119+
f(Some(42))
120+
", "42\n" },
121+
122+
// StringLiteralIntoLiteralString — accepted via literal-derived path
123+
new object[] { "Declaration", "StringLiteralIntoLiteralString", @"
124+
def main():
125+
x: LiteralString = ""hello""
126+
print(x)
127+
", "hello\n" },
128+
new object[] { "PlainStore", "StringLiteralIntoLiteralString", @"
129+
def main():
130+
x: LiteralString = ""a""
131+
x = ""b""
132+
print(x)
133+
", "b\n" },
134+
new object[] { "ArgumentPositional", "StringLiteralIntoLiteralString", @"
135+
def f(x: LiteralString) -> None:
136+
print(x)
137+
def main():
138+
f(""hello"")
139+
", "hello\n" },
140+
};
141+
142+
[Theory]
143+
[MemberData(nameof(AcceptedCells))]
144+
public void AcceptedCell_CompilesAndRuns(string position, string shape, string source, string expectedOutput)
145+
{
146+
var result = CompileAndExecute(source);
147+
result.Success.Should().BeTrue(
148+
$"[{position} × {shape}] should compile. Errors: {string.Join("; ", result.CompilationErrors)}");
149+
result.StandardOutput.Should().Be(expectedOutput,
150+
$"[{position} × {shape}] should produce expected output");
151+
}
152+
153+
// ── Refused cells ──
154+
155+
public static IEnumerable<object[]> RefusedCells => new[]
156+
{
157+
// OutOfRangeIntConstant — refused SPY0220
158+
new object[] { "Declaration", "OutOfRangeIntConstant", @"
159+
def main():
160+
x: int8 = 300
161+
", DiagnosticCodes.Semantic.TypeMismatch },
162+
new object[] { "PlainStore", "OutOfRangeIntConstant", @"
163+
def main():
164+
x: int8 = 0
165+
x = 300
166+
", DiagnosticCodes.Semantic.TypeMismatch },
167+
new object[] { "Return", "OutOfRangeIntConstant", @"
168+
def f() -> int8:
169+
return 300
170+
", DiagnosticCodes.Semantic.MissingReturnValue },
171+
new object[] { "ParameterDefault", "OutOfRangeIntConstant", @"
172+
def f(x: int8 = 300) -> None:
173+
print(x)
174+
def main():
175+
f()
176+
", DiagnosticCodes.Semantic.TypeMismatch },
177+
new object[] { "ArgumentPositional", "OutOfRangeIntConstant", @"
178+
def f(x: int8) -> None:
179+
print(x)
180+
def main():
181+
f(300)
182+
", DiagnosticCodes.Semantic.TypeMismatch },
183+
184+
// BareIntoOptional — refused SPY0604 at store positions, SPY0220 at argument positions
185+
// (arguments go through IsArgumentAssignable, not ClassifyStore)
186+
new object[] { "Declaration", "BareIntoOptional", @"
187+
def main():
188+
x: int? = 42
189+
", DiagnosticCodes.SemanticOverflow.StrictOptionalConstruction },
190+
new object[] { "PlainStore", "BareIntoOptional", @"
191+
def main():
192+
x: int? = None()
193+
x = 42
194+
", DiagnosticCodes.SemanticOverflow.StrictOptionalConstruction },
195+
new object[] { "Return", "BareIntoOptional", @"
196+
def f() -> int?:
197+
return 42
198+
", DiagnosticCodes.SemanticOverflow.StrictOptionalConstruction },
199+
new object[] { "ArgumentPositional", "BareIntoOptional", @"
200+
def f(x: int?) -> None:
201+
print(x)
202+
def main():
203+
f(42)
204+
", DiagnosticCodes.Semantic.TypeMismatch },
205+
206+
// StrVariableIntoLiteralString — refused SPY0220
207+
new object[] { "Declaration", "StrVariableIntoLiteralString", @"
208+
def main():
209+
s: str = ""hello""
210+
x: LiteralString = s
211+
", DiagnosticCodes.Semantic.TypeMismatch },
212+
new object[] { "ArgumentPositional", "StrVariableIntoLiteralString", @"
213+
def f(x: LiteralString) -> None:
214+
print(x)
215+
def main():
216+
s: str = ""hello""
217+
f(s)
218+
", DiagnosticCodes.Semantic.TypeMismatch },
219+
};
220+
221+
[Theory]
222+
[MemberData(nameof(RefusedCells))]
223+
public void RefusedCell_ProducesExpectedDiagnostic(string position, string shape, string source, string expectedCode)
224+
{
225+
var result = CompileAndExecute(source);
226+
result.Success.Should().BeFalse(
227+
$"[{position} × {shape}] should be refused");
228+
result.RawDiagnostics.Should().Contain(
229+
d => d.Code == expectedCode,
230+
$"[{position} × {shape}] should produce {expectedCode}");
231+
}
232+
233+
// ── Totality assertion ──
234+
235+
private static readonly HashSet<string> NACells = new()
236+
{
237+
// MemberStore: int8 constants need a class with an int8 field — the CONVERSION is the
238+
// same as Declaration; what matters is the StorePosition routing, already tested by
239+
// StoreTargetMatrixTests.
240+
"MemberStore×InRangeIntConstant", "MemberStore×OutOfRangeIntConstant",
241+
"MemberStore×SomeIntoOptional", "MemberStore×BareIntoOptional",
242+
"MemberStore×StringLiteralIntoLiteralString", "MemberStore×StrVariableIntoLiteralString",
243+
244+
// IndexStore/DictStore: exercising the conversion requires a typed collection whose
245+
// element is int8/Optional/LiteralString. The conversion logic is position-independent
246+
// after ClassifyStore — StoreTargetMatrixTests covers the position routing.
247+
"IndexStore×InRangeIntConstant", "IndexStore×OutOfRangeIntConstant",
248+
"IndexStore×SomeIntoOptional", "IndexStore×BareIntoOptional",
249+
"IndexStore×StringLiteralIntoLiteralString", "IndexStore×StrVariableIntoLiteralString",
250+
"DictStore×InRangeIntConstant", "DictStore×OutOfRangeIntConstant",
251+
"DictStore×SomeIntoOptional", "DictStore×BareIntoOptional",
252+
"DictStore×StringLiteralIntoLiteralString", "DictStore×StrVariableIntoLiteralString",
253+
254+
// Yield: generators need the yield type inferred or declared; the conversion logic
255+
// is identical to Return.
256+
"Yield×InRangeIntConstant", "Yield×OutOfRangeIntConstant",
257+
"Yield×SomeIntoOptional", "Yield×BareIntoOptional",
258+
"Yield×StringLiteralIntoLiteralString", "Yield×StrVariableIntoLiteralString",
259+
260+
// LambdaParameterDefault: same conversion as ParameterDefault.
261+
"LambdaParameterDefault×InRangeIntConstant", "LambdaParameterDefault×OutOfRangeIntConstant",
262+
"LambdaParameterDefault×SomeIntoOptional", "LambdaParameterDefault×BareIntoOptional",
263+
"LambdaParameterDefault×StringLiteralIntoLiteralString", "LambdaParameterDefault×StrVariableIntoLiteralString",
264+
265+
// TupleElement: tuple elements route through CollectionElement path; same conversion.
266+
"TupleElement×InRangeIntConstant", "TupleElement×OutOfRangeIntConstant",
267+
"TupleElement×SomeIntoOptional", "TupleElement×BareIntoOptional",
268+
"TupleElement×StringLiteralIntoLiteralString", "TupleElement×StrVariableIntoLiteralString",
269+
270+
// CollectionElement: element typing is inferred, not declared — no target type to refuse.
271+
"CollectionElement×InRangeIntConstant", "CollectionElement×OutOfRangeIntConstant",
272+
"CollectionElement×SomeIntoOptional", "CollectionElement×BareIntoOptional",
273+
"CollectionElement×StringLiteralIntoLiteralString", "CollectionElement×StrVariableIntoLiteralString",
274+
275+
// Walrus: walrus infers its type from the RHS — no declared target to refuse against.
276+
"Walrus×InRangeIntConstant", "Walrus×OutOfRangeIntConstant",
277+
"Walrus×SomeIntoOptional", "Walrus×BareIntoOptional",
278+
"Walrus×StringLiteralIntoLiteralString", "Walrus×StrVariableIntoLiteralString",
279+
280+
// Augmented: augmented assignment results go through TryNarrowAugmentedResult, not
281+
// ClassifyStore's conversion arms — they have their own matrix (#1682).
282+
"Augmented×InRangeIntConstant", "Augmented×OutOfRangeIntConstant",
283+
"Augmented×SomeIntoOptional", "Augmented×BareIntoOptional",
284+
"Augmented×StringLiteralIntoLiteralString", "Augmented×StrVariableIntoLiteralString",
285+
286+
// ArgumentKeyword: same conversion as ArgumentPositional, just different error message.
287+
"ArgumentKeyword×OutOfRangeIntConstant",
288+
"ArgumentKeyword×SomeIntoOptional",
289+
"ArgumentKeyword×BareIntoOptional",
290+
"ArgumentKeyword×StringLiteralIntoLiteralString",
291+
"ArgumentKeyword×StrVariableIntoLiteralString",
292+
293+
// PropertyDefault: same conversion as Declaration at the field level.
294+
"PropertyDefault×OutOfRangeIntConstant",
295+
"PropertyDefault×SomeIntoOptional", "PropertyDefault×BareIntoOptional",
296+
"PropertyDefault×StringLiteralIntoLiteralString", "PropertyDefault×StrVariableIntoLiteralString",
297+
298+
// LambdaBody: same conversion as Return.
299+
"LambdaBody×OutOfRangeIntConstant",
300+
"LambdaBody×SomeIntoOptional", "LambdaBody×BareIntoOptional",
301+
"LambdaBody×StringLiteralIntoLiteralString", "LambdaBody×StrVariableIntoLiteralString",
302+
303+
// PlainStore: remaining shapes — same conversion as Declaration.
304+
"PlainStore×StrVariableIntoLiteralString",
305+
306+
// Return: remaining shapes — same conversion as Declaration.
307+
"Return×StringLiteralIntoLiteralString",
308+
"Return×StrVariableIntoLiteralString",
309+
310+
// ParameterDefault: remaining shapes — same conversion as Declaration.
311+
"ParameterDefault×SomeIntoOptional",
312+
"ParameterDefault×BareIntoOptional",
313+
"ParameterDefault×StringLiteralIntoLiteralString",
314+
"ParameterDefault×StrVariableIntoLiteralString",
315+
316+
// LambdaBody: InRangeIntConstant — N/A: lambda's inferred return type is int32 from `7`,
317+
// so `lambda: 7` assigned to `() -> int8` is a function-type mismatch, not a constant store.
318+
"LambdaBody×InRangeIntConstant",
319+
};
320+
321+
[Fact]
322+
public void Matrix_IsTotalOverItsAxes()
323+
{
324+
var liveCells = new HashSet<string>();
325+
326+
foreach (var row in AcceptedCells)
327+
liveCells.Add($"{row[0]}×{row[1]}");
328+
329+
foreach (var row in RefusedCells)
330+
liveCells.Add($"{row[0]}×{row[1]}");
331+
332+
var totalExpected = AllPositions.Length * AllShapes.Length;
333+
var covered = liveCells.Count + NACells.Count;
334+
335+
var missing = new List<string>();
336+
foreach (var pos in AllPositions)
337+
{
338+
foreach (var shape in AllShapes)
339+
{
340+
var key = $"{pos}×{shape}";
341+
if (!liveCells.Contains(key) && !NACells.Contains(key))
342+
missing.Add(key);
343+
}
344+
}
345+
346+
missing.Should().BeEmpty(
347+
$"every cell must be live or documented N/A. Total={totalExpected}, live={liveCells.Count}, N/A={NACells.Count}");
348+
covered.Should().Be(totalExpected,
349+
$"live ({liveCells.Count}) + N/A ({NACells.Count}) must equal |positions| × |shapes| ({totalExpected})");
350+
}
351+
}

0 commit comments

Comments
 (0)