feat(matches): add match metadata updates

This commit is contained in:
Daniel Schädler 2026-05-04 15:10:11 +02:00
parent 547e1c3933
commit 0cbcb64066
14 changed files with 308 additions and 13 deletions

View File

@ -7,9 +7,11 @@ class MatchesController < ApplicationController
before_action :set_swap_target, only: %i[swap] before_action :set_swap_target, only: %i[swap]
before_action -> { require_owner! @match.owner }, only: %i[update swap] before_action -> { require_owner! @match.owner }, only: %i[update swap]
before_action -> { require_owner! @group.owner }, only: %i[create] before_action -> { require_owner! @group.owner }, only: %i[create]
before_action :set_tournament, only: %i[index] before_action :set_tournament, only: %i[index metadata]
before_action -> { require_owner! @tournament.owner }, only: %i[metadata]
before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[update swap] before_action -> { require_writable_tournament!(@match.tournament) }, only: %i[update swap]
before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create] before_action -> { require_writable_tournament!(@group.tournament) }, only: %i[create]
before_action -> { require_writable_tournament!(@tournament) }, only: %i[metadata]
# GET/tournaments/1/matches # GET/tournaments/1/matches
def index def index
@ -63,6 +65,28 @@ class MatchesController < ApplicationController
TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil? TournamentLiveBroadcast.call(synced_tournament.id) unless synced_tournament.nil?
end end
# PATCH /tournaments/1/matches/metadata
def metadata
attrs = match_metadata_params
if attrs.empty?
render json: { error: 'At least one metadata field is required' }, status: :bad_request
return
end
matches = bulk_metadata_matches
return if performed?
Match.transaction do
TournamentTransactionLogger.lock_tournament!(@tournament)
@tournament.reload
matches.each { |match| match.update!(attrs) }
end
render json: matches.map(&:reload), each_serializer: LiveMatchSerializer, include: %w[match_scores.team]
push_sync_if_needed!(@tournament)
TournamentLiveBroadcast.call(@tournament.id)
end
# PATCH /matches/1/swap # PATCH /matches/1/swap
def swap def swap
unless @match.swappable_with?(@swap_target) unless @match.swappable_with?(@swap_target)
@ -141,7 +165,7 @@ class MatchesController < ApplicationController
old_state = @match.state old_state = @match.state
if @match.update(match_params) if @match.update(match_params)
record_successful_match_update(old_state, new_state) record_successful_match_update(old_state, new_state) unless new_state.nil?
render json: @match render json: @match
tournament tournament
else else
@ -151,6 +175,8 @@ class MatchesController < ApplicationController
end end
def validate_transition_inside_transaction(new_state) def validate_transition_inside_transaction(new_state)
return if new_state.nil?
transition_error = match_transition_error(@match, new_state) transition_error = match_transition_error(@match, new_state)
return if transition_error.nil? return if transition_error.nil?
@ -175,6 +201,8 @@ class MatchesController < ApplicationController
end end
def validate_params def validate_params
return unless params.key?(:state)
transition_error = match_transition_error(@match, match_params['state']) transition_error = match_transition_error(@match, match_params['state'])
return if transition_error.nil? return if transition_error.nil?
@ -201,13 +229,52 @@ class MatchesController < ApplicationController
end end
def match_params def match_params
params.slice(:state).permit! params.slice(:state, :location, :start_time, :notes).permit!
end end
def match_index_params def match_index_params
params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit! params.slice(:state, :stage_id, :stage_level, :position, :group_stage_position).permit!
end end
def match_metadata_params
params.slice(:location, :start_time, :notes).permit!
end
def bulk_metadata_matches
return bulk_metadata_matches_by_id if params.key?(:match_ids)
return @tournament.matches if ActiveModel::Type::Boolean.new.cast(params[:all])
return filtered_bulk_metadata_matches if match_index_params.values.any?(&:present?)
render json: { error: 'Select matches with match_ids, filters, or all=true' }, status: :bad_request
[]
end
def bulk_metadata_matches_by_id
ids = parse_match_ids
return [] if performed?
matches = @tournament.matches.select { |match| ids.include?(match.id) }
missing_ids = ids - matches.map(&:id)
if missing_ids.any?
render json: { error: "Matches not found in tournament: #{missing_ids.join(', ')}" }, status: :not_found
return []
end
ids.map { |id| matches.find { |match| match.id == id } }
end
def filtered_bulk_metadata_matches
TournamentMatchesPayload.matches_for(@tournament, match_index_params)
end
def parse_match_ids
Array(params[:match_ids]).map { |id| Integer(id.to_s, 10) }.uniq
rescue ArgumentError, TypeError
render json: { error: 'match_ids must contain only integer ids' }, status: :bad_request
[]
end
def create_match_params def create_match_params
params.slice(:team_ids).permit(team_ids: []) params.slice(:team_ids).permit(team_ids: [])
end end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class LiveMatchSerializer < ApplicationSerializer class LiveMatchSerializer < ApplicationSerializer
attributes :position, :state, :teams, :group, :stage attributes :position, :state, :location, :start_time, :notes, :teams, :group, :stage
has_many :match_scores has_many :match_scores

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class MatchSerializer < ApplicationSerializer class MatchSerializer < ApplicationSerializer
attributes :state, :position, :winner, :decider attributes :state, :position, :winner, :decider, :location, :start_time, :notes
def winner def winner
ActiveModelSerializers::SerializableResource.new(object.winner).as_json ActiveModelSerializers::SerializableResource.new(object.winner).as_json

