chromium/chrome/browser/resources/chromeos/accessibility/chromevox/background/tts_background_test.js

// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// Include test fixture.
GEN_INCLUDE(['../testing/chromevox_e2e_test_base.js']);

// E2E tests for PrimaryTts.

/** Test fixture. */
ChromeVoxBackgroundTtsTest = class extends ChromeVoxE2ETest {
  /** @override */
  async setUpDeferred() {
    await super.setUpDeferred();
    globalThis.tts = new PrimaryTts();
  }

  expectUtteranceQueueIsLike(expectedObjects) {
    const queue = tts.getUtteranceQueueForTest();
    assertEquals(expectedObjects.length, queue.length);
    for (let i = 0; i < expectedObjects.length; i++) {
      for (const key in expectedObjects[i]) {
        assertEquals(expectedObjects[i][key], queue[i][key]);
      }
    }
  }
};

AX_TEST_F('ChromeVoxBackgroundTtsTest', 'Preprocess', function() {
  const preprocess = tts.preprocess.bind(tts);

  // Punctuation.
  assertEquals('dot', preprocess('.'));
  assertEquals('x.', preprocess('x.'));
  assertEquals('.x', preprocess('.x'));
  assertEquals('space', preprocess(' '));
  assertEquals('', preprocess('  '));
  assertEquals('A', preprocess('a'));
  assertEquals('A', preprocess('A'));
  assertEquals('a.', preprocess('a.'));
  assertEquals('.a', preprocess('.a'));
  assertEquals('A', preprocess('a'));
  assertEquals('Б', preprocess('б'));

  // Only summarize punctuation if there are three or more occurrences without
  // a space in between.
  assertEquals('10 equal signs', preprocess('=========='));
  assertEquals('bullet bullet bullet', preprocess('\u2022 \u2022\u2022'));
  assertEquals('3 bullets', preprocess('\u2022\u2022\u2022'));
  assertEquals('C plus plus', preprocess('C++'));
  assertEquals('C 3 plus signs', preprocess('C+++'));
  // There are some punctuation marks that we do not verbalize because they
  // result in an overly verbose experience (periods, commas, dashes, etc.).
  // This set of punctuation marks is defined in the |some_punctuation| regular
  // expression in PrimaryTts.
  assertEquals('C--', preprocess('C--'));
  assertEquals('Hello world.', preprocess('Hello world.'));

  assertEquals('new line', preprocess('\n'));
  assertEquals('return', preprocess('\r'));

  // Assert that ChromeVox reads the pound sterling (£) sign.
  assertEquals('pound sterling', preprocess('£'));
  assertEquals(
      'This is the pound sterling symbol and this is the pound symbol.',
      preprocess('This is the £ symbol and this is the # symbol.'));
  // Repetitions for the pound sterling shouldn't be summarized.
  assertEquals(
      'pound sterling pound sterling pound sterling', preprocess('£££'));

  // Negative currency values.
  assertEquals('-1 dollar', preprocess('-$1'));
  assertEquals('-1.12 dollar', preprocess('-$1.12'));
  assertEquals('-1,123.58 dollar', preprocess('-$1,123.58'));
  assertEquals('-1,123,581.3 dollar', preprocess('-$1,123,581.3'));
  assertEquals('-1,123.58 pound sterling', preprocess('-£1,123.58'));

  // Bullets
  assertEquals('bullet', preprocess('\u2022'));
  assertEquals('3 bullets', preprocess('\u2022\u2022\u2022'));
  assertEquals('white bullet', preprocess('\u25e6'));
  assertEquals('3 white bullets', preprocess('\u25e6\u25e6\u25e6'));
  assertEquals('square bullet', preprocess('\u25a0'));
  assertEquals('3 square bullets', preprocess('\u25a0\u25a0\u25a0'));

  assertEquals('space', preprocess('\u00a0'));
});

