Scouting App: Add ability to submit data with QR Codes

This patch adds the option for a scout to generate a QR Code
storing the collected data instead of submitting it. Next,
another user can submit the data by scanning the QR Code using the
new 'Scan' tab.

Since flatbuffers are pretty inefficient in terms of space usage, we
have trouble fitting all the data into a single QR code. The app
allows the user to split the data into multiple QR codes which have to
be scanned.

I tried a bunch of upstream QR code scanning libraries, but they're
all either unmaintained, not open source, or just don't work well. I
ended up settling on OpenCV because it was the most reliable library I
could find.

Co-Authored-By: Filip Kujawa <filip.j.kujawa@gmail.com>
Signed-off-by: Filip Kujawa <filip.j.kujawa@gmail.com>
Signed-off-by: Philipp Schrader <philipp.schrader+971@gmail.com>
Change-Id: I794b54bf7e8389200aa2abe8d05f622a987bca9c
diff --git a/scouting/scouting_qrcode_test.cy.js b/scouting/scouting_qrcode_test.cy.js
new file mode 100644
index 0000000..668cba8
--- /dev/null
+++ b/scouting/scouting_qrcode_test.cy.js
@@ -0,0 +1,157 @@
+/// <reference types="cypress" />
+
+// On the 3rd row of matches (index 2) click on the third team
+// (index 2) which resolves to team 333 in quals match 3.
+const QUALS_MATCH_3_TEAM_333 = 2 * 6 + 2;
+
+function disableAlerts() {
+  cy.get('#block_alerts').check({force: true}).should('be.checked');
+}
+
+function switchToTab(tabName) {
+  cy.contains('.nav-link', tabName).click();
+}
+
+function headerShouldBe(text) {
+  cy.get('.header').should('have.text', text);
+}
+
+function clickButton(buttonName) {
+  cy.contains('button', buttonName).click();
+}
+
+// Wrapper around cy.exec() because it truncates the output of the subprocess
+// if it fails. This is a work around to manually print the full error on the
+// console if a failure happends.
+function exec(command) {
+  cy.exec(command, {failOnNonZeroExit: false}).then((result) => {
+    if (result.code) {
+      throw new Error(`Execution of "${command}" failed
+      Exit code: ${result.code}
+      Stdout:\n${result.stdout}
+      Stderr:\n${result.stderr}`);
+    }
+  });
+}
+
+// Prepares data entry so that we _could_ hit Submit.
+//
+// Options:
+//  matchButtonKey: The index into the big matchlist table that we want to
+//    click on to start the data entry.
+//  teamNumber: The team number that matches the button that we click on as
+//    specified by `matchButtonKey`.
+//
+// TODO(phil): Deduplicate with scouting_test.cy.js.
+function prepareDataScouting(options) {
+  const {matchButtonKey = SEMI_FINAL_2_MATCH_3_TEAM_5254, teamNumber = 5254} =
+    options;
+
+  // Click on a random team in the Match list. The exact details here are not
+  // important, but we need to know what they are. This could as well be any
+  // other team from any other match.
+  cy.get('button.match-item').eq(matchButtonKey).click();
+
+  // Select Starting Position.
+  headerShouldBe(teamNumber + ' Init ');
+  cy.get('[type="radio"]').first().check();
+  clickButton('Start Match');
+
+  // Pick and Place Note in Auto.
+  clickButton('NOTE');
+  clickButton('AMP');
+
+  // Pick and Place Cube in Teleop.
+  clickButton('Start Teleop');
+  clickButton('NOTE');
+  clickButton('AMP AMPLIFIED');
+
+  // Generate some extra actions so that we are guaranteed to have at least 2
+  // QR codes.
+  for (let i = 0; i < 5; i++) {
+    clickButton('NOTE');
+    clickButton('AMP');
+  }
+
+  // Robot dead and revive.
+  clickButton('DEAD');
+  clickButton('Revive');
+
+  // Endgame.
+  clickButton('Endgame');
+  cy.contains(/Harmony/).click();
+
+  clickButton('End Match');
+  headerShouldBe(teamNumber + ' Review and Submit ');
+  cy.get('#review_data li')
+    .eq(0)
+    .should('have.text', ' Started match at position 1 ');
+  cy.get('#review_data li').eq(1).should('have.text', 'Picked up Note');
+  cy.get('#review_data li')
+    .last()
+    .should(
+      'have.text',
+      ' Ended Match; stageType: kHARMONY, trapNote: false, spotlight: false '
+    );
+}
+
+before(() => {
+  cy.visit('/');
+  disableAlerts();
+  cy.title().should('eq', 'FRC971 Scouting Application');
+});
+
+beforeEach(() => {
+  cy.visit('/');
+  disableAlerts();
+});
+
+describe('Scouting app tests', () => {
+  // This test collects some scouting data and then generates the corresponding
+  // QR codes. The test takes screenshots of those QR codes. The QR codes get
+  // turned into a little video file for the browser to use as a fake camera
+  // input. The test then switches to the Scan tab to scan the QR codes from
+  // the "camera". We then make sure that the data gets submitted.
+  it('should: be able to generate and scan QR codes.', () => {
+    prepareDataScouting({
+      matchButtonKey: QUALS_MATCH_3_TEAM_333,
+      teamNumber: 333,
+    });
+    clickButton('Create QR Code');
+    headerShouldBe('333 QR Code ');
+
+    cy.get('#qr_code_piece_size').select('150');
+
+    // Go into a mobile-phone view so that we can guarantee that the QR code is
+    // visible.
+    cy.viewport(400, 660);
+
+    cy.get('.qrcode-buttons > li > a')
+      .should('have.length.at.least', 4)
+      .each(($button, index, $buttons) => {
+        if (index == 0 || index + 1 == $buttons.length) {
+          // Skip the "Previous" and "Next" buttons.
+          return;
+        }
+        // Click on the button to switch to that particular QR code.
+        // We use force:true here because without bootstrap (inside the
+        // sandbox) the buttons overlap one another a bit.
+        cy.wrap($button).click({force: true});
+        cy.get('div.qrcode').screenshot(`qrcode_${index}_screenshot`);
+      });
+
+    exec('./testing/camera_simulator/camera_simulator_/camera_simulator');
+
+    switchToTab('Scan');
+
+    // Since we cannot reliably predict how long it will take to scan all the
+    // QR codes, we use a really long timeout here.
+    cy.get('.progress_message', {timeout: 80000}).should('contain', 'Success!');
+
+    // Now that the data is submitted, the button should be disabled.
+    switchToTab('Match List');
+    cy.get('button.match-item')
+      .eq(QUALS_MATCH_3_TEAM_333)
+      .should('be.disabled');
+  });
+});