diff --git a/README.md b/README.md index a3253b1..cd0e771 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ Current scenarios: - `server-initiated-pcm-24bit` (Server initiates connection and client wants 24-bit PCM): start the server first, let the client advertise a listener and 24-bit PCM as its only supported audio format, let the server connect in, negotiate the 24-bit packed wire format, stream it to the client, and compare canonical PCM hashes — a client SDK with no 24-bit decode path misreads the bytes and produces a hash mismatch - `client-initiated-state-format-pcm` (Client changes its preferred PCM bit depth via `client/state`): start the server first, let the client connect advertising two PCM formats (24-bit then 16-bit), let the server stream the higher-priority 24-bit format, have the client send a `client/state` whose player `format` prefers the 16-bit entry, and verify the server received that preference in `client/state` and the client observed a new `stream/start` in the 16-bit format - `client-initiated-state-format-flac` (Client changes its preferred format from PCM to FLAC via `client/state`): start the server first, let the client connect advertising PCM then FLAC, let the server stream the higher-priority PCM format, have the client send a `client/state` whose player `format` prefers the FLAC entry, and verify the server received that preference in `client/state` and the client observed a new `stream/start` in the FLAC format +- `client-initiated-metadata` (Client initiates connection and client wants Metadata): start the server first, let the client discover/connect to it, receive a metadata snapshot, and compare normalized metadata fields +- `client-initiated-artwork` (Client initiates connection and client wants Artwork): start the server first, let the client discover/connect to it, receive album artwork bytes, and compare the encoded image hash +- `client-initiated-controller` (Client initiates connection and client wants Controller): start the server first, let the client discover/connect to it, observe controller state, send a control command, and verify the server recorded it - `server-initiated-audio-chunk-framing` (Server initiates connection and frames PCM audio chunks): start the server first, let the client advertise a listener and PCM as its only supported audio format, let the server connect in and stream, and have both adapters record each audio frame as it crossed the transport; verify that every frame is message type 4 behind the 13-byte header (type, int64 timestamp, uint32 `send_ahead`), that the client read the same 13 bytes off each one, and that no chunk lasted longer than 150 ms or, the final chunk aside, less than 15 ms. The audio itself is not compared, and `send_ahead` saturation is not judged because no adapter can observe when a frame was transmitted ## Current coverage @@ -30,7 +33,9 @@ Current scenarios: The OPUS scenario is currently exercised only by the `SendspinKit` and `sendspin-cpp` clients against the `aiosendspin` server until other implementations opt in via `supports_opus`. -The `client-initiated-state-format-*` scenarios have no capability flag of their own: RC1 gives every player the `client/state` `format` preference, so a client that never sends one shows as a failed case. The FLAC variant still needs a client that can list FLAC (`supports_flac`). Only the `aiosendspin` client adapter drives them today. +The `client-initiated-state-format-*` scenarios have no capability flag of their own: RC1 gives every player the `client/state` `format` preference, so a client that never sends one shows as a failed case. The FLAC variant still needs a client that can list FLAC (`supports_flac`). The `aiosendspin` and `SendspinKit` client adapters drive them today. + +The `client-initiated-metadata`, `client-initiated-artwork` and `client-initiated-controller` scenarios have no capability flag of their own either: RC1 requires every server to support client-initiated connections, whichever roles the client brings. The `aiosendspin`, `sendspin-dotnet`, `SendspinKit` and `sendspin-cpp` client adapters drive all three over the outbound path they use for `client-initiated-pcm`. The `sendspin-js` client adapter drives the metadata and controller scenarios the same way; it declares no `artwork` role family, so the harness marks its `client-initiated-artwork` cases unsupported before launching anything. The `sendspin-go` and `sendspin-rs` client adapters have an outbound path for player sessions only, so their cases fail as a harness gap rather than being left out. The `server-initiated-audio-chunk-framing` scenario has no capability flag of its own either. A client adapter that is handed parsed chunks and never sees a raw frame reports that, and its case fails as a harness gap rather than being left out. Only the `aiosendspin` adapters, the `sendspin-go` server and the `sendspin-rs` client observe raw frames today. diff --git a/adapters/SendspinKit/client/Sources/ConformanceSendspinKitClient/main.swift b/adapters/SendspinKit/client/Sources/ConformanceSendspinKitClient/main.swift index 0491bbc..b826933 100644 --- a/adapters/SendspinKit/client/Sources/ConformanceSendspinKitClient/main.swift +++ b/adapters/SendspinKit/client/Sources/ConformanceSendspinKitClient/main.swift @@ -129,8 +129,8 @@ struct CliOptions { } var isFormatPreferenceScenario: Bool { - scenarioID == "client-initiated-request-format-pcm" - || scenarioID == "client-initiated-request-format-flac" + scenarioID == "client-initiated-state-format-pcm" + || scenarioID == "client-initiated-state-format-flac" } /// Codecs whose conformance check compares the raw encoded chunk bytes the @@ -989,8 +989,8 @@ struct ConformanceSendspinKitClient { if options.scenarioID == "server-initiated-pcm-24bit" { formats = try [AudioFormatSpec(codec: .pcm, channels: 1, sampleRate: 8000, bitDepth: 24)] } else if options.isFormatPreferenceScenario { - let initialDepth = options.scenarioID == "client-initiated-request-format-pcm" ? 24 : 16 - let targetCodec: AudioCodec = options.scenarioID == "client-initiated-request-format-flac" ? .flac : .pcm + let initialDepth = options.scenarioID == "client-initiated-state-format-pcm" ? 24 : 16 + let targetCodec: AudioCodec = options.scenarioID == "client-initiated-state-format-flac" ? .flac : .pcm formats = try [ AudioFormatSpec(codec: .pcm, channels: 1, sampleRate: 8000, bitDepth: initialDepth), AudioFormatSpec(codec: targetCodec, channels: 1, sampleRate: 8000, bitDepth: 16), @@ -1111,7 +1111,7 @@ struct ConformanceSendspinKitClient { await collector.recordStreamFormat(format, codecHeader: await client.currentCodecHeader) if !preferenceSent { preferenceSent = true - let codec: AudioCodec = options.scenarioID == "client-initiated-request-format-flac" ? .flac : .pcm + let codec: AudioCodec = options.scenarioID == "client-initiated-state-format-flac" ? .flac : .pcm let requested = try AudioFormatSpec(codec: codec, channels: 1, sampleRate: 8000, bitDepth: 16) try await client.setPlayerFormatPreference(requested) await collector.recordPreferenceRequest(requested) @@ -1125,7 +1125,7 @@ struct ConformanceSendspinKitClient { await collector.recordStreamFormat(format, codecHeader: codecHeader) if options.isFormatPreferenceScenario, !preferenceSent { preferenceSent = true - let targetCodec: AudioCodec = options.scenarioID == "client-initiated-request-format-flac" ? .flac : .pcm + let targetCodec: AudioCodec = options.scenarioID == "client-initiated-state-format-flac" ? .flac : .pcm let requested = try AudioFormatSpec(codec: targetCodec, channels: 1, sampleRate: 8000, bitDepth: 16) try await client.setPlayerFormatPreference(requested) await collector.recordPreferenceRequest(requested) diff --git a/adapters/sendspin-cpp/client/main.cpp b/adapters/sendspin-cpp/client/main.cpp index 9fffaf3..f3dd76b 100644 --- a/adapters/sendspin-cpp/client/main.cpp +++ b/adapters/sendspin-cpp/client/main.cpp @@ -160,15 +160,15 @@ static bool is_player_scenario(const std::string& id) { } static bool is_metadata_scenario(const std::string& id) { - return id == "server-initiated-metadata"; + return id == "client-initiated-metadata" || id == "server-initiated-metadata"; } static bool is_controller_scenario(const std::string& id) { - return id == "server-initiated-controller"; + return id == "client-initiated-controller" || id == "server-initiated-controller"; } static bool is_artwork_scenario(const std::string& id) { - return id == "server-initiated-artwork"; + return id == "client-initiated-artwork" || id == "server-initiated-artwork"; } static std::string get_arg(int argc, char* argv[], const std::string& name, diff --git a/adapters/sendspin-go/client/main.go b/adapters/sendspin-go/client/main.go index fbaa829..db5d6e6 100644 --- a/adapters/sendspin-go/client/main.go +++ b/adapters/sendspin-go/client/main.go @@ -103,6 +103,23 @@ func run(parsed args) int { ) } + // The outbound path drives a player session only, so any other role would + // report a missing audio stream instead of what its scenario asks for. + if parsed.InitiatorRole == "client" && !conformance.IsPlayerScenario(parsed.ScenarioID) { + if err := conformance.WriteJSON(parsed.Ready, map[string]any{"status": "error"}); err != nil { + log.Printf("failed to write ready file: %v", err) + } + return exitWithSummary( + parsed, + errorSummary( + parsed, + fmt.Sprintf("Harness gap, not a protocol result: the sendspin-go client adapter has no client-initiated path for scenario %s", parsed.ScenarioID), + nil, + nil, + ), + ) + } + if parsed.InitiatorRole == "client" { if err := conformance.WriteJSON(parsed.Ready, conformance.BuildReadyPayload(parsed.ScenarioID, parsed.InitiatorRole, "")); err != nil { log.Printf("failed to write ready file: %v", err) diff --git a/adapters/sendspin-js/client.mjs b/adapters/sendspin-js/client.mjs index 1cd83f2..a3d3da4 100644 --- a/adapters/sendspin-js/client.mjs +++ b/adapters/sendspin-js/client.mjs @@ -41,8 +41,14 @@ const PLAYER_SCENARIOS = new Set([ "server-initiated-legacy-unencrypted", "server-initiated-audio-chunk-framing", ]); -const METADATA_SCENARIOS = new Set(["server-initiated-metadata"]); -const CONTROLLER_SCENARIOS = new Set(["server-initiated-controller"]); +const METADATA_SCENARIOS = new Set([ + "client-initiated-metadata", + "server-initiated-metadata", +]); +const CONTROLLER_SCENARIOS = new Set([ + "client-initiated-controller", + "server-initiated-controller", +]); function parseArgs(argv) { const values = {}; diff --git a/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/Main.kt b/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/Main.kt index 7670b25..d9057b3 100644 --- a/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/Main.kt +++ b/adapters/sendspin-jvm/client/src/main/kotlin/com/sendspin/conformance/Main.kt @@ -322,7 +322,11 @@ private suspend fun runClientInitiated( val serverHello = client.serverHello.first { it != null }!! if (config.scenarioId == "client-initiated-controller") { - client.controllerState.first { it != null } + // Wait for a controller state that explicitly advertises the expected command — sending + // before the server includes the command in supported_commands causes it to reject it. + while (client.controllerState.value?.supportedCommands?.contains(config.controllerCommand) != true) { + kotlinx.coroutines.delay(10) + } client.sendControllerCommand(config.controllerCommand) onCommandSent(config.controllerCommand) } diff --git a/adapters/sendspin-rs/client/src/main.rs b/adapters/sendspin-rs/client/src/main.rs index 8176923..92644b9 100644 --- a/adapters/sendspin-rs/client/src/main.rs +++ b/adapters/sendspin-rs/client/src/main.rs @@ -5,7 +5,7 @@ use sendspin::protocol::messages::{ ArtworkChannel, ArtworkSource, ArtworkV1Support, AudioFormatSpec, ClientCommand, ClientHello, ClientState, ClientSyncState, ClientTime, ConnectionReason, ControllerCommand, ControllerCommandType, ControllerState, DeviceInfo, ImageFormat, Message, MetadataState, - PlayerFormatRequest, PlayerState, PlayerV1Support, ServerHello, StreamPlayerConfig, + PlayerState, PlayerV1Support, ServerHello, StreamPlayerConfig, }; use sendspin::ProtocolClientBuilder; use sha2::{Digest, Sha256}; @@ -218,100 +218,6 @@ fn is_artwork_scenario(scenario_id: &str) -> bool { ) } -fn is_request_format_scenario(scenario_id: &str) -> bool { - matches!( - scenario_id, - "client-initiated-request-format-pcm" | "client-initiated-request-format-flac" - ) -} - -/// The formats a renegotiation scenario advertises (priority order) and the -/// format it later requests via `stream/request-format`. The server starts on -/// the first advertised format, then switches to the requested one. -fn request_format_plan(scenario_id: &str) -> (Vec, PlayerFormatRequest) { - let pcm = |bit_depth: u8| AudioFormatSpec { - codec: "pcm".to_string(), - channels: 1, - sample_rate: 8000, - bit_depth, - }; - if scenario_id == "client-initiated-request-format-flac" { - let flac = AudioFormatSpec { - codec: "flac".to_string(), - channels: 1, - sample_rate: 8000, - bit_depth: 16, - }; - return ( - vec![pcm(16), flac], - PlayerFormatRequest { - codec: Some("flac".to_string()), - channels: Some(1), - sample_rate: Some(8000), - bit_depth: Some(16), - }, - ); - } - // Default: PCM bit-depth downgrade (24-bit preferred, request 16-bit). - ( - vec![pcm(24), pcm(16)], - PlayerFormatRequest { - codec: Some("pcm".to_string()), - channels: Some(1), - sample_rate: Some(8000), - bit_depth: Some(16), - }, - ) -} - -fn stream_format_json(stream: &StreamPlayerConfig) -> serde_json::Value { - serde_json::json!({ - "codec": stream.codec, - "sample_rate": stream.sample_rate, - "bit_depth": stream.bit_depth, - "channels": stream.channels, - }) -} - -fn player_format_request_json(request: &PlayerFormatRequest) -> serde_json::Value { - serde_json::json!({ - "codec": request.codec, - "sample_rate": request.sample_rate, - "bit_depth": request.bit_depth, - "channels": request.channels, - }) -} - -fn build_renegotiation_summary( - args: &Args, - status: &str, - reason: Option<&str>, - server_hello: Option<&ServerHello>, - renegotiation: serde_json::Value, -) -> serde_json::Value { - serde_json::json!({ - "status": status, - "reason": reason, - "implementation": "sendspin-rs", - "role": "client", - "scenario_id": args.scenario_id, - "initiator_role": args.initiator_role, - "preferred_codec": args.preferred_codec, - "client_name": args.client_name, - "client_id": args.client_id, - "server": server_hello.map(|hello| { - serde_json::json!({ - "server_id": hello.server_id, - "name": hello.name, - "version": hello.version, - "active_roles": hello.active_roles, - "connection_reason": hello.connection_reason, - }) - }), - "renegotiation": renegotiation, - }) -} - async fn wait_for_server_url( registry_path: &Path, server_name: &str, @@ -1104,132 +1010,25 @@ async fn run_outbound_protocol_client(args: &Args, server_url: &str) -> serde_js } } -async fn run_request_format_client(args: &Args, server_url: &str) -> serde_json::Value { - let (supported_formats, requested) = request_format_plan(&args.scenario_id); - - let builder = ProtocolClientBuilder::builder() - .client_id(args.client_id.clone()) - .name(args.client_name.clone()) - .product_name(Some("sendspin-rs Conformance Client".to_string())) - .manufacturer(Some("Sendspin Conformance".to_string())) - .software_version(Some("0.1.0".to_string())) - .player_v1_support(PlayerV1Support { - supported_formats, - buffer_capacity: 2_000_000, - supported_commands: vec!["volume".to_string(), "mute".to_string()], - }) - .build(); - - let client = match builder.connect(server_url).await { - Ok(client) => client, - Err(err) => { - return build_error_summary(args, &format!("Failed to connect to {server_url}: {err}")); - } - }; - - let conn = client.split(); - let mut message_rx = conn.messages; - let mut audio_rx = conn.audio; - let _artwork_rx = conn.artwork; - let _visualizer_rx = conn.visualizer; - let _clock_sync = conn.clock_sync; - let ws_tx = conn.sender; - let server_hello = conn.server_hello; - let _guard = conn.guard; - - // Renegotiation is verified from stream/start messages alone, but the audio - // channel must still be consumed so the SDK keeps the session flowing. - tokio::spawn(async move { while audio_rx.recv().await.is_some() {} }); - - if let Err(err) = ws_tx - .send_message(Message::ClientState(ClientState { - state: Some(ClientSyncState::Synchronized), - player: Some(PlayerState { - volume: Some(100), - muted: Some(false), - static_delay_ms: None, - min_buffer_ms: None, - required_lead_time_ms: None, - supported_commands: None, - }), - })) - .await - { - return build_error_summary(args, &err.to_string()); - } - - let mut stream_start_count = 0usize; - let mut initial_format: Option = None; - let mut final_format: Option = None; - let timeout = Duration::from_secs_f64(args.timeout_seconds); - - let read_result = tokio::time::timeout(timeout, async { - loop { - let Some(message) = message_rx.recv().await else { - break; - }; - match message { - Message::StreamStart(stream_start) => { - let Some(format) = stream_start.player else { - continue; - }; - stream_start_count += 1; - if stream_start_count == 1 { - initial_format = Some(format); - ws_tx - .request_player_format(requested.clone()) - .await - .map_err(|err| err.to_string())?; - } else { - final_format = Some(format); - } - } - Message::StreamEnd(_) - | Message::StreamClear(_) - | Message::ServerState(_) - | Message::GroupUpdate(_) - | Message::ServerCommand(_) => {} - other => { - return Err(format!("Unexpected server message: {other:?}")); - } - } - } - Ok::<(), String>(()) - }) - .await; - - let renegotiation = serde_json::json!({ - "stream_start_count": stream_start_count, - "requested": player_format_request_json(&requested), - "initial_format": initial_format.as_ref().map(stream_format_json), - "final_format": final_format.as_ref().map(stream_format_json), - }); - - let (status, reason): (&str, Option) = match read_result { - Err(_) => ( - "error", - Some("Timed out waiting for server disconnect".to_string()), - ), - Ok(Err(reason)) => ("error", Some(reason)), - Ok(Ok(())) => ("ok", None), - }; - - build_renegotiation_summary( - args, - status, - reason.as_deref(), - Some(&server_hello), - renegotiation, - ) -} - async fn run(args: Args) -> Result<(), String> { - let summary = if args.initiator_role == "client" { + // The outbound paths drive a player session only, so these roles would + // report a missing audio stream instead of what their scenario asks for. + let lacks_outbound_path = is_metadata_scenario(&args.scenario_id) + || is_controller_scenario(&args.scenario_id) + || is_artwork_scenario(&args.scenario_id); + let summary = if args.initiator_role == "client" && lacks_outbound_path { + write_json(&args.ready, &serde_json::json!({"status": "error"}))?; + build_error_summary( + &args, + &format!( + "Harness gap, not a protocol result: the sendspin-rs client adapter has no \ + client-initiated path for scenario {}", + args.scenario_id + ), + ) + } else if args.initiator_role == "client" { write_json(&args.ready, &build_ready(&args, None))?; match wait_for_server_url(&args.registry, &args.server_name, args.timeout_seconds).await { - Ok(server_url) if is_request_format_scenario(&args.scenario_id) => { - run_request_format_client(&args, &server_url).await - } Ok(server_url) => run_outbound_protocol_client(&args, &server_url).await, Err(reason) => build_error_summary(&args, &reason), } diff --git a/src/conformance/scenarios.py b/src/conformance/scenarios.py index fce7bf0..c51c519 100644 --- a/src/conformance/scenarios.py +++ b/src/conformance/scenarios.py @@ -115,6 +115,20 @@ ) +CONTROLLER_EXTRA_CLI_ARGS = ( + ("controller_command", "next"), + ("controller_repeat", "all"), + ("controller_shuffle", "false"), +) + + +ARTWORK_EXTRA_CLI_ARGS = ( + ("artwork_format", "jpeg"), + ("artwork_width", "256"), + ("artwork_height", "256"), +) + + SERVER_INITIATED_METADATA = ScenarioSpec( id="server-initiated-metadata", display_name="Server initiates connection and client wants Metadata", @@ -146,11 +160,7 @@ required_role_families=("controller",), verification_mode="controller", scenario_revision=5, - extra_cli_args=( - ("controller_command", "next"), - ("controller_repeat", "all"), - ("controller_shuffle", "false"), - ), + extra_cli_args=CONTROLLER_EXTRA_CLI_ARGS, ) @@ -167,11 +177,7 @@ required_role_families=("artwork",), verification_mode="artwork", scenario_revision=5, - extra_cli_args=( - ("artwork_format", "jpeg"), - ("artwork_width", "256"), - ("artwork_height", "256"), - ), + extra_cli_args=ARTWORK_EXTRA_CLI_ARGS, ) @@ -230,6 +236,61 @@ ) +CLIENT_INITIATED_METADATA = ScenarioSpec( + id="client-initiated-metadata", + display_name="Client initiates connection and client wants Metadata", + description=( + "Start the server first, then the client. The client discovers or looks up the " + "server and initiates the WebSocket connection, the server sends a metadata state " + "update, disconnects, and the matrix compares a normalized metadata snapshot and " + "checks that the first state carrying a metadata object bore a past or present " + "timestamp." + ), + initiator_role="client", + preferred_codec="none", + required_role_families=("metadata",), + verification_mode="metadata", + scenario_revision=1, + extra_cli_args=METADATA_EXTRA_CLI_ARGS, +) + + +CLIENT_INITIATED_CONTROLLER = ScenarioSpec( + id="client-initiated-controller", + display_name="Client initiates connection and client wants Controller", + description=( + "Start the server first, then the client. The client discovers or looks up the " + "server and initiates the WebSocket connection, the server observes controller " + "state, receives a control command, disconnects, and the matrix verifies the " + "recorded command." + ), + initiator_role="client", + preferred_codec="none", + required_role_families=("controller",), + verification_mode="controller", + scenario_revision=1, + extra_cli_args=CONTROLLER_EXTRA_CLI_ARGS, +) + + +CLIENT_INITIATED_ARTWORK = ScenarioSpec( + id="client-initiated-artwork", + display_name="Client initiates connection and client wants Artwork", + description=( + "Start the server first, then the client. The client discovers or looks up the " + "server and initiates the WebSocket connection, the server streams album artwork, " + "disconnects, and the matrix compares the received bytes against the server's " + "encoded artwork." + ), + initiator_role="client", + preferred_codec="none", + required_role_families=("artwork",), + verification_mode="artwork", + scenario_revision=1, + extra_cli_args=ARTWORK_EXTRA_CLI_ARGS, +) + + SERVER_INITIATED_LEGACY_UNENCRYPTED = ScenarioSpec( id="server-initiated-legacy-unencrypted", display_name="Server initiates connection over the legacy unencrypted transition mode", @@ -283,6 +344,9 @@ SERVER_INITIATED_PCM_24BIT, CLIENT_INITIATED_STATE_FORMAT_PCM, CLIENT_INITIATED_STATE_FORMAT_FLAC, + CLIENT_INITIATED_METADATA, + CLIENT_INITIATED_ARTWORK, + CLIENT_INITIATED_CONTROLLER, SERVER_INITIATED_LEGACY_UNENCRYPTED, SERVER_INITIATED_AUDIO_CHUNK_FRAMING, ) diff --git a/tests/test_client_initiated_role_scenarios.py b/tests/test_client_initiated_role_scenarios.py new file mode 100644 index 0000000..3edcccf --- /dev/null +++ b/tests/test_client_initiated_role_scenarios.py @@ -0,0 +1,59 @@ +"""Coverage for the client-initiated metadata, artwork and controller scenarios. + +Each one repeats a server-initiated scenario with the connection opened from the +other side, so the two must ask the same thing of a pair and be offered to the +same implementations. A sibling that drifted would make the two rows of the +report compare different behaviour under matching names. +""" + +from __future__ import annotations + +import unittest + +from conformance.implementations import IMPLEMENTATIONS, implementations_for_scenario +from conformance.scenarios import require_scenario + +ROLE_FAMILIES = ("metadata", "artwork", "controller") + + +class ClientInitiatedRoleScenarioTests(unittest.TestCase): + def _pairs(self): + for family in ROLE_FAMILIES: + yield ( + require_scenario(f"client-initiated-{family}"), + require_scenario(f"server-initiated-{family}"), + ) + + def test_differs_from_its_sibling_only_in_who_connects(self) -> None: + for scenario, sibling in self._pairs(): + with self.subTest(scenario=scenario.id): + self.assertEqual(scenario.initiator_role, "client") + self.assertEqual(sibling.initiator_role, "server") + self.assertEqual(scenario.verification_mode, sibling.verification_mode) + self.assertEqual(scenario.required_role_families, sibling.required_role_families) + self.assertEqual(scenario.preferred_codec, sibling.preferred_codec) + self.assertEqual(scenario.extra_cli_args, sibling.extra_cli_args) + + def test_runs_against_every_server_its_sibling_does(self) -> None: + for scenario, sibling in self._pairs(): + with self.subTest(scenario=scenario.id): + self.assertEqual( + implementations_for_scenario(role="server", scenario=scenario), + implementations_for_scenario(role="server", scenario=sibling), + ) + + def test_no_client_is_judged_unsupported_unless_its_sibling_is(self) -> None: + for scenario, sibling in self._pairs(): + for name, implementation in IMPLEMENTATIONS.items(): + with self.subTest(scenario=scenario.id, client=name): + unsupported = implementation.client.unsupported_reason( + implementation=name, role="client", scenario=scenario + ) + sibling_unsupported = implementation.client.unsupported_reason( + implementation=name, role="client", scenario=sibling + ) + self.assertEqual(unsupported is None, sibling_unsupported is None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_stream_start_gate.py b/tests/test_stream_start_gate.py index 824f1fa..aaccf58 100644 --- a/tests/test_stream_start_gate.py +++ b/tests/test_stream_start_gate.py @@ -219,7 +219,15 @@ def test_only_scenarios_that_open_a_stream_are_judged(self) -> None: } unjudged = {scenario_id for scenario_id, (matches, _) in verdicts.items() if matches} - self.assertEqual(unjudged, {"server-initiated-metadata", "server-initiated-controller"}) + self.assertEqual( + unjudged, + { + "server-initiated-metadata", + "server-initiated-controller", + "client-initiated-metadata", + "client-initiated-controller", + }, + ) for scenario_id in verdicts.keys() - unjudged: with self.subTest(scenario_id): self.assertTrue(verdicts[scenario_id][1].startswith(HARNESS_GAP))