TEST_F('ChromeVoxBackgroundTtsTest', 'UpdateVoice', function() {
  const voices = [
    {lang: 'zh-CN', voiceName: 'Chinese'},
    {lang: 'zh-TW', voiceName: 'Chinese (Taiwan)'},
    {lang: 'es', voiceName: 'Spanish'},
    {lang: 'en-US', voiceName: 'U.S. English'},
  ];

  chrome.tts.getVoices = function(callback) {
    callback(voices);
  };

  // Asks this test to process the next task immediately.
  const flushNextTask = () => {
    const task = tasks.shift();
    if (!task) {
      return;
    }

    if (task.setup) {
      task.setup();
    }
    tts.updateVoice(task.testVoice, this.newCallback(function(actualVoice) {
      assertEquals(task.expectedVoice, actualVoice);
      flushNextTask();
    }));
  };

  assertTrue(!tts.currentVoice);

  const tasks = [
    {testVoice: '', expectedVoice: constants.SYSTEM_VOICE},

    {
      setup() {
        voices[3].lang = 'en';
      },
      testVoice: 'U.S. English',
      expectedVoice: 'U.S. English',
    },

    {
      setup() {
        voices[3].lang = 'fr-FR';
        voices[3].voiceName = 'French';
      },
      testVoice: '',
      expectedVoice: constants.SYSTEM_VOICE,
    },

    {testVoice: 'French', expectedVoice: 'French'},

    {testVoice: 'NotFound', expectedVoice: constants.SYSTEM_VOICE},
  ];

  flushNextTask();
});

// This test only works if Google tts is installed. Run it locally.
TEST_F(
    'ChromeVoxBackgroundTtsTest', 'DISABLED_EmptyStringCallsCallbacks',
    function() {
      let startCalls = 0;
      let endCalls = 0;
      assertCallsCallbacks = (text, speakCalls) => {
        tts.speak(text, QueueMode.QUEUE, new TtsSpeechProperties({
                    startCallback() {
                      ++startCalls;
                    },
                    endCallback: this.newCallback(function() {
                      ++endCalls;
                      assertEquals(speakCalls, endCalls);
                      assertEquals(endCalls, startCalls);
                    }),
                  }));
      };

      assertCallsCallbacks('', 1);
      assertCallsCallbacks('  ', 2);
      assertCallsCallbacks(' \u00a0 ', 3);
    });

AX_TEST_F(
    'ChromeVoxBackgroundTtsTest', 'CapitalizeSingleLettersAfterNumbers',
    function() {
      const preprocess = tts.preprocess.bind(tts);

      // Capitalize single letters if they appear directly after a number.
      assertEquals(
          'Click to join the 5G network',
          preprocess('Click to join the 5g network'));
      assertEquals(
          'I ran a 100M sprint in 10 S',
          preprocess('I ran a 100m sprint in 10 s'));
      // Do not capitalize the letter "a".
      assertEquals(
          'Please do the shopping at 3 a thing came up at work',
          preprocess('Please do the shopping at 3 a thing came up at work'));
    });

AX_TEST_F('ChromeVoxBackgroundTtsTest', 'AnnounceCapitalLetters', function() {
  const preprocess = tts.preprocess.bind(tts);

  assertEquals('A', preprocess('A'));

  // Only announce capital for solo capital letters.
  SettingsManager.set('capitalStrategy', 'announceCapitals');
  assertEquals('Cap A', preprocess('A'));
  assertEquals('Cap Z', preprocess('Z'));
  assertEquals('Cap A', preprocess('A'));
  assertEquals('Cap Б', preprocess('Б'));

  // Do not announce capital for the following inputs.
  assertEquals('BB', preprocess('BB'));
  assertEquals('A.', preprocess('A.'));
});

AX_TEST_F('ChromeVoxBackgroundTtsTest', 'PunctuationMode', function() {
  const PUNCTUATION_ECHO_NONE = '0';
  const PUNCTUATION_ECHO_SOME = '1';
  const PUNCTUATION_ECHO_ALL = '2';

  const updatePunctuationEcho = tts.updatePunctuationEcho.bind(tts);
  let lastSpokenTextString = '';
  tts.speakUsingQueue_ = function(utterance, _) {
    lastSpokenTextString = utterance.textString;
  };

  // No punctuation.
  updatePunctuationEcho(PUNCTUATION_ECHO_NONE);

  tts.speak(`"That's all, folks!"`);
  assertEquals(`That's all, folks!`, lastSpokenTextString);

  tts.speak('"$1,234.56 (plus tax) for 78% of your #2 pencils?", they mused');
  assertEquals(
      '1,234.56 plus tax for 78% of your 2 pencils? , they mused',
      lastSpokenTextString);

  // Some punctuation.
  updatePunctuationEcho(PUNCTUATION_ECHO_SOME);

  tts.speak(`"That's all, folks!"`);
  assertEquals(`quote That's all, folks! quote`, lastSpokenTextString);

  tts.speak('"$1,234.56 (plus tax) for 78% of your #2 pencils?", they mused');
  assertEquals(
      'quote dollar 1,234.56 (plus tax) for 78 percent of your pound 2 ' +
          'pencils? quote , they mused',
      lastSpokenTextString);

  // All punctuation.
  updatePunctuationEcho(PUNCTUATION_ECHO_ALL);

  tts.speak(`"That's all, folks!"`);
  assertEquals(
      `quote That apostrophe' s all comma folks exclamation! quote`,
      lastSpokenTextString);

  tts.speak('"$1,234.56 (plus tax) for 78% of your #2 pencils?", they mused');
  assertEquals(
      'quote dollar 1 comma 234 dot 56 open paren plus tax close paren for ' +
          '78 percent of your pound 2 pencils question mark? quote comma ' +
          'they mused',
      lastSpokenTextString);
});

