|
| 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