Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using OpenCode.Sdk.Tools.Generator.Binding;
using OpenCode.Sdk.Tools.Generator.Binding.Models;
using OpenCode.Sdk.Tools.Tests.Support;
using TUnit.Assertions.Enums;
using static OpenCode.Sdk.Tools.Tests.Generator.Binding.Support.OwnershipPlanData;

namespace OpenCode.Sdk.Tools.Tests.Generator.Binding;

public sealed class GeneratedTypeNameInventoryTests
{
[Test]
public async Task Create_Should_Include_Every_Generated_Declaration_Without_Losing_Its_Owner()
{
var candidates = new OwnershipCandidates
{
Models = [Model("Body"), StructuralUnion("Shape", "ShapeKind")],
Unions = [Union("Event")],
HoistedInterfaces = [HoistedInterface("ICarrier"), HoistedInterface("INestedCarrier")],
};
var operation = BodyOperation("Widgets", "Body") with
{
QueryRequest = new QueryRequestPlan { TypeName = "Body", Properties = [], DerivesFromListRequest = false, RidesRequestBody = true },
Envelope = new EnvelopePlan { ResponseTypeName = "Reply", AdapterTypeName = "ReplyAdapter", EnvelopeDtoTypeName = "ReplyDto", Kind = EnvelopeKind.Data, SuccessStatusCode = 200, PayloadName = "Item", PayloadType = UnionHoistPlanData.Named("Shape") },
};
var stream = operation with
{
OperationId = "Widgets.Watch",
Envelope = null,
QueryRequest = operation.QueryRequest with { TypeName = "Query", RidesRequestBody = false },
Stream = new StreamPlan { AdapterTypeName = "StreamAdapter", PayloadTypeName = "Shape", FailureEventName = "error", CauseTypeName = "Shape[]" },
};
var client = new ClientPlan { Name = "Body", Namespace = GeneratedNamespace.Internal, Role = ClientRole.Collection, SubClients = [], Operations = [operation, stream] };
var inventory = GeneratedTypeNameInventory.Create(candidates, [client], [
new("ICarrier", "IEvent", GeneratedTypeNameInventory.ClaimRole.Hoisted, "data"),
new("INestedCarrier", "ICarrier", GeneratedTypeNameInventory.ClaimRole.Hoisted, "nested"),
], new Dictionary<string, string>(StringComparer.Ordinal) { ["BodySchema"] = "Body" });
await Assert.That(inventory.Claims.Select(static claim => (claim.Name, claim.Subject))).IsEquivalentTo([
("Body", "BodySchema"), ("Shape", "Shape"), ("IEvent", "IEvent"), ("ShapeKind", "Shape"), ("UnknownEvent", "IEvent"),
("Body", "Body"), ("Reply", "Widgets.SendBody"), ("StreamAdapter", "Widgets.Watch"), ("Query", "Widgets.Watch"),
("ReplyAdapter", "Widgets.SendBody"), ("ReplyDto", "Widgets.SendBody"), ("ICarrier", "IEvent"), ("INestedCarrier", "ICarrier"),
], CollectionOrdering.Matching);
await Assert.That(inventory.Names.Order(StringComparer.Ordinal)).IsEquivalentTo([
"Body", "ICarrier", "IEvent", "INestedCarrier", "Query", "Reply", "ReplyAdapter", "ReplyDto", "Shape", "ShapeKind", "StreamAdapter", "UnknownEvent",
], StringComparer.Ordinal, CollectionOrdering.Matching);
await Assert.That(inventory.OtherClaims(inventory.Claims[0]).Select(static claim => claim.Subject))
.IsEquivalentTo(["Body"], StringComparer.Ordinal, CollectionOrdering.Matching);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,10 @@ private static BindingErrorCollector Check(PlanNames names)
Operations = [ListOperation(names), StreamOperation(names)],
};

InternalNameCollisionCheck.Check(candidates, [client], errors);
var inventory = GeneratedTypeNameInventory.Create(candidates, [client],
[new GeneratedTypeNameClaim(names.Hoisted, names.Union, GeneratedTypeNameInventory.ClaimRole.Hoisted, "data")],
new Dictionary<string, string>(StringComparer.Ordinal));
InternalNameCollisionCheck.Check(inventory, errors);
return errors;
}

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using OpenCode.Sdk.Tools.Generator.Binding;
using OpenCode.Sdk.Tools.Generator.Binding.Models;
using OpenCode.Sdk.Tools.Tests.Support;
using TUnit.Assertions.Enums;
using static OpenCode.Sdk.Tools.Tests.Support.BindingScenarioData;

