Consolidate public JSON serialization
This commit is contained in:
328
json_contract_test.go
Normal file
328
json_contract_test.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package promptkit_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
func TestPreparedRunJSONContractRoundTripsAllFields(t *testing.T) {
|
||||
value := fullyPopulatedPreparedRun()
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal PreparedRun: %v", err)
|
||||
}
|
||||
|
||||
object := decodeJSONObject(t, payload)
|
||||
assertJSONFields(t, object,
|
||||
"prompt_id",
|
||||
"prompt_version",
|
||||
"prompt_hash",
|
||||
"selected_profile_id",
|
||||
"selected_backend_id",
|
||||
"effective_model_params",
|
||||
"output_contract",
|
||||
"structured_output",
|
||||
"input_hashes",
|
||||
"session_id",
|
||||
"rendered_prompt_hash",
|
||||
"messages",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"duration_ms",
|
||||
)
|
||||
|
||||
var messages []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(object["messages"], &messages); err != nil {
|
||||
t.Fatalf("decode PreparedRun messages: %v", err)
|
||||
}
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("message count = %d, want 2", len(messages))
|
||||
}
|
||||
if _, ok := messages[0]["cache_control"]; !ok {
|
||||
t.Fatalf("first message omitted cache_control: %s", object["messages"])
|
||||
}
|
||||
if _, ok := messages[1]["cache_control"]; ok {
|
||||
t.Fatalf("second message included empty cache_control: %s", object["messages"])
|
||||
}
|
||||
|
||||
var decoded promptkit.PreparedRun
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal PreparedRun: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, value) {
|
||||
t.Fatalf("PreparedRun did not round trip:\ngot %#v\nwant %#v", decoded, value)
|
||||
}
|
||||
|
||||
zeroPayload, err := json.Marshal(promptkit.PreparedRun{})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal zero PreparedRun: %v", err)
|
||||
}
|
||||
zeroObject := decodeJSONObject(t, zeroPayload)
|
||||
for _, field := range []string{
|
||||
"prompt_version",
|
||||
"prompt_hash",
|
||||
"selected_backend_id",
|
||||
"structured_output",
|
||||
"input_hashes",
|
||||
"session_id",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"duration_ms",
|
||||
} {
|
||||
if _, ok := zeroObject[field]; ok {
|
||||
t.Fatalf("zero PreparedRun included %q: %s", field, zeroPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResultJSONContractRoundTripsAllFields(t *testing.T) {
|
||||
value := fullyPopulatedRunResult()
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal RunResult: %v", err)
|
||||
}
|
||||
|
||||
object := decodeJSONObject(t, payload)
|
||||
assertJSONFields(t, object,
|
||||
"run_id",
|
||||
"artifact",
|
||||
"raw_output",
|
||||
"validation",
|
||||
"prompt_id",
|
||||
"prompt_version",
|
||||
"prompt_hash",
|
||||
"session_id",
|
||||
"rendered_prompt_hash",
|
||||
"selected_profile_id",
|
||||
"selected_backend_id",
|
||||
"model_name",
|
||||
"endpoint",
|
||||
"effective_model_params",
|
||||
"input_hashes",
|
||||
"usage",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"duration_ms",
|
||||
)
|
||||
if _, ok := object["duration"]; ok {
|
||||
t.Fatalf("RunResult included nanosecond duration field: %s", payload)
|
||||
}
|
||||
|
||||
var decoded promptkit.RunResult
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal RunResult: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, value) {
|
||||
t.Fatalf("RunResult did not round trip:\ngot %#v\nwant %#v", decoded, value)
|
||||
}
|
||||
|
||||
zeroPayload, err := json.Marshal(promptkit.RunResult{})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal zero RunResult: %v", err)
|
||||
}
|
||||
zeroObject := decodeJSONObject(t, zeroPayload)
|
||||
for _, field := range []string{
|
||||
"prompt_version",
|
||||
"prompt_hash",
|
||||
"session_id",
|
||||
"selected_backend_id",
|
||||
"input_hashes",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"duration_ms",
|
||||
"duration",
|
||||
} {
|
||||
if _, ok := zeroObject[field]; ok {
|
||||
t.Fatalf("zero RunResult included %q: %s", field, zeroPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResultJSONDurationMillisecondBoundaries(t *testing.T) {
|
||||
maxMilliseconds := int64(time.Duration(math.MaxInt64) / time.Millisecond)
|
||||
minMilliseconds := int64(time.Duration(math.MinInt64) / time.Millisecond)
|
||||
|
||||
for _, milliseconds := range []int64{minMilliseconds, maxMilliseconds} {
|
||||
t.Run(strconv.FormatInt(milliseconds, 10), func(t *testing.T) {
|
||||
var decoded promptkit.RunResult
|
||||
if err := json.Unmarshal(durationPayload(milliseconds), &decoded); err != nil {
|
||||
t.Fatalf("decode representable duration_ms: %v", err)
|
||||
}
|
||||
want := time.Duration(milliseconds) * time.Millisecond
|
||||
if decoded.Duration != want {
|
||||
t.Fatalf("Duration = %v, want %v", decoded.Duration, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, milliseconds := range []int64{
|
||||
minMilliseconds - 1,
|
||||
maxMilliseconds + 1,
|
||||
math.MinInt64,
|
||||
math.MaxInt64,
|
||||
} {
|
||||
t.Run(strconv.FormatInt(milliseconds, 10), func(t *testing.T) {
|
||||
original := fullyPopulatedRunResult()
|
||||
decoded := original
|
||||
err := json.Unmarshal(durationPayload(milliseconds), &decoded)
|
||||
if err == nil || !strings.Contains(err.Error(), "duration_ms") || !strings.Contains(err.Error(), "time.Duration") {
|
||||
t.Fatalf("overflow error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded, original) {
|
||||
t.Fatalf("failed decode partially updated receiver:\ngot %#v\nwant %#v", decoded, original)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fullyPopulatedPreparedRun() promptkit.PreparedRun {
|
||||
start := time.Date(2026, time.August, 11, 12, 13, 14, 150_000_000, time.UTC)
|
||||
return promptkit.PreparedRun{
|
||||
PromptID: "prompt.prepared",
|
||||
PromptVersion: "2.1.0",
|
||||
PromptHash: "prompt-hash",
|
||||
SelectedProfileID: "profile-prepared",
|
||||
SelectedBackendID: "backend-prepared",
|
||||
EffectiveModelParams: jsonContractExecutionTarget(),
|
||||
OutputContract: promptkit.OutputContract{
|
||||
Format: promptkit.FormatJSON,
|
||||
ValidationMode: promptkit.ValidationJSONSchema,
|
||||
SchemaPath: "schemas/prepared.json",
|
||||
RepairAttempts: 2,
|
||||
},
|
||||
StructuredOutput: &promptkit.StructuredOutputSpec{
|
||||
Type: promptkit.StructuredOutputJSONSchema,
|
||||
JSONSchema: &promptkit.StructuredOutputJSONSpec{
|
||||
Name: "prepared_schema",
|
||||
Strict: true,
|
||||
Schema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"value"},
|
||||
"properties": map[string]any{
|
||||
"value": map[string]any{"minimum": float64(1)},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputHashes: map[string]string{"first": "hash-1", "second": "hash-2"},
|
||||
SessionID: "session-prepared",
|
||||
RenderedPromptHash: "rendered-hash",
|
||||
Messages: []promptkit.RenderedMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "System message",
|
||||
CacheControl: &promptkit.CacheControl{
|
||||
Type: promptkit.CacheControlEphemeral,
|
||||
TTL: "1h",
|
||||
},
|
||||
},
|
||||
{Role: "user", Content: "User message"},
|
||||
},
|
||||
StartTime: start,
|
||||
EndTime: start.Add(1501 * time.Millisecond),
|
||||
DurationMS: 1501,
|
||||
}
|
||||
}
|
||||
|
||||
func fullyPopulatedRunResult() promptkit.RunResult {
|
||||
start := time.Date(2026, time.August, 11, 15, 16, 17, 250_000_000, time.UTC)
|
||||
return promptkit.RunResult{
|
||||
RunID: "run-id",
|
||||
Artifact: promptkit.Artifact{
|
||||
Name: "result.json",
|
||||
ContentType: "application/json",
|
||||
Body: []byte(`{"value":2}`),
|
||||
URI: "memory://result.json",
|
||||
Size: 11,
|
||||
Hash: "artifact-hash",
|
||||
},
|
||||
RawOutput: `{"value":2}`,
|
||||
Validation: promptkit.ValidationResult{
|
||||
Status: promptkit.ValidationFailed,
|
||||
Mode: promptkit.ValidationJSONSchema,
|
||||
Errors: []string{"first error", "second error"},
|
||||
SchemaPath: "schemas/result.json",
|
||||
RepairAttempts: 2,
|
||||
IsValid: false,
|
||||
},
|
||||
PromptID: "prompt.result",
|
||||
PromptVersion: "3.2.1",
|
||||
PromptHash: "result-prompt-hash",
|
||||
SessionID: "session-result",
|
||||
RenderedPromptHash: "result-rendered-hash",
|
||||
SelectedProfileID: "profile-result",
|
||||
SelectedBackendID: "backend-result",
|
||||
ModelName: "model-result",
|
||||
Endpoint: "https://result.example/v1",
|
||||
EffectiveModelParams: jsonContractExecutionTarget(),
|
||||
InputHashes: map[string]string{"input": "input-hash"},
|
||||
Usage: promptkit.TokenUsage{
|
||||
PromptTokens: 101,
|
||||
CompletionTokens: 202,
|
||||
TotalTokens: 303,
|
||||
CachedTokens: 44,
|
||||
CacheWriteTokens: 55,
|
||||
},
|
||||
StartTime: start,
|
||||
EndTime: start.Add(1750 * time.Millisecond),
|
||||
Duration: 1750 * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
func jsonContractExecutionTarget() promptkit.ExecutionTarget {
|
||||
return promptkit.ExecutionTarget{
|
||||
BackendID: "backend-target",
|
||||
Endpoint: "https://target.example/v1",
|
||||
Model: "model-target",
|
||||
Temperature: 0.75,
|
||||
MaxTokens: 321,
|
||||
TopP: 0.875,
|
||||
TimeoutSeconds: 43,
|
||||
ServiceTier: "priority",
|
||||
ReasoningEffort: "high",
|
||||
APIKeyEnv: "PROMPTKIT_JSON_CONTRACT_KEY",
|
||||
ExtraParams: map[string]any{
|
||||
"enabled": true,
|
||||
"weight": float64(1.25),
|
||||
"nested": map[string]any{"name": "value"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func durationPayload(milliseconds int64) []byte {
|
||||
return []byte(`{"duration_ms":` + strconv.FormatInt(milliseconds, 10) + `}`)
|
||||
}
|
||||
|
||||
func decodeJSONObject(t *testing.T, payload []byte) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
var object map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &object); err != nil {
|
||||
t.Fatalf("decode JSON object: %v", err)
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
func assertJSONFields(t *testing.T, object map[string]json.RawMessage, fields ...string) {
|
||||
t.Helper()
|
||||
want := make(map[string]struct{}, len(fields))
|
||||
for _, field := range fields {
|
||||
want[field] = struct{}{}
|
||||
}
|
||||
for field := range object {
|
||||
if _, ok := want[field]; !ok {
|
||||
t.Errorf("unexpected JSON field %q", field)
|
||||
}
|
||||
}
|
||||
for field := range want {
|
||||
if _, ok := object[field]; !ok {
|
||||
t.Errorf("missing JSON field %q", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user