diff --git a/sdk/csharp/SynorCompute.Tests/SynorCompute.Tests.csproj b/sdk/csharp/SynorCompute.Tests/SynorCompute.Tests.csproj new file mode 100644 index 0000000..f3e222e --- /dev/null +++ b/sdk/csharp/SynorCompute.Tests/SynorCompute.Tests.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/sdk/csharp/SynorCompute.Tests/TensorTests.cs b/sdk/csharp/SynorCompute.Tests/TensorTests.cs new file mode 100644 index 0000000..01f302d --- /dev/null +++ b/sdk/csharp/SynorCompute.Tests/TensorTests.cs @@ -0,0 +1,198 @@ +using Xunit; +using SynorCompute; + +namespace SynorCompute.Tests; + +public class TensorTests +{ + [Fact] + public void Constructor_WithShapeAndData_CreatesTensor() + { + var tensor = new Tensor([2, 3], [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + + Assert.Equal([2, 3], tensor.Shape); + Assert.Equal(6, tensor.Size); + Assert.Equal(2, tensor.Ndim); + } + + [Fact] + public void Zeros_CreatesZeroTensor() + { + var tensor = Tensor.Zeros([3, 4]); + + Assert.Equal([3, 4], tensor.Shape); + Assert.Equal(12, tensor.Size); + Assert.All(tensor.Data, v => Assert.Equal(0.0, v)); + } + + [Fact] + public void Ones_CreatesOnesTensor() + { + var tensor = Tensor.Ones([2, 2]); + + Assert.All(tensor.Data, v => Assert.Equal(1.0, v)); + } + + [Fact] + public void Random_CreatesRandomTensor() + { + var tensor = Tensor.Random([10, 10]); + + Assert.Equal([10, 10], tensor.Shape); + Assert.All(tensor.Data, v => Assert.InRange(v, 0.0, 1.0)); + } + + [Fact] + public void Randn_CreatesNormalDistributionTensor() + { + var tensor = Tensor.Randn([1000]); + + var mean = tensor.Mean(); + var std = tensor.Std(); + + Assert.InRange(Math.Abs(mean), 0, 0.2); + Assert.InRange(std, 0.8, 1.2); + } + + [Fact] + public void Eye_CreatesIdentityMatrix() + { + var tensor = Tensor.Eye(3); + + Assert.Equal([3, 3], tensor.Shape); + Assert.Equal(1.0, tensor[0, 0]); + Assert.Equal(1.0, tensor[1, 1]); + Assert.Equal(1.0, tensor[2, 2]); + Assert.Equal(0.0, tensor[0, 1]); + } + + [Fact] + public void Arange_CreatesRangeTensor() + { + var tensor = Tensor.Arange(0, 5, 1); + + Assert.Equal([5], tensor.Shape); + Assert.Equal(0.0, tensor[0]); + Assert.Equal(4.0, tensor[4]); + } + + [Fact] + public void Linspace_CreatesLinearlySpacedTensor() + { + var tensor = Tensor.Linspace(0, 10, 11); + + Assert.Equal([11], tensor.Shape); + Assert.Equal(0.0, tensor[0], 4); + Assert.Equal(10.0, tensor[10], 4); + Assert.Equal(5.0, tensor[5], 4); + } + + [Fact] + public void Reshape_ChangesShape() + { + var tensor = new Tensor([6], [1, 2, 3, 4, 5, 6]); + var reshaped = tensor.Reshape([2, 3]); + + Assert.Equal([2, 3], reshaped.Shape); + Assert.Equal(6, reshaped.Size); + } + + [Fact] + public void Reshape_ThrowsOnInvalidShape() + { + var tensor = Tensor.Zeros([4]); + + Assert.Throws(() => tensor.Reshape([2, 3])); + } + + [Fact] + public void Transpose_Transposes2DTensor() + { + var tensor = new Tensor([2, 3], [1, 2, 3, 4, 5, 6]); + var transposed = tensor.Transpose(); + + Assert.Equal([3, 2], transposed.Shape); + Assert.Equal(1.0, transposed[0, 0]); + Assert.Equal(4.0, transposed[0, 1]); + } + + [Fact] + public void Sum_CalculatesSum() + { + var tensor = new Tensor([4], [1, 2, 3, 4]); + + Assert.Equal(10.0, tensor.Sum()); + } + + [Fact] + public void Mean_CalculatesMean() + { + var tensor = new Tensor([4], [1, 2, 3, 4]); + + Assert.Equal(2.5, tensor.Mean()); + } + + [Fact] + public void Std_CalculatesStandardDeviation() + { + var tensor = new Tensor([4], [1, 2, 3, 4]); + + Assert.Equal(1.118, tensor.Std(), 3); + } + + [Fact] + public void Min_FindsMinimum() + { + var tensor = new Tensor([4], [3, 1, 4, 2]); + + Assert.Equal(1.0, tensor.Min()); + } + + [Fact] + public void Max_FindsMaximum() + { + var tensor = new Tensor([4], [3, 1, 4, 2]); + + Assert.Equal(4.0, tensor.Max()); + } + + [Fact] + public void ReLU_AppliesActivation() + { + var tensor = new Tensor([5], [-2, -1, 0, 1, 2]); + var result = tensor.ReLU(); + + Assert.Equal([0.0, 0.0, 0.0, 1.0, 2.0], result.Data); + } + + [Fact] + public void Sigmoid_AppliesActivation() + { + var tensor = new Tensor([1], [0]); + var result = tensor.Sigmoid(); + + Assert.Equal(0.5, result[0], 4); + } + + [Fact] + public void Softmax_AppliesActivation() + { + var tensor = new Tensor([3], [1, 2, 3]); + var result = tensor.Softmax(); + + Assert.Equal(1.0, result.Sum(), 4); + Assert.True(result[2] > result[1]); + Assert.True(result[1] > result[0]); + } + + [Fact] + public void Serialization_RoundTrip() + { + var original = new Tensor([2, 3], [1, 2, 3, 4, 5, 6]); + var json = original.ToJson(); + var restored = Tensor.FromJson(json); + + Assert.Equal(original.Shape, restored.Shape); + Assert.Equal(original.Data, restored.Data); + } +} diff --git a/sdk/csharp/SynorCompute.Tests/TypesTests.cs b/sdk/csharp/SynorCompute.Tests/TypesTests.cs new file mode 100644 index 0000000..e4d105c --- /dev/null +++ b/sdk/csharp/SynorCompute.Tests/TypesTests.cs @@ -0,0 +1,192 @@ +using Xunit; +using SynorCompute; + +namespace SynorCompute.Tests; + +public class TypesTests +{ + [Fact] + public void ProcessorType_HasAllValues() + { + Assert.Equal("cpu", ProcessorType.Cpu.ToValue()); + Assert.Equal("gpu", ProcessorType.Gpu.ToValue()); + Assert.Equal("tpu", ProcessorType.Tpu.ToValue()); + Assert.Equal("npu", ProcessorType.Npu.ToValue()); + Assert.Equal("lpu", ProcessorType.Lpu.ToValue()); + Assert.Equal("auto", ProcessorType.Auto.ToValue()); + } + + [Fact] + public void ProcessorType_ParsesFromString() + { + Assert.Equal(ProcessorType.Gpu, ProcessorTypeExtensions.FromString("gpu")); + Assert.Equal(ProcessorType.Tpu, ProcessorTypeExtensions.FromString("tpu")); + Assert.Equal(ProcessorType.Auto, ProcessorTypeExtensions.FromString("invalid")); + } + + [Fact] + public void Precision_HasAllValues() + { + Assert.Equal("fp64", Precision.FP64.ToValue()); + Assert.Equal("fp32", Precision.FP32.ToValue()); + Assert.Equal("fp16", Precision.FP16.ToValue()); + Assert.Equal("bf16", Precision.BF16.ToValue()); + Assert.Equal("int8", Precision.INT8.ToValue()); + Assert.Equal("int4", Precision.INT4.ToValue()); + } + + [Fact] + public void Priority_HasAllValues() + { + Assert.Equal("critical", Priority.Critical.ToValue()); + Assert.Equal("high", Priority.High.ToValue()); + Assert.Equal("normal", Priority.Normal.ToValue()); + Assert.Equal("low", Priority.Low.ToValue()); + Assert.Equal("background", Priority.Background.ToValue()); + } + + [Fact] + public void JobStatus_HasAllValues() + { + Assert.Equal("pending", JobStatus.Pending.ToValue()); + Assert.Equal("queued", JobStatus.Queued.ToValue()); + Assert.Equal("running", JobStatus.Running.ToValue()); + Assert.Equal("completed", JobStatus.Completed.ToValue()); + Assert.Equal("failed", JobStatus.Failed.ToValue()); + Assert.Equal("cancelled", JobStatus.Cancelled.ToValue()); + } + + [Fact] + public void JobStatus_IdentifiesTerminalStates() + { + Assert.True(JobStatus.Completed.IsTerminal()); + Assert.True(JobStatus.Failed.IsTerminal()); + Assert.True(JobStatus.Cancelled.IsTerminal()); + Assert.False(JobStatus.Pending.IsTerminal()); + Assert.False(JobStatus.Running.IsTerminal()); + } + + [Fact] + public void SynorConfig_CreatesWithDefaults() + { + var config = new SynorConfig("test-key"); + + Assert.Equal("test-key", config.ApiKey); + Assert.Equal("https://api.synor.io/compute/v1", config.BaseUrl); + Assert.Equal(TimeSpan.FromSeconds(30), config.Timeout); + Assert.Equal(ProcessorType.Auto, config.DefaultProcessor); + Assert.Equal(Precision.FP32, config.DefaultPrecision); + } + + [Fact] + public void SynorConfig_CreatesWithCustomValues() + { + var config = new SynorConfig("test-key") + { + BaseUrl = "https://custom.api.com", + Timeout = TimeSpan.FromSeconds(60), + DefaultProcessor = ProcessorType.Gpu, + DefaultPrecision = Precision.FP16 + }; + + Assert.Equal("https://custom.api.com", config.BaseUrl); + Assert.Equal(TimeSpan.FromSeconds(60), config.Timeout); + Assert.Equal(ProcessorType.Gpu, config.DefaultProcessor); + } + + [Fact] + public void MatMulOptions_HasCorrectDefaults() + { + var options = new MatMulOptions(); + + Assert.Equal(Precision.FP32, options.Precision); + Assert.Equal(ProcessorType.Auto, options.Processor); + Assert.Equal(Priority.Normal, options.Priority); + } + + [Fact] + public void Conv2dOptions_HasCorrectDefaults() + { + var options = new Conv2dOptions(); + + Assert.Equal((1, 1), options.Stride); + Assert.Equal((0, 0), options.Padding); + } + + [Fact] + public void AttentionOptions_HasCorrectDefaults() + { + var options = new AttentionOptions(); + + Assert.Equal(8, options.NumHeads); + Assert.True(options.Flash); + Assert.Equal(Precision.FP16, options.Precision); + } + + [Fact] + public void InferenceOptions_HasCorrectDefaults() + { + var options = new InferenceOptions(); + + Assert.Equal(256, options.MaxTokens); + Assert.Equal(0.7, options.Temperature, 3); + Assert.Equal(0.9, options.TopP, 3); + Assert.Equal(50, options.TopK); + } + + [Fact] + public void JobResult_IdentifiesSuccess() + { + var result = new JobResult + { + JobId = "job-123", + Status = JobStatus.Completed, + Result = "output" + }; + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailed); + } + + [Fact] + public void JobResult_IdentifiesFailure() + { + var result = new JobResult + { + JobId = "job-456", + Status = JobStatus.Failed, + Error = "Error message" + }; + + Assert.False(result.IsSuccess); + Assert.True(result.IsFailed); + } + + [Fact] + public void ModelInfo_FormatsParametersCorrectly() + { + var model = new ModelInfo + { + Id = "test", + Name = "Test Model", + Category = "llm", + Parameters = 70_000_000_000 + }; + + Assert.Equal("70B", model.FormattedParameters); + } + + [Fact] + public void ModelInfo_FormatsParametersInMillions() + { + var model = new ModelInfo + { + Id = "test", + Name = "Test Model", + Category = "embedding", + Parameters = 350_000_000 + }; + + Assert.Equal("350M", model.FormattedParameters); + } +} diff --git a/sdk/flutter/test/tensor_test.dart b/sdk/flutter/test/tensor_test.dart new file mode 100644 index 0000000..ea3aaca --- /dev/null +++ b/sdk/flutter/test/tensor_test.dart @@ -0,0 +1,400 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:synor_compute/synor_compute.dart'; + +void main() { + group('Tensor Creation', () { + test('creates tensor with shape and data', () { + final tensor = Tensor( + shape: [2, 3], + data: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + ); + + expect(tensor.shape, equals([2, 3])); + expect(tensor.size, equals(6)); + expect(tensor.ndim, equals(2)); + }); + + test('throws on data-shape mismatch', () { + expect( + () => Tensor( + shape: [2, 3], + data: [1.0, 2.0, 3.0], // Only 3 elements for shape [2, 3] + ), + throwsA(isA()), + ); + }); + + test('creates tensor from typed data', () { + final data = Float64List.fromList([1.0, 2.0, 3.0, 4.0]); + final tensor = Tensor.fromTypedData( + shape: [2, 2], + data: data, + ); + + expect(tensor.shape, equals([2, 2])); + expect(tensor.size, equals(4)); + }); + }); + + group('Tensor Factory Methods', () { + test('creates zeros tensor', () { + final tensor = Tensor.zeros([3, 4]); + + expect(tensor.shape, equals([3, 4])); + expect(tensor.size, equals(12)); + expect(tensor.data.every((v) => v == 0.0), isTrue); + }); + + test('creates ones tensor', () { + final tensor = Tensor.ones([2, 2]); + + expect(tensor.shape, equals([2, 2])); + expect(tensor.data.every((v) => v == 1.0), isTrue); + }); + + test('creates full tensor with value', () { + final tensor = Tensor.full([3, 3], 5.0); + + expect(tensor.data.every((v) => v == 5.0), isTrue); + }); + + test('creates random tensor', () { + final tensor = Tensor.rand([10, 10]); + + expect(tensor.shape, equals([10, 10])); + expect(tensor.size, equals(100)); + // Values should be in [0, 1) + expect(tensor.data.every((v) => v >= 0 && v < 1), isTrue); + }); + + test('creates randn tensor with normal distribution', () { + final tensor = Tensor.randn([1000]); + + // Mean should be close to 0, std close to 1 + expect(tensor.mean().abs(), lessThan(0.2)); + expect(tensor.std(), closeTo(1.0, 0.2)); + }); + + test('creates identity matrix', () { + final tensor = Tensor.eye(3); + + expect(tensor.shape, equals([3, 3])); + expect(tensor.at([0, 0]), equals(1.0)); + expect(tensor.at([1, 1]), equals(1.0)); + expect(tensor.at([2, 2]), equals(1.0)); + expect(tensor.at([0, 1]), equals(0.0)); + }); + + test('creates linspace tensor', () { + final tensor = Tensor.linspace(0.0, 10.0, 11); + + expect(tensor.shape, equals([11])); + expect(tensor[0], equals(0.0)); + expect(tensor[10], equals(10.0)); + expect(tensor[5], equals(5.0)); + }); + + test('creates arange tensor', () { + final tensor = Tensor.arange(0.0, 5.0, step: 1.0); + + expect(tensor.shape, equals([5])); + expect(tensor[0], equals(0.0)); + expect(tensor[4], equals(4.0)); + }); + }); + + group('Tensor Operations', () { + test('reshapes tensor', () { + final tensor = Tensor( + shape: [6], + data: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + ); + + final reshaped = tensor.reshape([2, 3]); + + expect(reshaped.shape, equals([2, 3])); + expect(reshaped.size, equals(6)); + }); + + test('throws on invalid reshape', () { + final tensor = Tensor.zeros([4]); + + expect( + () => tensor.reshape([2, 3]), + throwsA(isA()), + ); + }); + + test('flattens tensor', () { + final tensor = Tensor.zeros([2, 3, 4]); + final flat = tensor.flatten(); + + expect(flat.shape, equals([24])); + }); + + test('transposes 2D tensor', () { + final tensor = Tensor( + shape: [2, 3], + data: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + ); + + final transposed = tensor.transpose(); + + expect(transposed.shape, equals([3, 2])); + expect(transposed.at([0, 0]), equals(1.0)); + expect(transposed.at([0, 1]), equals(4.0)); + }); + + test('accesses element at index for 1D tensor', () { + final tensor = Tensor(shape: [4], data: [1.0, 2.0, 3.0, 4.0]); + + expect(tensor[0], equals(1.0)); + expect(tensor[3], equals(4.0)); + }); + + test('accesses element at multi-dimensional index', () { + final tensor = Tensor( + shape: [2, 3], + data: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + ); + + expect(tensor.at([0, 0]), equals(1.0)); + expect(tensor.at([0, 2]), equals(3.0)); + expect(tensor.at([1, 1]), equals(5.0)); + }); + + test('throws on out-of-bounds index', () { + final tensor = Tensor.zeros([2, 3]); + + expect( + () => tensor.at([2, 0]), + throwsA(isA()), + ); + }); + }); + + group('Tensor Reductions', () { + test('calculates sum', () { + final tensor = Tensor(shape: [4], data: [1.0, 2.0, 3.0, 4.0]); + + expect(tensor.sum(), equals(10.0)); + }); + + test('calculates mean', () { + final tensor = Tensor(shape: [4], data: [1.0, 2.0, 3.0, 4.0]); + + expect(tensor.mean(), equals(2.5)); + }); + + test('calculates std', () { + final tensor = Tensor(shape: [4], data: [1.0, 2.0, 3.0, 4.0]); + + expect(tensor.std(), closeTo(1.118, 0.001)); + }); + + test('finds min', () { + final tensor = Tensor(shape: [4], data: [3.0, 1.0, 4.0, 2.0]); + + expect(tensor.min(), equals(1.0)); + }); + + test('finds max', () { + final tensor = Tensor(shape: [4], data: [3.0, 1.0, 4.0, 2.0]); + + expect(tensor.max(), equals(4.0)); + }); + + test('finds argmin', () { + final tensor = Tensor(shape: [4], data: [3.0, 1.0, 4.0, 2.0]); + + expect(tensor.argmin(), equals(1)); + }); + + test('finds argmax', () { + final tensor = Tensor(shape: [4], data: [3.0, 1.0, 4.0, 2.0]); + + expect(tensor.argmax(), equals(2)); + }); + }); + + group('Tensor Element-wise Operations', () { + test('adds tensors', () { + final a = Tensor(shape: [3], data: [1.0, 2.0, 3.0]); + final b = Tensor(shape: [3], data: [4.0, 5.0, 6.0]); + + final result = a.add(b); + + expect(result.data, equals(Float64List.fromList([5.0, 7.0, 9.0]))); + }); + + test('subtracts tensors', () { + final a = Tensor(shape: [3], data: [5.0, 7.0, 9.0]); + final b = Tensor(shape: [3], data: [1.0, 2.0, 3.0]); + + final result = a.sub(b); + + expect(result.data, equals(Float64List.fromList([4.0, 5.0, 6.0]))); + }); + + test('multiplies tensors', () { + final a = Tensor(shape: [3], data: [2.0, 3.0, 4.0]); + final b = Tensor(shape: [3], data: [1.0, 2.0, 3.0]); + + final result = a.mul(b); + + expect(result.data, equals(Float64List.fromList([2.0, 6.0, 12.0]))); + }); + + test('divides tensors', () { + final a = Tensor(shape: [3], data: [6.0, 8.0, 9.0]); + final b = Tensor(shape: [3], data: [2.0, 4.0, 3.0]); + + final result = a.div(b); + + expect(result.data, equals(Float64List.fromList([3.0, 2.0, 3.0]))); + }); + + test('adds scalar', () { + final tensor = Tensor(shape: [3], data: [1.0, 2.0, 3.0]); + final result = tensor.addScalar(10.0); + + expect(result.data, equals(Float64List.fromList([11.0, 12.0, 13.0]))); + }); + + test('multiplies by scalar', () { + final tensor = Tensor(shape: [3], data: [1.0, 2.0, 3.0]); + final result = tensor.mulScalar(2.0); + + expect(result.data, equals(Float64List.fromList([2.0, 4.0, 6.0]))); + }); + + test('throws on shape mismatch', () { + final a = Tensor.zeros([2, 3]); + final b = Tensor.zeros([3, 2]); + + expect(() => a.add(b), throwsA(isA())); + }); + }); + + group('Tensor Activations', () { + test('applies relu', () { + final tensor = Tensor(shape: [5], data: [-2.0, -1.0, 0.0, 1.0, 2.0]); + final result = tensor.relu(); + + expect(result.data, equals(Float64List.fromList([0.0, 0.0, 0.0, 1.0, 2.0]))); + }); + + test('applies sigmoid', () { + final tensor = Tensor(shape: [1], data: [0.0]); + final result = tensor.sigmoid(); + + expect(result[0], closeTo(0.5, 0.001)); + }); + + test('applies tanh', () { + final tensor = Tensor(shape: [1], data: [0.0]); + final result = tensor.tanh(); + + expect(result[0], closeTo(0.0, 0.001)); + }); + + test('applies softmax on 1D tensor', () { + final tensor = Tensor(shape: [3], data: [1.0, 2.0, 3.0]); + final result = tensor.softmax(); + + expect(result.sum(), closeTo(1.0, 0.001)); + expect(result[2], greaterThan(result[1])); + expect(result[1], greaterThan(result[0])); + }); + }); + + group('Tensor Serialization', () { + test('serializes to JSON', () { + final tensor = Tensor( + shape: [2, 2], + data: [1.0, 2.0, 3.0, 4.0], + ); + + final json = tensor.toJson(); + + expect(json['shape'], equals([2, 2])); + expect(json['dtype'], equals('float64')); + expect(json['data'], isA()); + }); + + test('deserializes from JSON with base64 data', () { + final original = Tensor( + shape: [2, 3], + data: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + ); + final json = original.toJson(); + final restored = Tensor.fromJson(json); + + expect(restored.shape, equals(original.shape)); + expect(restored.dtype, equals(original.dtype)); + for (var i = 0; i < original.size; i++) { + expect(restored.data[i], closeTo(original.data[i], 0.0001)); + } + }); + + test('deserializes from JSON with list data', () { + final json = { + 'shape': [2, 2], + 'data': [ + [1.0, 2.0], + [3.0, 4.0] + ], + 'dtype': 'float64', + }; + + final tensor = Tensor.fromJson(json); + + expect(tensor.shape, equals([2, 2])); + expect(tensor.at([0, 0]), equals(1.0)); + expect(tensor.at([1, 1]), equals(4.0)); + }); + + test('converts to nested list', () { + final tensor = Tensor( + shape: [2, 3], + data: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + ); + + final nested = tensor.toNestedList(); + + expect(nested, equals([ + [1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + ])); + }); + }); + + group('Tensor Properties', () { + test('calculates nbytes correctly', () { + final tensor = Tensor.zeros([10]); + + // Float64List uses 8 bytes per element + expect(tensor.nbytes, equals(80)); + }); + + test('equality works correctly', () { + final a = Tensor(shape: [2], data: [1.0, 2.0]); + final b = Tensor(shape: [2], data: [1.0, 2.0]); + final c = Tensor(shape: [2], data: [1.0, 3.0]); + + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + + test('toString provides useful info', () { + final small = Tensor(shape: [2], data: [1.0, 2.0]); + final large = Tensor.zeros([100, 100]); + + expect(small.toString(), contains('Tensor')); + expect(large.toString(), contains('shape')); + }); + }); +} diff --git a/sdk/flutter/test/types_test.dart b/sdk/flutter/test/types_test.dart new file mode 100644 index 0000000..cbc3dcd --- /dev/null +++ b/sdk/flutter/test/types_test.dart @@ -0,0 +1,462 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:synor_compute/synor_compute.dart'; + +void main() { + group('Precision Enum', () { + test('has all expected values', () { + expect(Precision.values.length, equals(6)); + expect(Precision.fp64.value, equals('fp64')); + expect(Precision.fp32.value, equals('fp32')); + expect(Precision.fp16.value, equals('fp16')); + expect(Precision.bf16.value, equals('bf16')); + expect(Precision.int8.value, equals('int8')); + expect(Precision.int4.value, equals('int4')); + }); + + test('parses from string', () { + expect(Precision.fromString('fp16'), equals(Precision.fp16)); + expect(Precision.fromString('bf16'), equals(Precision.bf16)); + expect(Precision.fromString('invalid'), equals(Precision.fp32)); // default + }); + }); + + group('ProcessorType Enum', () { + test('has all expected values', () { + expect(ProcessorType.values.length, equals(10)); + expect(ProcessorType.cpu.value, equals('cpu')); + expect(ProcessorType.gpu.value, equals('gpu')); + expect(ProcessorType.tpu.value, equals('tpu')); + expect(ProcessorType.npu.value, equals('npu')); + expect(ProcessorType.lpu.value, equals('lpu')); + expect(ProcessorType.auto.value, equals('auto')); + }); + + test('parses from string', () { + expect(ProcessorType.fromString('gpu'), equals(ProcessorType.gpu)); + expect(ProcessorType.fromString('invalid'), equals(ProcessorType.auto)); + }); + }); + + group('Priority Enum', () { + test('has all expected values', () { + expect(Priority.values.length, equals(4)); + expect(Priority.low.value, equals('low')); + expect(Priority.normal.value, equals('normal')); + expect(Priority.high.value, equals('high')); + expect(Priority.critical.value, equals('critical')); + }); + + test('parses from string', () { + expect(Priority.fromString('high'), equals(Priority.high)); + expect(Priority.fromString('invalid'), equals(Priority.normal)); + }); + }); + + group('JobStatus Enum', () { + test('has all expected values', () { + expect(JobStatus.values.length, equals(6)); + expect(JobStatus.pending.value, equals('pending')); + expect(JobStatus.queued.value, equals('queued')); + expect(JobStatus.running.value, equals('running')); + expect(JobStatus.completed.value, equals('completed')); + expect(JobStatus.failed.value, equals('failed')); + expect(JobStatus.cancelled.value, equals('cancelled')); + }); + + test('identifies terminal states', () { + expect(JobStatus.completed.isTerminal, isTrue); + expect(JobStatus.failed.isTerminal, isTrue); + expect(JobStatus.cancelled.isTerminal, isTrue); + expect(JobStatus.pending.isTerminal, isFalse); + expect(JobStatus.running.isTerminal, isFalse); + }); + }); + + group('BalancingStrategy Enum', () { + test('has all expected values', () { + expect(BalancingStrategy.speed.value, equals('speed')); + expect(BalancingStrategy.cost.value, equals('cost')); + expect(BalancingStrategy.energy.value, equals('energy')); + expect(BalancingStrategy.balanced.value, equals('balanced')); + }); + }); + + group('DType Enum', () { + test('has all expected values', () { + expect(DType.float64.value, equals('float64')); + expect(DType.float32.value, equals('float32')); + expect(DType.int8.value, equals('int8')); + expect(DType.bool_.value, equals('bool')); + }); + }); + + group('ModelCategory Enum', () { + test('has all expected values', () { + expect(ModelCategory.llm.value, equals('llm')); + expect(ModelCategory.embedding.value, equals('embedding')); + expect(ModelCategory.imageGeneration.value, equals('image_generation')); + expect(ModelCategory.custom.value, equals('custom')); + }); + }); + + group('SynorConfig', () { + test('creates with required fields', () { + final config = SynorConfig(apiKey: 'test-key'); + + expect(config.apiKey, equals('test-key')); + expect(config.baseUrl, equals('https://compute.synor.io')); + expect(config.timeout, equals(const Duration(seconds: 30))); + expect(config.maxRetries, equals(3)); + expect(config.defaultProcessor, equals(ProcessorType.auto)); + expect(config.defaultPrecision, equals(Precision.fp32)); + }); + + test('creates with custom values', () { + final config = SynorConfig( + apiKey: 'test-key', + baseUrl: 'https://custom.api.com', + timeout: const Duration(seconds: 60), + maxRetries: 5, + defaultProcessor: ProcessorType.gpu, + defaultPrecision: Precision.fp16, + ); + + expect(config.baseUrl, equals('https://custom.api.com')); + expect(config.timeout, equals(const Duration(seconds: 60))); + expect(config.maxRetries, equals(5)); + }); + + test('copyWith updates fields', () { + final original = SynorConfig(apiKey: 'test-key'); + final updated = original.copyWith( + timeout: const Duration(seconds: 60), + defaultProcessor: ProcessorType.tpu, + ); + + expect(updated.apiKey, equals('test-key')); + expect(updated.timeout, equals(const Duration(seconds: 60))); + expect(updated.defaultProcessor, equals(ProcessorType.tpu)); + }); + }); + + group('MatMulOptions', () { + test('creates with defaults', () { + const options = MatMulOptions(); + + expect(options.precision, isNull); + expect(options.processor, isNull); + expect(options.transposeA, isFalse); + expect(options.transposeB, isFalse); + }); + + test('toJson generates correct output', () { + const options = MatMulOptions( + precision: Precision.fp16, + processor: ProcessorType.gpu, + transposeA: true, + ); + + final json = options.toJson(); + + expect(json['precision'], equals('fp16')); + expect(json['processor'], equals('gpu')); + expect(json['transpose_a'], isTrue); + expect(json['transpose_b'], isFalse); + }); + }); + + group('Conv2dOptions', () { + test('creates with defaults', () { + const options = Conv2dOptions(); + + expect(options.kernel, equals([3, 3])); + expect(options.stride, equals([1, 1])); + expect(options.padding, equals([0, 0])); + expect(options.groups, equals(1)); + }); + + test('toJson generates correct output', () { + const options = Conv2dOptions( + kernel: [5, 5], + stride: [2, 2], + padding: [2, 2], + ); + + final json = options.toJson(); + + expect(json['kernel'], equals([5, 5])); + expect(json['stride'], equals([2, 2])); + expect(json['padding'], equals([2, 2])); + }); + }); + + group('AttentionOptions', () { + test('creates with required fields', () { + const options = AttentionOptions(numHeads: 8); + + expect(options.numHeads, equals(8)); + expect(options.causal, isFalse); + expect(options.scale, isNull); + }); + + test('toJson generates correct output', () { + const options = AttentionOptions( + numHeads: 12, + scale: 0.125, + causal: true, + precision: Precision.fp16, + ); + + final json = options.toJson(); + + expect(json['num_heads'], equals(12)); + expect(json['scale'], equals(0.125)); + expect(json['causal'], isTrue); + expect(json['precision'], equals('fp16')); + }); + }); + + group('InferenceOptions', () { + test('creates with defaults', () { + const options = InferenceOptions(); + + expect(options.maxTokens, equals(256)); + expect(options.temperature, equals(0.7)); + expect(options.topP, equals(1.0)); + expect(options.stream, isFalse); + }); + + test('toJson generates correct output', () { + const options = InferenceOptions( + maxTokens: 512, + temperature: 0.9, + topK: 50, + stream: true, + ); + + final json = options.toJson(); + + expect(json['max_tokens'], equals(512)); + expect(json['temperature'], equals(0.9)); + expect(json['top_k'], equals(50)); + expect(json['stream'], isTrue); + }); + }); + + group('PricingInfo', () { + test('parses from JSON', () { + final json = { + 'processor': 'gpu', + 'price_per_second': 0.0001, + 'price_per_gflop': 0.000001, + 'available_units': 100, + 'utilization_percent': 75.5, + 'region': 'us-east-1', + }; + + final pricing = PricingInfo.fromJson(json); + + expect(pricing.processor, equals(ProcessorType.gpu)); + expect(pricing.pricePerSecond, equals(0.0001)); + expect(pricing.availableUnits, equals(100)); + expect(pricing.utilizationPercent, equals(75.5)); + }); + }); + + group('UsageStats', () { + test('parses from JSON', () { + final json = { + 'total_jobs': 1000, + 'completed_jobs': 950, + 'failed_jobs': 50, + 'total_compute_seconds': 3600.0, + 'total_cost': 0.36, + 'cost_by_processor': { + 'gpu': 0.30, + 'tpu': 0.06, + }, + }; + + final stats = UsageStats.fromJson(json); + + expect(stats.totalJobs, equals(1000)); + expect(stats.completedJobs, equals(950)); + expect(stats.failedJobs, equals(50)); + expect(stats.totalComputeSeconds, equals(3600.0)); + expect(stats.costByProcessor[ProcessorType.gpu], equals(0.30)); + }); + }); + + group('ModelInfo', () { + test('parses from JSON', () { + final json = { + 'id': 'llama-3-70b', + 'name': 'Llama 3 70B', + 'description': 'Large language model', + 'category': 'llm', + 'cid': 'QmTest...', + 'format': 'safetensors', + 'size_bytes': 140000000000, + 'parameters': 70000000000, + 'supported_precisions': ['fp16', 'bf16', 'fp32'], + 'recommended_processor': 'gpu', + 'context_length': 8192, + 'license': 'llama3', + 'provider': 'meta', + 'version': '1.0.0', + 'is_public': true, + }; + + final model = ModelInfo.fromJson(json); + + expect(model.id, equals('llama-3-70b')); + expect(model.category, equals(ModelCategory.llm)); + expect(model.parameters, equals(70000000000)); + expect(model.contextLength, equals(8192)); + }); + + test('formats parameters correctly', () { + final model = ModelInfo( + id: 'test', + name: 'Test', + description: 'Test', + category: ModelCategory.llm, + cid: 'QmTest', + format: ModelFormat.onnx, + sizeBytes: 1000, + parameters: 70000000000, + supportedPrecisions: [Precision.fp16], + recommendedProcessor: ProcessorType.gpu, + license: 'MIT', + provider: 'test', + version: '1.0', + isPublic: true, + ); + + expect(model.formattedParameters, equals('70.0B')); + }); + }); + + group('TrainingOptions', () { + test('creates with defaults', () { + const options = TrainingOptions(); + + expect(options.framework, equals(MlFramework.pytorch)); + expect(options.epochs, equals(1)); + expect(options.batchSize, equals(32)); + expect(options.learningRate, equals(0.001)); + }); + + test('toJson generates correct output', () { + const options = TrainingOptions( + framework: MlFramework.jax, + epochs: 10, + batchSize: 64, + learningRate: 0.0001, + distributed: true, + ); + + final json = options.toJson(); + + expect(json['framework'], equals('jax')); + expect(json['epochs'], equals(10)); + expect(json['batch_size'], equals(64)); + expect(json['distributed'], isTrue); + }); + }); + + group('TrainingResult', () { + test('parses from JSON', () { + final json = { + 'job_id': 'train-123', + 'model_cid': 'QmTrained...', + 'epochs': 10, + 'final_loss': 0.05, + 'metrics': {'accuracy': 0.95, 'f1': 0.94}, + 'duration_ms': 3600000, + 'cost': 1.50, + 'checkpoint_cids': ['QmChk1', 'QmChk2'], + }; + + final result = TrainingResult.fromJson(json); + + expect(result.jobId, equals('train-123')); + expect(result.epochs, equals(10)); + expect(result.finalLoss, equals(0.05)); + expect(result.metrics['accuracy'], equals(0.95)); + }); + }); + + group('DatasetUploadOptions', () { + test('creates with defaults', () { + const options = DatasetUploadOptions(name: 'test-dataset'); + + expect(options.name, equals('test-dataset')); + expect(options.format, equals(DatasetFormat.jsonl)); + expect(options.type, equals(DatasetType.textCompletion)); + expect(options.isPublic, isFalse); + }); + + test('toJson generates correct output', () { + const options = DatasetUploadOptions( + name: 'my-dataset', + description: 'Test dataset', + format: DatasetFormat.parquet, + type: DatasetType.instructionTuning, + isPublic: true, + ); + + final json = options.toJson(); + + expect(json['name'], equals('my-dataset')); + expect(json['format'], equals('parquet')); + expect(json['type'], equals('instruction_tuning')); + expect(json['is_public'], isTrue); + }); + }); + + group('TrainingProgress', () { + test('parses from JSON', () { + final json = { + 'job_id': 'train-123', + 'epoch': 5, + 'total_epochs': 10, + 'step': 500, + 'total_steps': 1000, + 'loss': 0.15, + 'metrics': {'accuracy': 0.85}, + 'learning_rate': 0.0001, + 'samples_per_second': 100, + }; + + final progress = TrainingProgress.fromJson(json); + + expect(progress.epoch, equals(5)); + expect(progress.progress, equals(0.5)); + expect(progress.progressText, contains('Epoch 6/10')); + }); + }); + + group('SynorException', () { + test('creates with message', () { + const exception = SynorException('Test error', code: 'ERR_TEST'); + + expect(exception.message, equals('Test error')); + expect(exception.code, equals('ERR_TEST')); + expect(exception.toString(), contains('Test error')); + }); + + test('parses from JSON', () { + final json = { + 'message': 'API Error', + 'code': 'API_ERR', + 'status_code': 500, + }; + + final exception = SynorException.fromJson(json); + + expect(exception.message, equals('API Error')); + expect(exception.statusCode, equals(500)); + }); + }); +} diff --git a/sdk/go/synor_test.go b/sdk/go/synor_test.go new file mode 100644 index 0000000..142abdc --- /dev/null +++ b/sdk/go/synor_test.go @@ -0,0 +1,416 @@ +package synor + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// TestClientCreation tests client initialization. +func TestClientCreation(t *testing.T) { + t.Run("creates client with API key", func(t *testing.T) { + client := NewClient("test-api-key") + if client == nil { + t.Fatal("expected non-nil client") + } + }) + + t.Run("creates client with config", func(t *testing.T) { + config := Config{ + APIKey: "test-api-key", + Endpoint: "https://custom.api.com", + Strategy: Cost, + Precision: FP16, + Timeout: 60 * time.Second, + } + client := NewClientWithConfig(config) + if client == nil { + t.Fatal("expected non-nil client") + } + if client.config.Endpoint != "https://custom.api.com" { + t.Errorf("expected custom endpoint, got %s", client.config.Endpoint) + } + }) +} + +// TestDefaultConfig tests default configuration. +func TestDefaultConfig(t *testing.T) { + config := DefaultConfig("my-key") + + if config.APIKey != "my-key" { + t.Errorf("expected API key 'my-key', got %s", config.APIKey) + } + if config.Endpoint != DefaultEndpoint { + t.Errorf("expected default endpoint, got %s", config.Endpoint) + } + if config.Strategy != Balanced { + t.Errorf("expected Balanced strategy, got %s", config.Strategy) + } + if config.Precision != FP32 { + t.Errorf("expected FP32 precision, got %s", config.Precision) + } + if config.Timeout != 30*time.Second { + t.Errorf("expected 30s timeout, got %v", config.Timeout) + } +} + +// TestTensorCreation tests tensor creation. +func TestTensorCreation(t *testing.T) { + t.Run("creates tensor from data", func(t *testing.T) { + data := []float32{1.0, 2.0, 3.0, 4.0, 5.0, 6.0} + shape := []int{2, 3} + tensor := NewTensor(data, shape, FP32) + + if tensor == nil { + t.Fatal("expected non-nil tensor") + } + if len(tensor.Data) != 6 { + t.Errorf("expected 6 elements, got %d", len(tensor.Data)) + } + if len(tensor.Shape) != 2 { + t.Errorf("expected 2 dimensions, got %d", len(tensor.Shape)) + } + if tensor.DType != FP32 { + t.Errorf("expected FP32 dtype, got %s", tensor.DType) + } + }) + + t.Run("creates zeros tensor", func(t *testing.T) { + tensor := Zeros([]int{3, 4}, FP32) + + if tensor == nil { + t.Fatal("expected non-nil tensor") + } + if len(tensor.Data) != 12 { + t.Errorf("expected 12 elements, got %d", len(tensor.Data)) + } + for i, v := range tensor.Data { + if v != 0.0 { + t.Errorf("expected 0 at index %d, got %f", i, v) + } + } + }) +} + +// TestTensorSerialize tests tensor serialization. +func TestTensorSerialize(t *testing.T) { + data := []float32{1.0, 2.0, 3.0, 4.0} + shape := []int{2, 2} + tensor := NewTensor(data, shape, FP32) + + serialized := tensor.Serialize() + + if _, ok := serialized["data"]; !ok { + t.Error("expected 'data' field in serialized tensor") + } + if _, ok := serialized["shape"]; !ok { + t.Error("expected 'shape' field in serialized tensor") + } + if _, ok := serialized["dtype"]; !ok { + t.Error("expected 'dtype' field in serialized tensor") + } + if serialized["dtype"] != FP32 { + t.Errorf("expected FP32 dtype, got %v", serialized["dtype"]) + } +} + +// TestProcessorTypes tests processor type constants. +func TestProcessorTypes(t *testing.T) { + types := []ProcessorType{CPU, GPU, TPU, NPU, LPU, FPGA, WASM, WebGPU} + expected := []string{"cpu", "gpu", "tpu", "npu", "lpu", "fpga", "wasm", "webgpu"} + + for i, pt := range types { + if string(pt) != expected[i] { + t.Errorf("expected %s, got %s", expected[i], pt) + } + } +} + +// TestPrecisionTypes tests precision type constants. +func TestPrecisionTypes(t *testing.T) { + precisions := []Precision{FP64, FP32, FP16, BF16, INT8, INT4} + expected := []string{"fp64", "fp32", "fp16", "bf16", "int8", "int4"} + + for i, p := range precisions { + if string(p) != expected[i] { + t.Errorf("expected %s, got %s", expected[i], p) + } + } +} + +// TestJobStatus tests job status constants. +func TestJobStatus(t *testing.T) { + statuses := []JobStatus{Pending, Queued, Running, Completed, Failed, Cancelled} + expected := []string{"pending", "queued", "running", "completed", "failed", "cancelled"} + + for i, s := range statuses { + if string(s) != expected[i] { + t.Errorf("expected %s, got %s", expected[i], s) + } + } +} + +// TestBalancingStrategies tests balancing strategy constants. +func TestBalancingStrategies(t *testing.T) { + strategies := []BalancingStrategy{Speed, Cost, Energy, Latency, Balanced} + expected := []string{"speed", "cost", "energy", "latency", "balanced"} + + for i, s := range strategies { + if string(s) != expected[i] { + t.Errorf("expected %s, got %s", expected[i], s) + } + } +} + +// TestSubmitJob tests job submission with mock server. +func TestSubmitJob(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/jobs" && r.Method == "POST" { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "job_id": "job-123", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + config := Config{ + APIKey: "test-key", + Endpoint: server.URL, + Timeout: 5 * time.Second, + } + client := NewClientWithConfig(config) + + ctx := context.Background() + job, err := client.SubmitJob(ctx, "matmul", map[string]interface{}{ + "a": map[string]interface{}{"data": "base64", "shape": []int{2, 2}}, + "b": map[string]interface{}{"data": "base64", "shape": []int{2, 2}}, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if job.ID != "job-123" { + t.Errorf("expected job ID 'job-123', got %s", job.ID) + } +} + +// TestGetJobStatus tests getting job status with mock server. +func TestGetJobStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/jobs/job-123" && r.Method == "GET" { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "job_id": "job-123", + "status": "completed", + "data": map[string]interface{}{"result": "success"}, + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + config := Config{ + APIKey: "test-key", + Endpoint: server.URL, + Timeout: 5 * time.Second, + } + client := NewClientWithConfig(config) + + ctx := context.Background() + result, err := client.GetJobStatus(ctx, "job-123") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Status != Completed { + t.Errorf("expected Completed status, got %s", result.Status) + } +} + +// TestGetPricing tests getting pricing information with mock server. +func TestGetPricing(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/pricing" && r.Method == "GET" { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "pricing": []map[string]interface{}{ + { + "processor_type": "gpu", + "spot_price": 0.0001, + "avg_price_24h": 0.00012, + "aws_equivalent": 0.001, + "savings_percent": 90.0, + }, + { + "processor_type": "tpu", + "spot_price": 0.0002, + "avg_price_24h": 0.00022, + "aws_equivalent": 0.002, + "savings_percent": 89.0, + }, + }, + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + config := Config{ + APIKey: "test-key", + Endpoint: server.URL, + Timeout: 5 * time.Second, + } + client := NewClientWithConfig(config) + + ctx := context.Background() + pricing, err := client.GetPricing(ctx) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(pricing) != 2 { + t.Errorf("expected 2 pricing entries, got %d", len(pricing)) + } +} + +// TestSynorError tests error formatting. +func TestSynorError(t *testing.T) { + err := &SynorError{ + Message: "Invalid API key", + StatusCode: 401, + } + + expected := "synor: Invalid API key (status 401)" + if err.Error() != expected { + t.Errorf("expected error message '%s', got '%s'", expected, err.Error()) + } +} + +// TestAPIError tests handling of API errors. +func TestAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Invalid API key", + }) + })) + defer server.Close() + + config := Config{ + APIKey: "bad-key", + Endpoint: server.URL, + Timeout: 5 * time.Second, + } + client := NewClientWithConfig(config) + + ctx := context.Background() + _, err := client.SubmitJob(ctx, "test", nil) + + if err == nil { + t.Fatal("expected error, got nil") + } + + synorErr, ok := err.(*SynorError) + if !ok { + t.Fatalf("expected SynorError, got %T", err) + } + if synorErr.StatusCode != 401 { + t.Errorf("expected status 401, got %d", synorErr.StatusCode) + } +} + +// TestCancelJob tests job cancellation with mock server. +func TestCancelJob(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/jobs/job-123" && r.Method == "DELETE" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + config := Config{ + APIKey: "test-key", + Endpoint: server.URL, + Timeout: 5 * time.Second, + } + client := NewClientWithConfig(config) + + ctx := context.Background() + err := client.CancelJob(ctx, "job-123") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestJobMetrics tests job metrics structure. +func TestJobMetrics(t *testing.T) { + metrics := JobMetrics{ + ExecutionTimeMs: 150.5, + QueueTimeMs: 10.2, + ProcessorType: GPU, + ProcessorID: "gpu-001", + FLOPS: 1e12, + MemoryBytes: 1073741824, + CostMicro: 100, + EnergyMJ: 5.5, + } + + if metrics.ExecutionTimeMs != 150.5 { + t.Errorf("expected execution time 150.5, got %f", metrics.ExecutionTimeMs) + } + if metrics.ProcessorType != GPU { + t.Errorf("expected GPU processor, got %s", metrics.ProcessorType) + } +} + +// TestPricingInfo tests pricing info structure. +func TestPricingInfo(t *testing.T) { + pricing := PricingInfo{ + ProcessorType: GPU, + SpotPrice: 0.0001, + AvgPrice24h: 0.00012, + AWSEquivalent: 0.001, + SavingsPercent: 90.0, + } + + if pricing.ProcessorType != GPU { + t.Errorf("expected GPU processor, got %s", pricing.ProcessorType) + } + if pricing.SavingsPercent != 90.0 { + t.Errorf("expected 90%% savings, got %f%%", pricing.SavingsPercent) + } +} + +// TestJobResult tests job result structure. +func TestJobResult(t *testing.T) { + result := JobResult{ + JobID: "job-123", + Status: Completed, + Data: map[string]interface{}{"output": "success"}, + Metrics: &JobMetrics{ + ExecutionTimeMs: 100, + ProcessorType: GPU, + }, + } + + if result.JobID != "job-123" { + t.Errorf("expected job ID 'job-123', got %s", result.JobID) + } + if result.Status != Completed { + t.Errorf("expected Completed status, got %s", result.Status) + } + if result.Metrics == nil { + t.Error("expected non-nil metrics") + } +} diff --git a/sdk/java/src/test/java/io/synor/compute/TensorTest.java b/sdk/java/src/test/java/io/synor/compute/TensorTest.java new file mode 100644 index 0000000..0a9abc8 --- /dev/null +++ b/sdk/java/src/test/java/io/synor/compute/TensorTest.java @@ -0,0 +1,239 @@ +package io.synor.compute; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; + +@DisplayName("Tensor Tests") +class TensorTest { + + @Test + @DisplayName("Creates tensor with shape and data") + void testCreateTensor() { + double[] data = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0}; + int[] shape = {2, 3}; + Tensor tensor = new Tensor(shape, data); + + assertArrayEquals(shape, tensor.getShape()); + assertEquals(6, tensor.getSize()); + assertEquals(2, tensor.getNdim()); + } + + @Test + @DisplayName("Creates zeros tensor") + void testZeros() { + Tensor tensor = Tensor.zeros(new int[]{3, 4}); + + assertArrayEquals(new int[]{3, 4}, tensor.getShape()); + assertEquals(12, tensor.getSize()); + for (double v : tensor.getData()) { + assertEquals(0.0, v); + } + } + + @Test + @DisplayName("Creates ones tensor") + void testOnes() { + Tensor tensor = Tensor.ones(new int[]{2, 2}); + + assertArrayEquals(new int[]{2, 2}, tensor.getShape()); + for (double v : tensor.getData()) { + assertEquals(1.0, v); + } + } + + @Test + @DisplayName("Creates random tensor") + void testRandom() { + Tensor tensor = Tensor.random(new int[]{10, 10}); + + assertArrayEquals(new int[]{10, 10}, tensor.getShape()); + assertEquals(100, tensor.getSize()); + for (double v : tensor.getData()) { + assertTrue(v >= 0 && v < 1, "Random values should be in [0, 1)"); + } + } + + @Test + @DisplayName("Creates randn tensor with normal distribution") + void testRandn() { + Tensor tensor = Tensor.randn(new int[]{1000}); + + double mean = tensor.mean(); + double std = tensor.std(); + + assertTrue(Math.abs(mean) < 0.2, "Mean should be close to 0"); + assertTrue(std > 0.8 && std < 1.2, "Std should be close to 1"); + } + + @Test + @DisplayName("Creates identity matrix") + void testEye() { + Tensor tensor = Tensor.eye(3); + + assertArrayEquals(new int[]{3, 3}, tensor.getShape()); + assertEquals(1.0, tensor.get(0, 0)); + assertEquals(1.0, tensor.get(1, 1)); + assertEquals(1.0, tensor.get(2, 2)); + assertEquals(0.0, tensor.get(0, 1)); + assertEquals(0.0, tensor.get(1, 0)); + } + + @Test + @DisplayName("Creates arange tensor") + void testArange() { + Tensor tensor = Tensor.arange(0, 5, 1); + + assertArrayEquals(new int[]{5}, tensor.getShape()); + assertEquals(0.0, tensor.get(0)); + assertEquals(4.0, tensor.get(4)); + } + + @Test + @DisplayName("Creates linspace tensor") + void testLinspace() { + Tensor tensor = Tensor.linspace(0, 10, 11); + + assertArrayEquals(new int[]{11}, tensor.getShape()); + assertEquals(0.0, tensor.get(0), 0.0001); + assertEquals(10.0, tensor.get(10), 0.0001); + assertEquals(5.0, tensor.get(5), 0.0001); + } + + @Test + @DisplayName("Reshapes tensor") + void testReshape() { + Tensor tensor = new Tensor(new int[]{6}, new double[]{1, 2, 3, 4, 5, 6}); + Tensor reshaped = tensor.reshape(new int[]{2, 3}); + + assertArrayEquals(new int[]{2, 3}, reshaped.getShape()); + assertEquals(6, reshaped.getSize()); + } + + @Test + @DisplayName("Throws on invalid reshape") + void testInvalidReshape() { + Tensor tensor = Tensor.zeros(new int[]{4}); + + assertThrows(IllegalArgumentException.class, () -> { + tensor.reshape(new int[]{2, 3}); + }); + } + + @Test + @DisplayName("Transposes 2D tensor") + void testTranspose() { + Tensor tensor = new Tensor(new int[]{2, 3}, new double[]{1, 2, 3, 4, 5, 6}); + Tensor transposed = tensor.transpose(); + + assertArrayEquals(new int[]{3, 2}, transposed.getShape()); + assertEquals(1.0, transposed.get(0, 0)); + assertEquals(4.0, transposed.get(0, 1)); + } + + @Test + @DisplayName("Gets element at index") + void testGet() { + Tensor tensor = new Tensor(new int[]{2, 3}, new double[]{1, 2, 3, 4, 5, 6}); + + assertEquals(1.0, tensor.get(0, 0)); + assertEquals(3.0, tensor.get(0, 2)); + assertEquals(5.0, tensor.get(1, 1)); + } + + @Test + @DisplayName("Calculates sum") + void testSum() { + Tensor tensor = new Tensor(new int[]{4}, new double[]{1, 2, 3, 4}); + + assertEquals(10.0, tensor.sum()); + } + + @Test + @DisplayName("Calculates mean") + void testMean() { + Tensor tensor = new Tensor(new int[]{4}, new double[]{1, 2, 3, 4}); + + assertEquals(2.5, tensor.mean()); + } + + @Test + @DisplayName("Calculates std") + void testStd() { + Tensor tensor = new Tensor(new int[]{4}, new double[]{1, 2, 3, 4}); + + assertEquals(1.118, tensor.std(), 0.001); + } + + @Test + @DisplayName("Finds min") + void testMin() { + Tensor tensor = new Tensor(new int[]{4}, new double[]{3, 1, 4, 2}); + + assertEquals(1.0, tensor.min()); + } + + @Test + @DisplayName("Finds max") + void testMax() { + Tensor tensor = new Tensor(new int[]{4}, new double[]{3, 1, 4, 2}); + + assertEquals(4.0, tensor.max()); + } + + @Test + @DisplayName("Applies ReLU activation") + void testRelu() { + Tensor tensor = new Tensor(new int[]{5}, new double[]{-2, -1, 0, 1, 2}); + Tensor result = tensor.relu(); + + assertArrayEquals(new double[]{0, 0, 0, 1, 2}, result.getData(), 0.0001); + } + + @Test + @DisplayName("Applies sigmoid activation") + void testSigmoid() { + Tensor tensor = new Tensor(new int[]{1}, new double[]{0}); + Tensor result = tensor.sigmoid(); + + assertEquals(0.5, result.get(0), 0.0001); + } + + @Test + @DisplayName("Applies softmax activation") + void testSoftmax() { + Tensor tensor = new Tensor(new int[]{3}, new double[]{1, 2, 3}); + Tensor result = tensor.softmax(); + + assertEquals(1.0, result.sum(), 0.0001); + assertTrue(result.get(2) > result.get(1)); + assertTrue(result.get(1) > result.get(0)); + } + + @Test + @DisplayName("Serializes and deserializes tensor") + void testSerialization() { + Tensor original = new Tensor(new int[]{2, 3}, new double[]{1, 2, 3, 4, 5, 6}); + var json = original.toJson(); + + assertNotNull(json.get("shape")); + assertNotNull(json.get("data")); + assertNotNull(json.get("dtype")); + + Tensor restored = Tensor.fromJson(json); + assertArrayEquals(original.getShape(), restored.getShape()); + assertArrayEquals(original.getData(), restored.getData(), 0.0001); + } + + @Test + @DisplayName("Converts to string representation") + void testToString() { + Tensor tensor = Tensor.zeros(new int[]{2, 3}); + String str = tensor.toString(); + + assertTrue(str.contains("Tensor")); + assertTrue(str.contains("2, 3") || str.contains("[2,3]")); + } +} diff --git a/sdk/java/src/test/java/io/synor/compute/TypesTest.java b/sdk/java/src/test/java/io/synor/compute/TypesTest.java new file mode 100644 index 0000000..e6040db --- /dev/null +++ b/sdk/java/src/test/java/io/synor/compute/TypesTest.java @@ -0,0 +1,255 @@ +package io.synor.compute; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Types Tests") +class TypesTest { + + @Test + @DisplayName("ProcessorType has all values") + void testProcessorTypeValues() { + assertEquals("cpu", ProcessorType.CPU.getValue()); + assertEquals("gpu", ProcessorType.GPU.getValue()); + assertEquals("tpu", ProcessorType.TPU.getValue()); + assertEquals("npu", ProcessorType.NPU.getValue()); + assertEquals("lpu", ProcessorType.LPU.getValue()); + assertEquals("auto", ProcessorType.AUTO.getValue()); + } + + @Test + @DisplayName("ProcessorType parses from string") + void testProcessorTypeFromString() { + assertEquals(ProcessorType.GPU, ProcessorType.fromString("gpu")); + assertEquals(ProcessorType.TPU, ProcessorType.fromString("tpu")); + assertEquals(ProcessorType.AUTO, ProcessorType.fromString("invalid")); + } + + @Test + @DisplayName("Precision has all values") + void testPrecisionValues() { + assertEquals("fp64", Precision.FP64.getValue()); + assertEquals("fp32", Precision.FP32.getValue()); + assertEquals("fp16", Precision.FP16.getValue()); + assertEquals("bf16", Precision.BF16.getValue()); + assertEquals("int8", Precision.INT8.getValue()); + assertEquals("int4", Precision.INT4.getValue()); + } + + @Test + @DisplayName("Precision parses from string") + void testPrecisionFromString() { + assertEquals(Precision.FP16, Precision.fromString("fp16")); + assertEquals(Precision.BF16, Precision.fromString("bf16")); + assertEquals(Precision.FP32, Precision.fromString("invalid")); + } + + @Test + @DisplayName("Priority has all values") + void testPriorityValues() { + assertEquals("critical", Priority.CRITICAL.getValue()); + assertEquals("high", Priority.HIGH.getValue()); + assertEquals("normal", Priority.NORMAL.getValue()); + assertEquals("low", Priority.LOW.getValue()); + assertEquals("background", Priority.BACKGROUND.getValue()); + } + + @Test + @DisplayName("JobStatus has all values") + void testJobStatusValues() { + assertEquals("pending", JobStatus.PENDING.getValue()); + assertEquals("queued", JobStatus.QUEUED.getValue()); + assertEquals("running", JobStatus.RUNNING.getValue()); + assertEquals("completed", JobStatus.COMPLETED.getValue()); + assertEquals("failed", JobStatus.FAILED.getValue()); + assertEquals("cancelled", JobStatus.CANCELLED.getValue()); + } + + @Test + @DisplayName("JobStatus identifies terminal states") + void testJobStatusTerminal() { + assertTrue(JobStatus.COMPLETED.isTerminal()); + assertTrue(JobStatus.FAILED.isTerminal()); + assertTrue(JobStatus.CANCELLED.isTerminal()); + assertFalse(JobStatus.PENDING.isTerminal()); + assertFalse(JobStatus.RUNNING.isTerminal()); + } + + @Test + @DisplayName("ModelCategory has all values") + void testModelCategoryValues() { + assertEquals("llm", ModelCategory.LLM.getValue()); + assertEquals("embedding", ModelCategory.EMBEDDING.getValue()); + assertEquals("image_generation", ModelCategory.IMAGE_GENERATION.getValue()); + assertEquals("custom", ModelCategory.CUSTOM.getValue()); + } + + @Test + @DisplayName("SynorConfig creates with defaults") + void testSynorConfigDefaults() { + SynorConfig config = SynorConfig.builder() + .apiKey("test-key") + .build(); + + assertEquals("test-key", config.getApiKey()); + assertEquals("https://api.synor.io/compute/v1", config.getBaseUrl()); + assertEquals(30, config.getTimeoutSeconds()); + assertEquals(ProcessorType.AUTO, config.getDefaultProcessor()); + assertEquals(Precision.FP32, config.getDefaultPrecision()); + } + + @Test + @DisplayName("SynorConfig creates with custom values") + void testSynorConfigCustom() { + SynorConfig config = SynorConfig.builder() + .apiKey("test-key") + .baseUrl("https://custom.api.com") + .timeoutSeconds(60) + .defaultProcessor(ProcessorType.GPU) + .defaultPrecision(Precision.FP16) + .debug(true) + .build(); + + assertEquals("https://custom.api.com", config.getBaseUrl()); + assertEquals(60, config.getTimeoutSeconds()); + assertEquals(ProcessorType.GPU, config.getDefaultProcessor()); + assertTrue(config.isDebug()); + } + + @Test + @DisplayName("MatMulOptions generates correct JSON") + void testMatMulOptions() { + MatMulOptions options = MatMulOptions.builder() + .precision(Precision.FP16) + .processor(ProcessorType.GPU) + .priority(Priority.HIGH) + .build(); + + var json = options.toJson(); + assertEquals("fp16", json.get("precision")); + assertEquals("gpu", json.get("processor")); + assertEquals("high", json.get("priority")); + } + + @Test + @DisplayName("Conv2dOptions has correct defaults") + void testConv2dOptionsDefaults() { + Conv2dOptions options = Conv2dOptions.builder().build(); + + assertArrayEquals(new int[]{1, 1}, options.getStride()); + assertArrayEquals(new int[]{0, 0}, options.getPadding()); + } + + @Test + @DisplayName("AttentionOptions generates correct JSON") + void testAttentionOptions() { + AttentionOptions options = AttentionOptions.builder() + .numHeads(8) + .flash(true) + .precision(Precision.FP16) + .build(); + + var json = options.toJson(); + assertEquals(8, json.get("num_heads")); + assertEquals(true, json.get("flash")); + assertEquals("fp16", json.get("precision")); + } + + @Test + @DisplayName("InferenceOptions has correct defaults") + void testInferenceOptionsDefaults() { + InferenceOptions options = InferenceOptions.builder().build(); + + assertEquals(256, options.getMaxTokens()); + assertEquals(0.7, options.getTemperature(), 0.001); + assertEquals(0.9, options.getTopP(), 0.001); + assertEquals(50, options.getTopK()); + } + + @Test + @DisplayName("InferenceOptions generates correct JSON") + void testInferenceOptionsJson() { + InferenceOptions options = InferenceOptions.builder() + .maxTokens(512) + .temperature(0.8) + .topK(40) + .build(); + + var json = options.toJson(); + assertEquals(512, json.get("max_tokens")); + assertEquals(0.8, ((Number)json.get("temperature")).doubleValue(), 0.001); + assertEquals(40, json.get("top_k")); + } + + @Test + @DisplayName("JobResult identifies success") + void testJobResultSuccess() { + JobResult success = JobResult.builder() + .jobId("job-123") + .status(JobStatus.COMPLETED) + .result("output") + .build(); + + assertTrue(success.isSuccess()); + assertFalse(success.isFailed()); + } + + @Test + @DisplayName("JobResult identifies failure") + void testJobResultFailure() { + JobResult failure = JobResult.builder() + .jobId("job-456") + .status(JobStatus.FAILED) + .error("Error message") + .build(); + + assertFalse(failure.isSuccess()); + assertTrue(failure.isFailed()); + } + + @Test + @DisplayName("ModelInfo formats parameters correctly") + void testModelInfoFormattedParameters() { + ModelInfo model = ModelInfo.builder() + .id("test") + .name("Test Model") + .parameters(70000000000L) + .build(); + + assertEquals("70B", model.getFormattedParameters()); + } + + @Test + @DisplayName("PricingInfo parses from JSON") + void testPricingInfoFromJson() { + var json = new java.util.HashMap(); + json.put("processor", "gpu"); + json.put("price_per_second", 0.0001); + json.put("available_units", 100); + json.put("utilization_percent", 75.5); + + PricingInfo pricing = PricingInfo.fromJson(json); + + assertEquals("gpu", pricing.getProcessor()); + assertEquals(0.0001, pricing.getPricePerSecond(), 0.00001); + assertEquals(100, pricing.getAvailableUnits()); + } + + @Test + @DisplayName("UsageStats parses from JSON") + void testUsageStatsFromJson() { + var json = new java.util.HashMap(); + json.put("total_jobs", 1000); + json.put("completed_jobs", 950); + json.put("failed_jobs", 50); + json.put("total_compute_seconds", 3600.0); + json.put("total_cost", 0.36); + + UsageStats stats = UsageStats.fromJson(json); + + assertEquals(1000, stats.getTotalJobs()); + assertEquals(950, stats.getCompletedJobs()); + assertEquals(3600.0, stats.getTotalComputeSeconds(), 0.001); + } +} diff --git a/sdk/js/src/__tests__/client.test.ts b/sdk/js/src/__tests__/client.test.ts new file mode 100644 index 0000000..4f3ad3a --- /dev/null +++ b/sdk/js/src/__tests__/client.test.ts @@ -0,0 +1,369 @@ +/** + * Unit tests for SynorCompute client + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SynorCompute } from '../client'; +import { Tensor } from '../tensor'; + +// Mock fetch for API calls +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('SynorCompute Client', () => { + let client: SynorCompute; + + beforeEach(() => { + mockFetch.mockReset(); + client = new SynorCompute({ apiKey: 'test-api-key' }); + }); + + describe('initialization', () => { + it('should create client with API key', () => { + const c = new SynorCompute({ apiKey: 'my-key' }); + expect(c).toBeInstanceOf(SynorCompute); + }); + + it('should create client with config object', () => { + const c = new SynorCompute({ + apiKey: 'my-key', + baseUrl: 'https://custom.api.com', + timeout: 60000, + }); + expect(c).toBeInstanceOf(SynorCompute); + }); + + it('should throw without API key', () => { + expect(() => new SynorCompute({ apiKey: '' })).toThrow(); + }); + }); + + describe('matmul', () => { + it('should send matmul request', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + jobId: 'job-123', + status: 'completed', + result: { data: 'base64result', shape: [2, 4], dtype: 'fp32' }, + executionTimeMs: 15, + processor: 'gpu', + }), + }); + + const a = Tensor.random([2, 3]); + const b = Tensor.random([3, 4]); + const result = await client.matmul({ a, b }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.status).toBe('completed'); + expect(result.jobId).toBe('job-123'); + }); + + it('should support precision option', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + jobId: 'job-456', + status: 'completed', + result: { data: 'base64', shape: [2, 2], dtype: 'fp16' }, + }), + }); + + const a = Tensor.random([2, 2]); + const b = Tensor.random([2, 2]); + await client.matmul({ a, b, precision: 'fp16' }); + + const [url, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.precision).toBe('fp16'); + }); + + it('should support processor selection', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + jobId: 'job-789', + status: 'completed', + result: { data: 'base64', shape: [2, 2], dtype: 'fp32' }, + }), + }); + + const a = Tensor.random([2, 2]); + const b = Tensor.random([2, 2]); + await client.matmul({ a, b, processor: 'tpu' }); + + const [url, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.processor).toBe('tpu'); + }); + }); + + describe('conv2d', () => { + it('should send conv2d request', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + jobId: 'conv-123', + status: 'completed', + result: { data: 'base64', shape: [1, 32, 30, 30], dtype: 'fp32' }, + }), + }); + + const input = Tensor.random([1, 3, 32, 32]); + const kernel = Tensor.random([32, 3, 3, 3]); + const result = await client.conv2d({ input, kernel }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.status).toBe('completed'); + }); + + it('should support stride and padding options', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + jobId: 'conv-456', + status: 'completed', + result: { data: 'base64', shape: [1, 32, 16, 16], dtype: 'fp32' }, + }), + }); + + const input = Tensor.random([1, 3, 32, 32]); + const kernel = Tensor.random([32, 3, 3, 3]); + await client.conv2d({ + input, + kernel, + stride: [2, 2], + padding: [1, 1], + }); + + const [url, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.stride).toEqual([2, 2]); + expect(body.padding).toEqual([1, 1]); + }); + }); + + describe('attention', () => { + it('should send attention request', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + jobId: 'attn-123', + status: 'completed', + result: { data: 'base64', shape: [1, 8, 64, 64], dtype: 'fp16' }, + }), + }); + + const query = Tensor.random([1, 8, 64, 64]); + const key = Tensor.random([1, 8, 64, 64]); + const value = Tensor.random([1, 8, 64, 64]); + const result = await client.attention({ query, key, value }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.status).toBe('completed'); + }); + + it('should support flash attention option', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + jobId: 'attn-456', + status: 'completed', + result: { data: 'base64', shape: [1, 8, 64, 64], dtype: 'fp16' }, + }), + }); + + const query = Tensor.random([1, 8, 64, 64]); + const key = Tensor.random([1, 8, 64, 64]); + const value = Tensor.random([1, 8, 64, 64]); + await client.attention({ query, key, value, flash: true }); + + const [url, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.flash).toBe(true); + }); + }); + + describe('inference', () => { + it('should send inference request', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + jobId: 'inf-123', + status: 'completed', + result: 'The answer to your question is...', + executionTimeMs: 2500, + processor: 'gpu', + }), + }); + + const result = await client.inference({ + model: 'llama-3-70b', + prompt: 'What is the capital of France?', + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.status).toBe('completed'); + expect(result.result).toContain('answer'); + }); + + it('should support inference options', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + jobId: 'inf-456', + status: 'completed', + result: 'Generated text...', + }), + }); + + await client.inference({ + model: 'llama-3-70b', + prompt: 'Hello', + maxTokens: 512, + temperature: 0.8, + topP: 0.95, + topK: 40, + }); + + const [url, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.maxTokens).toBe(512); + expect(body.temperature).toBe(0.8); + expect(body.topP).toBe(0.95); + expect(body.topK).toBe(40); + }); + }); + + describe('model registry', () => { + it('should list models', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + models: [ + { id: 'llama-3-70b', name: 'Llama 3 70B', category: 'llm' }, + { id: 'mistral-7b', name: 'Mistral 7B', category: 'llm' }, + ], + }), + }); + + const models = await client.listModels(); + expect(models).toHaveLength(2); + expect(models[0].id).toBe('llama-3-70b'); + }); + + it('should filter models by category', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + models: [ + { id: 'sd-xl', name: 'Stable Diffusion XL', category: 'image_generation' }, + ], + }), + }); + + await client.listModels({ category: 'image_generation' }); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('category=image_generation'); + }); + + it('should get model by ID', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + id: 'llama-3-70b', + name: 'Llama 3 70B', + category: 'llm', + parameters: 70000000000, + contextLength: 8192, + }), + }); + + const model = await client.getModel('llama-3-70b'); + expect(model.id).toBe('llama-3-70b'); + expect(model.parameters).toBe(70000000000); + }); + }); + + describe('pricing and usage', () => { + it('should get pricing', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + pricing: [ + { processor: 'gpu', pricePerSecond: 0.0001, availableUnits: 100 }, + { processor: 'tpu', pricePerSecond: 0.0002, availableUnits: 50 }, + ], + }), + }); + + const pricing = await client.getPricing(); + expect(pricing).toHaveLength(2); + expect(pricing[0].processor).toBe('gpu'); + }); + + it('should get usage', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + totalJobs: 1000, + completedJobs: 950, + failedJobs: 50, + totalComputeSeconds: 3600, + totalCost: 0.36, + }), + }); + + const usage = await client.getUsage(); + expect(usage.totalJobs).toBe(1000); + expect(usage.completedJobs).toBe(950); + }); + }); + + describe('error handling', () => { + it('should handle API errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: () => Promise.resolve({ error: 'Invalid API key' }), + }); + + const a = Tensor.random([2, 2]); + const b = Tensor.random([2, 2]); + + await expect(client.matmul({ a, b })).rejects.toThrow(); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const a = Tensor.random([2, 2]); + const b = Tensor.random([2, 2]); + + await expect(client.matmul({ a, b })).rejects.toThrow('Network error'); + }); + }); + + describe('health check', () => { + it('should return true for healthy service', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ status: 'healthy' }), + }); + + const healthy = await client.healthCheck(); + expect(healthy).toBe(true); + }); + + it('should return false for unhealthy service', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + }); + + const healthy = await client.healthCheck(); + expect(healthy).toBe(false); + }); + }); +}); diff --git a/sdk/js/src/__tests__/tensor.test.ts b/sdk/js/src/__tests__/tensor.test.ts new file mode 100644 index 0000000..c12b64c --- /dev/null +++ b/sdk/js/src/__tests__/tensor.test.ts @@ -0,0 +1,175 @@ +/** + * Unit tests for Tensor class + */ + +import { describe, it, expect } from 'vitest'; +import { Tensor } from '../tensor'; + +describe('Tensor', () => { + describe('creation', () => { + it('should create tensor from nested array', () => { + const t = Tensor.from([[1, 2, 3], [4, 5, 6]]); + expect(t.shape).toEqual([2, 3]); + expect(t.size).toBe(6); + expect(t.ndim).toBe(2); + }); + + it('should create tensor from flat array', () => { + const t = Tensor.from([1, 2, 3, 4]); + expect(t.shape).toEqual([4]); + expect(t.size).toBe(4); + expect(t.ndim).toBe(1); + }); + + it('should create tensor from Float32Array', () => { + const data = new Float32Array([1.0, 2.0, 3.0]); + const t = Tensor.from(data); + expect(t.dtype).toBe('fp32'); + expect(t.size).toBe(3); + }); + + it('should create tensor from Float64Array', () => { + const data = new Float64Array([1.0, 2.0, 3.0]); + const t = Tensor.from(data); + expect(t.dtype).toBe('fp64'); + }); + }); + + describe('factory methods', () => { + it('should create zeros tensor', () => { + const t = Tensor.zeros([2, 3]); + expect(t.shape).toEqual([2, 3]); + expect(t.size).toBe(6); + expect(Array.from(t.data).every(v => v === 0)).toBe(true); + }); + + it('should create ones tensor', () => { + const t = Tensor.ones([3, 2]); + expect(t.shape).toEqual([3, 2]); + expect(Array.from(t.data).every(v => v === 1)).toBe(true); + }); + + it('should create random tensor', () => { + const t = Tensor.random([10, 10]); + expect(t.shape).toEqual([10, 10]); + expect(t.size).toBe(100); + // Values should be in [0, 1) + expect(Array.from(t.data).every(v => v >= 0 && v < 1)).toBe(true); + }); + + it('should create randn tensor', () => { + const t = Tensor.randn([100]); + expect(t.shape).toEqual([100]); + // Check approximate normal distribution properties + const values = Array.from(t.data); + const mean = values.reduce((a, b) => a + b) / values.length; + expect(Math.abs(mean)).toBeLessThan(0.5); // Should be close to 0 + }); + + it('should support different dtypes', () => { + expect(Tensor.zeros([2, 2], 'fp64').dtype).toBe('fp64'); + expect(Tensor.zeros([2, 2], 'fp32').dtype).toBe('fp32'); + expect(Tensor.zeros([2, 2], 'int8').dtype).toBe('int8'); + }); + }); + + describe('operations', () => { + it('should reshape tensor', () => { + const t = Tensor.from([1, 2, 3, 4, 5, 6]); + const reshaped = t.reshape([2, 3]); + expect(reshaped.shape).toEqual([2, 3]); + expect(reshaped.size).toBe(6); + }); + + it('should throw on invalid reshape', () => { + const t = Tensor.from([1, 2, 3, 4]); + expect(() => t.reshape([2, 3])).toThrow(); + }); + + it('should convert dtype', () => { + const t = Tensor.from([1.5, 2.5, 3.5], 'fp32'); + const converted = t.to('fp64'); + expect(converted.dtype).toBe('fp64'); + expect(converted.data).toBeInstanceOf(Float64Array); + }); + + it('should get element at index', () => { + const t = Tensor.from([[1, 2, 3], [4, 5, 6]]); + expect(t.get(0, 0)).toBe(1); + expect(t.get(0, 2)).toBe(3); + expect(t.get(1, 1)).toBe(5); + }); + + it('should set element at index', () => { + const t = Tensor.from([[1, 2], [3, 4]]); + t.set(99, 0, 1); + expect(t.get(0, 1)).toBe(99); + }); + + it('should convert to array', () => { + const t = Tensor.from([[1, 2], [3, 4]]); + const arr = t.toArray(); + expect(arr).toEqual([[1, 2], [3, 4]]); + }); + }); + + describe('properties', () => { + it('should calculate byteSize correctly', () => { + const fp32 = Tensor.zeros([10], 'fp32'); + expect(fp32.byteSize).toBe(40); // 10 * 4 bytes + + const fp64 = Tensor.zeros([10], 'fp64'); + expect(fp64.byteSize).toBe(80); // 10 * 8 bytes + }); + }); + + describe('serialization', () => { + it('should serialize and deserialize tensor', () => { + const original = Tensor.from([[1, 2, 3], [4, 5, 6]]); + const serialized = original.serialize(); + + expect(serialized).toHaveProperty('data'); + expect(serialized).toHaveProperty('shape'); + expect(serialized).toHaveProperty('dtype'); + expect(serialized.shape).toEqual([2, 3]); + + const restored = Tensor.deserialize(serialized); + expect(restored.shape).toEqual(original.shape); + expect(restored.dtype).toBe(original.dtype); + expect(Array.from(restored.data)).toEqual(Array.from(original.data)); + }); + + it('should preserve dtype during serialization', () => { + const fp64Tensor = Tensor.zeros([3], 'fp64'); + fp64Tensor.set(1.234567890123, 0); + + const serialized = fp64Tensor.serialize(); + const restored = Tensor.deserialize(serialized); + + expect(restored.dtype).toBe('fp64'); + }); + }); + + describe('edge cases', () => { + it('should handle scalar-like tensor', () => { + const t = Tensor.from([42]); + expect(t.shape).toEqual([1]); + expect(t.size).toBe(1); + }); + + it('should handle large tensors', () => { + const t = Tensor.zeros([1000, 1000]); + expect(t.size).toBe(1000000); + }); + + it('should throw on out-of-bounds access', () => { + const t = Tensor.from([1, 2, 3]); + expect(() => t.get(5)).toThrow(); + }); + + it('should throw on wrong number of indices', () => { + const t = Tensor.from([[1, 2], [3, 4]]); + expect(() => t.get(0)).toThrow(); + }); + }); +}); diff --git a/sdk/js/src/__tests__/types.test.ts b/sdk/js/src/__tests__/types.test.ts new file mode 100644 index 0000000..4d3601c --- /dev/null +++ b/sdk/js/src/__tests__/types.test.ts @@ -0,0 +1,109 @@ +/** + * Unit tests for SDK types + */ + +import { describe, it, expect } from 'vitest'; +import type { + ProcessorType, + Precision, + BalancingStrategy, + TaskPriority, + JobStatus, + SynorConfig, + MatMulRequest, + InferenceRequest, +} from '../types'; + +describe('Types', () => { + describe('ProcessorType', () => { + it('should accept valid processor types', () => { + const processors: ProcessorType[] = [ + 'cpu', 'gpu', 'tpu', 'npu', 'lpu', 'fpga', 'dsp', 'webgpu', 'wasm', 'auto' + ]; + expect(processors).toHaveLength(10); + }); + }); + + describe('Precision', () => { + it('should accept valid precision types', () => { + const precisions: Precision[] = [ + 'fp64', 'fp32', 'fp16', 'bf16', 'int8', 'int4' + ]; + expect(precisions).toHaveLength(6); + }); + }); + + describe('BalancingStrategy', () => { + it('should accept valid balancing strategies', () => { + const strategies: BalancingStrategy[] = [ + 'cost', 'latency', 'energy', 'throughput', 'auto' + ]; + expect(strategies).toHaveLength(5); + }); + }); + + describe('TaskPriority', () => { + it('should accept valid priority levels', () => { + const priorities: TaskPriority[] = [ + 'critical', 'high', 'normal', 'low', 'background' + ]; + expect(priorities).toHaveLength(5); + }); + }); + + describe('JobStatus', () => { + it('should accept valid job statuses', () => { + const statuses: JobStatus[] = [ + 'pending', 'queued', 'running', 'completed', 'failed', 'cancelled' + ]; + expect(statuses).toHaveLength(6); + }); + }); + + describe('SynorConfig', () => { + it('should create valid config object', () => { + const config: SynorConfig = { + apiKey: 'test-api-key', + baseUrl: 'https://api.synor.io/compute/v1', + timeout: 30000, + }; + expect(config.apiKey).toBe('test-api-key'); + expect(config.baseUrl).toBeDefined(); + }); + + it('should allow optional fields', () => { + const config: SynorConfig = { + apiKey: 'test-api-key', + }; + expect(config.apiKey).toBe('test-api-key'); + }); + }); + + describe('MatMulRequest', () => { + it('should create valid matmul request', () => { + const request: MatMulRequest = { + a: { data: 'base64', shape: [2, 3], dtype: 'fp32' }, + b: { data: 'base64', shape: [3, 4], dtype: 'fp32' }, + precision: 'fp16', + processor: 'gpu', + }; + expect(request.a.shape).toEqual([2, 3]); + expect(request.b.shape).toEqual([3, 4]); + }); + }); + + describe('InferenceRequest', () => { + it('should create valid inference request', () => { + const request: InferenceRequest = { + model: 'llama-3-70b', + prompt: 'Hello, world!', + maxTokens: 256, + temperature: 0.7, + topP: 0.9, + topK: 50, + }; + expect(request.model).toBe('llama-3-70b'); + expect(request.maxTokens).toBe(256); + }); + }); +}); diff --git a/sdk/python/tests/__init__.py b/sdk/python/tests/__init__.py new file mode 100644 index 0000000..154576f --- /dev/null +++ b/sdk/python/tests/__init__.py @@ -0,0 +1 @@ +# Tests for Synor Compute SDK diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py new file mode 100644 index 0000000..7c07df7 --- /dev/null +++ b/sdk/python/tests/test_client.py @@ -0,0 +1,382 @@ +"""Unit tests for SynorCompute client.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +import numpy as np + +from synor_compute import SynorCompute, SynorError, Tensor +from synor_compute.types import ( + ProcessorType, + Precision, + JobStatus, + SynorConfig, +) + + +@pytest.fixture +def mock_response(): + """Create a mock HTTP response.""" + def _create_response(data, status=200): + response = AsyncMock() + response.status = status + response.json = AsyncMock(return_value=data) + response.text = AsyncMock(return_value=str(data)) + return response + return _create_response + + +@pytest.fixture +def client(): + """Create a test client.""" + return SynorCompute(api_key="test-api-key") + + +class TestClientInitialization: + """Tests for client initialization.""" + + def test_create_with_api_key(self): + """Should create client with API key.""" + client = SynorCompute(api_key="my-key") + assert client is not None + + def test_create_with_config(self): + """Should create client with config.""" + config = SynorConfig( + api_key="my-key", + base_url="https://custom.api.com", + timeout=60, + ) + client = SynorCompute(config=config) + assert client is not None + + def test_raise_without_api_key(self): + """Should raise error without API key.""" + with pytest.raises((ValueError, TypeError)): + SynorCompute(api_key="") + + +class TestMatMul: + """Tests for matrix multiplication.""" + + @pytest.mark.asyncio + async def test_matmul_request(self, client, mock_response): + """Should send matmul request.""" + response_data = { + "job_id": "job-123", + "status": "completed", + "result": {"data": "base64", "shape": [2, 4], "dtype": "fp32"}, + "execution_time_ms": 15, + "processor": "gpu", + } + + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = response_data + + a = Tensor.random((2, 3)) + b = Tensor.random((3, 4)) + result = await client.matmul(a, b) + + mock_req.assert_called_once() + assert result.status == JobStatus.COMPLETED + assert result.job_id == "job-123" + + @pytest.mark.asyncio + async def test_matmul_with_precision(self, client): + """Should support precision option.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = { + "job_id": "job-456", + "status": "completed", + "result": {"data": "base64", "shape": [2, 2], "dtype": "fp16"}, + } + + a = Tensor.random((2, 2)) + b = Tensor.random((2, 2)) + await client.matmul(a, b, precision=Precision.FP16) + + call_args = mock_req.call_args + assert call_args[1].get("precision") == "fp16" or \ + call_args[0][1].get("precision") == "fp16" + + @pytest.mark.asyncio + async def test_matmul_with_processor(self, client): + """Should support processor selection.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = { + "job_id": "job-789", + "status": "completed", + "result": {"data": "base64", "shape": [2, 2], "dtype": "fp32"}, + } + + a = Tensor.random((2, 2)) + b = Tensor.random((2, 2)) + await client.matmul(a, b, processor=ProcessorType.TPU) + + mock_req.assert_called_once() + + +class TestConv2d: + """Tests for 2D convolution.""" + + @pytest.mark.asyncio + async def test_conv2d_request(self, client): + """Should send conv2d request.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = { + "job_id": "conv-123", + "status": "completed", + "result": {"data": "base64", "shape": [1, 32, 30, 30], "dtype": "fp32"}, + } + + input_tensor = Tensor.random((1, 3, 32, 32)) + kernel = Tensor.random((32, 3, 3, 3)) + result = await client.conv2d(input_tensor, kernel) + + assert result.status == JobStatus.COMPLETED + + @pytest.mark.asyncio + async def test_conv2d_with_options(self, client): + """Should support stride and padding options.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = { + "job_id": "conv-456", + "status": "completed", + "result": {"data": "base64", "shape": [1, 32, 16, 16], "dtype": "fp32"}, + } + + input_tensor = Tensor.random((1, 3, 32, 32)) + kernel = Tensor.random((32, 3, 3, 3)) + await client.conv2d( + input_tensor, + kernel, + stride=(2, 2), + padding=(1, 1), + ) + + mock_req.assert_called_once() + + +class TestAttention: + """Tests for attention operation.""" + + @pytest.mark.asyncio + async def test_attention_request(self, client): + """Should send attention request.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = { + "job_id": "attn-123", + "status": "completed", + "result": {"data": "base64", "shape": [1, 8, 64, 64], "dtype": "fp16"}, + } + + query = Tensor.random((1, 8, 64, 64)) + key = Tensor.random((1, 8, 64, 64)) + value = Tensor.random((1, 8, 64, 64)) + result = await client.attention(query, key, value) + + assert result.status == JobStatus.COMPLETED + + @pytest.mark.asyncio + async def test_attention_flash(self, client): + """Should support flash attention option.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = { + "job_id": "attn-456", + "status": "completed", + "result": {"data": "base64", "shape": [1, 8, 64, 64], "dtype": "fp16"}, + } + + query = Tensor.random((1, 8, 64, 64)) + key = Tensor.random((1, 8, 64, 64)) + value = Tensor.random((1, 8, 64, 64)) + await client.attention(query, key, value, flash=True) + + mock_req.assert_called_once() + + +class TestInference: + """Tests for LLM inference.""" + + @pytest.mark.asyncio + async def test_inference_request(self, client): + """Should send inference request.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = { + "job_id": "inf-123", + "status": "completed", + "result": "The answer to your question is...", + "execution_time_ms": 2500, + "processor": "gpu", + } + + result = await client.inference( + model="llama-3-70b", + prompt="What is the capital of France?", + ) + + assert result.status == JobStatus.COMPLETED + assert "answer" in result.result + + @pytest.mark.asyncio + async def test_inference_with_options(self, client): + """Should support inference options.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = { + "job_id": "inf-456", + "status": "completed", + "result": "Generated text...", + } + + await client.inference( + model="llama-3-70b", + prompt="Hello", + max_tokens=512, + temperature=0.8, + top_p=0.95, + top_k=40, + ) + + mock_req.assert_called_once() + + +class TestModelRegistry: + """Tests for model registry operations.""" + + @pytest.mark.asyncio + async def test_list_models(self, client): + """Should list available models.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = { + "models": [ + {"id": "llama-3-70b", "name": "Llama 3 70B", "category": "llm"}, + {"id": "mistral-7b", "name": "Mistral 7B", "category": "llm"}, + ] + } + + models = await client.list_models() + + assert len(models) == 2 + assert models[0]["id"] == "llama-3-70b" + + @pytest.mark.asyncio + async def test_list_models_by_category(self, client): + """Should filter models by category.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = { + "models": [ + {"id": "sd-xl", "name": "Stable Diffusion XL", "category": "image_generation"}, + ] + } + + models = await client.list_models(category="image_generation") + + assert len(models) == 1 + assert models[0]["category"] == "image_generation" + + @pytest.mark.asyncio + async def test_get_model(self, client): + """Should get model by ID.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = { + "id": "llama-3-70b", + "name": "Llama 3 70B", + "category": "llm", + "parameters": 70000000000, + "context_length": 8192, + } + + model = await client.get_model("llama-3-70b") + + assert model["id"] == "llama-3-70b" + assert model["parameters"] == 70000000000 + + +class TestPricingAndUsage: + """Tests for pricing and usage APIs.""" + + @pytest.mark.asyncio + async def test_get_pricing(self, client): + """Should get pricing information.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = { + "pricing": [ + {"processor": "gpu", "price_per_second": 0.0001, "available_units": 100}, + {"processor": "tpu", "price_per_second": 0.0002, "available_units": 50}, + ] + } + + pricing = await client.get_pricing() + + assert len(pricing) == 2 + assert pricing[0]["processor"] == "gpu" + + @pytest.mark.asyncio + async def test_get_usage(self, client): + """Should get usage statistics.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = { + "total_jobs": 1000, + "completed_jobs": 950, + "failed_jobs": 50, + "total_compute_seconds": 3600, + "total_cost": 0.36, + } + + usage = await client.get_usage() + + assert usage["total_jobs"] == 1000 + assert usage["completed_jobs"] == 950 + + +class TestErrorHandling: + """Tests for error handling.""" + + @pytest.mark.asyncio + async def test_api_error(self, client): + """Should handle API errors.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.side_effect = SynorError("Invalid API key", status_code=401) + + a = Tensor.random((2, 2)) + b = Tensor.random((2, 2)) + + with pytest.raises(SynorError) as exc_info: + await client.matmul(a, b) + + assert exc_info.value.status_code == 401 + + @pytest.mark.asyncio + async def test_network_error(self, client): + """Should handle network errors.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.side_effect = Exception("Network error") + + a = Tensor.random((2, 2)) + b = Tensor.random((2, 2)) + + with pytest.raises(Exception, match="Network error"): + await client.matmul(a, b) + + +class TestHealthCheck: + """Tests for health check.""" + + @pytest.mark.asyncio + async def test_healthy_service(self, client): + """Should return True for healthy service.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.return_value = {"status": "healthy"} + + healthy = await client.health_check() + + assert healthy is True + + @pytest.mark.asyncio + async def test_unhealthy_service(self, client): + """Should return False for unhealthy service.""" + with patch.object(client, "_request", new_callable=AsyncMock) as mock_req: + mock_req.side_effect = Exception("Service unavailable") + + healthy = await client.health_check() + + assert healthy is False diff --git a/sdk/python/tests/test_tensor.py b/sdk/python/tests/test_tensor.py new file mode 100644 index 0000000..3e6ba6f --- /dev/null +++ b/sdk/python/tests/test_tensor.py @@ -0,0 +1,239 @@ +"""Unit tests for Tensor class.""" + +import pytest +import numpy as np +from synor_compute import Tensor +from synor_compute.types import Precision + + +class TestTensorCreation: + """Tests for tensor creation methods.""" + + def test_from_numpy_array(self): + """Should create tensor from numpy array.""" + arr = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32) + tensor = Tensor.from_numpy(arr) + + assert tensor.shape == (2, 3) + assert tensor.size == 6 + assert tensor.ndim == 2 + assert tensor.dtype == Precision.FP32 + + def test_from_numpy_with_dtype(self): + """Should create tensor with specified dtype.""" + arr = np.array([1, 2, 3]) + tensor = Tensor.from_numpy(arr, dtype=Precision.FP16) + + assert tensor.dtype == Precision.FP16 + + def test_infer_dtype_fp64(self): + """Should infer FP64 from float64 array.""" + arr = np.array([1.0, 2.0], dtype=np.float64) + tensor = Tensor.from_numpy(arr) + + assert tensor.dtype == Precision.FP64 + + def test_infer_dtype_int8(self): + """Should infer INT8 from int8 array.""" + arr = np.array([1, 2, 3], dtype=np.int8) + tensor = Tensor.from_numpy(arr) + + assert tensor.dtype == Precision.INT8 + + +class TestTensorFactoryMethods: + """Tests for tensor factory methods.""" + + def test_zeros(self): + """Should create tensor of zeros.""" + tensor = Tensor.zeros((2, 3)) + + assert tensor.shape == (2, 3) + assert np.allclose(tensor.data, 0) + + def test_zeros_with_dtype(self): + """Should create zeros tensor with specified dtype.""" + tensor = Tensor.zeros((2, 2), dtype=Precision.FP64) + + assert tensor.dtype == Precision.FP64 + assert tensor.data.dtype == np.float64 + + def test_ones(self): + """Should create tensor of ones.""" + tensor = Tensor.ones((3, 2)) + + assert tensor.shape == (3, 2) + assert np.allclose(tensor.data, 1) + + def test_random(self): + """Should create tensor with random values in [0, 1).""" + tensor = Tensor.random((10, 10)) + + assert tensor.shape == (10, 10) + assert tensor.data.min() >= 0 + assert tensor.data.max() < 1 + + def test_randn(self): + """Should create tensor with normal distribution.""" + tensor = Tensor.randn((1000,)) + + # Check approximate normal distribution properties + assert abs(tensor.data.mean()) < 0.2 # Mean ~= 0 + assert 0.8 < tensor.data.std() < 1.2 # Std ~= 1 + + +class TestTensorOperations: + """Tests for tensor operations.""" + + def test_reshape(self): + """Should reshape tensor.""" + tensor = Tensor.from_numpy(np.arange(6)) + reshaped = tensor.reshape((2, 3)) + + assert reshaped.shape == (2, 3) + assert reshaped.size == 6 + + def test_reshape_preserves_data(self): + """Should preserve data when reshaping.""" + arr = np.array([1, 2, 3, 4, 5, 6]) + tensor = Tensor.from_numpy(arr) + reshaped = tensor.reshape((2, 3)) + + assert reshaped.data[0, 0] == 1 + assert reshaped.data[1, 2] == 6 + + def test_to_different_dtype(self): + """Should convert to different dtype.""" + tensor = Tensor.from_numpy(np.array([1.5, 2.5], dtype=np.float32)) + converted = tensor.to(Precision.FP64) + + assert converted.dtype == Precision.FP64 + assert converted.data.dtype == np.float64 + + def test_to_same_dtype_returns_self(self): + """Should return self when converting to same dtype.""" + tensor = Tensor.from_numpy(np.array([1.0, 2.0], dtype=np.float32)) + same = tensor.to(Precision.FP32) + + assert same is tensor + + def test_numpy_returns_underlying_array(self): + """Should return underlying numpy array.""" + arr = np.array([1, 2, 3], dtype=np.float32) + tensor = Tensor.from_numpy(arr) + + assert np.array_equal(tensor.numpy(), arr) + + +class TestTensorProperties: + """Tests for tensor properties.""" + + def test_shape(self): + """Should return correct shape.""" + tensor = Tensor.zeros((3, 4, 5)) + assert tensor.shape == (3, 4, 5) + + def test_size(self): + """Should return correct size.""" + tensor = Tensor.zeros((3, 4, 5)) + assert tensor.size == 60 + + def test_ndim(self): + """Should return correct number of dimensions.""" + tensor = Tensor.zeros((3, 4, 5)) + assert tensor.ndim == 3 + + def test_nbytes(self): + """Should return correct byte size.""" + tensor = Tensor.zeros((10,), dtype=Precision.FP32) + assert tensor.nbytes == 40 # 10 * 4 bytes + + +class TestTensorSerialization: + """Tests for tensor serialization.""" + + def test_serialize(self): + """Should serialize tensor to dict.""" + tensor = Tensor.from_numpy(np.array([[1, 2], [3, 4]], dtype=np.float32)) + serialized = tensor.serialize() + + assert "data" in serialized + assert "shape" in serialized + assert "dtype" in serialized + assert serialized["shape"] == [2, 2] + assert serialized["dtype"] == "fp32" + + def test_deserialize(self): + """Should deserialize tensor from dict.""" + original = Tensor.from_numpy(np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)) + serialized = original.serialize() + restored = Tensor.deserialize(serialized) + + assert restored.shape == original.shape + assert restored.dtype == original.dtype + assert np.array_equal(restored.data, original.data) + + def test_serialize_deserialize_fp64(self): + """Should preserve FP64 precision.""" + original = Tensor.from_numpy(np.array([1.234567890123456], dtype=np.float64)) + serialized = original.serialize() + restored = Tensor.deserialize(serialized) + + assert restored.dtype == Precision.FP64 + assert np.allclose(restored.data, original.data) + + def test_serialize_deserialize_large_tensor(self): + """Should handle large tensors.""" + original = Tensor.random((100, 100)) + serialized = original.serialize() + restored = Tensor.deserialize(serialized) + + assert restored.shape == original.shape + assert np.allclose(restored.data, original.data) + + +class TestTensorRepr: + """Tests for tensor string representations.""" + + def test_repr(self): + """Should return informative repr.""" + tensor = Tensor.zeros((2, 3), dtype=Precision.FP32) + rep = repr(tensor) + + assert "Tensor" in rep + assert "(2, 3)" in rep + assert "fp32" in rep + + def test_str(self): + """Should return string with data.""" + tensor = Tensor.from_numpy(np.array([1, 2, 3], dtype=np.float32)) + s = str(tensor) + + assert "Tensor" in s + assert "fp32" in s + + +class TestTensorEdgeCases: + """Tests for edge cases.""" + + def test_scalar_like_tensor(self): + """Should handle 1-element tensor.""" + tensor = Tensor.from_numpy(np.array([42], dtype=np.float32)) + + assert tensor.shape == (1,) + assert tensor.size == 1 + assert tensor.ndim == 1 + + def test_empty_shape_dimension(self): + """Should handle tensors with zero-size dimensions.""" + tensor = Tensor.zeros((0, 3)) + + assert tensor.shape == (0, 3) + assert tensor.size == 0 + + def test_high_dimensional_tensor(self): + """Should handle high-dimensional tensors.""" + tensor = Tensor.zeros((2, 3, 4, 5, 6)) + + assert tensor.ndim == 5 + assert tensor.size == 720 diff --git a/sdk/python/tests/test_types.py b/sdk/python/tests/test_types.py new file mode 100644 index 0000000..67d2289 --- /dev/null +++ b/sdk/python/tests/test_types.py @@ -0,0 +1,212 @@ +"""Unit tests for SDK types.""" + +import pytest +from synor_compute.types import ( + ProcessorType, + Precision, + BalancingStrategy, + TaskPriority, + JobStatus, + SynorConfig, + JobResult, + JobMetrics, + PricingInfo, +) + + +class TestProcessorType: + """Tests for ProcessorType enum.""" + + def test_all_processor_types(self): + """Should have all expected processor types.""" + expected = ["cpu", "gpu", "tpu", "npu", "lpu", "fpga", "dsp", "webgpu", "wasm", "auto"] + actual = [p.value for p in ProcessorType] + + for e in expected: + assert e in actual + + def test_processor_value(self): + """Should have correct values.""" + assert ProcessorType.GPU.value == "gpu" + assert ProcessorType.TPU.value == "tpu" + assert ProcessorType.AUTO.value == "auto" + + +class TestPrecision: + """Tests for Precision enum.""" + + def test_all_precision_types(self): + """Should have all expected precision types.""" + expected = ["fp64", "fp32", "fp16", "bf16", "int8", "int4"] + actual = [p.value for p in Precision] + + for e in expected: + assert e in actual + + def test_precision_value(self): + """Should have correct values.""" + assert Precision.FP64.value == "fp64" + assert Precision.FP32.value == "fp32" + assert Precision.FP16.value == "fp16" + + +class TestBalancingStrategy: + """Tests for BalancingStrategy enum.""" + + def test_all_strategies(self): + """Should have all expected strategies.""" + expected = ["cost", "latency", "energy", "throughput", "auto"] + actual = [s.value for s in BalancingStrategy] + + for e in expected: + assert e in actual + + +class TestTaskPriority: + """Tests for TaskPriority enum.""" + + def test_all_priorities(self): + """Should have all expected priorities.""" + expected = ["critical", "high", "normal", "low", "background"] + actual = [p.value for p in TaskPriority] + + for e in expected: + assert e in actual + + +class TestJobStatus: + """Tests for JobStatus enum.""" + + def test_all_statuses(self): + """Should have all expected statuses.""" + expected = ["pending", "queued", "running", "completed", "failed", "cancelled"] + actual = [s.value for s in JobStatus] + + for e in expected: + assert e in actual + + +class TestSynorConfig: + """Tests for SynorConfig dataclass.""" + + def test_create_with_defaults(self): + """Should create config with defaults.""" + config = SynorConfig(api_key="test-key") + + assert config.api_key == "test-key" + assert config.base_url == "https://api.synor.io/compute/v1" + assert config.timeout == 30 + + def test_create_with_custom_values(self): + """Should create config with custom values.""" + config = SynorConfig( + api_key="test-key", + base_url="https://custom.api.com", + timeout=60, + default_processor=ProcessorType.GPU, + default_precision=Precision.FP16, + ) + + assert config.base_url == "https://custom.api.com" + assert config.timeout == 60 + assert config.default_processor == ProcessorType.GPU + assert config.default_precision == Precision.FP16 + + +class TestJobResult: + """Tests for JobResult dataclass.""" + + def test_create_job_result(self): + """Should create job result.""" + result = JobResult( + job_id="job-123", + status=JobStatus.COMPLETED, + result={"data": "value"}, + execution_time_ms=150, + processor=ProcessorType.GPU, + ) + + assert result.job_id == "job-123" + assert result.status == JobStatus.COMPLETED + assert result.execution_time_ms == 150 + + def test_is_success(self): + """Should identify successful job.""" + success = JobResult( + job_id="job-1", + status=JobStatus.COMPLETED, + result="data", + ) + failure = JobResult( + job_id="job-2", + status=JobStatus.FAILED, + error="Error message", + ) + + assert success.is_success() + assert not failure.is_success() + + def test_is_failed(self): + """Should identify failed job.""" + success = JobResult( + job_id="job-1", + status=JobStatus.COMPLETED, + result="data", + ) + failure = JobResult( + job_id="job-2", + status=JobStatus.FAILED, + error="Error message", + ) + + assert not success.is_failed() + assert failure.is_failed() + + +class TestJobMetrics: + """Tests for JobMetrics dataclass.""" + + def test_create_metrics(self): + """Should create job metrics.""" + metrics = JobMetrics( + queue_time_ms=10, + compute_time_ms=100, + transfer_time_ms=5, + total_time_ms=115, + flops=1e12, + memory_used_mb=1024, + ) + + assert metrics.compute_time_ms == 100 + assert metrics.flops == 1e12 + + +class TestPricingInfo: + """Tests for PricingInfo dataclass.""" + + def test_create_pricing_info(self): + """Should create pricing info.""" + pricing = PricingInfo( + processor=ProcessorType.GPU, + price_per_second=0.0001, + available_units=100, + utilization_percent=75.5, + ) + + assert pricing.processor == ProcessorType.GPU + assert pricing.price_per_second == 0.0001 + assert pricing.available_units == 100 + + def test_optional_fields(self): + """Should handle optional fields.""" + pricing = PricingInfo( + processor=ProcessorType.TPU, + price_per_second=0.0002, + available_units=50, + utilization_percent=60.0, + aws_equivalent_price=0.0010, + savings_percent=80.0, + ) + + assert pricing.aws_equivalent_price == 0.0010 + assert pricing.savings_percent == 80.0 diff --git a/sdk/ruby/test/test_tensor.rb b/sdk/ruby/test/test_tensor.rb new file mode 100644 index 0000000..a2f8a13 --- /dev/null +++ b/sdk/ruby/test/test_tensor.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require_relative '../lib/synor_compute' + +class TestTensor < Minitest::Test + def test_creation + tensor = SynorCompute::Tensor.new([2, 3], [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) + assert_equal [2, 3], tensor.shape + assert_equal 6, tensor.size + assert_equal 2, tensor.ndim + end + + def test_zeros + tensor = SynorCompute::Tensor.zeros([3, 4]) + assert_equal [3, 4], tensor.shape + assert_equal 12, tensor.size + assert tensor.data.all? { |v| v == 0.0 } + end + + def test_ones + tensor = SynorCompute::Tensor.ones([2, 2]) + assert tensor.data.all? { |v| v == 1.0 } + end + + def test_random + tensor = SynorCompute::Tensor.rand([10, 10]) + assert_equal [10, 10], tensor.shape + assert tensor.data.all? { |v| v >= 0 && v < 1 } + end + + def test_randn + tensor = SynorCompute::Tensor.randn([1000]) + mean = tensor.mean + std = tensor.std + assert mean.abs < 0.2, "Mean should be close to 0, got #{mean}" + assert std > 0.8 && std < 1.2, "Std should be close to 1, got #{std}" + end + + def test_eye + tensor = SynorCompute::Tensor.eye(3) + assert_equal [3, 3], tensor.shape + assert_equal 1.0, tensor[[0, 0]] + assert_equal 1.0, tensor[[1, 1]] + assert_equal 0.0, tensor[[0, 1]] + end + + def test_arange + tensor = SynorCompute::Tensor.arange(0, 5, 1) + assert_equal [5], tensor.shape + assert_equal 0.0, tensor[[0]] + assert_equal 4.0, tensor[[4]] + end + + def test_linspace + tensor = SynorCompute::Tensor.linspace(0, 10, 11) + assert_equal [11], tensor.shape + assert_in_delta 0.0, tensor[[0]], 0.0001 + assert_in_delta 10.0, tensor[[10]], 0.0001 + end + + def test_reshape + tensor = SynorCompute::Tensor.new([6], [1, 2, 3, 4, 5, 6]) + reshaped = tensor.reshape([2, 3]) + assert_equal [2, 3], reshaped.shape + assert_equal 6, reshaped.size + end + + def test_transpose + tensor = SynorCompute::Tensor.new([2, 3], [1, 2, 3, 4, 5, 6]) + transposed = tensor.transpose + assert_equal [3, 2], transposed.shape + end + + def test_sum + tensor = SynorCompute::Tensor.new([4], [1, 2, 3, 4]) + assert_equal 10.0, tensor.sum + end + + def test_mean + tensor = SynorCompute::Tensor.new([4], [1, 2, 3, 4]) + assert_equal 2.5, tensor.mean + end + + def test_min_max + tensor = SynorCompute::Tensor.new([4], [3, 1, 4, 2]) + assert_equal 1.0, tensor.min + assert_equal 4.0, tensor.max + end + + def test_relu + tensor = SynorCompute::Tensor.new([5], [-2, -1, 0, 1, 2]) + result = tensor.relu + assert_equal [0, 0, 0, 1, 2], result.data + end + + def test_sigmoid + tensor = SynorCompute::Tensor.new([1], [0]) + result = tensor.sigmoid + assert_in_delta 0.5, result[[0]], 0.0001 + end + + def test_softmax + tensor = SynorCompute::Tensor.new([3], [1, 2, 3]) + result = tensor.softmax + assert_in_delta 1.0, result.sum, 0.0001 + assert result[[2]] > result[[1]] + end + + def test_serialization + original = SynorCompute::Tensor.new([2, 3], [1, 2, 3, 4, 5, 6]) + json = original.to_json + restored = SynorCompute::Tensor.from_json(json) + assert_equal original.shape, restored.shape + assert_equal original.data, restored.data + end +end diff --git a/sdk/ruby/test/test_types.rb b/sdk/ruby/test/test_types.rb new file mode 100644 index 0000000..d592e18 --- /dev/null +++ b/sdk/ruby/test/test_types.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require_relative '../lib/synor_compute' + +class TestTypes < Minitest::Test + def test_processor_type_values + assert_equal 'cpu', SynorCompute::ProcessorType::CPU + assert_equal 'gpu', SynorCompute::ProcessorType::GPU + assert_equal 'tpu', SynorCompute::ProcessorType::TPU + assert_equal 'npu', SynorCompute::ProcessorType::NPU + assert_equal 'auto', SynorCompute::ProcessorType::AUTO + end + + def test_precision_values + assert_equal 'fp64', SynorCompute::Precision::FP64 + assert_equal 'fp32', SynorCompute::Precision::FP32 + assert_equal 'fp16', SynorCompute::Precision::FP16 + assert_equal 'bf16', SynorCompute::Precision::BF16 + assert_equal 'int8', SynorCompute::Precision::INT8 + end + + def test_priority_values + assert_equal 'critical', SynorCompute::Priority::CRITICAL + assert_equal 'high', SynorCompute::Priority::HIGH + assert_equal 'normal', SynorCompute::Priority::NORMAL + assert_equal 'low', SynorCompute::Priority::LOW + end + + def test_job_status_values + assert_equal 'pending', SynorCompute::JobStatus::PENDING + assert_equal 'running', SynorCompute::JobStatus::RUNNING + assert_equal 'completed', SynorCompute::JobStatus::COMPLETED + assert_equal 'failed', SynorCompute::JobStatus::FAILED + end + + def test_config_defaults + config = SynorCompute::Config.new(api_key: 'test-key') + assert_equal 'test-key', config.api_key + assert_equal 'https://api.synor.io/compute/v1', config.base_url + assert_equal 30, config.timeout + end + + def test_config_custom + config = SynorCompute::Config.new( + api_key: 'test-key', + base_url: 'https://custom.api.com', + timeout: 60, + default_processor: SynorCompute::ProcessorType::GPU + ) + assert_equal 'https://custom.api.com', config.base_url + assert_equal 60, config.timeout + end + + def test_matmul_options_to_hash + options = SynorCompute::MatMulOptions.new( + precision: SynorCompute::Precision::FP16, + processor: SynorCompute::ProcessorType::GPU + ) + hash = options.to_h + assert_equal 'fp16', hash[:precision] + assert_equal 'gpu', hash[:processor] + end + + def test_inference_options_defaults + options = SynorCompute::InferenceOptions.new + assert_equal 256, options.max_tokens + assert_in_delta 0.7, options.temperature, 0.001 + assert_in_delta 0.9, options.top_p, 0.001 + assert_equal 50, options.top_k + end + + def test_job_result_success + result = SynorCompute::JobResult.new( + job_id: 'job-123', + status: SynorCompute::JobStatus::COMPLETED, + result: 'output' + ) + assert result.success? + refute result.failed? + end + + def test_job_result_failure + result = SynorCompute::JobResult.new( + job_id: 'job-456', + status: SynorCompute::JobStatus::FAILED, + error: 'Error message' + ) + refute result.success? + assert result.failed? + end + + def test_model_info_formatted_parameters + model = SynorCompute::ModelInfo.new( + id: 'test', + name: 'Test Model', + category: 'llm', + parameters: 70_000_000_000 + ) + assert_equal '70B', model.formatted_parameters + end +end diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index ee2e586..b4aad91 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -48,6 +48,9 @@ mod tensor; mod client; mod error; +#[cfg(test)] +mod tests; + pub use types::*; pub use tensor::Tensor; pub use client::SynorCompute; diff --git a/sdk/rust/src/tests.rs b/sdk/rust/src/tests.rs new file mode 100644 index 0000000..e65af36 --- /dev/null +++ b/sdk/rust/src/tests.rs @@ -0,0 +1,334 @@ +//! Comprehensive tests for Synor Compute Rust SDK. + +#[cfg(test)] +mod tensor_tests { + use crate::tensor::Tensor; + use crate::types::Precision; + + #[test] + fn test_tensor_creation() { + let t = Tensor::new(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + assert_eq!(t.shape(), &[2, 3]); + assert_eq!(t.size(), 6); + assert_eq!(t.ndim(), 2); + } + + #[test] + fn test_tensor_zeros() { + let t = Tensor::zeros(&[3, 3]); + assert_eq!(t.shape(), &[3, 3]); + assert!(t.data().iter().all(|&x| x == 0.0)); + } + + #[test] + fn test_tensor_ones() { + let t = Tensor::ones(&[2, 2]); + assert!(t.data().iter().all(|&x| x == 1.0)); + } + + #[test] + fn test_tensor_rand() { + let t = Tensor::rand(&[10, 10]); + assert_eq!(t.size(), 100); + assert!(t.data().iter().all(|&x| x >= 0.0 && x < 1.0)); + } + + #[test] + fn test_tensor_randn() { + let t = Tensor::randn(&[1000]); + let mean = t.mean(); + let std = t.std(); + assert!(mean.abs() < 0.2, "Mean should be close to 0"); + assert!(std > 0.8 && std < 1.2, "Std should be close to 1"); + } + + #[test] + fn test_tensor_eye() { + let t = Tensor::eye(3); + assert_eq!(t.shape(), &[3, 3]); + assert_eq!(t.get(&[0, 0]), 1.0); + assert_eq!(t.get(&[1, 1]), 1.0); + assert_eq!(t.get(&[2, 2]), 1.0); + assert_eq!(t.get(&[0, 1]), 0.0); + } + + #[test] + fn test_tensor_arange() { + let t = Tensor::arange(0.0, 5.0, 1.0); + assert_eq!(t.shape(), &[5]); + assert_eq!(t.data()[0], 0.0); + assert_eq!(t.data()[4], 4.0); + } + + #[test] + fn test_tensor_linspace() { + let t = Tensor::linspace(0.0, 10.0, 11); + assert_eq!(t.shape(), &[11]); + assert!((t.data()[0] - 0.0).abs() < 1e-10); + assert!((t.data()[10] - 10.0).abs() < 1e-10); + } + + #[test] + fn test_tensor_reshape() { + let t = Tensor::new(&[6], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + let reshaped = t.reshape(&[2, 3]); + assert_eq!(reshaped.shape(), &[2, 3]); + assert_eq!(reshaped.size(), 6); + } + + #[test] + #[should_panic] + fn test_tensor_invalid_reshape() { + let t = Tensor::zeros(&[4]); + t.reshape(&[2, 3]); // Should panic + } + + #[test] + fn test_tensor_transpose() { + let t = Tensor::new(&[2, 3], vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + let transposed = t.transpose(); + assert_eq!(transposed.shape(), &[3, 2]); + } + + #[test] + fn test_tensor_mean() { + let t = Tensor::new(&[4], vec![1.0, 2.0, 3.0, 4.0]); + assert!((t.mean() - 2.5).abs() < 1e-10); + } + + #[test] + fn test_tensor_sum() { + let t = Tensor::new(&[4], vec![1.0, 2.0, 3.0, 4.0]); + assert!((t.sum() - 10.0).abs() < 1e-10); + } + + #[test] + fn test_tensor_std() { + let t = Tensor::new(&[4], vec![1.0, 2.0, 3.0, 4.0]); + assert!((t.std() - 1.118).abs() < 0.01); + } + + #[test] + fn test_tensor_min_max() { + let t = Tensor::new(&[4], vec![3.0, 1.0, 4.0, 2.0]); + assert_eq!(t.min(), 1.0); + assert_eq!(t.max(), 4.0); + } + + #[test] + fn test_tensor_relu() { + let t = Tensor::new(&[5], vec![-2.0, -1.0, 0.0, 1.0, 2.0]); + let result = t.relu(); + assert_eq!(result.data(), &[0.0, 0.0, 0.0, 1.0, 2.0]); + } + + #[test] + fn test_tensor_sigmoid() { + let t = Tensor::new(&[1], vec![0.0]); + let result = t.sigmoid(); + assert!((result.data()[0] - 0.5).abs() < 1e-10); + } + + #[test] + fn test_tensor_softmax() { + let t = Tensor::new(&[3], vec![1.0, 2.0, 3.0]); + let result = t.softmax(); + assert!((result.sum() - 1.0).abs() < 1e-10); + assert!(result.data()[2] > result.data()[1]); + } + + #[test] + fn test_tensor_dtype() { + let t = Tensor::zeros(&[2, 2]).with_dtype(Precision::FP16); + assert_eq!(t.dtype(), Precision::FP16); + } +} + +#[cfg(test)] +mod types_tests { + use crate::types::*; + + #[test] + fn test_processor_type_values() { + assert_eq!(ProcessorType::Cpu.as_str(), "cpu"); + assert_eq!(ProcessorType::Gpu.as_str(), "gpu"); + assert_eq!(ProcessorType::Tpu.as_str(), "tpu"); + assert_eq!(ProcessorType::Npu.as_str(), "npu"); + assert_eq!(ProcessorType::Lpu.as_str(), "lpu"); + assert_eq!(ProcessorType::Auto.as_str(), "auto"); + } + + #[test] + fn test_precision_values() { + assert_eq!(Precision::FP64.as_str(), "fp64"); + assert_eq!(Precision::FP32.as_str(), "fp32"); + assert_eq!(Precision::FP16.as_str(), "fp16"); + assert_eq!(Precision::BF16.as_str(), "bf16"); + assert_eq!(Precision::INT8.as_str(), "int8"); + assert_eq!(Precision::INT4.as_str(), "int4"); + } + + #[test] + fn test_priority_default() { + let priority = Priority::default(); + assert_eq!(priority, Priority::Normal); + } + + #[test] + fn test_job_status_default() { + let status = JobStatus::default(); + assert_eq!(status, JobStatus::Pending); + } + + #[test] + fn test_config_builder() { + let config = Config::new("test-api-key"); + assert_eq!(config.api_key, "test-api-key"); + assert_eq!(config.base_url, "https://api.synor.io/compute/v1"); + assert_eq!(config.timeout_secs, 30); + assert!(!config.debug); + } + + #[test] + fn test_config_custom() { + let config = Config::new("test-key") + .base_url("https://custom.api.com") + .default_processor(ProcessorType::Gpu) + .default_precision(Precision::FP16) + .timeout_secs(60) + .debug(true); + + assert_eq!(config.base_url, "https://custom.api.com"); + assert_eq!(config.default_processor, ProcessorType::Gpu); + assert_eq!(config.default_precision, Precision::FP16); + assert_eq!(config.timeout_secs, 60); + assert!(config.debug); + } + + #[test] + fn test_matmul_options_default() { + let options = MatMulOptions::default(); + assert_eq!(options.precision, Precision::FP32); + assert_eq!(options.processor, ProcessorType::Auto); + assert_eq!(options.priority, Priority::Normal); + } + + #[test] + fn test_conv2d_options_default() { + let options = Conv2dOptions::default(); + assert_eq!(options.stride, (1, 1)); + assert_eq!(options.padding, (0, 0)); + } + + #[test] + fn test_attention_options_default() { + let options = AttentionOptions::default(); + assert_eq!(options.num_heads, 8); + assert!(options.flash); + assert_eq!(options.precision, Precision::FP16); + } + + #[test] + fn test_inference_options_default() { + let options = InferenceOptions::default(); + assert_eq!(options.max_tokens, 256); + assert!((options.temperature - 0.7).abs() < 0.001); + assert!((options.top_p - 0.9).abs() < 0.001); + assert_eq!(options.top_k, 50); + } + + #[test] + fn test_job_result_success() { + let result: JobResult = JobResult { + job_id: Some("job-123".to_string()), + status: JobStatus::Completed, + result: Some("output".to_string()), + error: None, + execution_time_ms: Some(100), + processor: Some(ProcessorType::Gpu), + cost: Some(0.01), + }; + + assert!(result.is_success()); + assert!(!result.is_failed()); + } + + #[test] + fn test_job_result_failure() { + let result: JobResult = JobResult { + job_id: Some("job-456".to_string()), + status: JobStatus::Failed, + result: None, + error: Some("Error message".to_string()), + execution_time_ms: None, + processor: None, + cost: None, + }; + + assert!(!result.is_success()); + assert!(result.is_failed()); + } + + #[test] + fn test_model_info_formatted_parameters() { + let model = ModelInfo { + id: "test".to_string(), + name: "Test Model".to_string(), + description: None, + category: "llm".to_string(), + parameters: Some(70_000_000_000), + context_length: Some(8192), + format: None, + recommended_processor: None, + license: None, + cid: None, + }; + + assert_eq!(model.formatted_parameters(), "70B"); + } + + #[test] + fn test_model_info_formatted_parameters_millions() { + let model = ModelInfo { + id: "test".to_string(), + name: "Test Model".to_string(), + description: None, + category: "embedding".to_string(), + parameters: Some(350_000_000), + context_length: None, + format: None, + recommended_processor: None, + license: None, + cid: None, + }; + + assert_eq!(model.formatted_parameters(), "350M"); + } +} + +#[cfg(test)] +mod error_tests { + use crate::error::Error; + + #[test] + fn test_client_closed_error() { + let err = Error::ClientClosed; + assert_eq!(format!("{}", err), "Client has been closed"); + } + + #[test] + fn test_api_error() { + let err = Error::Api { + status_code: 401, + message: "Invalid API key".to_string(), + }; + assert!(format!("{}", err).contains("401")); + assert!(format!("{}", err).contains("Invalid API key")); + } + + #[test] + fn test_invalid_argument_error() { + let err = Error::InvalidArgument("Bad parameter".to_string()); + assert!(format!("{}", err).contains("Bad parameter")); + } +}