AX_TEST_F('ChromeVoxBackgroundTtsTest', 'NumberReadingStyle', function() {
  let lastSpokenTextString = '';
  tts.speakUsingQueue_ = function(utterance, _) {
    lastSpokenTextString = utterance.textString;
  };

  // Check the default preference.
  assertEquals('asWords', SettingsManager.get('numberReadingStyle'));

  tts.speak('100');
  assertEquals('100', lastSpokenTextString);

  tts.speak('An unanswered call lasts for 30 seconds.');
  assertEquals(
      'An unanswered call lasts for 30 seconds.', lastSpokenTextString);

  tts.speak('100');
  assertEquals('100', lastSpokenTextString);

  tts.speak('An unanswered call lasts for 30 seconds.');
  assertEquals(
      'An unanswered call lasts for 30 seconds.', lastSpokenTextString);

  SettingsManager.set('numberReadingStyle', 'asDigits');
  tts.speak('100');
  assertEquals('1 0 0', lastSpokenTextString);

  tts.speak('An unanswered call lasts for 30 seconds.');
  assertEquals(
      'An unanswered call lasts for 3 0 seconds.', lastSpokenTextString);

  tts.speak('100');
  assertEquals('1 0 0', lastSpokenTextString);

  tts.speak('An unanswered call lasts for 30 seconds.');
  assertEquals(
      'An unanswered call lasts for 3 0 seconds.', lastSpokenTextString);
});

AX_TEST_F('ChromeVoxBackgroundTtsTest', 'SplitLongText', function() {
  const spokenTextStrings = [];
  tts.speakUsingQueue_ = function(utterance, _) {
    spokenTextStrings.push(utterance.textString);
  };

  // There are three new lines, but only the first chunk exceeds the max
  // character count.
  const text = 'a'.repeat(constants.OBJECT_MAX_CHARCOUNT) + '\n' +
      'a'.repeat(10) + '\n' +
      'a'.repeat(10);
  tts.speak(text);
  assertEquals(2, spokenTextStrings.length);
});

AX_TEST_F('ChromeVoxBackgroundTtsTest', 'SplitUntilSmall', function() {
  const split = PrimaryTts.splitUntilSmall;

  // A single delimiter.
  constants.OBJECT_MAX_CHARCOUNT = 3;
  assertEqualsJSON(['12', '345', '789'], split('12345a789', 'a'));

  constants.OBJECT_MAX_CHARCOUNT = 4;
  assertEqualsJSON(['12', '345', '789'], split('12345a789', 'a'));

  constants.OBJECT_MAX_CHARCOUNT = 7;
  assertEqualsJSON(['12345', '789'], split('12345a789', 'a'));

  constants.OBJECT_MAX_CHARCOUNT = 10;
  assertEqualsJSON(['12345a789'], split('12345a789', 'a'));

  // Multiple delimiters.
  constants.OBJECT_MAX_CHARCOUNT = 3;
  assertEqualsJSON(['12', '34', '57', '89'], split('1234b57a89', 'ab'));

  constants.OBJECT_MAX_CHARCOUNT = 4;
  assertEqualsJSON(['1234', '57', '89'], split('1234b57a89', 'ab'));

  constants.OBJECT_MAX_CHARCOUNT = 5;
  assertEqualsJSON(['12345', '789'], split('12345b789a', 'ab'));
  assertEqualsJSON(['12345', '789a'], split('12345b789a', 'ba'));

  // No delimiters.
  constants.OBJECT_MAX_CHARCOUNT = 3;
  assertEqualsJSON(['12', '34', '57', '89'], split('12345789', ''));

  constants.OBJECT_MAX_CHARCOUNT = 4;
  assertEqualsJSON(['1234', '5789'], split('12345789', ''));

  // Some corner cases.
  assertEqualsJSON([], split('', ''));
  assertEqualsJSON(['a'], split('a', ''));
  assertEqualsJSON(['a'], split('a', 'a'));
  assertEqualsJSON(['a'], split('a', 'b'));
});

