improve tests, especially for navigation.
This commit is contained in:
parent
6f941c56d1
commit
87446784ae
@ -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{
|
||||
|
10
__mocks__/db.ts
Normal file
10
__mocks__/db.ts
Normal file
@ -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 [];
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
16
__mocks__/settings.ts
Normal file
16
__mocks__/settings.ts
Normal file
@ -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,
|
||||
};
|
@ -1,8 +1,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<queries>
|
||||
<intent>
|
||||
|
6
app.json
6
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"
|
||||
]
|
||||
|
@ -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 (
|
||||
<NavigationContainer>
|
||||
<TTNavStack />
|
||||
</NavigationContainer>
|
||||
);
|
||||
const [loaded, setLoaded] = React.useState<boolean>(true);
|
||||
React.useEffect(() => {
|
||||
(async function () {
|
||||
await migrateDb();
|
||||
await ScreenOrientation.unlockAsync();
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
|
||||
);
|
||||
setLoaded(true);
|
||||
})();
|
||||
});
|
||||
return loaded ? <TTNavStack /> : <Text>Loading...</Text>;
|
||||
}
|
||||
|
@ -24,16 +24,23 @@ export type language_matrix = {
|
||||
[key:string] : language_matrix_entry
|
||||
}
|
||||
|
||||
export async function fetchWithTimeout(url : string, options : RequestInit, timeout = 5000) : Promise<Response> {
|
||||
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<language_matrix> {
|
||||
async fetchLanguages(timeout = 500) : Promise<language_matrix> {
|
||||
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())
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -43,6 +43,9 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable onPress={() => navigation.navigate("Settings")}>
|
||||
<Text>Settings</Text>
|
||||
</Pressable>
|
||||
<LanguageSelection onLangSelected={onLangSelected} />
|
||||
</View>
|
||||
);
|
||||
|
@ -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);
|
||||
|
@ -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<Message> {
|
||||
|
||||
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<Message> {
|
||||
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));
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
23
app/lib/migrations.ts
Normal file
23
app/lib/migrations.ts
Normal file
@ -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`],
|
||||
};
|
@ -8,7 +8,7 @@ export class Settings {
|
||||
"host_language",
|
||||
"libretranslate_base_url",
|
||||
'ui_direction',
|
||||
"wisper_model",
|
||||
"whisper_model",
|
||||
]
|
||||
|
||||
constructor(public db: SQLiteDatabase) {
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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<undefined | {
|
||||
hostNative: {
|
||||
host: string,
|
||||
guest: string,
|
||||
},
|
||||
guestNative: {
|
||||
host: string,
|
||||
guest: string,
|
||||
}
|
||||
}>()
|
||||
|
||||
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 ? (
|
||||
<View style={{ flex: 1, flexDirection: "column" }}>
|
||||
<Text>Conversation Thread</Text>
|
||||
{languageLabels && (<View style={styles.languageLabels}>
|
||||
<Text style={styles.nativeHostLabel}>{ languageLabels.hostNative.host } / { languageLabels.hostNative.guest }</Text>
|
||||
<Text style={styles.nativeGuestLabel}>{ languageLabels.guestNative.host } / { languageLabels.guestNative.guest }</Text>
|
||||
</View>)
|
||||
}
|
||||
<ScrollView
|
||||
style={{
|
||||
borderColor: "black",
|
||||
@ -106,4 +133,16 @@ const ConversationThread = ({ route } : {route?: Route<"Conversation", {conversa
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
languageLabels: {
|
||||
|
||||
},
|
||||
nativeHostLabel: {
|
||||
|
||||
},
|
||||
nativeGuestLabel: {
|
||||
|
||||
},
|
||||
})
|
||||
|
||||
export default ConversationThread;
|
||||
|
@ -3,11 +3,11 @@ import { LIBRETRANSLATE_BASE_URL } from "@/constants/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import ISpeakButton from "./ui/ISpeakButton";
|
||||
import { LANG_FLAGS } from "@/app/i18n/lang";
|
||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Conversation, Speaker } from "@/app/lib/conversation";
|
||||
import { NavigationProp, ParamListBase } from "@react-navigation/native";
|
||||
import { Link } from "expo-router";
|
||||
import { Link, useNavigation } from "expo-router";
|
||||
|
||||
|
||||
export function LanguageSelection(props: {
|
||||
@ -17,9 +17,11 @@ export function LanguageSelection(props: {
|
||||
}) {
|
||||
const [languages, setLanguages] = useState<language_matrix | undefined>();
|
||||
const [languagesLoaded, setLanguagesLoaded] = useState<boolean>(false);
|
||||
const [translator, setTranslator] = useState<Translator|undefined>();
|
||||
|
||||
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 (
|
||||
<View>
|
||||
<Link href={"/settings"}>
|
||||
<Pressable onPress={() => nav.navigate('Settings')}>
|
||||
<Text>Settings</Text>
|
||||
</Link>
|
||||
</Pressable>
|
||||
<ScrollView >
|
||||
<SafeAreaProvider >
|
||||
<SafeAreaView>
|
||||
{(languages && languagesLoaded) ? Object.entries(languages).filter((l) => (LANG_FLAGS as any)[l[0]] !== undefined).map(
|
||||
([lang, lang_entry]) => {
|
||||
return (
|
||||
<ISpeakButton language={lang_entry} key={lang_entry.code} onLangSelected={onLangSelected} />
|
||||
<ISpeakButton language={lang_entry} key={lang_entry.code} onLangSelected={onLangSelected} translator={translator} />
|
||||
);
|
||||
}
|
||||
) : <Text>Waiting...</Text>
|
||||
|
@ -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<string | null>(null);
|
||||
const [libretranslateBaseUrl, setLibretranslateBaseUrl] = useState<string | null>(null);
|
||||
const [languages, setLanguages] = useState<undefined|LanguageMatrix>();
|
||||
const [isLoaded, setIsLoaded] = useState<boolean>(false);
|
||||
const [whisperModel, setWhisperModel] = useState<undefined|whisper_model_tag_t>()
|
||||
const [downloadStatus, setDownloadStatus] = useState<undefined|download_status>();
|
||||
const [hostLanguage, setHostLanguage] = useState<string | null>(null);
|
||||
const [libretranslateBaseUrl, setLibretranslateBaseUrl] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [languages, setLanguages] = useState<undefined | LanguageMatrix>();
|
||||
const [isLoaded, setIsLoaded] = useState<boolean>(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 ? <View style={styles.container}>
|
||||
<Text style={styles.label}>Host Language:</Text>
|
||||
<Picker
|
||||
selectedValue={hostLanguage || ""}
|
||||
style={{ height: 50, width: "100%" }}
|
||||
onValueChange={handleHostLanguageChange}
|
||||
accessibilityHint="language"
|
||||
>
|
||||
{languages && Object.keys(languages).map((langCode) => (
|
||||
<Picker.Item key={langCode} label={longLang(langCode)} value={langCode} />
|
||||
))}
|
||||
</Picker>
|
||||
|
||||
<Text style={styles.label}>LibreTranslate Base URL:</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={libretranslateBaseUrl || ""}
|
||||
onChangeText={handleLibretranslateBaseUrlChange}
|
||||
accessibilityHint="libretranslate base url"
|
||||
/>
|
||||
<Picker
|
||||
selectedValue={whisperModel || ""}
|
||||
style={{ height: 50, width: "100%" }}
|
||||
onValueChange={setWhisperModel}
|
||||
accessibilityHint="language"
|
||||
>
|
||||
{Object.entries(WHISPER_MODELS).map(([key, {label}]) => (
|
||||
<Picker.Item key={key} label={label} value={key} />
|
||||
))}
|
||||
</Picker>
|
||||
{whisperModel && (
|
||||
<View>
|
||||
{
|
||||
downloadStatus?.status === "complete" ? (
|
||||
<Pressable onPress={doReadownload}>
|
||||
Re-Download
|
||||
</Pressable>
|
||||
) : (
|
||||
downloadStatus?.status === "in_progress" ? (
|
||||
<Text>
|
||||
{downloadStatus.bytes.done / downloadStatus.bytes.total * 100.0} % complete
|
||||
{ downloadStatus.bytes.done } bytes of { downloadStatus.bytes.total }
|
||||
</Text>
|
||||
) : (
|
||||
<Pressable onPress={doDownload}>
|
||||
Download
|
||||
</Pressable>
|
||||
)
|
||||
)
|
||||
}
|
||||
</View>
|
||||
)}
|
||||
</View> : <View><Text>Loading ...</Text></View>
|
||||
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 ? (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>Host Language:</Text>
|
||||
<Picker
|
||||
selectedValue={hostLanguage || ""}
|
||||
style={{ height: 50, width: "100%" }}
|
||||
onValueChange={handleHostLanguageChange}
|
||||
accessibilityHint="hostLanguage"
|
||||
>
|
||||
{languages &&
|
||||
Object.entries(languages).map((lang) => (
|
||||
<Picker.Item key={lang[0]} label={lang[1].name} value={lang[0]} />
|
||||
))}
|
||||
</Picker>
|
||||
|
||||
<Text style={styles.label}>LibreTranslate Base URL:</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={libretranslateBaseUrl || LIBRETRANSLATE_BASE_URL}
|
||||
onChangeText={handleLibretranslateBaseUrlChange}
|
||||
accessibilityHint="libretranslate base url"
|
||||
/>
|
||||
{langServerConn &&
|
||||
(langServerConn.success ? (
|
||||
<Text>Success connecting to {libretranslateBaseUrl}</Text>
|
||||
) : (
|
||||
<Text>
|
||||
Error connecting to {libretranslateBaseUrl}: {langServerConn.error}
|
||||
</Text>
|
||||
))}
|
||||
<Picker
|
||||
selectedValue={whisperModel || ""}
|
||||
style={{ height: 50, width: "100%" }}
|
||||
onValueChange={setWhisperModel}
|
||||
accessibilityHint="language"
|
||||
>
|
||||
{Object.entries(WHISPER_MODELS).map(([key, { label }]) => (
|
||||
<Picker.Item key={key} label={label} value={key} />
|
||||
))}
|
||||
</Picker>
|
||||
{whisperModel && (
|
||||
<View>
|
||||
{downloadStatus?.status === "complete" ? (
|
||||
<Pressable onPress={doReadownload}>Re-Download</Pressable>
|
||||
) : downloadStatus?.status === "in_progress" ? (
|
||||
<Text>
|
||||
{(downloadStatus.bytes.done / downloadStatus.bytes.total) * 100.0}{" "}
|
||||
% complete
|
||||
{downloadStatus.bytes.done} bytes of {downloadStatus.bytes.total}
|
||||
</Text>
|
||||
) : (
|
||||
<View>
|
||||
<Pressable onPress={doDownload} style={styles.button}>
|
||||
<Text style={styles.buttonText}>Download</Text>
|
||||
</Pressable>
|
||||
<Text>
|
||||
This will download to {Paths.join(WHISPER_MODEL_DIR, WHISPER_MODELS[whisperModel].target)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
<Text>Loading ...</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
export default SettingsComponent;
|
||||
|
@ -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<NativeStackNavigationProp<RootStackParamList, 'ConversationThread'>>();
|
||||
|
||||
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, "Conversation">>()
|
||||
|
||||
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 (
|
||||
<Stack.Navigator initialRouteName='LanguageSelection'>
|
||||
<Stack.Screen name="LanguageSelection" >
|
||||
{ props => <LanguageSelection {...props} onLangSelected={(l) => onLangSelected(l)} />}
|
||||
</Stack.Screen>
|
||||
<Stack.Screen name="ConversationThread" component={ConversationThread} />
|
||||
<Stack.Screen name="Settings" component={SettingsComponent} />
|
||||
</Stack.Navigator>
|
||||
<Stack.Navigator initialRouteName="LanguageSelection">
|
||||
<Stack.Screen name="LanguageSelection">
|
||||
{(props) => (
|
||||
<LanguageSelection {...props} onLangSelected={onLangSelected} />
|
||||
)}
|
||||
</Stack.Screen>
|
||||
<Stack.Screen name="ConversationThread" component={ConversationThread} />
|
||||
<Stack.Screen name="Settings" component={SettingsComponent} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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(<TTNavStack />);
|
||||
const languageSelectionText = await screen.findByText(/I Speak French\./i);
|
||||
it("Navigates to ConversationThread on language selection", async () => {
|
||||
const MockComponent = jest.fn(() => <TTNavStack />);
|
||||
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(<TTNavStack />);
|
||||
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(() => <TTNavStack />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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<any>,
|
||||
language: language_matrix_entry;
|
||||
translator?: Translator;
|
||||
onLangSelected?: (lang: language_matrix_entry) => any | Promise<any>;
|
||||
};
|
||||
|
||||
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<string | undefined>();
|
||||
const [titleLoaded, setTitleLoaded] = useState<boolean>(false);
|
||||
const [translator, setTranslator] = useState<Translator | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const [title, setTitle] = useState<string | undefined>();
|
||||
const [titleLoaded, setTitleLoaded] = useState<boolean>(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 ? (
|
||||
<TouchableOpacity style={styles.button} onPress={() => props.onLangSelected && props.onLangSelected(props.language)}>
|
||||
<View>
|
||||
<View style={styles.flag}>
|
||||
{countries &&
|
||||
countries.map( c => {
|
||||
return <CountryFlag isoCode={c} size={25} key={c}/> }
|
||||
)
|
||||
}
|
||||
</View>
|
||||
<View style={styles.iSpeak}>
|
||||
<Text style={styles.iSpeakText}>{ title }</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<Text>Loading...</Text>
|
||||
)
|
||||
)
|
||||
const countries =
|
||||
// @ts-ignore
|
||||
DEFAULT_FLAGS[props.language.code] || chooseCountry(props.language.code);
|
||||
|
||||
}
|
||||
return title ? (
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() =>
|
||||
props.onLangSelected && props.onLangSelected(props.language)
|
||||
}
|
||||
>
|
||||
<View>
|
||||
<View style={styles.flag}>
|
||||
{countries &&
|
||||
countries.map((c) => {
|
||||
return <CountryFlag isoCode={c} size={25} key={c} />;
|
||||
})}
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.iSpeakText}>{title}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<Text>Loading...</Text>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
export default ISpeakButton;
|
||||
|
@ -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(<SettingsComponent />);
|
||||
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
92
jestSetup.ts
92
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);
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
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');
|
Loading…
x
Reference in New Issue
Block a user