diff --git a/PyMCU.SDK.slnx b/PyMCU.SDK.slnx
index 5b84c50..a1b98d9 100644
--- a/PyMCU.SDK.slnx
+++ b/PyMCU.SDK.slnx
@@ -2,4 +2,7 @@
+
+
+
diff --git a/pyproject.toml b/pyproject.toml
index 7078455..28f90f0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,3 +19,9 @@ dependencies = [
[tool.hatch.build.targets.wheel]
packages = ["src/python/pymcu"]
+[tool.pytest.ini_options]
+testpaths = ["tests/python"]
+python_files = ["test_*.py"]
+python_classes = ["Test*"]
+python_functions = ["test_*"]
+
diff --git a/tests/csharp/Backend/Analysis/DynamicStackAllocatorTests.cs b/tests/csharp/Backend/Analysis/DynamicStackAllocatorTests.cs
new file mode 100644
index 0000000..d6b86d4
--- /dev/null
+++ b/tests/csharp/Backend/Analysis/DynamicStackAllocatorTests.cs
@@ -0,0 +1,184 @@
+// SPDX-License-Identifier: MIT
+// PyMCU Backend SDK — Unit tests for DynamicStackAllocator.
+
+using FluentAssertions;
+using PyMCU.Backend.Analysis;
+using PyMCU.IR;
+using Xunit;
+
+namespace PyMCU.Backend.SDK.Tests.Backend.Analysis;
+
+public class DynamicStackAllocatorTests
+{
+ private static Function MakeFunc(string name, IList body, params string[] parms)
+ {
+ return new Function
+ {
+ Name = name,
+ Params = [.. parms],
+ Body = [.. body]
+ };
+ }
+
+ // ── Word size and reserved-top defaults ───────────────────────────────
+
+ [Fact]
+ public void Allocate_EmptyFunction_TotalSizeIsAlignedReserved()
+ {
+ // No locals, no params: totalSize = reservedTop rounded up to 16.
+ // reservedTop=8 → -currentOffset starts at 8, totalSize=8 → rounds up to 16.
+ var alloc = new DynamicStackAllocator(wordSize: 4, reservedTop: 8);
+ var func = MakeFunc("main", [new Return(new NoneVal())]);
+ var (offsets, total) = alloc.Allocate(func);
+
+ offsets.Should().BeEmpty();
+ total.Should().Be(16); // 8 rounded up to next multiple of 16
+ }
+
+ // ── Single variable allocation ─────────────────────────────────────────
+
+ [Fact]
+ public void Allocate_OneVariable_HasNegativeOffset()
+ {
+ var alloc = new DynamicStackAllocator(wordSize: 4, reservedTop: 8);
+ var func = MakeFunc("main", [
+ new Copy(new Constant(1), new Variable("x")),
+ new Return(new Variable("x"))
+ ]);
+ var (offsets, total) = alloc.Allocate(func);
+
+ offsets.Should().ContainKey("x");
+ offsets["x"].Should().BeNegative(); // frame grows downwards
+ total.Should().BeGreaterThan(0);
+ }
+
+ // ── Parameters come first ─────────────────────────────────────────────
+
+ [Fact]
+ public void Allocate_Params_AllocatedBeforeBodyLocals()
+ {
+ var alloc = new DynamicStackAllocator(wordSize: 4, reservedTop: 8);
+ var func = MakeFunc("add", [
+ new Binary(BinaryOp.Add, new Variable("a"), new Variable("b"), new Variable("result")),
+ new Return(new Variable("result"))
+ ], "a", "b");
+
+ var (offsets, _) = alloc.Allocate(func);
+
+ offsets.Should().ContainKeys("a", "b", "result");
+ // Params a and b are allocated first → more-negative offsets (DynamicStackAllocator
+ // grows downward, so first-allocated = highest address = least-negative offset).
+ // Params come before body locals, so params have a HIGHER (less-negative) offset.
+ offsets["result"].Should().BeLessThan(offsets["a"]);
+ offsets["result"].Should().BeLessThan(offsets["b"]);
+ }
+
+ // ── Alignment: total size is always a multiple of 16 ─────────────────
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(3)]
+ [InlineData(5)]
+ public void Allocate_TotalSize_IsAlwaysMultipleOf16(int numVars)
+ {
+ var alloc = new DynamicStackAllocator(wordSize: 4, reservedTop: 0);
+ var body = Enumerable.Range(0, numVars)
+ .Select(i => (Instruction)new Copy(new Constant(i), new Variable($"v{i}")))
+ .Append(new Return(new NoneVal()))
+ .ToList();
+
+ var func = MakeFunc("main", body);
+ var (_, total) = alloc.Allocate(func);
+
+ (total % 16).Should().Be(0);
+ }
+
+ // ── Each variable gets a unique offset ────────────────────────────────
+
+ [Fact]
+ public void Allocate_MultipleVars_AllGetDistinctOffsets()
+ {
+ var alloc = new DynamicStackAllocator(wordSize: 4, reservedTop: 0);
+ var func = MakeFunc("main", [
+ new Copy(new Constant(1), new Variable("a")),
+ new Copy(new Constant(2), new Variable("b")),
+ new Copy(new Constant(3), new Variable("c")),
+ new Return(new NoneVal())
+ ]);
+
+ var (offsets, _) = alloc.Allocate(func);
+
+ var vals = new[] { offsets["a"], offsets["b"], offsets["c"] };
+ vals.Should().OnlyHaveUniqueItems();
+ }
+
+ // ── Same variable in multiple instructions — allocated once ───────────
+
+ [Fact]
+ public void Allocate_SameVarReferencedMultipleTimes_AllocatedOnce()
+ {
+ var alloc = new DynamicStackAllocator(wordSize: 4, reservedTop: 0);
+ var func = MakeFunc("main", [
+ new Copy(new Constant(1), new Variable("x")),
+ new Copy(new Variable("x"), new Variable("y")),
+ new Return(new Variable("x"))
+ ]);
+
+ var (offsets, _) = alloc.Allocate(func);
+
+ offsets.Should().ContainKey("x");
+ offsets.Keys.Count(k => k == "x").Should().Be(1); // only one entry for x
+ }
+
+ // ── Temporaries are also allocated ────────────────────────────────────
+
+ [Fact]
+ public void Allocate_Temporaries_AreIncluded()
+ {
+ var alloc = new DynamicStackAllocator(wordSize: 4, reservedTop: 0);
+ var func = MakeFunc("main", [
+ new Binary(BinaryOp.Add, new Variable("a"), new Variable("b"), new Temporary("t1")),
+ new Return(new Temporary("t1"))
+ ]);
+
+ var (offsets, _) = alloc.Allocate(func);
+
+ offsets.Should().ContainKey("t1");
+ }
+
+ // ── Bit instructions ────────────────────────────────────────────────────
+
+ [Fact]
+ public void Allocate_BitInstructions_RegisterVars()
+ {
+ var alloc = new DynamicStackAllocator(wordSize: 4, reservedTop: 0);
+ var func = MakeFunc("main", [
+ new BitSet(new Variable("port"), 3),
+ new BitClear(new Variable("port"), 5),
+ new BitCheck(new Variable("port"), 2, new Temporary("t1")),
+ new BitWrite(new Variable("port"), 4, new Variable("val")),
+ new Return(new NoneVal())
+ ]);
+
+ var (offsets, _) = alloc.Allocate(func);
+
+ offsets.Should().ContainKeys("port", "t1", "val");
+ }
+
+ // ── Custom word size propagates correctly ─────────────────────────────
+
+ [Fact]
+ public void Allocate_CustomWordSize_OffsetsAreSeparatedByWordSize()
+ {
+ var alloc = new DynamicStackAllocator(wordSize: 2, reservedTop: 0);
+ var func = MakeFunc("main", [
+ new Copy(new Constant(1), new Variable("a")),
+ new Copy(new Constant(2), new Variable("b")),
+ new Return(new NoneVal())
+ ]);
+
+ var (offsets, _) = alloc.Allocate(func);
+
+ Math.Abs(offsets["a"] - offsets["b"]).Should().Be(2);
+ }
+}
diff --git a/tests/csharp/Backend/Analysis/StackAllocatorTests.cs b/tests/csharp/Backend/Analysis/StackAllocatorTests.cs
new file mode 100644
index 0000000..1e71782
--- /dev/null
+++ b/tests/csharp/Backend/Analysis/StackAllocatorTests.cs
@@ -0,0 +1,235 @@
+// SPDX-License-Identifier: MIT
+// PyMCU Backend SDK — Unit tests for StackAllocator.
+
+using FluentAssertions;
+using PyMCU.Backend.Analysis;
+using PyMCU.IR;
+using Xunit;
+
+namespace PyMCU.Backend.SDK.Tests.Backend.Analysis;
+
+public class StackAllocatorTests
+{
+ private static ProgramIR MakeProgram(
+ IEnumerable? globals = null,
+ IEnumerable? funcs = null)
+ {
+ var prog = new ProgramIR();
+ if (globals != null) prog.Globals.AddRange(globals);
+ if (funcs != null) prog.Functions.AddRange(funcs);
+ return prog;
+ }
+
+ private static Function MakeFunc(string name, IList body, bool isInterrupt = false, int vector = 0, IList? parms = null)
+ {
+ return new Function
+ {
+ Name = name,
+ Params = parms != null ? [.. parms] : [],
+ Body = [.. body],
+ IsInterrupt = isInterrupt,
+ InterruptVector = vector
+ };
+ }
+
+ // ── Global allocation ──────────────────────────────────────────────────
+
+ [Fact]
+ public void Allocate_NoFunctions_AllocatesGlobals()
+ {
+ var prog = MakeProgram(globals: [
+ new Variable("a", DataType.UINT8),
+ new Variable("b", DataType.UINT16)
+ ]);
+
+ var allocator = new StackAllocator();
+ var (offsets, maxStack) = allocator.Allocate(prog);
+
+ offsets["a"].Should().Be(0);
+ offsets["b"].Should().Be(1); // UINT8 occupies 1 byte
+ maxStack.Should().Be(3); // 1 + 2
+ }
+
+ // ── Single function — local variables ─────────────────────────────────
+
+ [Fact]
+ public void Allocate_SingleFunction_AssignsSequentialOffsets()
+ {
+ var prog = MakeProgram(funcs: [MakeFunc("main", [
+ new Copy(new Constant(1), new Variable("x")),
+ new Copy(new Constant(2), new Variable("y")),
+ new Return(new Variable("x"))
+ ])]);
+
+ var allocator = new StackAllocator();
+ var (offsets, maxStack) = allocator.Allocate(prog);
+
+ offsets.Should().ContainKey("x");
+ offsets.Should().ContainKey("y");
+ // x and y are 1-byte locals starting at 0
+ offsets["x"].Should().Be(0);
+ offsets["y"].Should().Be(1);
+ maxStack.Should().BeGreaterThanOrEqualTo(2);
+ }
+
+ // ── Parameter registration ─────────────────────────────────────────────
+
+ [Fact]
+ public void Allocate_FunctionParams_AreRegisteredAsLocals()
+ {
+ // "add" is called from main — params get allocated as part of the call graph.
+ var prog = MakeProgram(funcs: [
+ MakeFunc("main", [
+ new Call("add", [new Constant(1), new Constant(2)], new Variable("out")),
+ new Return(new Variable("out"))
+ ]),
+ MakeFunc("add", [
+ new Binary(BinaryOp.Add, new Variable("a"), new Variable("b"), new Variable("result")),
+ new Return(new Variable("result"))
+ ], parms: ["a", "b"])
+ ]);
+
+ var allocator = new StackAllocator();
+ var (offsets, _) = allocator.Allocate(prog);
+
+ // params a and b should be in the offset table
+ offsets.Should().ContainKey("a");
+ offsets.Should().ContainKey("b");
+ }
+
+ // ── Globals vs locals: globals must not be re-allocated in functions ───
+
+ [Fact]
+ public void Allocate_GlobalNotReallocatedAsLocal()
+ {
+ var prog = MakeProgram(
+ globals: [new Variable("led", DataType.UINT8)],
+ funcs: [MakeFunc("main", [
+ new Copy(new Constant(1), new Variable("led")), // led is global
+ new Copy(new Constant(0), new Variable("local")),
+ new Return(new NoneVal())
+ ])]);
+
+ var allocator = new StackAllocator();
+ var (offsets, maxStack) = allocator.Allocate(prog);
+
+ // led is global at offset 0
+ offsets["led"].Should().Be(0);
+ // local starts right after the globals (offset 1)
+ offsets["local"].Should().Be(1);
+ }
+
+ // ── Call graph: caller + callee stack frames are sequenced ─────────────
+
+ [Fact]
+ public void Allocate_CallerCallee_SequencesFrames()
+ {
+ var callee = MakeFunc("helper", [
+ new Return(new Variable("ret"))
+ ], parms: ["ret"]);
+
+ var caller = MakeFunc("main", [
+ new Call("helper", [new Constant(1)], new Variable("res")),
+ new Return(new Variable("res"))
+ ]);
+
+ var prog = MakeProgram(funcs: [caller, callee]);
+ var allocator = new StackAllocator();
+ var (offsets, maxStack) = allocator.Allocate(prog);
+
+ // caller's locals come first, then callee's locals start at a higher offset
+ offsets.Should().ContainKey("res");
+ offsets.Should().ContainKey("ret");
+ offsets["ret"].Should().BeGreaterThan(offsets["res"]);
+ }
+
+ // ── Interrupt functions are also allocated ─────────────────────────────
+
+ [Fact]
+ public void Allocate_InterruptFunction_GetsOffsets()
+ {
+ var isr = MakeFunc("isr_tim0", [
+ new Copy(new Constant(0), new Variable("tick")),
+ new Return(new NoneVal())
+ ], isInterrupt: true, vector: 1);
+
+ var main = MakeFunc("main", [new Return(new NoneVal())]);
+
+ var prog = MakeProgram(funcs: [main, isr]);
+ var allocator = new StackAllocator();
+ var (offsets, _) = allocator.Allocate(prog);
+
+ offsets.Should().ContainKey("tick");
+ }
+
+ // ── VariableSizes is populated ────────────────────────────────────────
+
+ [Fact]
+ public void Allocate_VariableSizes_ReflectsDataTypes()
+ {
+ var prog = MakeProgram(funcs: [MakeFunc("main", [
+ new Copy(new Constant(0), new Variable("b8", DataType.UINT8)),
+ new Copy(new Constant(0), new Variable("b16", DataType.UINT16)),
+ new Copy(new Constant(0), new Variable("b32", DataType.UINT32)),
+ new Return(new NoneVal())
+ ])]);
+
+ var allocator = new StackAllocator();
+ allocator.Allocate(prog);
+
+ allocator.VariableSizes["b8"].Should().Be(1);
+ allocator.VariableSizes["b16"].Should().Be(2);
+ allocator.VariableSizes["b32"].Should().Be(4);
+ }
+
+ // ── BitSet/BitClear/BitCheck/BitWrite variables are captured ──────────
+
+ [Fact]
+ public void Allocate_BitInstructions_RegisterVars()
+ {
+ var prog = MakeProgram(funcs: [MakeFunc("main", [
+ new BitSet(new Variable("port"), 3),
+ new BitClear(new Variable("port"), 5),
+ new BitCheck(new Variable("port"), 2, new Temporary("t1")),
+ new BitWrite(new Variable("port"), 4, new Variable("val")),
+ new Return(new NoneVal())
+ ])]);
+
+ var allocator = new StackAllocator();
+ var (offsets, _) = allocator.Allocate(prog);
+
+ offsets.Should().ContainKey("port");
+ offsets.Should().ContainKey("t1");
+ offsets.Should().ContainKey("val");
+ }
+
+ // ── Array instructions register array name with correct total size ─────
+
+ [Fact]
+ public void Allocate_ArrayLoad_RegistersArrayWithTotalByteSize()
+ {
+ var prog = MakeProgram(funcs: [MakeFunc("main", [
+ new ArrayLoad("buf", new Variable("i"), new Temporary("t1"), DataType.UINT8, 10),
+ new Return(new NoneVal())
+ ])]);
+
+ var allocator = new StackAllocator();
+ allocator.Allocate(prog);
+
+ allocator.VariableSizes["buf"].Should().Be(10); // 10 × UINT8 (1 byte)
+ }
+
+ [Fact]
+ public void Allocate_ArrayStore_RegistersArrayWithTotalByteSize()
+ {
+ var prog = MakeProgram(funcs: [MakeFunc("main", [
+ new ArrayStore("buf", new Variable("i"), new Variable("v"), DataType.UINT16, 4),
+ new Return(new NoneVal())
+ ])]);
+
+ var allocator = new StackAllocator();
+ allocator.Allocate(prog);
+
+ allocator.VariableSizes["buf"].Should().Be(8); // 4 × UINT16 (2 bytes)
+ }
+}
diff --git a/tests/csharp/Backend/License/LicenseResultTests.cs b/tests/csharp/Backend/License/LicenseResultTests.cs
new file mode 100644
index 0000000..a20f26e
--- /dev/null
+++ b/tests/csharp/Backend/License/LicenseResultTests.cs
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: MIT
+// PyMCU Backend SDK — Unit tests for LicenseResult factory methods.
+
+using FluentAssertions;
+using PyMCU.Backend.License;
+using Xunit;
+
+namespace PyMCU.Backend.SDK.Tests.Backend.License;
+
+public class LicenseResultTests
+{
+ [Fact]
+ public void Free_ReturnsValidStatus_WithFreeMessage()
+ {
+ var result = LicenseResult.Free();
+
+ result.Status.Should().Be(LicenseStatus.Valid);
+ result.Message.Should().Be("free");
+ result.Email.Should().BeNull();
+ result.ExpiryDate.Should().BeNull();
+ }
+
+ [Fact]
+ public void Ok_ReturnsValidStatus_WithEmailAndExpiry()
+ {
+ var expiry = new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var result = LicenseResult.Ok("dev@example.com", expiry);
+
+ result.Status.Should().Be(LicenseStatus.Valid);
+ result.Email.Should().Be("dev@example.com");
+ result.ExpiryDate.Should().Be(expiry);
+ }
+
+ [Fact]
+ public void NotFound_ReturnsMissingStatus()
+ {
+ var result = LicenseResult.NotFound("avr");
+
+ result.Status.Should().Be(LicenseStatus.Missing);
+ result.Message.Should().Contain("avr");
+ result.Message.Should().Contain("PYMCU_LICENSE_KEY");
+ }
+
+ [Fact]
+ public void Expired_ReturnsExpiredStatus_WithEmailAndDate()
+ {
+ var expiry = new DateTime(2024, 6, 1, 0, 0, 0, DateTimeKind.Utc);
+ var result = LicenseResult.Expired("user@example.com", expiry);
+
+ result.Status.Should().Be(LicenseStatus.Expired);
+ result.Email.Should().Be("user@example.com");
+ result.ExpiryDate.Should().Be(expiry);
+ result.Message.Should().Contain("2024-06-01");
+ }
+
+ [Fact]
+ public void WrongFamily_ReturnsInvalidTargetStatus()
+ {
+ var result = LicenseResult.WrongFamily("pic14");
+
+ result.Status.Should().Be(LicenseStatus.InvalidTarget);
+ result.Message.Should().Contain("pic14");
+ }
+}
diff --git a/tests/csharp/Backend/License/LicenseValidatorTests.cs b/tests/csharp/Backend/License/LicenseValidatorTests.cs
new file mode 100644
index 0000000..0dd759b
--- /dev/null
+++ b/tests/csharp/Backend/License/LicenseValidatorTests.cs
@@ -0,0 +1,169 @@
+// SPDX-License-Identifier: MIT
+// PyMCU Backend SDK — Unit tests for LicenseValidator.
+
+using FluentAssertions;
+using PyMCU.Backend.License;
+using Xunit;
+
+namespace PyMCU.Backend.SDK.Tests.Backend.License;
+
+///
+/// Tests for LicenseValidator key resolution and JWT parsing.
+///
+/// Because the SDK ships with the placeholder public key
+/// ("REPLACE_WITH_REAL_RSA2048_PUBLIC_KEY_BASE64_HERE"), signature
+/// verification is intentionally skipped in dev builds. These tests
+/// therefore exercise key resolution, expiry, family matching, and
+/// structural parsing — not the actual RSA signature.
+///
+public class LicenseValidatorResolveKeyTests : IDisposable
+{
+ private readonly string _origEnv;
+ private readonly string _tmpHome;
+
+ public LicenseValidatorResolveKeyTests()
+ {
+ _origEnv = Environment.GetEnvironmentVariable("PYMCU_LICENSE_KEY") ?? "";
+ Environment.SetEnvironmentVariable("PYMCU_LICENSE_KEY", null);
+ _tmpHome = Path.Combine(Path.GetTempPath(), $"pymcu_test_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tmpHome);
+ }
+
+ public void Dispose()
+ {
+ Environment.SetEnvironmentVariable("PYMCU_LICENSE_KEY",
+ string.IsNullOrEmpty(_origEnv) ? null : _origEnv);
+ if (Directory.Exists(_tmpHome))
+ Directory.Delete(_tmpHome, recursive: true);
+ }
+
+ [Fact]
+ public void ResolveKey_ExplicitKey_TakesPriority()
+ {
+ Environment.SetEnvironmentVariable("PYMCU_LICENSE_KEY", "env-key");
+ LicenseValidator.ResolveKey("explicit-key").Should().Be("explicit-key");
+ }
+
+ [Fact]
+ public void ResolveKey_EnvVar_ResolvedWhenNoExplicit()
+ {
+ Environment.SetEnvironmentVariable("PYMCU_LICENSE_KEY", "env-key");
+ LicenseValidator.ResolveKey().Should().Be("env-key");
+ }
+
+ [Fact]
+ public void ResolveKey_NoSources_ReturnsNull()
+ {
+ // Ensure env var is cleared and no home file exists for this test
+ Environment.SetEnvironmentVariable("PYMCU_LICENSE_KEY", null);
+ // Note: we can't easily fake the home dir in .NET without reflection,
+ // so we only verify when no env is set and ResolveKey returns null or a
+ // value from the home file (which may legitimately exist in the environment).
+ var result = LicenseValidator.ResolveKey();
+ // Accept null or a non-empty string (in case a real license.key exists on the CI runner)
+ if (result != null) result.Should().NotBeEmpty();
+ }
+
+ [Fact]
+ public void ResolveKey_WhitespaceExplicit_TreatedAsNotSet()
+ {
+ LicenseValidator.ResolveKey(" ").Should().BeNull();
+ }
+}
+
+public class LicenseValidatorValidateTests
+{
+ // ── Pre-built test JWTs (placeholder sig — sig check is skipped in dev mode) ──
+ //
+ // Generated from:
+ // header = base64url({"alg":"RS256","typ":"JWT"})
+ // payload = base64url({"sub":"...","iat":...,"exp":...,"backends":[...]})
+ // jwt = header + "." + payload + ".ZmFrZXNpZ25hdHVyZQ" (fake sig bytes)
+ //
+ // These JWTs are intentionally unsigned dev tokens used only in tests.
+
+ // Expires 2099 — "avr" backend
+ private const string ValidAvrJwt =
+ "eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCJ9" +
+ ".eyJzdWIiOiAidXNlckBleGFtcGxlLmNvbSIsICJpYXQiOiAxNzAwMDAwMDAwLCAiZXhwIjogNDEwMjQ0NDgwMCwgImJhY2tlbmRzIjogWyJhdnIiXX0" +
+ ".ZmFrZXNpZ25hdHVyZQ";
+
+ // Expired in 2001 — "avr" backend
+ private const string ExpiredJwt =
+ "eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCJ9" +
+ ".eyJzdWIiOiAidXNlckBleGFtcGxlLmNvbSIsICJpYXQiOiAxNzAwMDAwMDAwLCAiZXhwIjogMTAwMDAwMDAwMCwgImJhY2tlbmRzIjogWyJhdnIiXX0" +
+ ".ZmFrZXNpZ25hdHVyZQ";
+
+ // Expires 2099, "avr" only — queried for "pic14"
+ private const string WrongFamilyJwt =
+ "eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCJ9" +
+ ".eyJzdWIiOiAidXNlckBleGFtcGxlLmNvbSIsICJpYXQiOiAxNzAwMDAwMDAwLCAiZXhwIjogNDEwMjQ0NDgwMCwgImJhY2tlbmRzIjogWyJhdnIiXX0" +
+ ".ZmFrZXNpZ25hdHVyZQ";
+
+ // Expires 2099, no "backends" field — covers any family
+ private const string AllFamiliesJwt =
+ "eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCJ9" +
+ ".eyJzdWIiOiAidXNlckBleGFtcGxlLmNvbSIsICJpYXQiOiAxNzAwMDAwMDAwLCAiZXhwIjogNDEwMjQ0NDgwMH0" +
+ ".ZmFrZXNpZ25hdHVyZQ";
+
+ [Fact]
+ public void Validate_NoKey_ReturnsMissing()
+ {
+ // Pass an explicit null so it doesn't read from env or file
+ var result = LicenseValidator.Validate("avr", "");
+ result.Status.Should().Be(LicenseStatus.Missing);
+ }
+
+ [Fact]
+ public void Validate_MalformedKey_ReturnsMalformed()
+ {
+ var result = LicenseValidator.Validate("avr", "not.a.jwt.with.too.many.dots.here.x");
+ result.Status.Should().Be(LicenseStatus.Malformed);
+ }
+
+ [Fact]
+ public void Validate_ValidAvrKey_CorrectFamily_ReturnsValid()
+ {
+ var result = LicenseValidator.Validate("avr", ValidAvrJwt);
+ result.Status.Should().Be(LicenseStatus.Valid);
+ result.Email.Should().Be("user@example.com");
+ }
+
+ [Fact]
+ public void Validate_ExpiredKey_ReturnsExpired()
+ {
+ var result = LicenseValidator.Validate("avr", ExpiredJwt);
+ result.Status.Should().Be(LicenseStatus.Expired);
+ result.Email.Should().Be("user@example.com");
+ result.ExpiryDate.Should().BeBefore(DateTime.UtcNow);
+ }
+
+ [Fact]
+ public void Validate_WrongFamily_ReturnsInvalidTarget()
+ {
+ var result = LicenseValidator.Validate("pic14", WrongFamilyJwt);
+ result.Status.Should().Be(LicenseStatus.InvalidTarget);
+ }
+
+ [Fact]
+ public void Validate_AllFamilies_AllowsAnyFamily()
+ {
+ // No "backends" array in payload → all families accepted
+ var result = LicenseValidator.Validate("riscv", AllFamiliesJwt);
+ result.Status.Should().Be(LicenseStatus.Valid);
+ }
+
+ [Fact]
+ public void Free_AlwaysReturnsValid()
+ {
+ LicenseValidator.Free().Status.Should().Be(LicenseStatus.Valid);
+ }
+
+ [Fact]
+ public void Validate_CaseInsensitiveFamily_Match()
+ {
+ // "avr" in JWT, "AVR" queried
+ var result = LicenseValidator.Validate("AVR", ValidAvrJwt);
+ result.Status.Should().Be(LicenseStatus.Valid);
+ }
+}
diff --git a/tests/csharp/Backend/Serialization/IrSerializerTests.cs b/tests/csharp/Backend/Serialization/IrSerializerTests.cs
new file mode 100644
index 0000000..cfa7a39
--- /dev/null
+++ b/tests/csharp/Backend/Serialization/IrSerializerTests.cs
@@ -0,0 +1,364 @@
+// SPDX-License-Identifier: MIT
+// PyMCU Backend SDK — Unit tests for IrSerializer (JSON round-trip).
+
+using FluentAssertions;
+using PyMCU.Backend.Serialization;
+using PyMCU.IR;
+using Xunit;
+
+namespace PyMCU.Backend.SDK.Tests.Backend.Serialization;
+
+public class IrSerializerTests : IDisposable
+{
+ private readonly string _tmpDir = Path.Combine(Path.GetTempPath(), $"irser_{Guid.NewGuid():N}");
+
+ public IrSerializerTests() => Directory.CreateDirectory(_tmpDir);
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_tmpDir))
+ Directory.Delete(_tmpDir, recursive: true);
+ }
+
+ private string TempFile(string name = "test.mir") => Path.Combine(_tmpDir, name);
+
+ private static ProgramIR RoundTrip(ProgramIR prog)
+ {
+ var tmp = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.mir");
+ try
+ {
+ IrSerializer.Serialize(prog, tmp);
+ return IrSerializer.Deserialize(tmp);
+ }
+ finally
+ {
+ if (File.Exists(tmp)) File.Delete(tmp);
+ }
+ }
+
+ // ── Empty program round-trip ───────────────────────────────────────────
+
+ [Fact]
+ public void Serialize_EmptyProgram_RoundTrips()
+ {
+ var prog = new ProgramIR();
+ var rt = RoundTrip(prog);
+
+ rt.Globals.Should().BeEmpty();
+ rt.Functions.Should().BeEmpty();
+ rt.ExternSymbols.Should().BeEmpty();
+ }
+
+ // ── Globals ────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Serialize_Globals_RoundTrip()
+ {
+ var prog = new ProgramIR();
+ prog.Globals.Add(new Variable("led", DataType.UINT8));
+ prog.Globals.Add(new Variable("counter", DataType.UINT16));
+
+ var rt = RoundTrip(prog);
+
+ rt.Globals.Should().HaveCount(2);
+ rt.Globals[0].Name.Should().Be("led");
+ rt.Globals[0].Type.Should().Be(DataType.UINT8);
+ rt.Globals[1].Name.Should().Be("counter");
+ rt.Globals[1].Type.Should().Be(DataType.UINT16);
+ }
+
+ // ── ExternSymbols ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void Serialize_ExternSymbols_RoundTrip()
+ {
+ var prog = new ProgramIR();
+ prog.ExternSymbols.AddRange(["uart_write", "delay_ms"]);
+
+ var rt = RoundTrip(prog);
+
+ rt.ExternSymbols.Should().BeEquivalentTo(["uart_write", "delay_ms"]);
+ }
+
+ // ── Function metadata ─────────────────────────────────────────────────
+
+ [Fact]
+ public void Serialize_FunctionMetadata_RoundTrips()
+ {
+ var prog = new ProgramIR();
+ prog.Functions.Add(new Function
+ {
+ Name = "isr_tim0",
+ ReturnType = DataType.VOID,
+ IsInterrupt = true,
+ IsInline = false,
+ InterruptVector = 2,
+ Params = ["arg1"]
+ });
+
+ var rt = RoundTrip(prog);
+ var f = rt.Functions[0];
+
+ f.Name.Should().Be("isr_tim0");
+ f.ReturnType.Should().Be(DataType.VOID);
+ f.IsInterrupt.Should().BeTrue();
+ f.InterruptVector.Should().Be(2);
+ f.Params.Should().ContainSingle("arg1");
+ }
+
+ // ── Instruction type round-trips ───────────────────────────────────────
+
+ [Fact]
+ public void Serialize_ReturnWithConstant_RoundTrips()
+ {
+ var prog = new ProgramIR();
+ prog.Functions.Add(new Function
+ {
+ Name = "main",
+ Body = [new Return(new Constant(42))]
+ });
+
+ var rt = RoundTrip(prog);
+ var ret = rt.Functions[0].Body.OfType().First();
+
+ ret.Value.Should().BeOfType().Which.Value.Should().Be(42);
+ }
+
+ [Fact]
+ public void Serialize_CopyInstruction_RoundTrips()
+ {
+ var prog = new ProgramIR();
+ prog.Functions.Add(new Function
+ {
+ Name = "main",
+ Body = [new Copy(new Constant(1), new Variable("x", DataType.UINT8))]
+ });
+
+ var rt = RoundTrip(prog);
+ var copy = rt.Functions[0].Body.OfType().First();
+
+ copy.Src.Should().BeOfType().Which.Value.Should().Be(1);
+ copy.Dst.Should().BeOfType().Which.Name.Should().Be("x");
+ }
+
+ [Fact]
+ public void Serialize_BinaryInstruction_RoundTrips()
+ {
+ var prog = new ProgramIR();
+ prog.Functions.Add(new Function
+ {
+ Name = "main",
+ Body = [new Binary(BinaryOp.Add, new Variable("a"), new Variable("b"), new Temporary("t1"))]
+ });
+
+ var rt = RoundTrip(prog);
+ var bin = rt.Functions[0].Body.OfType().First();
+
+ bin.Op.Should().Be(BinaryOp.Add);
+ bin.Src1.Should().BeOfType().Which.Name.Should().Be("a");
+ bin.Src2.Should().BeOfType().Which.Name.Should().Be("b");
+ bin.Dst.Should().BeOfType().Which.Name.Should().Be("t1");
+ }
+
+ [Fact]
+ public void Serialize_UnaryInstruction_RoundTrips()
+ {
+ var prog = new ProgramIR();
+ prog.Functions.Add(new Function
+ {
+ Name = "main",
+ Body = [new Unary(UnaryOp.Neg, new Constant(5), new Temporary("t1"))]
+ });
+
+ var rt = RoundTrip(prog);
+ var u = rt.Functions[0].Body.OfType().First();
+
+ u.Op.Should().Be(UnaryOp.Neg);
+ u.Src.Should().BeOfType().Which.Value.Should().Be(5);
+ }
+
+ [Fact]
+ public void Serialize_BitInstructions_RoundTrip()
+ {
+ var prog = new ProgramIR();
+ prog.Functions.Add(new Function
+ {
+ Name = "main",
+ Body =
+ [
+ new BitSet(new Variable("port"), 3),
+ new BitClear(new Variable("port"), 5),
+ new BitCheck(new Variable("port"), 2, new Temporary("t1")),
+ new BitWrite(new Variable("port"), 4, new Variable("val")),
+ new Return(new NoneVal())
+ ]
+ });
+
+ var rt = RoundTrip(prog);
+ var body = rt.Functions[0].Body;
+
+ body.OfType().First().Bit.Should().Be(3);
+ body.OfType().First().Bit.Should().Be(5);
+ body.OfType().First().Bit.Should().Be(2);
+ body.OfType().First().Bit.Should().Be(4);
+ }
+
+ [Fact]
+ public void Serialize_JumpInstructions_RoundTrip()
+ {
+ var prog = new ProgramIR();
+ prog.Functions.Add(new Function
+ {
+ Name = "main",
+ Body =
+ [
+ new JumpIfZero(new Variable("cond"), "end"),
+ new Jump("end"),
+ new Label("end"),
+ new Return(new NoneVal())
+ ]
+ });
+
+ var rt = RoundTrip(prog);
+ var body = rt.Functions[0].Body;
+
+ body.OfType().First().Target.Should().Be("end");
+ body.OfType().First().Target.Should().Be("end");
+ body.OfType