Skip to content

Commit 1f179ed

Browse files
committed
feat(sdk/csharp): 呈现 hints 便捷层 FieldHints(x-ui 契约,最后一格)
- Validation/FieldHints.cs:SetFieldHint/SetFieldWidget(System.Text.Json, 不可变风格克隆描述符),7 例单测 - 矩阵:C# hints ✅——六语言呈现 hints 全对齐
1 parent 1f8b92d commit 1f179ed

2 files changed

Lines changed: 230 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright 2025 Croupier Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
using System.Text.Json;
16+
using Croupier.Sdk.Models;
17+
using Xunit;
18+
19+
namespace Croupier.Sdk.Tests;
20+
21+
/// <summary>
22+
/// F:x-ui 呈现 hints 便捷层测试。
23+
/// </summary>
24+
public class FieldHintsTests
25+
{
26+
[Fact]
27+
public void EmptySchema_CreatesObjectSkeleton()
28+
{
29+
var descriptor = Validation.FieldHints.SetFieldWidget(
30+
new FunctionDescriptor { Id = "player.ban", Version = "1.0.0" }, "id", "Select");
31+
Assert.NotNull(descriptor.InputSchema);
32+
var schema = JsonDocument.Parse(descriptor.InputSchema!);
33+
Assert.Equal("object", schema.RootElement.GetProperty("type").GetString());
34+
Assert.Equal("Select", schema.RootElement
35+
.GetProperty("properties").GetProperty("id")
36+
.GetProperty("x-widget").GetString());
37+
}
38+
39+
[Fact]
40+
public void PreservesExistingAttributes_AndOverrides()
41+
{
42+
var descriptor = new FunctionDescriptor
43+
{
44+
Id = "player.ban",
45+
InputSchema = "{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"title\":\"玩家 ID\",\"x-widget\":\"Input\"}}}",
46+
};
47+
var updated = Validation.FieldHints.SetFieldWidget(descriptor, "id", "TreeSelect");
48+
Assert.Contains("Input", descriptor.InputSchema); // 不可变:原描述符不变
49+
var prop = JsonDocument.Parse(updated.InputSchema!)
50+
.RootElement.GetProperty("properties").GetProperty("id");
51+
Assert.Equal("TreeSelect", prop.GetProperty("x-widget").GetString());
52+
Assert.Equal("玩家 ID", prop.GetProperty("title").GetString());
53+
}
54+
55+
[Fact]
56+
public void OptionsSource_Object()
57+
{
58+
var descriptor = Validation.FieldHints.SetFieldHint(
59+
new FunctionDescriptor { Id = "player.ban" }, "id", "x-options-source",
60+
JsonSerializer.SerializeToElement(new Dictionary<string, string>
61+
{
62+
["functionId"] = "player.list",
63+
["labelPath"] = "/items/*/name",
64+
["valuePath"] = "/items/*/id",
65+
}));
66+
Assert.Contains("player.list", descriptor.InputSchema);
67+
Assert.Contains("/items/*/name", descriptor.InputSchema);
68+
}
69+
70+
[Fact]
71+
public void XUnderscore_NormalizedToXDash()
72+
{
73+
var descriptor = Validation.FieldHints.SetFieldHint(
74+
new FunctionDescriptor { Id = "f" }, "a", "x_widget",
75+
JsonSerializer.SerializeToElement("Input"));
76+
Assert.Contains("x-widget", descriptor.InputSchema);
77+
Assert.DoesNotContain("x_widget", descriptor.InputSchema);
78+
}
79+
80+
[Fact]
81+
public void InvalidHint_Rejected()
82+
{
83+
Assert.Throws<ArgumentException>(() => Validation.FieldHints.SetFieldHint(
84+
new FunctionDescriptor { Id = "f" }, "a", "widget",
85+
JsonSerializer.SerializeToElement("Input")));
86+
}
87+
88+
[Fact]
89+
public void EmptyField_Rejected()
90+
{
91+
Assert.Throws<ArgumentException>(() => Validation.FieldHints.SetFieldHint(
92+
new FunctionDescriptor { Id = "f" }, " ", "x-widget",
93+
JsonSerializer.SerializeToElement("Input")));
94+
}
95+
96+
[Fact]
97+
public void EmptyWidget_Rejected()
98+
{
99+
Assert.Throws<ArgumentException>(() => Validation.FieldHints.SetFieldWidget(
100+
new FunctionDescriptor { Id = "f" }, "a", " "));
101+
}
102+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright 2025 Croupier Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
using System.Text.Json;
16+
using Croupier.Sdk.Models;
17+
18+
namespace Croupier.Sdk.Validation;
19+
20+
/// <summary>
21+
/// F:x-ui 呈现 hints 便捷层(契约见 docs/architecture/presentation-hints.md)。
22+
/// 向函数描述符的 input schema 合并 x-* 呈现意图,供 Dashboard 生成更友好的表单。
23+
/// </summary>
24+
public static class FieldHints
25+
{
26+
/// <summary>
27+
/// 向 input_schema 的 properties[field] 合并单个 x-* hint。
28+
/// schema 为空时创建 object 骨架;重复设置覆盖;
29+
/// hint 非法(非 x-/x_ 前缀)或 field 为空时抛 <see cref="ArgumentException"/>。
30+
/// </summary>
31+
/// <returns>合并后的新描述符(不可变风格,原描述符不变)</returns>
32+
public static FunctionDescriptor SetFieldHint(FunctionDescriptor descriptor, string field,
33+
string hint, JsonElement value)
34+
{
35+
var normalized = NormalizeHintKey(hint)
36+
?? throw new ArgumentException(
37+
$"hint \"{hint}\" must be an x- extension key (e.g. x-widget)", nameof(hint));
38+
if (string.IsNullOrWhiteSpace(field))
39+
{
40+
throw new ArgumentException("field key is required for SetFieldHint", nameof(field));
41+
}
42+
43+
// 不可变风格:克隆描述符后再修改(避免污染调用方持有的原对象)
44+
var workingDescriptor = JsonSerializer.Deserialize<FunctionDescriptor>(
45+
JsonSerializer.Serialize(descriptor));
46+
if (workingDescriptor == null)
47+
{
48+
throw new ArgumentException("descriptor could not be cloned", nameof(descriptor));
49+
}
50+
var schema = ParseSchemaObject(workingDescriptor.InputSchema);
51+
var properties = schema.TryGetValue("properties", out var propertiesElement)
52+
&& propertiesElement.ValueKind == JsonValueKind.Object
53+
? JsonElementToMutable(propertiesElement)
54+
: new Dictionary<string, JsonElement>();
55+
56+
var fieldObject = properties.TryGetValue(field, out var fieldElement)
57+
&& fieldElement.ValueKind == JsonValueKind.Object
58+
? JsonElementToMutable(fieldElement)
59+
: new Dictionary<string, JsonElement>();
60+
fieldObject[normalized] = value.Clone();
61+
properties[field] = JsonSerializer.SerializeToElement(fieldObject);
62+
63+
schema["properties"] = JsonSerializer.SerializeToElement(properties);
64+
workingDescriptor.InputSchema = JsonSerializer.Serialize(schema, new JsonSerializerOptions
65+
{
66+
WriteIndented = false,
67+
});
68+
return workingDescriptor;
69+
}
70+
71+
/// <summary>等价于 <see cref="SetFieldHint"/>(descriptor, field, "x-widget", widget)。</summary>
72+
public static FunctionDescriptor SetFieldWidget(FunctionDescriptor descriptor, string field,
73+
string widget)
74+
{
75+
if (string.IsNullOrWhiteSpace(widget))
76+
{
77+
throw new ArgumentException("widget is required for SetFieldWidget", nameof(widget));
78+
}
79+
return SetFieldHint(descriptor, field, "x-widget",
80+
JsonSerializer.SerializeToElement(widget));
81+
}
82+
83+
private static string? NormalizeHintKey(string hint)
84+
{
85+
if (string.IsNullOrWhiteSpace(hint) || hint.Trim().Length < 3)
86+
{
87+
return null;
88+
}
89+
var trimmed = hint.Trim();
90+
var first = char.ToLowerInvariant(trimmed[0]);
91+
if (first != 'x' || (trimmed[1] != '-' && trimmed[1] != '_'))
92+
{
93+
return null;
94+
}
95+
return "x-" + trimmed[2..];
96+
}
97+
98+
private static Dictionary<string, JsonElement> ParseSchemaObject(string? raw)
99+
{
100+
if (string.IsNullOrWhiteSpace(raw))
101+
{
102+
return new Dictionary<string, JsonElement>
103+
{
104+
["type"] = JsonSerializer.SerializeToElement("object"),
105+
};
106+
}
107+
var parsed = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(raw);
108+
if (parsed == null)
109+
{
110+
throw new ArgumentException("input schema must be a JSON object");
111+
}
112+
if (!parsed.ContainsKey("type"))
113+
{
114+
parsed["type"] = JsonSerializer.SerializeToElement("object");
115+
}
116+
return parsed;
117+
}
118+
119+
private static Dictionary<string, JsonElement> JsonElementToMutable(JsonElement element)
120+
{
121+
var mutable = new Dictionary<string, JsonElement>();
122+
foreach (var property in element.EnumerateObject())
123+
{
124+
mutable[property.Name] = property.Value.Clone();
125+
}
126+
return mutable;
127+
}
128+
}

0 commit comments

Comments
 (0)