AX_TEST_F('ChromeVoxBackgroundTtsTest', 'Phonetics', function() {
  let spokenStrings = [];
  tts.speakUsingQueue_ = (utterance, ...rest) => {
    spokenStrings.push(utterance.textString);
  };

  const checkFor = str => assertTrue(
      spokenStrings.includes(str), 'spoken strings should include ' + str);

  // English.
  tts.speak(
      't', QueueMode.QUEUE,
      new TtsSpeechProperties({lang: 'en-us', phoneticCharacters: true}));
  checkFor('T');
  checkFor('tango');
  tts.speak(
      'a', QueueMode.QUEUE,
      new TtsSpeechProperties({lang: 'en-us', phoneticCharacters: true}));
  checkFor('A');
  checkFor('alpha');
  spokenStrings = [];

  // German.
  tts.speak(
      't', QueueMode.QUEUE,
      new TtsSpeechProperties({lang: 'de', phoneticCharacters: true}));
  checkFor('T');
  checkFor('Theodor');
  tts.speak(
      'a', QueueMode.QUEUE,
      new TtsSpeechProperties({lang: 'de', phoneticCharacters: true}));
  checkFor('A');
  checkFor('Anton');
  spokenStrings = [];

  // Japanese.
  tts.speak(
      't', QueueMode.QUEUE,
      new TtsSpeechProperties({lang: 'ja', phoneticCharacters: true}));
  checkFor('T');
  checkFor('ティー タイム');

  tts.speak(
      'a', QueueMode.QUEUE,
      new TtsSpeechProperties({lang: 'ja', phoneticCharacters: true}));
  checkFor('A');
  checkFor('エイ アニマル');

  tts.speak(
      'A', QueueMode.QUEUE,
      new TtsSpeechProperties({lang: 'ja', phoneticCharacters: true}));
  checkFor('A');
  checkFor('エイ アニマル');

  tts.speak(
      '人', QueueMode.QUEUE,
      new TtsSpeechProperties({lang: 'ja', phoneticCharacters: true}));
  checkFor('人');
  checkFor('ヒト,ニンゲン ノ ニン');
  spokenStrings = [];

  // Error handling.
  tts.speak(
      't', QueueMode.QUEUE,
      new TtsSpeechProperties({lang: 'qwerty', phoneticCharacters: true}));
  checkFor('T');
  assertEquals(1, spokenStrings.length);
});

AX_TEST_F('ChromeVoxBackgroundTtsTest', 'PitchChanges', function() {
  const preprocess = tts.preprocess.bind(tts);
  const props = {relativePitch: -0.3};
  SettingsManager.set('usePitchChanges', true);
  preprocess('Hello world', props);
  assertTrue(props.hasOwnProperty('relativePitch'));
  SettingsManager.set('usePitchChanges', false);
  preprocess('Hello world', props);
  assertFalse(props.hasOwnProperty('relativePitch'));
});