View File

@ -139,6 +139,9 @@ class TournamentSnapshotImporter
match.state = attrs.fetch(:state) match.state = attrs.fetch(:state)
match.position = attrs[:position] match.position = attrs[:position]
match.decider = attrs[:decider] match.decider = attrs[:decider]
match.location = attrs[:location]
match.start_time = attrs[:start_time]
match.notes = attrs[:notes]
end end
end end

View File

@ -48,7 +48,7 @@ class TournamentSyncSchema
ignored: %w[created_at updated_at sync_source_id].freeze ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze, }.freeze,
Match => { Match => {
synced: %w[id state position stage_id group_id decider].freeze, synced: %w[id state position stage_id group_id decider location start_time notes].freeze,
ignored: %w[created_at updated_at sync_source_id].freeze ignored: %w[created_at updated_at sync_source_id].freeze
}.freeze, }.freeze,
MatchScore => { MatchScore => {

View File

@ -24,7 +24,11 @@ Rails.application.routes.draw do
resources :team_action_lists, only: %i[create] resources :team_action_lists, only: %i[create]
patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item' patch 'team_action_lists/:key/teams/:team_id', to: 'team_action_lists#update_item'
resources :statistics, only: %i[index] resources :statistics, only: %i[index]
resources :matches, only: %i[index] resources :matches, only: %i[index] do
collection do
patch :metadata
end
end
resources :beamers, only: %i[index show create update destroy] do resources :beamers, only: %i[index show create update destroy] do
collection do collection do
get :by_name get :by_name

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddMatchMetadataToMatches < ActiveRecord::Migration[7.0]
def change
add_column :matches, :location, :string
add_column :matches, :start_time, :string
add_column :matches, :notes, :text
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do ActiveRecord::Schema[8.1].define(version: 2026_05_04_100000) do
create_table "beamers", force: :cascade do |t| create_table "beamers", force: :cascade do |t|
t.json "config", default: {}, null: false t.json "config", default: {}, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@ -74,8 +74,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_30_121000) do
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.boolean "decider", default: false, null: false t.boolean "decider", default: false, null: false
t.integer "group_id" t.integer "group_id"
t.string "location"
t.text "notes"
t.integer "position" t.integer "position"
t.integer "stage_id" t.integer "stage_id"
t.string "start_time"
t.integer "state", default: 0 t.integer "state", default: 0
t.integer "sync_source_id" t.integer "sync_source_id"
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false

View File

@ -47,7 +47,7 @@ RSpec.describe MatchesController, type: :controller do
get :index, params: { tournament_id: @tournament.to_param } get :index, params: { tournament_id: @tournament.to_param }
match = deserialize_response(response).first match = deserialize_response(response).first
expect(match.keys).to match_array(%i[id position state teams match_scores group stage]) expect(match.keys).to match_array(%i[id position state location start_time notes teams match_scores group stage])
expect(match[:teams]).to all(include(:id, :name)) expect(match[:teams]).to all(include(:id, :name))
end end
@ -196,6 +196,102 @@ RSpec.describe MatchesController, type: :controller do
end end
end end
describe 'PATCH #metadata' do
let(:group_stage_tournament) { create(:group_stage_tournament, group_count: 2, match_factory: :filled_group_match) }
context 'as owner' do
before do
apply_authentication_headers_for group_stage_tournament.owner
allow(TournamentSyncEnqueue).to receive(:call)
allow(TournamentLiveBroadcast).to receive(:call)
end
it 'updates selected matches by explicit ids' do
selected_matches = group_stage_tournament.matches.first(2)
patch :metadata, params: {
tournament_id: group_stage_tournament.to_param,
match_ids: selected_matches.map(&:id),
location: 'Court A',
start_time: '17:00',
notes: 'Bring player cards'
}
expect(response).to be_successful
expect(selected_matches.map { |match| match.reload.location }).to all(eq('Court A'))
expect(selected_matches.map { |match| match.reload.start_time }).to all(eq('17:00'))
expect(selected_matches.map { |match| match.reload.notes }).to all(eq('Bring player cards'))
expect(deserialize_response(response).map { |match| match[:id] }).to eq(selected_matches.map(&:id))
expect(TournamentSyncEnqueue).to have_received(:call).with(group_stage_tournament)
expect(TournamentLiveBroadcast).to have_received(:call).with(group_stage_tournament.id)
end
it 'updates group stage matches by position filter' do
patch :metadata, params: {
tournament_id: group_stage_tournament.to_param,
group_stage_position: 1,
start_time: '18:00'
}
expect(response).to be_successful
group_stage_tournament.matches.each do |match|
expected_start_time = match.group_match? && match.position == 1 ? '18:00' : nil
expect(match.reload.start_time).to eq(expected_start_time)
end
end
it 'updates playoff matches by stage level filter' do
playoff_tournament = create(:stage_tournament, stage_count: 3, user: group_stage_tournament.owner)
target_stage = playoff_tournament.stages.find_by!(level: 3)
patch :metadata, params: {
tournament_id: playoff_tournament.to_param,
stage_level: target_stage.level,
start_time: '21:00'
}
expect(response).to be_successful
playoff_tournament.matches.each do |match|
expected_start_time = match.stage == target_stage ? '21:00' : nil
expect(match.reload.start_time).to eq(expected_start_time)
end
end
it 'requires a selector' do
patch :metadata, params: { tournament_id: group_stage_tournament.to_param, start_time: '17:00' }
expect(response).to have_http_status(:bad_request)
expect(deserialize_response(response)[:error]).to eq('Select matches with match_ids, filters, or all=true')
end
it 'rejects matches outside the tournament' do
other_match = create(:match)
patch :metadata, params: {
tournament_id: group_stage_tournament.to_param,
match_ids: [other_match.id],
start_time: '17:00'
}
expect(response).to have_http_status(:not_found)
expect(deserialize_response(response)[:error]).to eq("Matches not found in tournament: #{other_match.id}")
end
it 'rejects read only follower tournaments' do
group_stage_tournament.update!(read_only_mode: true, sync_auth_token: 'shared-secret')
patch :metadata, params: {
tournament_id: group_stage_tournament.to_param,
match_ids: [group_stage_tournament.matches.first.id],
start_time: '17:00'
}
expect(response).to have_http_status(:locked)
expect(deserialize_response(response)[:error]).to eq('Tournament is in read only follower mode')
end
end
end
describe 'POST #update' do describe 'POST #update' do
context 'on a running playoff match' do context 'on a running playoff match' do
let(:valid_update) do let(:valid_update) do
@ -237,6 +333,29 @@ RSpec.describe MatchesController, type: :controller do
expect(body[:state]).to eq(valid_update[:state]) expect(body[:state]).to eq(valid_update[:state])
end end
it 'updates metadata without changing match state' do
expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id)
put :update, params: {
id: @match.to_param,
location: 'Main Hall',
start_time: '17:00',
notes: 'Stream setup needed'
}
expect(response).to be_successful
@match.reload
expect(@match.state).to eq('not_started')
expect(@match.location).to eq('Main Hall')
expect(@match.start_time).to eq('17:00')
expect(@match.notes).to eq('Stream setup needed')
expect(deserialize_response(response)).to include(
location: 'Main Hall',
start_time: '17:00',
notes: 'Stream setup needed'
)
end
it 'writes a transaction log entry for the state change' do it 'writes a transaction log entry for the state change' do
expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id) expect(TournamentLiveBroadcast).to receive(:call).with(@match.tournament.id)

