diff --git a/.chronus/changes/copilot-add-spector-case-serialization-2026-5-1-18-57-48.md b/.chronus/changes/copilot-add-spector-case-serialization-2026-5-1-18-57-48.md new file mode 100644 index 00000000000..6a25bdb8a4a --- /dev/null +++ b/.chronus/changes/copilot-add-spector-case-serialization-2026-5-1-18-57-48.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/http-specs" +--- + +Add encode/duration lossy Spector scenarios verifying a duration whose value carries more precision than the target integer encoding (fractional seconds and sub-millisecond milliseconds) is serialized as an integer \ No newline at end of file diff --git a/packages/http-specs/spec-summary.md b/packages/http-specs/spec-summary.md index c623a91cc95..44cb91ad026 100644 --- a/packages/http-specs/spec-summary.md +++ b/packages/http-specs/spec-summary.md @@ -910,6 +910,60 @@ Expected header `duration: P40D` Test iso8601 encode for a duration array header. Expected header `duration: [P40D,P50D]` +### Encode_Duration_Lossy_headerInt32Milliseconds + +- Endpoint: `get /encode/duration/lossy/header/int32-milliseconds` + +Test int32 milliseconds encode for a duration header whose value has a sub-millisecond fractional component. +The duration is 36250.25 milliseconds, e.g. TimeSpan.FromMilliseconds(36250.25) in C#. +The client must serialize the value as an integer (not a floating point number such as `36250.25`), discarding the sub-millisecond precision. +Because emitters may floor, round, or ceil when discarding that precision, the expected header is `duration: 36250` or `duration: 36251`. + +### Encode_Duration_Lossy_headerInt32Seconds + +- Endpoint: `get /encode/duration/lossy/header/int32-seconds` + +Test int32 seconds encode for a duration header whose value has a fractional (sub-second) component. +The duration is 36.25 seconds, e.g. TimeSpan.FromSeconds(36.25) in C#. +The client must serialize the value as an integer (not a floating point number such as `36.25`), discarding the sub-second precision. +Because emitters may floor, round, or ceil when discarding that precision, the expected header is `duration: 36` or `duration: 37`. + +### Encode_Duration_Lossy_propertyInt32Milliseconds + +- Endpoint: `post /encode/duration/lossy/property/int32-milliseconds` + +Test int32 milliseconds encode for a duration property whose value has a sub-millisecond fractional component. +The duration is 36250.25 milliseconds, e.g. TimeSpan.FromMilliseconds(36250.25) in C#. +The client must serialize the value as an integer (not a floating point number such as `36250.25`), discarding the sub-millisecond precision. +Because emitters may floor, round, or ceil when discarding that precision, the expected request body `value` is `36250` or `36251`. + +### Encode_Duration_Lossy_propertyInt32Seconds + +- Endpoint: `post /encode/duration/lossy/property/int32-seconds` + +Test int32 seconds encode for a duration property whose value has a fractional (sub-second) component. +The duration is 36.25 seconds, e.g. TimeSpan.FromSeconds(36.25) in C#. +The client must serialize the value as an integer (not a floating point number such as `36.25`), discarding the sub-second precision. +Because emitters may floor, round, or ceil when discarding that precision, the expected request body `value` is `36` or `37`. + +### Encode_Duration_Lossy_queryInt32Milliseconds + +- Endpoint: `get /encode/duration/lossy/query/int32-milliseconds` + +Test int32 milliseconds encode for a duration query parameter whose value has a sub-millisecond fractional component. +The duration is 36250.25 milliseconds, e.g. TimeSpan.FromMilliseconds(36250.25) in C#. +The client must serialize the value as an integer (not a floating point number such as `36250.25`), discarding the sub-millisecond precision. +Because emitters may floor, round, or ceil when discarding that precision, the expected query parameter is `input=36250` or `input=36251`. + +### Encode_Duration_Lossy_queryInt32Seconds + +- Endpoint: `get /encode/duration/lossy/query/int32-seconds` + +Test int32 seconds encode for a duration query parameter whose value has a fractional (sub-second) component. +The duration is 36.25 seconds, e.g. TimeSpan.FromSeconds(36.25) in C#. +The client must serialize the value as an integer (not a floating point number such as `36.25`), discarding the sub-second precision. +Because emitters may floor, round, or ceil when discarding that precision, the expected query parameter is `input=36` or `input=37`. + ### Encode_Duration_Property_default - Endpoint: `post /encode/duration/property/default` diff --git a/packages/http-specs/specs/encode/duration/main.tsp b/packages/http-specs/specs/encode/duration/main.tsp index cb0cf16aee0..faeffbd1c85 100644 --- a/packages/http-specs/specs/encode/duration/main.tsp +++ b/packages/http-specs/specs/encode/duration/main.tsp @@ -729,3 +729,105 @@ namespace Header { duration: Int32MillisecondsDuration[], ): NoContentResponse; } + +/** + * Lossy encode scenarios. + * + * These scenarios cover the case where the source `duration` carries more precision than the + * target encoding can represent (e.g. a sub-second value encoded as integer seconds). The client + * must still serialize the value using the target number type (an integer), discarding the extra + * precision, rather than emitting a floating point number. This is distinct from arbitrary type + * mismatches, which are already covered by the round-trip scenarios above. + */ +@route("/lossy") +namespace Lossy { + model Int32SecondsDurationProperty { + @encode(DurationKnownEncoding.seconds, int32) + value: duration; + } + + model Int32MillisecondsDurationProperty { + @encode(DurationKnownEncoding.milliseconds, int32) + value: duration; + } + + @route("/query/int32-seconds") + @scenario + @scenarioDoc(""" + Test int32 seconds encode for a duration query parameter whose value has a fractional (sub-second) component. + The duration is 36.25 seconds, e.g. TimeSpan.FromSeconds(36.25) in C#. + The client must serialize the value as an integer (not a floating point number such as `36.25`), discarding the sub-second precision. + Because emitters may floor, round, or ceil when discarding that precision, the expected query parameter is `input=36` or `input=37`. + """) + op queryInt32Seconds( + @query + @encode(DurationKnownEncoding.seconds, int32) + input: duration, + ): NoContentResponse; + + @route("/query/int32-milliseconds") + @scenario + @scenarioDoc(""" + Test int32 milliseconds encode for a duration query parameter whose value has a sub-millisecond fractional component. + The duration is 36250.25 milliseconds, e.g. TimeSpan.FromMilliseconds(36250.25) in C#. + The client must serialize the value as an integer (not a floating point number such as `36250.25`), discarding the sub-millisecond precision. + Because emitters may floor, round, or ceil when discarding that precision, the expected query parameter is `input=36250` or `input=36251`. + """) + op queryInt32Milliseconds( + @query + @encode(DurationKnownEncoding.milliseconds, int32) + input: duration, + ): NoContentResponse; + + @route("/property/int32-seconds") + @scenario + @scenarioDoc(""" + Test int32 seconds encode for a duration property whose value has a fractional (sub-second) component. + The duration is 36.25 seconds, e.g. TimeSpan.FromSeconds(36.25) in C#. + The client must serialize the value as an integer (not a floating point number such as `36.25`), discarding the sub-second precision. + Because emitters may floor, round, or ceil when discarding that precision, the expected request body `value` is `36` or `37`. + """) + @post + op propertyInt32Seconds(@body body: Int32SecondsDurationProperty): Int32SecondsDurationProperty; + + @route("/property/int32-milliseconds") + @scenario + @scenarioDoc(""" + Test int32 milliseconds encode for a duration property whose value has a sub-millisecond fractional component. + The duration is 36250.25 milliseconds, e.g. TimeSpan.FromMilliseconds(36250.25) in C#. + The client must serialize the value as an integer (not a floating point number such as `36250.25`), discarding the sub-millisecond precision. + Because emitters may floor, round, or ceil when discarding that precision, the expected request body `value` is `36250` or `36251`. + """) + @post + op propertyInt32Milliseconds( + @body body: Int32MillisecondsDurationProperty, + ): Int32MillisecondsDurationProperty; + + @route("/header/int32-seconds") + @scenario + @scenarioDoc(""" + Test int32 seconds encode for a duration header whose value has a fractional (sub-second) component. + The duration is 36.25 seconds, e.g. TimeSpan.FromSeconds(36.25) in C#. + The client must serialize the value as an integer (not a floating point number such as `36.25`), discarding the sub-second precision. + Because emitters may floor, round, or ceil when discarding that precision, the expected header is `duration: 36` or `duration: 37`. + """) + op headerInt32Seconds( + @header + @encode(DurationKnownEncoding.seconds, int32) + duration: duration, + ): NoContentResponse; + + @route("/header/int32-milliseconds") + @scenario + @scenarioDoc(""" + Test int32 milliseconds encode for a duration header whose value has a sub-millisecond fractional component. + The duration is 36250.25 milliseconds, e.g. TimeSpan.FromMilliseconds(36250.25) in C#. + The client must serialize the value as an integer (not a floating point number such as `36250.25`), discarding the sub-millisecond precision. + Because emitters may floor, round, or ceil when discarding that precision, the expected header is `duration: 36250` or `duration: 36251`. + """) + op headerInt32Milliseconds( + @header + @encode(DurationKnownEncoding.milliseconds, int32) + duration: duration, + ): NoContentResponse; +} diff --git a/packages/http-specs/specs/encode/duration/mockapi.ts b/packages/http-specs/specs/encode/duration/mockapi.ts index c74a77d300b..868f5c54947 100644 --- a/packages/http-specs/specs/encode/duration/mockapi.ts +++ b/packages/http-specs/specs/encode/duration/mockapi.ts @@ -23,6 +23,45 @@ function createBodyServerTests(uri: string, data: any, value: any) { kind: "MockApiDefinition", }); } + +// Validates that a duration whose value carries more precision than the target encoding (a lossy +// encode) is serialized as an integer. The allowed values cover floor, round and ceil so the test +// does not take a position on an emitter's rounding mode while still rejecting floating point output. +function createLossyBodyServerTests(uri: string, allowed: number[]) { + return passOnSuccess({ + uri, + method: "post", + request: { + body: json({ value: allowed[0] }), + }, + response: { + status: 200, + body: json({ value: allowed[0] }), + }, + handler: (req: MockRequest) => { + const value = req.body?.value; + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new ValidationError( + `Expected body property "value" to be serialized as an integer but got ${value}`, + "an integer", + value, + ); + } + if (!allowed.includes(value)) { + throw new ValidationError( + `Expected body property "value" to be one of ${allowed.join(", ")} but got ${value}`, + allowed.join(" | "), + value, + ); + } + return { + status: 200, + body: json({ value }), + }; + }, + kind: "MockApiDefinition", + }); +} Scenarios.Encode_Duration_Property_default = createBodyServerTests( "/encode/duration/property/default", { @@ -175,6 +214,45 @@ function createQueryFloatServerTests(uri: string, paramData: any, value: number) kind: "MockApiDefinition", }); } + +// Validates that a duration whose value carries more precision than the target encoding (a lossy +// encode) is serialized as an integer. The allowed values cover floor, round and ceil so the test +// does not take a position on an emitter's rounding mode while still rejecting floating point output. +function createLossyQueryServerTests(uri: string, allowed: number[]) { + return passOnSuccess({ + uri, + method: "get", + request: { + query: { + input: allowed[0], + }, + }, + response: { + status: 204, + }, + handler: (req: MockRequest) => { + const actual = req.query["input"] as string; + if (!/^[-+]?\d+$/.test(actual)) { + throw new ValidationError( + `Expected query param input to be serialized as an integer but got ${actual}`, + "an integer", + actual, + ); + } + if (!allowed.map(String).includes(actual)) { + throw new ValidationError( + `Expected query param input to be one of ${allowed.join(", ")} but got ${actual}`, + allowed.join(" | "), + actual, + ); + } + return { + status: 204, + }; + }, + kind: "MockApiDefinition", + }); +} Scenarios.Encode_Duration_Query_default = createQueryServerTests( "/encode/duration/query/default", { @@ -321,6 +399,45 @@ function createHeaderFloatServerTests(uri: string, value: number) { }); } +// Validates that a duration whose value carries more precision than the target encoding (a lossy +// encode) is serialized as an integer. The allowed values cover floor, round and ceil so the test +// does not take a position on an emitter's rounding mode while still rejecting floating point output. +function createLossyHeaderServerTests(uri: string, allowed: number[]) { + return passOnSuccess({ + uri, + method: "get", + request: { + headers: { + duration: String(allowed[0]), + }, + }, + response: { + status: 204, + }, + handler: (req: MockRequest) => { + const actual = req.headers["duration"]; + if (!/^[-+]?\d+$/.test(actual)) { + throw new ValidationError( + `Expected header duration to be serialized as an integer but got ${actual}`, + "an integer", + actual, + ); + } + if (!allowed.map(String).includes(actual)) { + throw new ValidationError( + `Expected header duration to be one of ${allowed.join(", ")} but got ${actual}`, + allowed.join(" | "), + actual, + ); + } + return { + status: 204, + }; + }, + kind: "MockApiDefinition", + }); +} + Scenarios.Encode_Duration_Header_default = createHeaderServerTests( "/encode/duration/header/default", { @@ -408,3 +525,30 @@ Scenarios.Encode_Duration_Header_floatMillisecondsLargerUnit = createHeaderFloat "/encode/duration/header/float-milliseconds-larger-unit", 210000, ); + +// Lossy encode scenarios: the source duration carries more precision than the target integer +// encoding, so floor/round/ceil are all acceptable results (e.g. 36.25s -> 36 or 37). +Scenarios.Encode_Duration_Lossy_queryInt32Seconds = createLossyQueryServerTests( + "/encode/duration/lossy/query/int32-seconds", + [36, 37], +); +Scenarios.Encode_Duration_Lossy_queryInt32Milliseconds = createLossyQueryServerTests( + "/encode/duration/lossy/query/int32-milliseconds", + [36250, 36251], +); +Scenarios.Encode_Duration_Lossy_propertyInt32Seconds = createLossyBodyServerTests( + "/encode/duration/lossy/property/int32-seconds", + [36, 37], +); +Scenarios.Encode_Duration_Lossy_propertyInt32Milliseconds = createLossyBodyServerTests( + "/encode/duration/lossy/property/int32-milliseconds", + [36250, 36251], +); +Scenarios.Encode_Duration_Lossy_headerInt32Seconds = createLossyHeaderServerTests( + "/encode/duration/lossy/header/int32-seconds", + [36, 37], +); +Scenarios.Encode_Duration_Lossy_headerInt32Milliseconds = createLossyHeaderServerTests( + "/encode/duration/lossy/header/int32-milliseconds", + [36250, 36251], +);