AX_TEST_F('ChromeVoxBackgroundTtsTest', 'InterjectUtterances', function() {
  // Fake out setTimeout for our purposes.
  let lastSetTimeoutCallback;
  setTimeout = (callback, delay) => {
    lastSetTimeoutCallback = callback;
  };

  // Mock out to not trigger any events.
  chrome.tts.speak = () => {};

  // Flush and queue a few utterances to build the speech queue.
  tts.speak('Hi', QueueMode.FLUSH);
  tts.speak('there.', QueueMode.QUEUE);
  tts.speak('How are you?', QueueMode.QUEUE);

  // Verify the contents of the speech queue at this point.
  this.expectUtteranceQueueIsLike([
    {textString: 'Hi', queueMode: QueueMode.FLUSH},
    {textString: 'there.', queueMode: QueueMode.QUEUE},
    {textString: 'How are you?', queueMode: QueueMode.QUEUE},
  ]);

  // Interject a single utterance now.
  tts.speak('Sorry; busy!', QueueMode.INTERJECT);
  this.expectUtteranceQueueIsLike(
      [{textString: 'Sorry; busy!', queueMode: QueueMode.INTERJECT}]);

  // The above call should have resulted in a setTimeout; call it.
  assertTrue(Boolean(lastSetTimeoutCallback));
  lastSetTimeoutCallback();
  lastSetTimeoutCallback = undefined;

  // The previous utterances should now be restored.
  this.expectUtteranceQueueIsLike([
    {textString: 'Sorry; busy!', queueMode: QueueMode.INTERJECT},
    {textString: 'Hi', queueMode: QueueMode.FLUSH},
    {textString: 'there.', queueMode: QueueMode.QUEUE},
    {textString: 'How are you?', queueMode: QueueMode.QUEUE},
  ]);

  // Try interjecting again. Notice it interrupts the previous interjection.
  tts.speak('Actually, not busy after all!', QueueMode.INTERJECT);
  this.expectUtteranceQueueIsLike([{
    textString: 'Actually, not busy after all!',
    queueMode: QueueMode.INTERJECT,
  }]);

  // Before the end of the current callstack, simulated by calling the callback
  // to setTimeout, we can keep calling speak. These are also interjections (see
  // below).
  tts.speak('I am good.', QueueMode.QUEUE);
  tts.speak('How about you?', QueueMode.QUEUE);
  this.expectUtteranceQueueIsLike([
    {
      textString: 'Actually, not busy after all!',
      queueMode: QueueMode.INTERJECT,
    },
    {textString: 'I am good.', queueMode: QueueMode.QUEUE},
    {textString: 'How about you?', queueMode: QueueMode.QUEUE},
  ]);

  // The above call should have resulted in a setTimeout; call it.
  assertTrue(Boolean(lastSetTimeoutCallback));
  lastSetTimeoutCallback();
  lastSetTimeoutCallback = undefined;

  // The previous utterances should now be restored.
  this.expectUtteranceQueueIsLike([
    {
      textString: 'Actually, not busy after all!',
      queueMode: QueueMode.INTERJECT,
    },
    {textString: 'I am good.', queueMode: QueueMode.INTERJECT},
    {textString: 'How about you?', queueMode: QueueMode.INTERJECT},
    {textString: 'Hi', queueMode: QueueMode.FLUSH},
    {textString: 'there.', queueMode: QueueMode.QUEUE},
    {textString: 'How are you?', queueMode: QueueMode.QUEUE},
  ]);

  // Interject again. Notice all previous interjections get cancelled again.
  // This is crucial to not leak utterances out of the chaining that some
  // modules like Output do.
  tts.speak('Sorry! Gotta go!', QueueMode.INTERJECT);
  this.expectUtteranceQueueIsLike(
      [{textString: 'Sorry! Gotta go!', queueMode: QueueMode.INTERJECT}]);
  assertTrue(Boolean(lastSetTimeoutCallback));
  lastSetTimeoutCallback();
  lastSetTimeoutCallback = undefined;

  // All other interjections except the last one are gone.
  this.expectUtteranceQueueIsLike([
    {textString: 'Sorry! Gotta go!', queueMode: QueueMode.INTERJECT},
    {textString: 'Hi', queueMode: QueueMode.FLUSH},
    {textString: 'there.', queueMode: QueueMode.QUEUE},
    {textString: 'How are you?', queueMode: QueueMode.QUEUE},
  ]);
});

AX_TEST_F('ChromeVoxBackgroundTtsTest', 'Mute', function() {
  // Fake out setTimeout for our purposes.
  let lastSetTimeoutCallback;
  setTimeout = (callback, delay) => {
    lastSetTimeoutCallback = callback;
  };

  // Mock this to ensure no events are triggered.
  chrome.tts.speak = () => {};
  // Push some text into the speech queue and verify state.
  tts.speak('Hello', QueueMode.FLUSH);
  tts.speak('world.', QueueMode.QUEUE);
  this.expectUtteranceQueueIsLike([
    {textString: 'Hello', queueMode: QueueMode.FLUSH},
    {textString: 'world.', queueMode: QueueMode.QUEUE},
  ]);

  // Toggle speech off.
  tts.toggleSpeechOnOrOff();

  // The above call should have resulted in a setTimeout; call it.
  assertTrue(Boolean(lastSetTimeoutCallback));
  lastSetTimeoutCallback();
  lastSetTimeoutCallback = undefined;

  // Make assertions.
  assertEquals(null, tts.currentUtterance_);
  this.expectUtteranceQueueIsLike([]);
});

TEST_F('ChromeVoxBackgroundTtsTest', 'ResetTtsSettingsClearsVoice', function() {
  this.newCallback(async () => {
    ChromeVox.tts.ttsEngines_[0].currentVoice = '';
    CommandHandlerInterface.instance.onCommand('resetTextToSpeechSettings');
    await new Promise(r => {
      ChromeVox.tts.speak = textString => {
        if (textString === 'Reset text to speech settings to default values') {
          r();
        }
      };
    });
    assertEquals(
        constants.SYSTEM_VOICE, ChromeVox.tts.ttsEngines_[0].currentVoice);
  })();
});