From 87446784aed121d3b9022e76dd498b4f16e5c533 Mon Sep 17 00:00:00 2001 From: Jordan Date: Thu, 27 Feb 2025 08:23:27 -0800 Subject: [PATCH] improve tests, especially for navigation. --- __mocks__/api.ts | 6 + __mocks__/db.ts | 10 + __mocks__/settings.ts | 16 + android/app/src/main/AndroidManifest.xml | 2 + app.json | 6 +- app/_layout.tsx | 22 +- app/i18n/api.ts | 29 +- app/i18n/countries.ts | 2 +- app/i18n/lang.ts | 4 +- app/index.tsx | 3 + app/lib/__tests__/settings.spec.tsx | 3 +- app/lib/conversation.ts | 13 + app/lib/db.ts | 52 +-- app/lib/migrations.ts | 23 ++ app/lib/settings.ts | 2 +- app/lib/whisper.ts | 10 +- components/ConversationThread.tsx | 71 +++- components/LanguageSelection.tsx | 27 +- components/Settings.tsx | 400 ++++++++++++++-------- components/TTNavStack.tsx | 56 +-- components/__tests__/index.spec.tsx | 70 ++-- components/ui/ISpeakButton.tsx | 231 +++++++------ components/ui/__tests__/Settings.spec.tsx | 46 +-- jestSetup.ts | 92 +++-- 24 files changed, 748 insertions(+), 448 deletions(-) create mode 100644 __mocks__/db.ts create mode 100644 __mocks__/settings.ts create mode 100644 app/lib/migrations.ts diff --git a/__mocks__/api.ts b/__mocks__/api.ts index 7cddfdc..22c9b4c 100644 --- a/__mocks__/api.ts +++ b/__mocks__/api.ts @@ -14,6 +14,9 @@ class LanguageServer { "es": { code: "es", name: "Spanish", targets: ['fr', 'en'] }, } } + static getDefault() { + return new LanguageServer("http://localhost:5002"); + } } class Translator { @@ -21,6 +24,9 @@ class Translator { translate(message : string, target : string) { return message; } + static getDefault(code : string) { + return new Translator(code); + } } class CachedTranslator extends Translator{ diff --git a/__mocks__/db.ts b/__mocks__/db.ts new file mode 100644 index 0000000..54d8c12 --- /dev/null +++ b/__mocks__/db.ts @@ -0,0 +1,10 @@ +export default { + getDb: jest.fn(() => { + return { + runAsync: jest.fn((statement: string, value: string) => {}), + getFirstAsync: jest.fn((statement: string, value: string) => { + return []; + }), + }; + }), +}; diff --git a/__mocks__/settings.ts b/__mocks__/settings.ts new file mode 100644 index 0000000..1720a8f --- /dev/null +++ b/__mocks__/settings.ts @@ -0,0 +1,16 @@ +const originalModule = jest.requireActual("@/app/lib/settings"); +class MockSettings { + public constructor(public db = {}) {} + public setHostLanguage = jest.fn((val: string) => {}); + public setLibretranslateBaseUrl(val: string) {} + getHostLanguage = jest.fn(() => { + return "en"; + }); + getLibretranslateBaseUrl = jest.fn(() => { + return "http://localhost:5004"; + }); +} +export default { + ...originalModule, + Settings: MockSettings, +}; diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8b00f62..f3fb253 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,10 @@ + + diff --git a/app.json b/app.json index a2740ca..6737023 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,7 @@ "name": "translation-terrace", "slug": "translation-terrace", "version": "1.0.0", - "orientation": "portrait", + "orientation": "landscape", "icon": "./assets/images/icon.png", "scheme": "myapp", "userInterfaceStyle": "automatic", @@ -30,7 +30,7 @@ [ "expo-screen-orientation", { - "initialOrientation": "LANDSCAPE" + "orientation": "landscape" } ], [ @@ -48,12 +48,10 @@ "enableFTS": true, "useSQLCipher": true, "android": { - // Override the shared configuration for Android "enableFTS": false, "useSQLCipher": false }, "ios": { - // You can also override the shared configurations for iOS "customBuildFlags": [ "-DSQLITE_ENABLE_DBSTAT_VTAB=1 -DSQLITE_ENABLE_SNAPSHOT=1" ] diff --git a/app/_layout.tsx b/app/_layout.tsx index 41f32ad..a148ebb 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,11 +1,21 @@ import * as React from "react"; -import { NavigationContainer } from "@react-navigation/native"; import TTNavStack from "@/components/TTNavStack"; +import * as ScreenOrientation from "expo-screen-orientation"; +import { NavigationContainer } from "@react-navigation/native"; +import { migrateDb } from "./lib/db"; +import { Text } from "react-native"; export default function Layout() { - return ( - - - - ); + const [loaded, setLoaded] = React.useState(true); + React.useEffect(() => { + (async function () { + await migrateDb(); + await ScreenOrientation.unlockAsync(); + await ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.LANDSCAPE_LEFT + ); + setLoaded(true); + })(); + }); + return loaded ? : Loading...; } diff --git a/app/i18n/api.ts b/app/i18n/api.ts index c81a3a7..e2ab4ec 100644 --- a/app/i18n/api.ts +++ b/app/i18n/api.ts @@ -24,16 +24,23 @@ export type language_matrix = { [key:string] : language_matrix_entry } +export async function fetchWithTimeout(url : string, options : RequestInit, timeout = 5000) : Promise { + return Promise.race([ + fetch(url, options), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout)) + ]); +} + export class LanguageServer { constructor(public baseUrl : string) {} - async fetchLanguages() : Promise { + async fetchLanguages(timeout = 500) : Promise { let data = {}; - const res = await fetch(this.baseUrl + "/languages", { + const res = await fetchWithTimeout(this.baseUrl + "/languages", { headers: { "Content-Type": "application/json" } - }); + }, timeout); try { data = await res.json(); } catch (e) { @@ -55,16 +62,20 @@ export class LanguageServer { static async getDefault() { const settings = await Settings.getDefault(); - return new LanguageServer(await settings.getLibretranslateBaseUrl()); + return new LanguageServer(await settings.getLibretranslateBaseUrl() || LIBRETRANSLATE_BASE_URL); } } export class Translator { - constructor(public source : language_t, public defaultTarget : string = "en", private languageServer : LanguageServer) { + constructor(public source : language_t, public defaultTarget : string = "en", private _languageServer : LanguageServer) { + } + + get languageServer() { + return this._languageServer; } async translate(text : string, target : string|undefined = undefined) { - const url = this.languageServer.baseUrl + `/translate`; + const url = this._languageServer.baseUrl + `/translate`; const res = await fetch(url, { method: "POST", body: JSON.stringify({ @@ -103,4 +114,10 @@ export class CachedTranslator extends Translator { await cache.set(key2, tr2); return tr2; } + + static async getDefault(defaultTarget: string | undefined = undefined) { + const settings = await Settings.getDefault(); + const source = await settings.getHostLanguage(); + return new CachedTranslator(source, defaultTarget, await LanguageServer.getDefault()) + } } \ No newline at end of file diff --git a/app/i18n/countries.ts b/app/i18n/countries.ts index dee5d9b..02e57f7 100644 --- a/app/i18n/countries.ts +++ b/app/i18n/countries.ts @@ -12,7 +12,7 @@ export function chooseCountry(lang_a2 : string) { c => c.languages.includes(lang_a3.alpha3) ); - console.log("cc = %x, ", cs.map(c => c.alpha2)) + // console.log("cc = %x, ", cs.map(c => c.alpha2)) return cs.filter(cc => Object.keys(LANG_FLAGS).includes(cc.alpha2.toLowerCase())).map(c => c.alpha2.toLowerCase()); } diff --git a/app/i18n/lang.ts b/app/i18n/lang.ts index 9a43f7f..e556684 100644 --- a/app/i18n/lang.ts +++ b/app/i18n/lang.ts @@ -4,7 +4,9 @@ import _LANGUAGES from "@/assets/languages.min.json" export const LANG_FLAGS = _LANG_FLAGS export function longLang(shortLang : string) { - return ((LANG_FLAGS as any)[shortLang] as any)["name"] as string + const obj = LANG_FLAGS[shortLang]; + if (!obj) return undefined; + return obj["name"] as string; } export function lang_a3_a2(a3 : string) { diff --git a/app/index.tsx b/app/index.tsx index 96f6119..180851e 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -43,6 +43,9 @@ export default function Home() { return ( + navigation.navigate("Settings")}> + Settings + ); diff --git a/app/lib/__tests__/settings.spec.tsx b/app/lib/__tests__/settings.spec.tsx index b057eee..2de95bf 100644 --- a/app/lib/__tests__/settings.spec.tsx +++ b/app/lib/__tests__/settings.spec.tsx @@ -1,12 +1,13 @@ import {describe, expect, beforeEach} from '@jest/globals'; import {Settings} from '@/app/lib/settings'; -import { getDb } from '@/app/lib/db'; +import { getDb, migrateDb } from '@/app/lib/db'; describe('Settings', () => { let settings: Settings; beforeEach(async () => { // Initialize your Settings class here with a fresh database instance + await migrateDb(); const db = await getDb(); if (!db) throw new Error("Could not get db"); settings = new Settings(db); diff --git a/app/lib/conversation.ts b/app/lib/conversation.ts index 87502c4..2cc921d 100644 --- a/app/lib/conversation.ts +++ b/app/lib/conversation.ts @@ -31,11 +31,15 @@ export class Message { } } +type conversation_event_t = "add_message" | "translation_done" +type conversation_callback_t = (conversation : Conversation) => any; + export class Conversation extends Array { public onAddMessage? : (conversation : Conversation) => any; public onTranslationDone? : (conversation : Conversation) => any; + constructor ( public translator : Translator, public host : Speaker, @@ -44,6 +48,15 @@ export class Conversation extends Array { super(); } + public on(event : conversation_event_t, callback : conversation_callback_t) { + if (event === "add_message") { + this.onAddMessage = callback; + } + if (event === "translation_done") { + this.onTranslationDone = callback; + } + } + public addMessage(speaker : Speaker, text? : string) { this.push(new Message(this, speaker, text)); } diff --git a/app/lib/db.ts b/app/lib/db.ts index fae9f8b..57f8776 100644 --- a/app/lib/db.ts +++ b/app/lib/db.ts @@ -1,40 +1,26 @@ -import * as SQLite from 'expo-sqlite'; +import * as SQLite from "expo-sqlite"; +import { MIGRATE_UP, MIGRATE_DOWN } from "./migrations"; -export const MIGRATE_UP = { - 1: [ - `CREATE TABLE IF NOT EXISTS settings ( - host_language TEXT, - libretranslate_base_url TEXT, - ui_direction INTEGER - )`, - ], - 2: [ - `CREATE TABLE IF NOT EXISTS whisper_models ( - model TEXT PRIMARY KEY, - bytes_done INTEGER, - bytes_total INTEGER, - )`, - ] +export async function getDb() { + return await SQLite.openDatabaseAsync("translation_terrace"); } -export const MIGRATE_DOWN = { - 1: [ - `DROP TABLE IF EXISTS settings` - ], - 2: [ - `DROP TABLE IF EXISTS whisper_models` - ] -} -export async function getDb(migrationDirection : "up" | "down" = "up") { - const db = await SQLite.openDatabaseAsync('translation_terrace'); +export async function migrateDb(direction: "up" | "down" = "up") { - for (let [migration, statements] of Object.entries(MIGRATE_UP)) { - for (let statement of statements) { - console.log(statement) - await db.runAsync(statement); - } + const db = await getDb(); + + const m = direction === "up" ? MIGRATE_UP : MIGRATE_DOWN; + + for (let [migration, statements] of Object.entries(m)) { + for (let statement of statements) { + console.log(statement); + try { + const result = await db.runAsync(statement); + console.log(result); + } catch (err) { + console.error(err); + } } - - return db; + } } \ No newline at end of file diff --git a/app/lib/migrations.ts b/app/lib/migrations.ts new file mode 100644 index 0000000..9da55d8 --- /dev/null +++ b/app/lib/migrations.ts @@ -0,0 +1,23 @@ + +export const MIGRATE_UP = { + 1: [ + `CREATE TABLE IF NOT EXISTS settings ( + host_language TEXT, + libretranslate_base_url TEXT, + ui_direction INTEGER, + whisper_model TEXT + )`, + ], + 2: [ + `CREATE TABLE IF NOT EXISTS whisper_models ( + model TEXT PRIMARY KEY, + bytes_done INTEGER, + bytes_total INTEGER + )`, + ], + }; + + export const MIGRATE_DOWN = { + 1: [`DROP TABLE IF EXISTS settings`], + 2: [`DROP TABLE IF EXISTS whisper_models`], + }; \ No newline at end of file diff --git a/app/lib/settings.ts b/app/lib/settings.ts index 5e1a2b9..3911eaa 100644 --- a/app/lib/settings.ts +++ b/app/lib/settings.ts @@ -8,7 +8,7 @@ export class Settings { "host_language", "libretranslate_base_url", 'ui_direction', - "wisper_model", + "whisper_model", ] constructor(public db: SQLiteDatabase) { diff --git a/app/lib/whisper.ts b/app/lib/whisper.ts index e50a9fb..e3937c7 100644 --- a/app/lib/whisper.ts +++ b/app/lib/whisper.ts @@ -1,5 +1,5 @@ import { Platform } from "react-native"; -import FileSystem from "expo-file-system"; +import * as FileSystem from "expo-file-system"; import { File, Paths } from 'expo-file-system/next'; import { getDb } from "./db"; @@ -145,10 +145,13 @@ export async function downloadWhisperModel( } ) { + console.debug("Starting download of %s", whisper_model); + if (!WHISPER_MODEL_DIR.exists) { await FileSystem.makeDirectoryAsync(WHISPER_MODEL_PATH, { intermediates: true, - }) + }); + console.debug("Created %s", WHISPER_MODEL_DIR); } const whisperTarget = getWhisperTarget(whisper_model); @@ -179,6 +182,7 @@ export async function downloadWhisperModel( data.totalBytesWritten, data.totalBytesExpectedToWrite, ]; + console.log("%s, %s of %s", whisper_model, data.totalBytesWritten, data.totalBytesExpectedToWrite); await db.runAsync( `INSERT OR REPLACE INTO whisper_models (model, bytes_done, bytes_remaining) VALUES (?, ?, ?)`, args @@ -187,4 +191,4 @@ export async function downloadWhisperModel( ); await resumable.downloadAsync(); -} +} \ No newline at end of file diff --git a/components/ConversationThread.tsx b/components/ConversationThread.tsx index 6972f58..c7d873a 100644 --- a/components/ConversationThread.tsx +++ b/components/ConversationThread.tsx @@ -1,13 +1,9 @@ import React, { useState, useEffect } from "react"; -import { ScrollView, Text, TouchableHighlight, View } from "react-native"; +import { ScrollView, StyleSheet, Text, TouchableHighlight, View } from "react-native"; import { useNavigation, Route } from "@react-navigation/native"; import { Conversation, Message } from "@/app/lib/conversation"; import MessageBubble from "@/components/ui/MessageBubble"; -import { WhisperContext } from "whisper.rn"; -import { NavigationProp, ParamListBase } from "@react-navigation/native"; -import { CachedTranslator, LanguageServer } from "@/app/i18n/api"; -import { getDb } from "@/app/lib/db"; -import LiveAudioStream from "react-native-live-audio-stream"; +import { CachedTranslator, LanguageServer, language_matrix_entry } from "@/app/i18n/api"; const lasOptions = { sampleRate: 32000, // default is 44100 but 32000 is adequate for accurate voice recognition @@ -35,18 +31,45 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa undefined | CachedTranslator >(); + const [languageLabels, setLanguageLabels] = useState() + useEffect(() => { (async () => { - setCachedTranslator( - new CachedTranslator( - "en", - conversation.guest.language, - await LanguageServer.getDefault() - ) - ); - if (!cachedTranslator) throw new Error("cachedTranslator is undefined"); - setGuestSpeak(await cachedTranslator.translate("Speak")); + + const languageServer = await LanguageServer.getDefault(); + const languages = await languageServer.fetchLanguages(); + const cc = new CachedTranslator( + "en", + conversation.guest.language, + languageServer, + ) + setCachedTranslator(cc); + setGuestSpeak(await cc.translate("Speak")); + const hostLang1 = languages[conversation.host.language].name; + const guestLang1 = languages[conversation.host.language].name; + const hostLang2 = await cc.translate(languages[conversation.host.language].name); + const guestLang2 = await cc.translate(languages[conversation.host.language].name); + setLanguageLabels({ + hostNative: { + host: hostLang1, + guest: guestLang1, + }, + guestNative: { + host: hostLang2, + guest: guestLang2, + } + }) })(); + const updateMessages = (c: Conversation) => { setMessages([...c]); }; @@ -67,7 +90,11 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa return cachedTranslator ? ( - Conversation Thread + {languageLabels && ( + { languageLabels.hostNative.host } / { languageLabels.hostNative.guest } + { languageLabels.guestNative.host } / { languageLabels.guestNative.guest } + ) + } (); const [languagesLoaded, setLanguagesLoaded] = useState(false); + const [translator, setTranslator] = useState(); + + const nav = useNavigation(); const languageServer = new LanguageServer(LIBRETRANSLATE_BASE_URL); - const translator = props.translator || new CachedTranslator("en", undefined, languageServer); function onLangSelected(language: language_matrix_entry) { props.onLangSelected && props.onLangSelected(language) @@ -27,33 +29,32 @@ export function LanguageSelection(props: { useEffect(() => { - const fetchData = async () => { + (async () => { try { // Replace with your actual async data fetching logic - const languages = await languageServer.fetchLanguages(); + setTranslator(await CachedTranslator.getDefault()); + const languageServer = await LanguageServer.getDefault(); + const languages = await languageServer.fetchLanguages(5000); setLanguages(languages); setLanguagesLoaded(true); } catch (error) { - error = error as Response; - console.error('Error fetching data (%d %s): %s', (error as Response).status, (error as Response).statusText, (error as Response).body); + console.error('Error fetching languages from %s: %s', languageServer.baseUrl, error); } - }; - - fetchData(); + })(); }, []); return ( - + nav.navigate('Settings')}> Settings - + {(languages && languagesLoaded) ? Object.entries(languages).filter((l) => (LANG_FLAGS as any)[l[0]] !== undefined).map( ([lang, lang_entry]) => { return ( - + ); } ) : Waiting... diff --git a/components/Settings.tsx b/components/Settings.tsx index 6095684..2f04949 100644 --- a/components/Settings.tsx +++ b/components/Settings.tsx @@ -3,164 +3,280 @@ import React, { useState, useEffect } from "react"; import { View, Text, TextInput, StyleSheet, Pressable } from "react-native"; // Add Picker import import { getDb } from "@/app/lib/db"; import { Settings } from "@/app/lib/settings"; -import { LanguageServer } from "@/app/i18n/api"; -import {Picker} from "@react-native-picker/picker" +import { LanguageServer, fetchWithTimeout } from "@/app/i18n/api"; +import { Picker } from "@react-native-picker/picker"; import { longLang } from "@/app/i18n/lang"; import { LIBRETRANSLATE_BASE_URL } from "@/constants/api"; -import { WHISPER_MODELS, downloadWhisperModel, download_status, getWhisperDownloadStatus, whisper_model_tag_t } from "@/app/lib/whisper"; +import { + WHISPER_MODELS, + WHISPER_MODEL_DIR, + downloadWhisperModel, + download_status, + getWhisperDownloadStatus, + getWhisperTarget, + whisper_model_tag_t, +} from "@/app/lib/whisper"; +import { Paths } from "expo-file-system/next"; type Language = { - code: string; - name: string; + code: string; + name: string; }; type LanguageMatrix = { - [key: string]: Language; + [key: string]: Language; }; +type connection_test_t = + | { + success: true; + } + | { + success: false; + error: string; + }; + const SettingsComponent: React.FC = () => { - const [hostLanguage, setHostLanguage] = useState(null); - const [libretranslateBaseUrl, setLibretranslateBaseUrl] = useState(null); - const [languages, setLanguages] = useState(); - const [isLoaded, setIsLoaded] = useState(false); - const [whisperModel, setWhisperModel] = useState() - const [downloadStatus, setDownloadStatus] = useState(); + const [hostLanguage, setHostLanguage] = useState(null); + const [libretranslateBaseUrl, setLibretranslateBaseUrl] = useState< + string | null + >(null); + const [languages, setLanguages] = useState(); + const [isLoaded, setIsLoaded] = useState(false); + const [whisperModel, setWhisperModel] = useState< + undefined | whisper_model_tag_t + >(); + const [downloadStatus, setDownloadStatus] = useState< + undefined | download_status + >(); - useEffect(() => { - (async () => { - // Fetch the database connection - const db = await getDb(); - const settings = new Settings(db); - - // Get the current settings values - const hostLang = await settings.getHostLanguage(); - const libretranslateUrl = await settings.getLibretranslateBaseUrl(); - const langServer = new LanguageServer(libretranslateBaseUrl || LIBRETRANSLATE_BASE_URL); - const wModel = await settings.getWhisperModel() + const [langServerConn, setLangServerConn] = useState< + undefined | connection_test_t + >(); - // Fetch languages from API - const langData = await langServer.fetchLanguages(); - setLanguages(langData); - setHostLanguage(hostLang || "en"); - setLibretranslateBaseUrl(libretranslateUrl); - setWhisperModel(wModel); - setIsLoaded(true); - })(); - - // Check for whether a model is currently downloading and set the status. - setInterval(async () => { - if (!whisperModel) return null; - const dlStatus = await getWhisperDownloadStatus(whisperModel); - setDownloadStatus(dlStatus) - }, 200); - }, []); - - const doReadownload = async () => { - if (!whisperModel) return; - await downloadWhisperModel(whisperModel, {force_redownload: true}); - } - - const doDownload = async () => { - if (!whisperModel) return; - await downloadWhisperModel(whisperModel) - } - - const handleHostLanguageChange = async (value: string) => { - setHostLanguage(value); - - // Fetch the database connection - const db = await getDb(); - const settings = new Settings(db); - - // Save the updated setting value - await settings.setHostLanguage(value); - }; - - const handleLibretranslateBaseUrlChange = async (value: string) => { - setLibretranslateBaseUrl(value); - - // Fetch the database connection - const db = await getDb(); - const settings = new Settings(db); - - // Save the updated setting value - await settings.setLibretranslateBaseUrl(value); - }; - - return ( - isLoaded ? - Host Language: - - {languages && Object.keys(languages).map((langCode) => ( - - ))} - - - LibreTranslate Base URL: - - - {Object.entries(WHISPER_MODELS).map(([key, {label}]) => ( - - ))} - - {whisperModel && ( - - { - downloadStatus?.status === "complete" ? ( - - Re-Download - - ) : ( - downloadStatus?.status === "in_progress" ? ( - - {downloadStatus.bytes.done / downloadStatus.bytes.total * 100.0} % complete - { downloadStatus.bytes.done } bytes of { downloadStatus.bytes.total } - - ) : ( - - Download - - ) - ) - } - - )} - : Loading ... + const fillHostLanguageOptions = async () => { + const settings = await Settings.getDefault(); + const hostLang = await settings.getHostLanguage(); + setHostLanguage(hostLang || "en"); + const langServer = new LanguageServer( + libretranslateBaseUrl || LIBRETRANSLATE_BASE_URL ); + // Fetch languages from API + try { + const langData = await langServer.fetchLanguages(); + setLanguages(langData); + setLangServerConn({ success: true }); + } catch (err) { + console.warn("Got an error fetching: %s", err); + setLangServerConn({ + success: false, + error: `Could not connect to ${libretranslateBaseUrl}: ${err}`, + }); + } + } + + useEffect(() => { + (async () => { + // Fetch the database connection + // const db = await getDb("down"); + const settings = await Settings.getDefault(); + + await fillHostLanguageOptions(); + + console.log("Fetched settings"); + + // Get the current settings values + const libretranslateUrl = + (await settings.getLibretranslateBaseUrl()) || LIBRETRANSLATE_BASE_URL; + setLibretranslateBaseUrl(libretranslateUrl); + console.log("libretranslate url = %s", libretranslateUrl); + try { + const wModel = await settings.getWhisperModel(); + setWhisperModel(wModel || "small"); + } catch (err) { + console.warn(err); + } + + // setWhisperModel(wModel); + setIsLoaded(true); + // console.log("Set is loaded: %s", isLoaded); + })(); + + // Check for whether a model is currently downloading and set the status. + setInterval(async () => { + if (!whisperModel) return null; + const dlStatus = await getWhisperDownloadStatus(whisperModel); + setDownloadStatus(dlStatus); + }, 200); + + setInterval(async () => { + if (!libretranslateBaseUrl) return; + try { + const resp = await fetchWithTimeout( + libretranslateBaseUrl + "/languages", + { + method: "HEAD", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }, + 5000 + ); + if (resp.status !== 200) { + throw new Error(resp.statusText); + } + setLangServerConn({ success: true }); + } catch (err) { + setLangServerConn({ + success: false, + error: `Could not connect to ${libretranslateBaseUrl}: ${err}`, + }); + } + }, 1000); + + setInterval(async () => { + const settings = await Settings.getDefault(); + await settings.setHostLanguage(hostLanguage || "en"); + await settings.setLibretranslateBaseUrl( + libretranslateBaseUrl || LIBRETRANSLATE_BASE_URL + ); + await settings.setWhisperModel(whisperModel || "small"); + }, 1000); + }, []); + + const doReadownload = async () => { + if (!whisperModel) return; + await downloadWhisperModel(whisperModel, { force_redownload: true }); + }; + + const doDownload = async () => { + if (!whisperModel) return; + await downloadWhisperModel(whisperModel); + }; + + const handleHostLanguageChange = async (value: string) => { + setHostLanguage(value); + + // Fetch the database connection + const db = await getDb(); + const settings = new Settings(db); + + // Save the updated setting value + await settings.setHostLanguage(value); + }; + + const handleLibretranslateBaseUrlChange = async (value: string) => { + setLibretranslateBaseUrl(value); + + const settings = await Settings.getDefault(); + + // Save the updated setting value + await settings.setLibretranslateBaseUrl(value); + + await fillHostLanguageOptions(); + }; + + return isLoaded ? ( + + Host Language: + + {languages && + Object.entries(languages).map((lang) => ( + + ))} + + + LibreTranslate Base URL: + + {langServerConn && + (langServerConn.success ? ( + Success connecting to {libretranslateBaseUrl} + ) : ( + + Error connecting to {libretranslateBaseUrl}: {langServerConn.error} + + ))} + + {Object.entries(WHISPER_MODELS).map(([key, { label }]) => ( + + ))} + + {whisperModel && ( + + {downloadStatus?.status === "complete" ? ( + Re-Download + ) : downloadStatus?.status === "in_progress" ? ( + + {(downloadStatus.bytes.done / downloadStatus.bytes.total) * 100.0}{" "} + % complete + {downloadStatus.bytes.done} bytes of {downloadStatus.bytes.total} + + ) : ( + + + Download + + + This will download to {Paths.join(WHISPER_MODEL_DIR, WHISPER_MODELS[whisperModel].target)} + + + )} + + )} + + ) : ( + + Loading ... + + ); }; // Create styles for the component const styles = StyleSheet.create({ - container: { - flex: 1, - padding: 20, - }, - label: { - fontSize: 16, - marginBottom: 8, - }, - input: { - height: 40, - borderColor: "gray", - borderWidth: 1, - marginBottom: 20, - paddingHorizontal: 8, - }, + button: { + backgroundColor: "blue", + flexDirection: "row", + display: "flex", + flexShrink: 1, + padding: 20, + alignItems: "center", + alignContent: "center", + }, + buttonText: { + color: "white", + alignSelf: "center", + }, + container: { + flex: 1, + padding: 20, + }, + label: { + fontSize: 16, + marginBottom: 8, + }, + input: { + height: 40, + borderColor: "gray", + borderWidth: 1, + marginBottom: 20, + paddingHorizontal: 8, + }, }); -export default SettingsComponent; \ No newline at end of file +export default SettingsComponent; diff --git a/components/TTNavStack.tsx b/components/TTNavStack.tsx index 59eb538..3fb0c71 100644 --- a/components/TTNavStack.tsx +++ b/components/TTNavStack.tsx @@ -1,42 +1,42 @@ -import * as React from 'react'; -import { NavigationContainer } from '@react-navigation/native'; -import SettingsComponent from '@/components/Settings'; -import { LanguageSelection } from '@/components/LanguageSelection'; -import { createNativeStackNavigator, NativeStackNavigationProp } from '@react-navigation/native-stack'; +import * as React from "react"; +import SettingsComponent from "@/components/Settings"; +import { LanguageSelection } from "@/components/LanguageSelection"; import { - useNavigation, -} from '@react-navigation/native' -import ConversationThread from '@/components/ConversationThread'; -import { language_matrix_entry, Translator } from '@/app/i18n/api'; -import { useRouter } from 'expo-router'; -import { Conversation } from '@/app/lib/conversation'; -import { Settings } from '@/app/lib/settings'; -import { RootStackParamList } from '@/navigation.types'; + createNativeStackNavigator, + NativeStackNavigationProp, +} from "@react-navigation/native-stack"; +import { useNavigation } from "@react-navigation/native"; +import ConversationThread from "@/components/ConversationThread"; +import { language_matrix_entry, Translator } from "@/app/i18n/api"; +import { Conversation } from "@/app/lib/conversation"; +import { Settings } from "@/app/lib/settings"; +import { RootStackParamList } from "@/navigation.types"; const Stack = createNativeStackNavigator(); export default function TTNavStack() { - - const nav = useNavigation>(); - + const nav = useNavigation>() + async function onLangSelected(lang: language_matrix_entry) { const settings = await Settings.getDefault(); const hostLanguage = await settings.getHostLanguage(); const conversation = new Conversation( - (await Translator.getDefault(lang.code)), + await Translator.getDefault(lang.code), { id: "host", language: hostLanguage }, - { "id": "guest", language: lang.code, } - ) - nav.navigate("Conversation", { conversation, }) + { id: "guest", language: lang.code } + ); + nav.navigate("ConversationThread", { conversation }); } return ( - - - { props => onLangSelected(l)} />} - - - - + + + {(props) => ( + + )} + + + + ); -} \ No newline at end of file +} diff --git a/components/__tests__/index.spec.tsx b/components/__tests__/index.spec.tsx index b576561..b4ea7a0 100644 --- a/components/__tests__/index.spec.tsx +++ b/components/__tests__/index.spec.tsx @@ -1,35 +1,61 @@ -import {dirname, resolve} from 'path' -import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react-native'; -import { createStackNavigator } from '@react-navigation/stack'; - jest.mock("@/app/i18n/api", () => require("../../__mocks__/api.ts")); +import { renderRouter} from 'expo-router/testing-library'; +import React from "react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react-native"; +import { + NavigationContainer, + createNavigationContainerRef, +} from "@react-navigation/native"; +import TTNavStack from "../TTNavStack"; -import TTNavStack from '../TTNavStack'; - -const Stack = createStackNavigator(); - -describe('Navigation', () => { +describe("Navigation", () => { beforeEach(() => { // Reset the navigation state before each test jest.clearAllMocks(); + jest.useFakeTimers(); }); - it('Navigates to ConversationThread on language selection', async () => { - render(); - const languageSelectionText = await screen.findByText(/I Speak French\./i); + it("Navigates to ConversationThread on language selection", async () => { + const MockComponent = jest.fn(() => ); + renderRouter( + { + index: MockComponent, + }, + { + initialUrl: '/', + } + ); + const languageSelectionText = await waitFor(() => + screen.getByText(/.*I Speak French.*/i) + ); act(() => { fireEvent.press(languageSelectionText); - }) + }); expect(await screen.findByText("Conversation Thread")).toBeOnTheScreen(); }); - it('Navigates to Settings on settings selection', async () => { - render(); - const settingsButton = await screen.findByText("Settings"); - act(() => { - fireEvent.press(settingsButton) - }) - expect(await screen.findByText("Settings")).toBeOnTheScreen(); + it("Navigates to Settings on settings selection", async () => { + const MockComponent = jest.fn(() => ); + renderRouter( + { + index: MockComponent, + }, + { + initialUrl: '/', + } + ); + const settingsButton = await waitFor(() => + screen.getByText(/.*Settings.*/i) + ); + fireEvent.press(settingsButton); + expect(await waitFor(() => screen.getByText(/Settings/i))).toBeOnTheScreen(); + // expect(waitFor(() => screen.getByText(/Settings/i))).toBeTruthy() + expect(screen.getByText("Settings")).toBeOnTheScreen(); }); -}); \ No newline at end of file +}); diff --git a/components/ui/ISpeakButton.tsx b/components/ui/ISpeakButton.tsx index 7d5e463..ec6a754 100644 --- a/components/ui/ISpeakButton.tsx +++ b/components/ui/ISpeakButton.tsx @@ -1,121 +1,142 @@ // import AsyncStorage from '@react-native-async-storage/async-storage'; -import { CachedTranslator, Translator, language_matrix_entry } from "@/app/i18n/api" -import { longLang } from "@/app/i18n/lang" -import React, { useEffect, useRef, useState } from "react" -import { Button, Image, ImageBackground, Pressable, StyleSheet, TouchableOpacity, View } from "react-native" -import { Text } from 'react-native'; +import { + CachedTranslator, + Translator, + language_matrix_entry, +} from "@/app/i18n/api"; +import { longLang } from "@/app/i18n/lang"; +import React, { useEffect, useRef, useState } from "react"; +import { + Button, + Image, + ImageBackground, + Pressable, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { Text } from "react-native"; import CountryFlag from "react-native-country-flag"; -import { chooseCountry } from '@/app/i18n/countries'; +import { chooseCountry } from "@/app/i18n/countries"; type ISpeakButtonProps = { - language: language_matrix_entry, - translator?: Translator, - onLangSelected?: (lang : language_matrix_entry) => any | Promise, + language: language_matrix_entry; + translator?: Translator; + onLangSelected?: (lang: language_matrix_entry) => any | Promise; +}; + +function iSpeak(language: language_matrix_entry) { + return `I speak ${language.name}.`; } -function iSpeak(language : language_matrix_entry) { - return `I speak ${language.name}.` -} - -async function iSpeakTr(translator : CachedTranslator, targetLang : language_matrix_entry) { - const sourceStr = iSpeak(targetLang) - return await translator.translate(sourceStr, targetLang.code); +async function iSpeakTr( + translator: CachedTranslator, + targetLang: language_matrix_entry +) { + const sourceStr = iSpeak(targetLang); + return await translator.translate(sourceStr, targetLang.code); } const DEFAULT_FLAGS = { - "en": ["us", "gb"], - // "sq": ["al"], - "ar": ["ae"], - "es": ["es"], - "pt": ["pt"], - "ru": ["ru"], - "it": ["it"], - "ir": ["ie"], - "sk": ["sk"], - "ro": ["ro"], - "ja": ["jp"], - "ko": ["kp", "kr"], - "el": ["gr"], - "fr": ["fr"], - "de": ["de"], - "nl": ["nl"], - "cz": ["cz"], - "uk": ["ua"], - "he": ["il"], - "hi": ["in"], - "gl": ["es"], - "fa": ["ir"], - "ur": ["pk"], - "ga": ["ie"], - "eo": ["es"] -} + en: ["us", "gb"], + // "sq": ["al"], + ar: ["ae"], + es: ["es"], + pt: ["pt"], + ru: ["ru"], + it: ["it"], + ir: ["ie"], + sk: ["sk"], + ro: ["ro"], + ja: ["jp"], + ko: ["kp", "kr"], + el: ["gr"], + fr: ["fr"], + de: ["de"], + nl: ["nl"], + cz: ["cz"], + uk: ["ua"], + he: ["il"], + hi: ["in"], + gl: ["es"], + fa: ["ir"], + ur: ["pk"], + ga: ["ie"], + eo: ["es"], +}; -const ISpeakButton = (props : ISpeakButtonProps) => { +const ISpeakButton = (props: ISpeakButtonProps) => { + const [title, setTitle] = useState(); + const [titleLoaded, setTitleLoaded] = useState(false); + const [translator, setTranslator] = useState( + undefined + ); - const [title, setTitle] = useState(); - const [titleLoaded, setTitleLoaded] = useState(false); - const translator = props.translator || new CachedTranslator("en"); + useEffect(() => { + (async function () { + const tr = props.translator || (await CachedTranslator.getDefault()); + if (!tr) { + console.error("Failed to construct cachedTranslator"); + } + setTranslator(tr); + try { + // Replace with your actual async data fetching logic + const title2 = await iSpeakTr(tr, props.language); + setTitle(title2); + } catch (error) { + console.error("Error fetching data from %s: %s", tr.languageServer.baseUrl, error); + } finally { + setTitleLoaded(true); + } + })(); + }, []); - useEffect(() => { - const fetchData = async () => { - try { - // Replace with your actual async data fetching logic - const title = await iSpeakTr(translator, props.language); - setTitle(title); - } catch (error) { - console.error('Error fetching data:', error); - } finally { - setTitleLoaded(true); - } - }; - - fetchData(); - }, []); - - const countries = DEFAULT_FLAGS[props.language.code] || chooseCountry(props.language.code); - - return ( - title ? ( - props.onLangSelected && props.onLangSelected(props.language)}> - - - {countries && - countries.map( c => { - return } - ) - } - - - { title } - - - - ) : ( - Loading... - ) - ) + const countries = + // @ts-ignore + DEFAULT_FLAGS[props.language.code] || chooseCountry(props.language.code); -} + return title ? ( + + props.onLangSelected && props.onLangSelected(props.language) + } + > + + + {countries && + countries.map((c) => { + return ; + })} + + + {title} + + + + ) : ( + Loading... + ); +}; const styles = StyleSheet.create({ - button: { - width: "20%", - borderRadius: 10, - borderColor: "white", - borderWidth: 1, - borderStyle: "solid", - height: 110, - alignSelf: "flex-start", - margin: 8, - }, - flag: { - }, - iSpeak: { - textAlign: "center", - }, - iSpeakText: { - textAlign: "center" - } -}) + button: { + width: "20%", + borderRadius: 10, + borderColor: "white", + borderWidth: 1, + borderStyle: "solid", + height: 110, + alignSelf: "flex-start", + margin: 8, + }, + flag: {}, + iSpeak: { + textAlign: "center", + }, + iSpeakText: { + textAlign: "center", + }, +}); -export default ISpeakButton; \ No newline at end of file +export default ISpeakButton; diff --git a/components/ui/__tests__/Settings.spec.tsx b/components/ui/__tests__/Settings.spec.tsx index bf381f5..da39c6e 100644 --- a/components/ui/__tests__/Settings.spec.tsx +++ b/components/ui/__tests__/Settings.spec.tsx @@ -1,9 +1,9 @@ import React, { Dispatch } from "react"; import { render, screen, fireEvent, act } from "@testing-library/react-native"; import SettingsComponent from "@/components/Settings"; -import { Settings } from "@/app/lib/settings"; -import { getDb } from "@/app/lib/db"; import { language_matrix } from "@/app/i18n/api"; +import { Settings } from "@/app/lib/settings"; +import { getDb, migrateDb } from "@/app/lib/db"; const RENDER_TIME = 1000; @@ -49,45 +49,10 @@ jest.mock("@/app/i18n/api", () => { } }) -jest.mock("@/app/lib/db", () => { - return { - getDb: jest.fn(() => { - return { - runAsync: jest.fn((statement : string, value : string) => {}), - getFirstAsync: jest.fn((statement : string, value : string) => { - return [] - }), - } - }) - } -}) - -jest.mock("@/app/lib/settings", () => { - const originalModule = jest.requireActual('@/app/lib/settings'); - class MockSettings { - public constructor(public db = {}) {} - public setHostLanguage = jest.fn((val : string) => { - - }) - public setLibretranslateBaseUrl(val : string) { - - } - getHostLanguage = jest.fn(() => { - return "en" - }) - getLibretranslateBaseUrl = jest.fn(() => { - return "http://localhost:5004" - }); - } - return { - ...originalModule, - Settings: MockSettings - } -}) - describe("SettingsComponent", () => { beforeEach(async() => { - const settings = new Settings(await getDb()); + await migrateDb(); + const settings = await Settings.getDefault(); await settings.setHostLanguage("en"); await settings.setLibretranslateBaseUrl("https://example.com"); }) @@ -116,13 +81,12 @@ describe("SettingsComponent", () => { test("updates host language setting when input changes", async () => { render(); - // Wait for the component to fetch and display the initial settings await screen.findByText(/Host Language:/i); await screen.findByText(/LibreTranslate Base URL:/i); // Change the host language input value - const picker = screen.getByAccessibilityHint("language"); + const picker = screen.getByAccessibilityHint("hostLanguage"); fireEvent(picker, "onvalueChange", "es"); expect(picker.props.selectedIndex).toStrictEqual(0); }); diff --git a/jestSetup.ts b/jestSetup.ts index c97a007..749b5f2 100644 --- a/jestSetup.ts +++ b/jestSetup.ts @@ -1,29 +1,71 @@ // jestSetup.ts -jest.mock('expo-sqlite', () => { - return { - openDatabaseAsync: async (name: string) => { - const {DatabaseSync} = require("node:sqlite") - const db = new DatabaseSync(':memory:'); +// include this line for mocking react-native-gesture-handler +import 'react-native-gesture-handler/jestSetup'; - return { - closeAsync: jest.fn(() => db.close()), - executeSql: jest.fn((sql: string) => db.exec(sql)), - runAsync: jest.fn(async (sql: string, params = []) => { - const stmt = db.prepare(sql); - // console.log("Running %s with %s", sql, params); - try { - stmt.run(params); - } catch (e) { - throw new Error(`running ${sql} with params ${JSON.stringify(params)}: ${e}`); - } - }), - getFirstAsync: jest.fn(async (sql : string, params = []) => { - const stmt = db.prepare(sql) - // const result = stmt.run(...params); - return stmt.get(params) - }) - }; - }, +jest.mock("expo-sqlite", () => { + const { DatabaseSync } = require("node:sqlite"); + const db = new DatabaseSync(":memory:"); + + const { MIGRATE_UP } = jest.requireActual("./app/lib/migrations"); + + const openDatabaseAsync = async (name: string) => { + return { + closeAsync: jest.fn(() => db.close()), + executeSql: jest.fn((sql: string) => db.exec(sql)), + runAsync: jest.fn(async (sql: string, params = []) => { + for (let m of Object.values(MIGRATE_UP)) { + for (let stmt of m) { + const s = db.prepare(stmt); + s.run(); + } + } + const stmt = db.prepare(sql); + // console.log("Running %s with %s", sql, params); + try { + stmt.run(params); + } catch (e) { + throw new Error( + `running ${sql} with params ${JSON.stringify(params)}: ${e}` + ); + } + }), + getFirstAsync: jest.fn(async (sql: string, params = []) => { + for (let m of Object.values(MIGRATE_UP)) { + for (let stmt of m) { + const s = db.prepare(stmt); + s.run(); + } + } + const stmt = db.prepare(sql); + // const result = stmt.run(...params); + return stmt.get(params); + }), }; -}); \ No newline at end of file + }; + return { + migrateDb: async (direction: "up" | "down" = "up") => { + const db = await openDatabaseAsync("translation_terrace"); + for (let m of Object.values(MIGRATE_UP)) { + for (let stmt of m) { + await db.executeSql(stmt); + } + } + }, + openDatabaseAsync, + }; +}); + +// include this section and the NativeAnimatedHelper section for mocking react-native-reanimated +jest.mock('react-native-reanimated', () => { + const Reanimated = require('react-native-reanimated/mock'); + + // The mock for `call` immediately calls the callback which is incorrect + // So we override it with a no-op + Reanimated.default.call = () => {}; + + return Reanimated; +}); + +// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing +// jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); \ No newline at end of file