View File

@ -50,6 +50,51 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
expect(tournament_signature(follower)).to eq(tournament_signature(source)) expect(tournament_signature(follower)).to eq(tournament_signature(source))
expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty expect(synced_team_id_pairs(source: source, follower: follower)).not_to be_empty
log_phase!('update match metadata and verify follower mirrors it')
source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first
first_group_matches = first_source_group.fetch(:matches).sort_by { |match| match.fetch(:position) }
single_metadata = source_owner_client.patch("/matches/#{first_group_matches.fetch(0).fetch(:id)}", body: {
location: 'Main Hall',
start_time: '17:00',
notes: 'Opening match'
})
explicit_bulk_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: {
match_ids: first_group_matches.values_at(1, 2).map { |match| match.fetch(:id) },
location: 'Court B',
start_time: '17:30'
})
group_position_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: {
group_stage_position: 3,
start_time: '18:00',
notes: 'Group position 3 wave'
})
expect(single_metadata[:status]).to eq(200)
expect(single_metadata.dig(:json, :location)).to eq('Main Hall')
expect(explicit_bulk_metadata[:status]).to eq(200)
expect(explicit_bulk_metadata[:json].map { |match| match.fetch(:location) }).to all(eq('Court B'))
expect(group_position_metadata[:status]).to eq(200)
expect(group_position_metadata[:json].map { |match| match.fetch(:start_time) }).to all(eq('18:00'))
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
expect(tournament_signature(follower)).to eq(tournament_signature(source))
follower_group_stage = follower.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
follower_group = follower_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first
follower_first_match = follower_group.fetch(:matches).sort_by { |match| match.fetch(:position) }.first
locked_match_metadata = follower_owner_client.patch("/matches/#{follower_first_match.fetch(:id)}", body: {
location: 'Follower edit'
})
locked_bulk_metadata = follower_owner_client.patch("/tournaments/#{follower.fetch(:id)}/matches/metadata", body: {
match_ids: [follower_first_match.fetch(:id)],
start_time: '19:00'
})
expect(locked_match_metadata[:status]).to eq(423)
expect(locked_match_metadata.dig(:json, :error)).to eq('Tournament is in read only follower mode')
expect(locked_bulk_metadata[:status]).to eq(423)
expect(locked_bulk_metadata.dig(:json, :error)).to eq('Tournament is in read only follower mode')
log_phase!('swap matches on leader and verify follower behavior') log_phase!('swap matches on leader and verify follower behavior')
source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } source_group_stage = source.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }
first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first first_source_group = source_group_stage.fetch(:groups).sort_by { |group| group.fetch(:number) }.first
@ -144,6 +189,20 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
expect(source.fetch(:team_action_lists)).to eq([]) expect(source.fetch(:team_action_lists)).to eq([])
expect(follower.fetch(:team_action_lists)).to eq([]) expect(follower.fetch(:team_action_lists)).to eq([])
log_phase!('bulk update playoff match metadata and verify follower mirrors it')
source_playoff_stage = source.fetch(:stages).select { |stage| stage.fetch(:level) >= 0 }.max_by { |stage| stage.fetch(:level) }
playoff_metadata = source_owner_client.patch("/tournaments/#{source.fetch(:id)}/matches/metadata", body: {
stage_level: source_playoff_stage.fetch(:level),
location: 'Finals Arena',
start_time: '21:00',
notes: 'Playoff wave'
})
expect(playoff_metadata[:status]).to eq(200)
expect(playoff_metadata[:json].map { |match| match.fetch(:location) }).to all(eq('Finals Arena'))
source = fetch_tournament(client: source_anonymous_client, tournament_id: source.fetch(:id))
follower = wait_for_tournament_sync!(source_tournament_id: source.fetch(:id), follower_tournament_id: follower.fetch(:id))
expect(tournament_signature(follower)).to eq(tournament_signature(source))
log_phase!('create team action list on source and verify follower mirrors it') log_phase!('create team action list on source and verify follower mirrors it')
created_list = create_team_action_list!(source.fetch(:id)) created_list = create_team_action_list!(source.fetch(:id))
expect(created_list.fetch(:key)).to eq(playoff_token_list_key) expect(created_list.fetch(:key)).to eq(playoff_token_list_key)
@ -548,6 +607,9 @@ RSpec.describe 'Tournament follower sync HTTP E2E' do
state: match.fetch(:state), state: match.fetch(:state),
position: match.fetch(:position), position: match.fetch(:position),
decider: match.fetch(:decider), decider: match.fetch(:decider),
location: match[:location],
start_time: match[:start_time],
notes: match[:notes],
scores: match.fetch(:match_scores).map do |score| scores: match.fetch(:match_scores).map do |score|
{ {
team_name: score.dig(:team, :name), team_name: score.dig(:team, :name),

View File

@ -15,5 +15,9 @@ RSpec.describe MatchesController, type: :routing do
it 'routes to #swap' do it 'routes to #swap' do
expect(patch: '/matches/1/swap').to route_to('matches#swap', id: '1') expect(patch: '/matches/1/swap').to route_to('matches#swap', id: '1')
end end
it 'routes nested tournament match metadata updates' do
expect(patch: '/tournaments/1/matches/metadata').to route_to('matches#metadata', tournament_id: '1')
end
end end
end end

View File

@ -26,9 +26,14 @@ RSpec.describe 'Tournament live payloads' do
payload = described_class.build(tournament) payload = described_class.build(tournament)
serialized_match = payload.find { |candidate| candidate[:id] == match.id } serialized_match = payload.find { |candidate| candidate[:id] == match.id }
expect(serialized_match.keys).to match_array(%i[id position state teams match_scores group stage]) expect(serialized_match.keys).to match_array(
%i[id position state location start_time notes teams match_scores group stage]
)
expect(serialized_match[:state]).to eq(match.state) expect(serialized_match[:state]).to eq(match.state)
expect(serialized_match[:position]).to eq(match.position) expect(serialized_match[:position]).to eq(match.position)
expect(serialized_match[:location]).to eq(match.location)
expect(serialized_match[:start_time]).to eq(match.start_time)
expect(serialized_match[:notes]).to eq(match.notes)
expect(serialized_match.dig(:group, :id)).to eq(match.group_id) expect(serialized_match.dig(:group, :id)).to eq(match.group_id)
expect(serialized_match.dig(:stage, :id)).to eq(match.group.stage_id) expect(serialized_match.dig(:stage, :id)).to eq(match.group.stage_id)
expect(serialized_match[:teams].map { |team| team[:id] }).to match_array(match.teams.map(&:id)) expect(serialized_match[:teams].map { |team| team[:id] }).to match_array(match.teams.map(&:id))

View File

@ -12,6 +12,11 @@ RSpec.describe TournamentSnapshotImporter do
timer_reason: 'custom', timer_reason: 'custom',
timer_reason_text: 'Opening ceremony' timer_reason_text: 'Opening ceremony'
) )
source.matches.first.update!(
location: 'Main Hall',
start_time: '17:00',
notes: 'Opening match'
)
source.beamers.create!( source.beamers.create!(
name: 'stream-a', name: 'stream-a',
display_state: 'during_game', display_state: 'during_game',
@ -37,6 +42,11 @@ RSpec.describe TournamentSnapshotImporter do
expect(follower.team_action_lists.first.team_action_items.count).to eq(snapshot.fetch(:team_action_items).count) expect(follower.team_action_lists.first.team_action_items.count).to eq(snapshot.fetch(:team_action_items).count)
expect(follower.stages.count).to eq(source.stages.count) expect(follower.stages.count).to eq(source.stages.count)
expect(follower.matches.count).to eq(source.matches.count) expect(follower.matches.count).to eq(source.matches.count)
expect(follower.matches.find { |match| match.sync_source_id == source.matches.first.id }).to have_attributes(
location: 'Main Hall',
start_time: '17:00',
notes: 'Opening match'
)
expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map { |team| team.fetch(:id) }) expect(follower.teams.pluck(:sync_source_id)).to match_array(snapshot.fetch(:teams).map { |team| team.fetch(:id) })
expect(follower.beamers.pluck(:sync_source_id)).to match_array(snapshot.fetch(:beamers).map { |beamer| beamer.fetch(:id) }) expect(follower.beamers.pluck(:sync_source_id)).to match_array(snapshot.fetch(:beamers).map { |beamer| beamer.fetch(:id) })
end end
@ -136,6 +146,11 @@ RSpec.describe TournamentSnapshotImporter do
tournament.reload tournament.reload
play_some_playoff_matches!(tournament) play_some_playoff_matches!(tournament)
tournament.matches.min_by(&:id).update!(
location: 'Feature Court',
start_time: '18:30',
notes: 'Follower sync metadata fixture'
)
tournament.update!( tournament.update!(
timestamp: Time.utc(2026, 4, 24, 10, 15, 0), timestamp: Time.utc(2026, 4, 24, 10, 15, 0),
timer_mode: 'countdown', timer_mode: 'countdown',
@ -254,6 +269,9 @@ RSpec.describe TournamentSnapshotImporter do
state: match.state, state: match.state,
position: match.position, position: match.position,
decider: match.decider, decider: match.decider,
location: match.location,
start_time: match.start_time,
notes: match.notes,
scores: match.match_scores.includes(:team).sort_by { |score| score.team.name }.map do |score| scores: match.match_scores.includes(:team).sort_by { |score| score.team.name }.map do |score|
{ {
team_name: score.team.name, team_name: score.team.name,

View File

@ -751,9 +751,10 @@ def start_test_local(
@task @task
def test(c): def test(c, spec=""):
"""Run the normal RSpec suite.""" """Run the normal RSpec suite, or one or more spec files via --spec."""
_stream_subprocess(_rspec_command(), env=_env()) spec_paths = shlex.split(spec) if spec else []
_stream_subprocess(_rspec_command(*spec_paths), env=_env())
@task(name="test-shard") @task(name="test-shard")