namespace OpenCode.Sdk.Tools.Tests.Generator.Binding;

public sealed class QueryRequestFacetBinderTests
{
[Test]
[Arguments(false)]
[Arguments(true)]
public async Task Bind_Should_Refuse_The_Whole_Facet_When_One_Parameter_Is_Unclassified(bool multiple)
{
var context = await CreateContextAsync(operation =>
{
operation.Parameter("good", "query", static schema => schema.Type("string").Enum("true", "false"))
.Parameter("search", "query", static schema => schema.Type("string"));
if (multiple)
{
operation.Parameter("bad", "query", static schema => schema.Type("array").Items(static item => item.Type("string")));
}
});
var result = new QueryRequestFacetBinder(context).Bind();
await Assert.That(result).IsTypeOf<FacetResult<QueryRequestPlan?>.Refused>();
var expected = new List<BindingError>
{
new(BindingErrorCategory.Curation, "widget.list:search",
"the query parameter is a plain string that no curation row classifies: add a queryParameterTypes row when upstream decodes it as a number, or a stringQueryParameters row that says why it stays a string") { Kind = BindingErrorKind.MissingRow },
};
if (multiple)
{
expected.Add(new BindingError(BindingErrorCategory.Operation, "widget.list", "query parameter 'bad' has an unsupported schema shape"));
}
await Assert.That(((FacetResult<QueryRequestPlan?>.Refused)result).Errors).IsEquivalentTo(expected, CollectionOrdering.Matching);
await Assert.That(context.Errors.Snapshot()).IsEmpty();
}

[Test]
public async Task Bind_Should_Refuse_An_Enum_Without_A_Generated_Model()
{
var context = await CreateContextAsync(static operation => operation.Parameter("state", "query",
static schema => schema.Type("string").Enum("open", "closed")));
var result = new QueryRequestFacetBinder(context).Bind();
await Assert.That(result).IsTypeOf<FacetResult<QueryRequestPlan?>.Refused>();
await Assert.That(((FacetResult<QueryRequestPlan?>.Refused)result).Errors).IsEquivalentTo([
new BindingError(BindingErrorCategory.Operation, "widget.list", "query parameter 'state' binds an enum that has no generated model"),
], CollectionOrdering.Matching);
}

[Test]
public async Task Bind_Should_Distinguish_An_Absent_Query_From_A_Refused_Query()
{
var absent = new QueryRequestFacetBinder(await CreateContextAsync(static _ => { })).Bind();
await Assert.That(absent).IsTypeOf<FacetResult<QueryRequestPlan?>.Bound>();
await Assert.That(((FacetResult<QueryRequestPlan?>.Bound)absent).Value).IsNull();
var refused = new QueryRequestFacetBinder(await CreateContextAsync(static operation => operation
.Parameter("bad", "query", static schema => schema.Type("integer")))).Bind();
await Assert.That(refused).IsTypeOf<FacetResult<QueryRequestPlan?>.Refused>();
await Assert.That(((FacetResult<QueryRequestPlan?>.Refused)refused).Errors).IsEquivalentTo([
new BindingError(BindingErrorCategory.Operation, "widget.list", "query parameter 'bad' has an unsupported schema shape"),
], CollectionOrdering.Matching);
}

private static async Task<OperationFacetContext> CreateContextAsync(Action<OperationBuilder> configure)
{
var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec.WithOperation("widget.list", configure: configure)));
var errors = new BindingErrorCollector();
var names = new Dictionary<string, string>(StringComparer.Ordinal);
return new OperationFacetContext(document, document.Operations.Single(), Curation(Groups("widget", RootGroup())), names, errors,
new TypePlanBinder(document.Schemas, names, errors));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,49 @@ namespace OpenCode.Sdk.Tools.Tests.Generator.Binding;

