From a311b7761df01b977cac1f183e36e37c1b800de6 Mon Sep 17 00:00:00 2001 From: lemintare Date: Sun, 19 Jul 2026 13:43:25 +0300 Subject: [PATCH] Refactor bracket validation logic to support draft state This commit updates the bracket validation process by introducing a new `ValidateDraft` method, allowing for incomplete edits in the draft state. The existing `Validate` method has been modified to enforce complete validation only upon confirmation. Additionally, new tests have been added to ensure that incomplete drafts are accepted until confirmation, while still rejecting invalid references. Documentation has been updated to reflect these changes in the API and architecture. --- backend/internal/application/workflow.go | 2 +- backend/internal/domain/bracket.go | 21 +++++++++++++--- backend/internal/domain/bracket_test.go | 31 ++++++++++++++++++++++++ memory_bank/architecture.md | 2 +- memory_bank/domain_model.md | 2 +- openapi/openapi.yaml | 2 ++ 6 files changed, 53 insertions(+), 7 deletions(-) diff --git a/backend/internal/application/workflow.go b/backend/internal/application/workflow.go index 522a943..e8b1350 100644 --- a/backend/internal/application/workflow.go +++ b/backend/internal/application/workflow.go @@ -485,7 +485,7 @@ func (s *Service) UpdateBracket(ctx context.Context, actor domain.Account, event return draft, err } draft.Matches, draft.Confirmed, draft.Version = matches, false, draft.Version+1 - if err = draft.Validate(); err != nil { + if err = draft.ValidateDraft(); err != nil { return draft, err } out, err := s.Store.SaveBracketDraft(ctx, draft, expectedVersion) diff --git a/backend/internal/domain/bracket.go b/backend/internal/domain/bracket.go index d1a0928..79273e6 100644 --- a/backend/internal/domain/bracket.go +++ b/backend/internal/domain/bracket.go @@ -81,7 +81,15 @@ func NewBracketDraft(eventID string, teamIDs []string) (*BracketDraft, error) { return draft, draft.Validate() } +func (d BracketDraft) ValidateDraft() error { + return d.validate(false) +} + func (d BracketDraft) Validate() error { + return d.validate(true) +} + +func (d BracketDraft) validate(requireComplete bool) error { if d.EventID == "" || len(d.TeamIDs) < 2 || len(d.Matches) == 0 { return fmt.Errorf("%w: incomplete bracket", ErrInvalid) } @@ -114,23 +122,28 @@ func (d BracketDraft) Validate() error { for _, source := range []SlotSource{match.SlotA, match.SlotB} { switch source.Kind { case SlotTeam: - if !teams[source.TeamID] || source.MatchID != "" { + if source.MatchID != "" || (source.TeamID != "" && !teams[source.TeamID]) { return fmt.Errorf("%w: unknown team source", ErrInvalid) } + if requireComplete && source.TeamID == "" { + return fmt.Errorf("%w: empty bracket slot", ErrInvalid) + } case SlotWinner, SlotLoser: upstream, ok := matches[source.MatchID] if !ok || upstream.Round >= match.Round || source.TeamID != "" { return fmt.Errorf("%w: match source must reference an earlier round", ErrInvalid) } default: - return fmt.Errorf("%w: empty bracket slot", ErrInvalid) + if requireComplete || source.Kind != "" || source.TeamID != "" || source.MatchID != "" { + return fmt.Errorf("%w: empty bracket slot", ErrInvalid) + } } } - if match.SlotA.Kind == SlotTeam && match.SlotB.Kind == SlotTeam && match.SlotA.TeamID == match.SlotB.TeamID { + if requireComplete && match.SlotA.Kind == SlotTeam && match.SlotB.Kind == SlotTeam && match.SlotA.TeamID == match.SlotB.TeamID { return fmt.Errorf("%w: a team cannot play itself", ErrInvalid) } } - if finals != 1 { + if requireComplete && finals != 1 { return fmt.Errorf("%w: the last round must contain exactly one match", ErrInvalid) } return nil diff --git a/backend/internal/domain/bracket_test.go b/backend/internal/domain/bracket_test.go index 72d1b48..e02eee2 100644 --- a/backend/internal/domain/bracket_test.go +++ b/backend/internal/domain/bracket_test.go @@ -49,3 +49,34 @@ func TestBracketRejectsForwardReferencesAndMultipleFinals(t *testing.T) { t.Fatal("expected invalid graph") } } + +func TestBracketDraftAllowsIncompleteEditsUntilConfirmation(t *testing.T) { + draft := BracketDraft{ + EventID: "event", TeamIDs: []string{"a", "b"}, + Matches: []BracketMatch{ + {ID: "m1", Round: 0, SlotA: SlotSource{Kind: SlotTeam, TeamID: "a"}, SlotB: SlotSource{Kind: SlotTeam, TeamID: "b"}}, + {ID: "m2", Round: 1, SlotA: SlotSource{Kind: SlotWinner, MatchID: "m1"}, SlotB: SlotSource{Kind: SlotTeam}}, + {ID: "m3", Round: 1, Order: 1, SlotA: SlotSource{Kind: SlotTeam, TeamID: "a"}, SlotB: SlotSource{Kind: SlotTeam, TeamID: "a"}}, + }, + } + + if err := draft.ValidateDraft(); err != nil { + t.Fatalf("incomplete editable draft was rejected: %v", err) + } + if err := draft.Validate(); err == nil { + t.Fatal("expected incomplete bracket to be rejected at confirmation") + } +} + +func TestBracketDraftStillRejectsInvalidReferences(t *testing.T) { + draft := BracketDraft{ + EventID: "event", TeamIDs: []string{"a", "b"}, + Matches: []BracketMatch{ + {ID: "m1", Round: 0, SlotA: SlotSource{Kind: SlotWinner, MatchID: "missing"}, SlotB: SlotSource{Kind: SlotTeam}}, + }, + } + + if err := draft.ValidateDraft(); err == nil { + t.Fatal("expected invalid match reference to be rejected") + } +} diff --git a/memory_bank/architecture.md b/memory_bank/architecture.md index 1c4aa53..3654d77 100644 --- a/memory_bank/architecture.md +++ b/memory_bank/architecture.md @@ -88,7 +88,7 @@ Application-слой управляет единым versioned workflow собы Ручное редактирование работает с серверным roster draft. Backend разрешает обмен только между одинаковыми ролевыми слотами и замену слота игроком из резерва, после чего пересчитывает средние рейтинги и метрики. Подтверждение требует полного состава 1/2/2, уникальных игроков и капитана внутри каждой команды. -После подтверждения составов workflow переходит в versioned `BracketDraft`. Staff собирает DAG матчей из источников Team / Winner / Loser; ссылки разрешены только на предыдущие колонки, а в последней колонке должен быть один финальный матч. `start-scrim` материализует все готовые Bo3, а завершение серии разрешает зависимости и атомарно создаёт следующие матчи. Поэтому одна колонка может содержать параллельные серии, а три команды могут играть последовательную ротацию через проигравшего. Tournament read-model гидратируется свежими версиями Series. Умный event-level live-вход направляет участника в его матч, капитана/staff — к доступным командам, а остальных — в spectator mode. +После подтверждения составов workflow переходит в versioned `BracketDraft`. Staff собирает DAG матчей из источников Team / Winner / Loser; API обновления сохраняет структурно корректные промежуточные черновики с незаполненными слотами, а полную проверку команд, матчей и единственного финала выполняет команда подтверждения. Ссылки Winner / Loser всегда разрешены только на предыдущие колонки. `start-scrim` материализует все готовые Bo3, а завершение серии разрешает зависимости и атомарно создаёт следующие матчи. Поэтому одна колонка может содержать параллельные серии, а три команды могут играть последовательную ротацию через проигравшего. Tournament read-model гидратируется свежими версиями Series. Умный event-level live-вход направляет участника в его матч, капитана/staff — к доступным командам, а остальных — в spectator mode. ### Драфт как конечный автомат diff --git a/memory_bank/domain_model.md b/memory_bank/domain_model.md index bcea691..a55f95e 100644 --- a/memory_bank/domain_model.md +++ b/memory_bank/domain_model.md @@ -90,7 +90,7 @@ Mixmaker является источником истины только для ### Tournament -Versioned граф матчей, в котором каждый слот ссылается на конкретную Team либо Winner/Loser матча из предыдущей колонки. Готовые зависимости материализуются в отдельные Bo3-серии автоматически; несколько готовых матчей могут идти параллельно. Последняя колонка содержит один матч, победитель которого становится общим победителем. Для трёх команд стартовый шаблон проводит `A–B`, затем `Loser(M1)–C`, затем финал победителей первых двух матчей. +Versioned граф матчей, в котором каждый слот ссылается на конкретную Team либо Winner/Loser матча из предыдущей колонки. Во время редактирования сохраняются промежуточные состояния с пустыми слотами, повторяющимися командами и несколькими матчами в последней колонке; полные инварианты применяются при подтверждении сетки. Готовые зависимости материализуются в отдельные Bo3-серии автоматически; несколько готовых матчей могут идти параллельно. Подтверждённая последняя колонка содержит один матч, победитель которого становится общим победителем. Для трёх команд стартовый шаблон проводит `A–B`, затем `Loser(M1)–C`, затем финал победителей первых двух матчей. ## Правила текущего регламента diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index a26b862..dc9b30d 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -256,6 +256,7 @@ paths: put: tags: [Tournaments] operationId: updateBracketDraft + description: Saves a structurally valid work-in-progress graph; slots and final layout may remain incomplete until confirmation. parameters: [{ $ref: "#/components/parameters/EventId" }] requestBody: required: true @@ -283,6 +284,7 @@ paths: post: tags: [Tournaments] operationId: confirmBracketDraft + description: Validates the complete graph, including filled slots and exactly one final match, then marks it confirmed. parameters: [{ $ref: "#/components/parameters/EventId" }] requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/VersionCommand" } } } } responses: