Add support for entering eliminations matches on the scouting app

This patch plumbs through the "round" and "compLevel" fields where
necessary in order to let folks scout eliminations matches.

Signed-off-by: Philipp Schrader <philipp.schrader@gmail.com>
Change-Id: Idf20d77ed36b79f7dffb598f98e382260e6b81c9
diff --git a/scouting/scouting_test.ts b/scouting/scouting_test.ts
index 98e1d00..5740cc8 100644
--- a/scouting/scouting_test.ts
+++ b/scouting/scouting_test.ts
@@ -41,6 +41,12 @@
   return element(by.css('.error_message')).getText();
 }
 
+// Returns the currently displayed error message on the screen. This only
+// exists on screens where the web page interacts with the web server.
+function getValueOfInputById(id: string) {
+  return element(by.id(id)).getAttribute('value');
+}
+
 // Asserts that the field on the "Submit and Review" screen has a specific
 // value.
 function expectReviewFieldToBe(fieldName: string, expectedValue: string) {
@@ -120,14 +126,39 @@
   it('should: show matches in chronological order.', async () => {
     await loadPage();
 
-    expect(await getNthMatchLabel(0)).toEqual('Quals 1');
-    expect(await getNthMatchLabel(1)).toEqual('Quals 2');
-    expect(await getNthMatchLabel(2)).toEqual('Quals 3');
-    expect(await getNthMatchLabel(9)).toEqual('Quals 10');
-    // TODO(phil): Validate quarter finals and friends. Right now we don't
-    // distinguish between "sets". I.e. we display 4 "Quarter Final 1" matches
-    // without being able to distinguish between them.
-    expect(await getNthMatchLabel(87)).toEqual('Final 1');
+    expect(await getNthMatchLabel(0)).toEqual('Quals Match 1');
+    expect(await getNthMatchLabel(1)).toEqual('Quals Match 2');
+    expect(await getNthMatchLabel(2)).toEqual('Quals Match 3');
+    expect(await getNthMatchLabel(9)).toEqual('Quals Match 10');
+    expect(await getNthMatchLabel(72)).toEqual('Quarter Final 1 Match 1');
+    expect(await getNthMatchLabel(73)).toEqual('Quarter Final 2 Match 1');
+    expect(await getNthMatchLabel(74)).toEqual('Quarter Final 3 Match 1');
+    expect(await getNthMatchLabel(75)).toEqual('Quarter Final 4 Match 1');
+    expect(await getNthMatchLabel(76)).toEqual('Quarter Final 1 Match 2');
+    expect(await getNthMatchLabel(82)).toEqual('Semi Final 1 Match 1');
+    expect(await getNthMatchLabel(83)).toEqual('Semi Final 2 Match 1');
+    expect(await getNthMatchLabel(84)).toEqual('Semi Final 1 Match 2');
+    expect(await getNthMatchLabel(85)).toEqual('Semi Final 2 Match 2');
+    expect(await getNthMatchLabel(89)).toEqual('Final 1 Match 3');
+  });
+
+  it('should: prefill the match information.', async () => {
+    await loadPage();
+
+    expect(await getHeadingText()).toEqual('Matches');
+
+    // On the 87th row of matches (index 86) click on the second team
+    // (index 1) which resolves to team 5254 in semi final 2 match 3.
+    await element
+      .all(by.css('button.match-item'))
+      .get(86 * 6 + 1)
+      .click();
+
+    expect(await getHeadingText()).toEqual('Team Selection');
+    expect(await getValueOfInputById('match_number')).toEqual('3');
+    expect(await getValueOfInputById('team_number')).toEqual('5254');
+    expect(await getValueOfInputById('round')).toEqual('2');
+    expect(await getValueOfInputById('comp_level')).toEqual('3: sf');
   });
 
   it('should: error on unknown match.', async () => {
@@ -197,6 +228,8 @@
     expect(await getHeadingText()).toEqual('Team Selection');
     await setTextboxByIdTo('match_number', '2');
     await setTextboxByIdTo('team_number', '5254');
+    await setTextboxByIdTo('round', '42');
+    await element(by.cssContainingText('option', 'Semi Finals')).click();
     await element(by.buttonText('Next')).click();
 
     expect(await getHeadingText()).toEqual('Auto');
@@ -224,6 +257,8 @@
     // Validate Team Selection.
     await expectReviewFieldToBe('Match number', '2');
     await expectReviewFieldToBe('Team number', '5254');
+    await expectReviewFieldToBe('Round', '42');
+    await expectReviewFieldToBe('Comp Level', 'Semi Finals');
 
     // Validate Auto.
     await expectNthReviewFieldToBe('Upper Shots Made', 0, '0');
@@ -237,7 +272,7 @@
     await expectNthReviewFieldToBe('Missed Shots', 1, '0');
 
     // Validate Climb.
-    await expectReviewFieldToBe('Level', 'High');
+    await expectReviewFieldToBe('Climb Level', 'High');
     await expectReviewFieldToBe('Comments', 'A very useful comment here.');
 
     // Validate Other.
diff --git a/scouting/www/app.ng.html b/scouting/www/app.ng.html
index 3c9c2c2..7bf7e63 100644
--- a/scouting/www/app.ng.html
+++ b/scouting/www/app.ng.html
@@ -75,6 +75,8 @@
     (switchTabsEvent)="switchTabTo($event)"
     [teamNumber]="selectedTeamInMatch.teamNumber"
     [matchNumber]="selectedTeamInMatch.matchNumber"
+    [round]="selectedTeamInMatch.round"
+    [compLevel]="selectedTeamInMatch.compLevel"
     *ngSwitchCase="'Entry'"
   ></app-entry>
   <frc971-notes *ngSwitchCase="'Notes'"></frc971-notes>
diff --git a/scouting/www/app.ts b/scouting/www/app.ts
index 9d6c539..5180218 100644
--- a/scouting/www/app.ts
+++ b/scouting/www/app.ts
@@ -14,6 +14,7 @@
 type TeamInMatch = {
   teamNumber: number;
   matchNumber: number;
+  round: number;
   compLevel: string;
 };
 
@@ -26,6 +27,7 @@
   selectedTeamInMatch: TeamInMatch = {
     teamNumber: 1,
     matchNumber: 1,
+    round: 1,
     compLevel: 'qm',
   };
   tab: Tab = 'MatchList';
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index c90e3d0..9637e8c 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -25,6 +25,19 @@
   | 'Review and Submit'
   | 'Success';
 
+// TODO(phil): Deduplicate with match_list.component.ts.
+const COMP_LEVELS = ['qm', 'ef', 'qf', 'sf', 'f'] as const;
+type CompLevel = typeof COMP_LEVELS[number];
+
+// TODO(phil): Deduplicate with match_list.component.ts.
+const COMP_LEVEL_LABELS: Record<CompLevel, string> = {
+  qm: 'Qualifications',
+  ef: 'Eighth Finals',
+  qf: 'Quarter Finals',
+  sf: 'Semi Finals',
+  f: 'Finals',
+};
+
 @Component({
   selector: 'app-entry',
   templateUrl: './entry.ng.html',
@@ -34,11 +47,15 @@
   // Re-export the type here so that we can use it in the `[value]` attribute
   // of radio buttons.
   readonly ClimbLevel = ClimbLevel;
+  readonly COMP_LEVELS = COMP_LEVELS;
+  readonly COMP_LEVEL_LABELS = COMP_LEVEL_LABELS;
 
   section: Section = 'Team Selection';
   @Output() switchTabsEvent = new EventEmitter<string>();
   @Input() matchNumber: number = 1;
   @Input() teamNumber: number = 1;
+  @Input() round: number = 1;
+  @Input() compLevel: CompLevel = 'qm';
   autoUpperShotsMade: number = 0;
   autoLowerShotsMade: number = 0;
   autoShotsMissed: number = 0;
@@ -113,10 +130,13 @@
     this.errorMessage = '';
 
     const builder = new Builder();
+    const compLevel = builder.createString(this.compLevel);
     const comment = builder.createString(this.comment);
     SubmitDataScouting.startSubmitDataScouting(builder);
     SubmitDataScouting.addTeam(builder, this.teamNumber);
     SubmitDataScouting.addMatch(builder, this.matchNumber);
+    SubmitDataScouting.addRound(builder, this.round);
+    SubmitDataScouting.addCompLevel(builder, compLevel);
     SubmitDataScouting.addMissedShotsAuto(builder, this.autoShotsMissed);
     SubmitDataScouting.addUpperGoalAuto(builder, this.autoUpperShotsMade);
     SubmitDataScouting.addLowerGoalAuto(builder, this.autoLowerShotsMade);
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index b8eaa78..46e5989 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -28,6 +28,18 @@
         max="9999"
       />
     </div>
+    <div class="row">
+      <label for="round">Round</label>
+      <input [(ngModel)]="round" type="number" id="round" min="1" max="10" />
+    </div>
+    <div class="row">
+      <label for="comp_level">Round</label>
+      <select [(ngModel)]="compLevel" type="number" id="comp_level">
+        <option *ngFor="let level of COMP_LEVELS" [ngValue]="level">
+          {{COMP_LEVEL_LABELS[level]}}
+        </option>
+      </select>
+    </div>
     <div class="buttons">
       <!-- hack to right align the next button -->
       <div></div>
@@ -348,6 +360,8 @@
     <ul>
       <li>Match number: {{matchNumber}}</li>
       <li>Team number: {{teamNumber}}</li>
+      <li>Round: {{round}}</li>
+      <li>Comp Level: {{COMP_LEVEL_LABELS[compLevel]}}</li>
     </ul>
 
     <h4>Auto</h4>
@@ -372,7 +386,7 @@
 
     <h4>Climb</h4>
     <ul>
-      <li>Level: {{level | levelToString}}</li>
+      <li>Climb Level: {{level | levelToString}}</li>
       <li>Comments: {{comment}}</li>
     </ul>
 
diff --git a/scouting/www/match_list/match_list.component.ts b/scouting/www/match_list/match_list.component.ts
index e311664..1dc1f49 100644
--- a/scouting/www/match_list/match_list.component.ts
+++ b/scouting/www/match_list/match_list.component.ts
@@ -12,6 +12,7 @@
 type TeamInMatch = {
   teamNumber: number;
   matchNumber: number;
+  round: number;
   compLevel: string;
 };
 
@@ -61,7 +62,9 @@
   }
 
   displayMatchNumber(match: Match): string {
-    return `${this.matchType(match)} ${match.matchNumber()}`;
+    // Only display the set number ("round") for eliminations matches.
+    const round = match.compLevel() == 'qm' ? '' : `${match.round()}`;
+    return `${this.matchType(match)} ${round} Match ${match.matchNumber()}`;
   }
 
   ngOnInit() {
diff --git a/scouting/www/match_list/match_list.ng.html b/scouting/www/match_list/match_list.ng.html
index 2f77198..b2e66f8 100644
--- a/scouting/www/match_list/match_list.ng.html
+++ b/scouting/www/match_list/match_list.ng.html
@@ -13,6 +13,7 @@
         (click)="setTeamInMatch({
             teamNumber: team.teamNumber,
             matchNumber: match.matchNumber(),
+            round: match.round(),
             compLevel: match.compLevel()
             })"
         class="match-item"
diff --git a/scouting/www/rpc/match_list_requestor.ts b/scouting/www/rpc/match_list_requestor.ts
index 917a9b3..ddbb221 100644
--- a/scouting/www/rpc/match_list_requestor.ts
+++ b/scouting/www/rpc/match_list_requestor.ts
@@ -36,6 +36,7 @@
 
       // Sort the list so it is in chronological order.
       matchList.sort((a, b) => {
+        // First sort by match type. E.g. finals are last.
         const aMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(a.compLevel());
         const bMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(b.compLevel());
         if (aMatchTypeIndex < bMatchTypeIndex) {
@@ -44,6 +45,9 @@
         if (aMatchTypeIndex > bMatchTypeIndex) {
           return 1;
         }
+        // Then sort by match number. E.g. in semi finals, all match 1 rounds
+        // are done first. Then come match 2 rounds. And then, if necessary,
+        // the match 3 rounds.
         const aMatchNumber = a.matchNumber();
         const bMatchNumber = b.matchNumber();
         if (aMatchNumber < bMatchNumber) {
@@ -52,6 +56,17 @@
         if (aMatchNumber > bMatchNumber) {
           return 1;
         }
+        // Lastly, sort by round. I.e. Semi Final 1 Match 1 happens first. Then
+        // comes Semi Final 2 Match 1. Then comes Semi Final 1 Match 2. Then
+        // Semi Final 2 Match 2.
+        const aRound = a.round();
+        const bRound = b.round();
+        if (aRound < bRound) {
+          return -1;
+        }
+        if (aRound > bRound) {
+          return 1;
+        }
         return 0;
       });