public sealed class SpecBinderTests
{
[Test]
public async Task Bind_Should_Preserve_The_Marker_Refusal_For_An_Unnamed_Error_Schema()
{
var document = await IngestAsync(SpecScenario.Define(static spec =>
{
AddTaggedError(spec, "AError", "A");
AddTaggedError(spec, "BError", "B");
spec.WithOperation("health.get", configure: static operation => operation.WithoutResponse(200).Response(204)
.Response(400, "application/json", static schema => schema.Ref("AError"))
.Response(500, "application/json", static schema => schema.Ref("BError")));
}));
var schemas = document.Schemas.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal);
schemas["BError"] = (ObjectNode)schemas["BError"] with { LiteralMarkers = [] };
document = document with { Schemas = schemas };
var exception = Assert.Throws<BindingException>(() => new BindingTestHost().Bind(document, Selection("health.get"),
Curation(Groups("health", RootGroup()), schemaNames: [SchemaName("AError", "SharedError"), SchemaName("BError", "SharedError")])));

await Assert.That(exception.Errors).IsEquivalentTo([
new BindingError(BindingErrorCategory.Naming, "BError", "C# type name 'SharedError' collides with schema 'AError'"),
new BindingError(BindingErrorCategory.Naming, "BError", "error schema has no unique C# type name"),
new BindingError(BindingErrorCategory.Operation, "health.get", "error responses must reference tagged error schemas: 'BError': a tagged error must declare exactly one required '_tag' literal"),
], CollectionOrdering.Matching);
}

[Test]
public async Task Bind_Should_Report_An_Unnamed_Error_Schema_Once()
{
var document = await IngestAsync(SpecScenario.Define(static spec => spec
.WithSchema("AError", static schema => schema.Type("object")
.Property("_tag", static property => property.Type("string").Enum("A"), required: true))
.WithSchema("BError", static schema => schema.Type("object")
.Property("_tag", static property => property.Type("string").Enum("B"), required: true))
.WithOperation("health.get", configure: static operation => operation.WithoutResponse(200).Response(204)
.Response(400, "application/json", static schema => schema.Ref("AError"))
.Response(500, "application/json", static schema => schema.Ref("BError")))));
var curation = Curation(Groups("health", RootGroup()), schemaNames: [SchemaName("AError", "SharedError"), SchemaName("BError", "SharedError")]);
var exception = Assert.Throws<BindingException>(() => new BindingTestHost().Bind(document, Selection("health.get"), curation));
await Assert.That(exception.Errors).IsEquivalentTo([
new BindingError(BindingErrorCategory.Naming, "BError", "C# type name 'SharedError' collides with schema 'AError'"),
new BindingError(BindingErrorCategory.Naming, "BError", "error schema has no unique C# type name"),
], CollectionOrdering.Matching);
}

private static readonly string[] ParentScenarioLeaves = ["First", "Second", "Other", "Another"];

[Test]
Expand Down Expand Up @@ -999,11 +1042,9 @@ await Assert.That(exception.Errors).Contains(static error => error.Subject == "m
[Arguments(false, "ReviewedAsync", true, "missing.get", BindingErrorCategory.Curation, "curated operation does not exist in the spec")]
[Arguments(false, "ReviewedAsync", true, "widget.other", BindingErrorCategory.Curation, "curated operation is not selected by the current profile")]
[Arguments(false, "bad-nameAsync", true, "widget.list", BindingErrorCategory.Naming, "method name 'bad-nameAsync' must be a valid C# identifier ending in 'Async'")]
[Arguments(false, "Reviewed", true, "widget.list", BindingErrorCategory.Naming, "method name 'Reviewed' must be a valid C# identifier ending in 'Async'", "the operation's names cannot be derived mechanically: the group does not pluralize naively")]
[Arguments(false, "Async", true, "widget.list", BindingErrorCategory.Naming, "method name 'Async' must be a valid C# identifier ending in 'Async'", "the operation's names cannot be derived mechanically: the group does not pluralize naively")]
[Arguments(false, "", true, "widget.run", BindingErrorCategory.Operation, "operation 'widget.run' closes with 'run', which is not a naming verb, and no operationNames row names it; the HTTP method is never a name source (ADR-0008)")]
public async Task Bind_Should_Refuse_Invalid_Operation_Name_Rows(bool handle, string methodName, bool refused,
string operationId, BindingErrorCategory category, string? problem, string? operationProblem = null)
string operationId, BindingErrorCategory category, string? problem)
{
ArgumentNullException.ThrowIfNull(methodName);
var document = await IngestAsync(SpecScenario.Define(spec =>
Expand All @@ -1030,19 +1071,9 @@ public async Task Bind_Should_Refuse_Invalid_Operation_Name_Rows(bool handle, st
{
var selected = operationId == "widget.run" ? operationId : "widget.list";
var exception = Assert.Throws<BindingException>(() => _ = new BindingTestHost().Bind(document, Selection(selected), curation));
if (operationProblem is not null)
{
await Assert.That(exception.Errors).IsEquivalentTo([
new BindingError(category, operationId, problem!),
new BindingError(BindingErrorCategory.Operation, operationId, operationProblem),
], CollectionOrdering.Matching);
}
else
{
var error = operationId == "widget.list" && category == BindingErrorCategory.Curation ? exception.Errors.Single()
: exception.Errors.Single(error => error.Category == category && error.Subject == operationId);
await Assert.That(error).IsEqualTo(new BindingError(category, operationId, problem!));
}
var error = operationId == "widget.list" && category == BindingErrorCategory.Curation ? exception.Errors.Single()
: exception.Errors.Single(error => error.Category == category && error.Subject == operationId);
await Assert.That(error).IsEqualTo(new BindingError(category, operationId, problem!));
}
else
{
Expand All @@ -1051,6 +1082,21 @@ await Assert.That(exception.Errors).IsEquivalentTo([
}
}

[Test]
[Arguments("Reviewed", "method name 'Reviewed' must be a valid C# identifier ending in 'Async'")]
[Arguments("Async", "method name 'Async' must be a valid C# identifier ending in 'Async'")]
public async Task Bind_Should_Report_Only_The_Invalid_Curated_Method_Name(string methodName, string problem)
{
var document = await IngestAsync(SpecScenario.Define(static spec => spec.WithOperation("widget.list",
configure: static operation => operation.WithoutResponse(200).Response(204))));
var curation = Curation(Groups("widget", ClientGroup("Widgets", null, null)),
operationNames: [OperationName("widget.list", methodName)]);
var exception = Assert.Throws<BindingException>(() => new BindingTestHost().Bind(document, Selection("widget.list"), curation));
await Assert.That(exception.Errors).IsEquivalentTo([
new BindingError(BindingErrorCategory.Naming, "widget.list", problem),
], CollectionOrdering.Matching);
}

[Test]
public async Task Bind_Should_Refuse_Curated_Operation_Name_Collisions()
{
Expand Down Expand Up @@ -1776,7 +1822,9 @@ await Assert.That(shared.ImplementedUnionNames.Order(StringComparer.Ordinal))
}

[Test]
public async Task Bind_Should_Refuse_A_Tag_Owned_By_Two_Closure_Schemas()
[Arguments(false)]
[Arguments(true)]
public async Task Bind_Should_Refuse_A_Tag_Owned_By_Two_Closure_Schemas(bool responseCollision)
{
var document = await IngestAsync(SpecScenario.Define(spec => spec
.WithSchema("ItemInfo", schema => schema
Expand All @@ -1795,10 +1843,19 @@ public async Task Bind_Should_Refuse_A_Tag_Owned_By_Two_Closure_Schemas()
.Response(404, "application/json", schema => schema.Ref("GoneError"))
.Response(410, "application/json", schema => schema.Ref("LostError")))));

var exception = RefuseHealth(document);

await Assert.That(exception.Errors).Contains(static error =>
error.Problem.Contains("multiple error schemas declare tag 'SharedError'", StringComparison.Ordinal));
var exception = Assert.Throws<BindingException>(() => new BindingTestHost().Bind(document, Selection("health.get"),
Curation(Groups("health", RootGroup()), schemaNames: responseCollision ? [SchemaName("ItemInfo", "HealthResponse")] : [])));
BindingError[] expected = [
new(BindingErrorCategory.Schema, "OpenCodeError", "multiple error schemas declare tag 'SharedError' (GoneError, LostError)"),
new(BindingErrorCategory.Schema, "GoneError", "union membership references absent union 'IOpenCodeError'"),
new(BindingErrorCategory.Schema, "LostError", "union membership references absent union 'IOpenCodeError'"),
];
if (responseCollision)
{
expected = [.. expected, new(BindingErrorCategory.Naming, "health.get", "multiple operations map to response type 'HealthResponse'")];
}
await Assert.That(exception.Errors).IsEquivalentTo(expected, CollectionOrdering.Matching)
.Because(string.Join(" | ", exception.Errors));
}

[Test]
Expand Down
Loading
Loading