chromium/chrome/test/data/webui/side_panel/read_anything/language_menu_test.ts

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

import 'chrome-untrusted://read-anything-side-panel.top-chrome/read_anything.js';

import type {CrInputElement} from '//resources/cr_elements/cr_input/cr_input.js';
import type {CrToggleElement} from '//resources/cr_elements/cr_toggle/cr_toggle.js';
import type {LanguageMenuElement} from 'chrome-untrusted://read-anything-side-panel.top-chrome/read_anything.js';
import {AVAILABLE_GOOGLE_TTS_LOCALES, VoiceClientSideStatusCode} from 'chrome-untrusted://read-anything-side-panel.top-chrome/read_anything.js';
import {assertEquals, assertFalse, assertTrue} from 'chrome-untrusted://webui-test/chai_assert.js';
import {microtasksFinished} from 'chrome-untrusted://webui-test/test_util.js';

import {createSpeechSynthesisVoice} from './common.js';

suite('LanguageMenu', () => {
  let languageMenu: LanguageMenuElement;
  let availableVoices: SpeechSynthesisVoice[];
  let enabledLangs: string[];
  const languagesToNotificationMap:
      {[language: string]: VoiceClientSideStatusCode} = {};

  function getLanguageLineItems() {
    return languageMenu.$.languageMenu.querySelectorAll<HTMLElement>(
        '.language-line');
  }

  function getNotificationItems() {
    return languageMenu.$.languageMenu.querySelectorAll<HTMLElement>(
        '#notificationText');
  }

  function getLanguageSearchField() {
    return languageMenu.$.languageMenu.querySelector<CrInputElement>(
        '.search-field')!;
  }

  function getNoResultsFoundMessage() {
    return languageMenu.$.languageMenu.querySelector<HTMLElement>(
        '#noResultsMessage');
  }

  setup(() => {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    languageMenu = document.createElement('language-menu');
    languageMenu.localesOfLangPackVoices = new Set(['it-it']);
    languageMenu.voicePackInstallStatus = {};
  });

  test('with existing available language no duplicates added', () => {
    availableVoices =
        [createSpeechSynthesisVoice({name: 'test voice 1', lang: 'en-US'})];
    languageMenu.availableVoices = availableVoices;
    languageMenu.localesOfLangPackVoices = AVAILABLE_GOOGLE_TTS_LOCALES;
    document.body.appendChild(languageMenu);

    assertTrue(isPositionedOnPage(languageMenu));
    assertEquals(34, getLanguageLineItems().length);
  });

  suite('using some base languages', () => {
    setup(() => {
      languageMenu.localesOfLangPackVoices = new Set(['en-us']);
    });

    test('with existing available language no duplicates added', () => {
      availableVoices =
          [createSpeechSynthesisVoice({name: 'test voice 1', lang: 'en-US'})];
      languageMenu.availableVoices = availableVoices;
      document.body.appendChild(languageMenu);

      assertTrue(isPositionedOnPage(languageMenu));
      assertEquals(1, getLanguageLineItems().length);
    });

    test('adds language from available voice', () => {
      availableVoices =
          [createSpeechSynthesisVoice({name: 'test voice 5', lang: 'en-es'})];
      languageMenu.availableVoices = availableVoices;
      document.body.appendChild(languageMenu);

      assertTrue(isPositionedOnPage(languageMenu));
      assertEquals(2, getLanguageLineItems().length);
    });

    test('sorts alphabetically', () => {
      availableVoices = [
        createSpeechSynthesisVoice({name: 'Steve', lang: 'da-dk'}),
        createSpeechSynthesisVoice({name: 'Dustin', lang: 'bn-bd'}),
      ];
      languageMenu.availableVoices = availableVoices;
      document.body.appendChild(languageMenu);

      assertTrue(isPositionedOnPage(languageMenu));
      assertEquals(3, getLanguageLineItems().length);
      assertLanguageLineWithTextAndSwitch('bn-bd', getLanguageLineItems()[0]!);
      assertLanguageLineWithTextAndSwitch('da-dk', getLanguageLineItems()[1]!);
    });
  });

  suite('with one language', () => {
    setup(() => {
      languageMenu.localesOfLangPackVoices = new Set(['en-us']);
      availableVoices =
          [createSpeechSynthesisVoice({name: 'test voice 1', lang: 'en-US'})];
      languageMenu.availableVoices = availableVoices;
    });

    test(
        'defaults to the locale when there is no display name with a switch',
        () => {
          document.body.appendChild(languageMenu);

          assertTrue(isPositionedOnPage(languageMenu));
          assertEquals(1, getLanguageLineItems().length);
          assertLanguageLineWithTextAndSwitch(
              'en-us', getLanguageLineItems()[0]!);
          assertEquals('', getLanguageSearchField().value);
        });

    test('when availableVoices updates menu displays the new languages', () => {
      availableVoices = [
        createSpeechSynthesisVoice({name: 'test voice 1', lang: 'en-US'}),
        createSpeechSynthesisVoice({name: 'test voice 2', lang: 'en-UK'}),
      ];
      languageMenu.availableVoices = availableVoices;
      document.body.appendChild(languageMenu);

      assertTrue(isPositionedOnPage(languageMenu));
      assertEquals(2, getLanguageLineItems().length);
      assertLanguageLineWithTextAndSwitch('en-uk', getLanguageLineItems()[0]!);
      assertLanguageLineWithTextAndSwitch('en-us', getLanguageLineItems()[1]!);
      assertEquals('', getLanguageSearchField().value);
      assertEquals(true, getNoResultsFoundMessage()!.hidden);
    });

    suite('with display names for locales', () => {
      setup(() => {
        languageMenu.localeToDisplayName = {
          'en-us': 'English (United States)',
        };
      });

      test('it displays the display name', () => {
        document.body.appendChild(languageMenu);

        assertTrue(isPositionedOnPage(languageMenu));
        assertEquals(1, getLanguageLineItems().length);
        assertLanguageLineWithTextAndSwitch(
            'English (United States)', getLanguageLineItems()[0]!);
      });

      test('it displays no language without a match', async () => {
        document.body.appendChild(languageMenu);
        getLanguageSearchField().value = 'test';
        await microtasksFinished();

        assertTrue(isPositionedOnPage(languageMenu));
        assertEquals(0, getLanguageLineItems().length);
        assertEquals(false, getNoResultsFoundMessage()!.hidden);
      });

      test('it displays matching language with a match', async () => {
        document.body.appendChild(languageMenu);
        getLanguageSearchField().value = 'english';
        await microtasksFinished();

        assertEquals(1, getLanguageLineItems().length);
        assertLanguageLineWithTextAndSwitch(
            'English (United States)', getLanguageLineItems()[0]!);
        assertEquals(true, getNoResultsFoundMessage()!.hidden);
      });
    });
  });

  suite('with multiple languages', () => {
    setup(() => {
      availableVoices = [
        createSpeechSynthesisVoice({name: 'test voice 0', lang: 'en-US'}),
        createSpeechSynthesisVoice({name: 'test voice 1', lang: 'it-IT'}),
        createSpeechSynthesisVoice({name: 'test voice 2', lang: 'en-UK'}),
      ];
      languageMenu.availableVoices = availableVoices;
      enabledLangs = ['Italian'];
      languageMenu.enabledLangs = enabledLangs;
    });

    test(
        'defaults to the locale when there is no display name with a switch',
        () => {
          document.body.appendChild(languageMenu);

          assertTrue(isPositionedOnPage(languageMenu));
          assertEquals(3, getLanguageLineItems().length);
          assertLanguageLineWithTextAndSwitch(
              'en-uk', getLanguageLineItems()[0]!);
          assertLanguageLineWithTextAndSwitch(
              'en-us', getLanguageLineItems()[1]!);
          assertLanguageLineWithTextAndSwitch(
              'it-it', getLanguageLineItems()[2]!);
          assertEquals('', getLanguageSearchField().value);
        });

    suite('with display names for locales', () => {
      setup(() => {
        languageMenu.localeToDisplayName = {
          'en-us': 'English (United States)',
          'it-it': 'Italian',
          'en-uk': 'English (United Kingdom)',
        };
      });

      test('it displays the display name', () => {
        document.body.appendChild(languageMenu);

        assertTrue(isPositionedOnPage(languageMenu));
        assertEquals(3, getLanguageLineItems().length);
        assertLanguageLineWithTextAndSwitch(
            'English (United Kingdom)', getLanguageLineItems()[0]!);
        assertLanguageLineWithTextAndSwitch(
            'English (United States)', getLanguageLineItems()[1]!);
        assertLanguageLineWithTextAndSwitch(
            'Italian', getLanguageLineItems()[2]!);
        assertEquals('', getLanguageSearchField().value);
      });

      test('it does not group languages with different names', () => {
        languageMenu.localesOfLangPackVoices = new Set(['en-us']);
        availableVoices = [
          createSpeechSynthesisVoice({name: 'test voice 0', lang: 'en-US'}),
          createSpeechSynthesisVoice({name: 'test voice 3', lang: 'en'}),
        ];
        languageMenu.availableVoices = availableVoices;
        languageMenu.localeToDisplayName = {
          'en-us': 'English (United States)',
          'en': 'English',
        };
        document.body.appendChild(languageMenu);

        assertTrue(isPositionedOnPage(languageMenu));
        assertEquals(2, getLanguageLineItems().length);
        assertLanguageLineWithTextAndSwitch(
            'English', getLanguageLineItems()[0]!);
        assertLanguageLineWithTextAndSwitch(
            'English (United States)', getLanguageLineItems()[1]!);
      });

      test('it toggles switch on for initially enabled line', () => {
        document.body.appendChild(languageMenu);

        assertTrue(isPositionedOnPage(languageMenu));
        assertEquals(3, getLanguageLineItems().length);
        assertLanguageLineWithToggleChecked(false, getLanguageLineItems()[0]!);
        assertLanguageLineWithToggleChecked(true, getLanguageLineItems()[1]!);
        assertLanguageLineWithToggleChecked(false, getLanguageLineItems()[2]!);
      });

      test('it toggles switch when language pref changes', () => {
        enabledLangs = ['Italian', 'English (United States)'];
        languageMenu.enabledLangs = enabledLangs;
        document.body.appendChild(languageMenu);

        assertTrue(isPositionedOnPage(languageMenu));
        assertEquals(3, getLanguageLineItems().length);
        assertLanguageLineWithToggleChecked(true, getLanguageLineItems()[0]!);
        assertLanguageLineWithToggleChecked(true, getLanguageLineItems()[1]!);
        assertLanguageLineWithToggleChecked(false, getLanguageLineItems()[2]!);
      });

      test('it shows no notification initially', () => {
        enabledLangs = ['Italian', 'English (United States)'];
        languageMenu.enabledLangs = enabledLangs;
        document.body.appendChild(languageMenu);

        assertEquals(3, getNotificationItems().length);
        assertLanguageNotification('', getNotificationItems()[0]!);
        assertLanguageNotification('', getNotificationItems()[1]!);
        assertLanguageNotification('', getNotificationItems()[2]!);
      });

      test('it shows and hides downloading notification', async () => {
        languageMenu.localesOfLangPackVoices = new Set(['it-it']);
        enabledLangs = ['it-it', 'English (United States)'];
        languageMenu.enabledLangs = enabledLangs;
        languagesToNotificationMap['it'] =
            VoiceClientSideStatusCode.SENT_INSTALL_REQUEST;
        languageMenu.voicePackInstallStatus = {...languagesToNotificationMap};
        document.body.appendChild(languageMenu);
        await microtasksFinished();

        assertEquals(3, getNotificationItems().length);
        assertLanguageNotification('', getNotificationItems()[0]!);
        assertLanguageNotification('', getNotificationItems()[1]!);
        assertLanguageNotification(
            'Downloading voices…', getNotificationItems()[2]!);

        languagesToNotificationMap['it'] =
            VoiceClientSideStatusCode.INSTALLED_AND_UNAVAILABLE;
        languageMenu.voicePackInstallStatus = {...languagesToNotificationMap};
        await microtasksFinished();

        assertEquals(3, getNotificationItems().length);
        assertLanguageNotification('', getNotificationItems()[0]!);
        assertLanguageNotification('', getNotificationItems()[1]!);
        assertLanguageNotification(
            'Downloading voices…', getNotificationItems()[2]!);

        languagesToNotificationMap['it'] = VoiceClientSideStatusCode.AVAILABLE;
        languageMenu.voicePackInstallStatus = {...languagesToNotificationMap};
        await microtasksFinished();

        assertEquals(3, getNotificationItems().length);
        assertLanguageNotification('', getNotificationItems()[0]!);
        assertLanguageNotification('', getNotificationItems()[1]!);
        assertLanguageNotification('', getNotificationItems()[2]!);
      });

      test(
          'non-Google language does not show downloading notification',
          async () => {
            languageMenu.localesOfLangPackVoices = new Set(['en-us']);
            enabledLangs = ['it', 'en-us', 'es'];
            languageMenu.enabledLangs = enabledLangs;
            availableVoices = [
              createSpeechSynthesisVoice({name: 'test voice 1', lang: 'en-us'}),
              createSpeechSynthesisVoice({name: 'espeak voice', lang: 'es'}),
            ];
            languageMenu.availableVoices = availableVoices;
            languagesToNotificationMap['es'] =
                VoiceClientSideStatusCode.SENT_INSTALL_REQUEST;
            languageMenu.voicePackInstallStatus = {
                ...languagesToNotificationMap};
            document.body.appendChild(languageMenu);
            await microtasksFinished();

            assertEquals(2, getNotificationItems().length);
            assertLanguageNotification('', getNotificationItems()[0]!);
            assertLanguageNotification('', getNotificationItems()[1]!);
          });

      test('shows generic error notification with internet', async () => {
        enabledLangs = ['Italian', 'English (United States)'];
        languageMenu.enabledLangs = enabledLangs;
        document.body.appendChild(languageMenu);
        languagesToNotificationMap['it'] =
            VoiceClientSideStatusCode.ERROR_INSTALLING;
        languageMenu.voicePackInstallStatus = {...languagesToNotificationMap};
        await microtasksFinished();

        assertEquals(3, getNotificationItems().length);
        assertLanguageNotification('', getNotificationItems()[0]!);
        assertLanguageNotification('', getNotificationItems()[1]!);
        assertLanguageNotification(
            'Download failed', getNotificationItems()[2]!);
      });

      test('does not show old error notifications', async () => {
        languageMenu.voicePackInstallStatus = {
          'it': VoiceClientSideStatusCode.ERROR_INSTALLING,
        };
        languageMenu.availableVoices = [
          createSpeechSynthesisVoice({name: 'test voice 0', lang: 'en-US'}),
          createSpeechSynthesisVoice({name: 'test voice 1', lang: 'it-IT'}),
          createSpeechSynthesisVoice({name: 'test voice 2', lang: 'en-UK'}),
        ];
        document.body.appendChild(languageMenu);
        await microtasksFinished();

        const notificationItems: HTMLElement[] = Array.from(
            languageMenu.$.languageMenu.querySelectorAll<HTMLElement>(
                '#notificationText'));

        const noNotifications = notificationItems.every(
            notification => notification.innerText === '');
        assertTrue(noNotifications);
      });

      test('shows old downloading notifications', async () => {
        languageMenu.voicePackInstallStatus = {
          'it': VoiceClientSideStatusCode.SENT_INSTALL_REQUEST,
        };
        languageMenu.availableVoices = [
          createSpeechSynthesisVoice({name: 'test voice 0', lang: 'en-US'}),
          createSpeechSynthesisVoice({name: 'test voice 1', lang: 'it-IT'}),
          createSpeechSynthesisVoice({name: 'test voice 2', lang: 'en-UK'}),
        ];
        document.body.appendChild(languageMenu);
        await microtasksFinished();

        const notificationItems: HTMLElement[] = Array.from(
            languageMenu.$.languageMenu.querySelectorAll<HTMLElement>(
                '#notificationText'));

        const downloadingNotifications = notificationItems.filter(
            notification => notification.innerText === 'Downloading voices…');
        assertEquals(downloadingNotifications.length, 1);
      });

      test('shows high quality allocation notification', async () => {
        enabledLangs = ['Italian', 'English (United States)'];
        languageMenu.enabledLangs = enabledLangs;
        document.body.appendChild(languageMenu);
        languagesToNotificationMap['it'] =
            VoiceClientSideStatusCode.INSTALL_ERROR_ALLOCATION;
        languageMenu.voicePackInstallStatus = {...languagesToNotificationMap};
        await microtasksFinished();

        assertEquals(3, getNotificationItems().length);
        assertLanguageNotification('', getNotificationItems()[0]!);
        assertLanguageNotification('', getNotificationItems()[1]!);
        assertLanguageNotification(
            'For higher quality voices, clear space on your device',
            getNotificationItems()[2]!);
      });

      test('with no voices it shows allocation notification ', async () => {
        languageMenu.localesOfLangPackVoices =
            new Set(['it', 'English (United States)']);
        enabledLangs = ['it', 'English (United States)'];
        languageMenu.enabledLangs = enabledLangs;
        availableVoices =
            [createSpeechSynthesisVoice({name: 'test voice 1', lang: 'en-US'})];
        languageMenu.availableVoices = availableVoices;
        document.body.appendChild(languageMenu);

        languagesToNotificationMap['it'] =
            VoiceClientSideStatusCode.INSTALL_ERROR_ALLOCATION;
        languageMenu.voicePackInstallStatus = {...languagesToNotificationMap};
        await microtasksFinished();

        assertEquals(3, getNotificationItems().length);
        assertLanguageNotification('', getNotificationItems()[0]!);
        assertLanguageNotification('', getNotificationItems()[1]!);

        assertLanguageNotification(
            'To install this language, clear space on your device',
            getNotificationItems()[2]!);
      });

      test('it displays no language without a match', async () => {
        document.body.appendChild(languageMenu);
        getLanguageSearchField().value = 'test';
        await microtasksFinished();

        assertTrue(isPositionedOnPage(languageMenu));
        assertEquals(0, getLanguageLineItems().length);
      });

      test('it displays matching language with a match', async () => {
        document.body.appendChild(languageMenu);
        getLanguageSearchField().value = 'italian';
        await microtasksFinished();

        assertEquals(1, getLanguageLineItems().length);
        assertLanguageLineWithTextAndSwitch(
            'Italian', getLanguageLineItems()[0]!);
      });
    });
  });

  suite('with multiple voices for the same language', () => {
    setup(() => {
      availableVoices = [
        createSpeechSynthesisVoice({name: 'test voice 0', lang: 'en-US'}),
        createSpeechSynthesisVoice({name: 'test voice 1', lang: 'en-US'}),
        createSpeechSynthesisVoice({name: 'test voice 2', lang: 'en-UK'}),
        createSpeechSynthesisVoice({name: 'test voice 3', lang: 'en-UK'}),
        createSpeechSynthesisVoice({name: 'test voice 4', lang: 'it-IT'}),
        createSpeechSynthesisVoice({name: 'test voice 5', lang: 'zh-CN'}),
      ];
      languageMenu.availableVoices = availableVoices;
    });

    test('only shows one line per unique language name', () => {
      document.body.appendChild(languageMenu);

      assertTrue(isPositionedOnPage(languageMenu));
      assertEquals(4, getLanguageLineItems().length);
      assertLanguageLineWithTextAndSwitch('en-uk', getLanguageLineItems()[0]!);
      assertLanguageLineWithTextAndSwitch('en-us', getLanguageLineItems()[1]!);
      assertLanguageLineWithTextAndSwitch('it-it', getLanguageLineItems()[2]!);
      assertLanguageLineWithTextAndSwitch('zh-cn', getLanguageLineItems()[3]!);
    });

    suite('with display names for locales', () => {
      setup(() => {
        languageMenu.localeToDisplayName = {
          'en-us': 'English (United States)',
          'it-it': 'Italian',
          'en-uk': 'English (United Kingdom)',
          'zh-cn': 'Chinese',
        };
      });

      test('it displays the display name', () => {
        document.body.appendChild(languageMenu);

        assertTrue(isPositionedOnPage(languageMenu));
        assertEquals(4, getLanguageLineItems().length);
        assertLanguageLineWithTextAndSwitch(
            'Chinese', getLanguageLineItems()[0]!);
        assertLanguageLineWithTextAndSwitch(
            'English (United Kingdom)', getLanguageLineItems()[1]!);
        assertLanguageLineWithTextAndSwitch(
            'English (United States)', getLanguageLineItems()[2]!);
        assertLanguageLineWithTextAndSwitch(
            'Italian', getLanguageLineItems()[3]!);
        assertEquals('', getLanguageSearchField().value);
      });

      test('it displays no language without a match', async () => {
        document.body.appendChild(languageMenu);

        getLanguageSearchField().value = 'test';
        await microtasksFinished();
        assertTrue(isPositionedOnPage(languageMenu));
        assertEquals(0, getLanguageLineItems().length);
      });

      test('it displays matching language with a match', async () => {
        document.body.appendChild(languageMenu);

        getLanguageSearchField().value = 'chin';
        await microtasksFinished();
        assertEquals(1, getLanguageLineItems().length);
        assertLanguageLineWithTextAndSwitch(
            'Chinese', getLanguageLineItems()[0]!);
      });
    });
  });
});

function isPositionedOnPage(element: HTMLElement) {
  return !!element &&
      !!(element.offsetWidth || element.offsetHeight ||
         element.getClientRects().length);
}

function assertLanguageLineWithTextAndSwitch(
    expectedText: string, element: HTMLElement) {
  assertEquals(expectedText, element.textContent!.trim());
  assertEquals(2, element.children.length);
  assertEquals('CR-TOGGLE', element.children[1]!.tagName);
}

async function assertLanguageLineWithToggleChecked(
    expectedChecked: boolean, element: HTMLElement) {
  const toggle: CrToggleElement = (element.querySelector('cr-toggle'))!;
  await toggle.updateComplete;
  if (expectedChecked) {
    assertTrue(toggle.checked);
    assertTrue(toggle.hasAttribute('checked'));
    assertEquals('true', toggle.getAttribute('aria-pressed'));
  } else {
    assertFalse(toggle.checked);
    assertEquals(null, toggle.getAttribute('checked'));
    assertEquals('false', toggle.getAttribute('aria-pressed'));
  }
}

function assertLanguageNotification(
    expectedNotification: string, element: HTMLElement) {
  assertEquals(expectedNotification, element.innerText);
}