@learnpack/learnpack 5.0.359 → 5.0.360
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/creatorDist/assets/{index-zrPponAn.css → index-B7s5Nmq8.css} +59 -0
- package/lib/creatorDist/assets/{index-D6pmbMe9.js → index-GYxGB_X0.js} +22609 -22363
- package/lib/creatorDist/index.html +2 -2
- package/package.json +1 -1
- package/src/creator/src/App.test.tsx +338 -0
- package/src/creator/src/App.tsx +152 -26
- package/src/creator/src/components/CourseLanguagePicker.tsx +51 -0
- package/src/creator/src/components/LanguageDetectionModal.tsx +29 -9
- package/src/creator/src/components/StepWizard.tsx +11 -3
- package/src/creator/src/components/Uploader.test.tsx +65 -0
- package/src/creator/src/components/Uploader.tsx +9 -3
- package/src/creator/src/locales/en.json +7 -0
- package/src/creator/src/locales/es.json +7 -0
- package/src/creator/src/utils/courseLanguage.test.ts +215 -0
- package/src/creator/src/utils/courseLanguage.ts +173 -0
- package/src/creator/src/utils/creationRequest.test.ts +104 -0
- package/src/creator/src/utils/creationRequest.ts +63 -0
- package/src/creator/src/utils/store.ts +7 -0
- package/src/creatorDist/assets/{index-zrPponAn.css → index-B7s5Nmq8.css} +59 -0
- package/src/creatorDist/assets/{index-D6pmbMe9.js → index-GYxGB_X0.js} +22609 -22363
- package/src/creatorDist/index.html +2 -2
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +2105 -2105
- package/src/ui/_app/sw.js +13 -0
- package/src/ui/app.tar.gz +0 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import { useTranslation } from "react-i18next"
|
|
3
|
+
import { Icon } from "./Icon"
|
|
4
|
+
import {
|
|
5
|
+
Tooltip,
|
|
6
|
+
TooltipContent,
|
|
7
|
+
TooltipTrigger,
|
|
8
|
+
} from "@/components/ui/tooltip"
|
|
9
|
+
import { TCourseLanguage } from "../utils/courseLanguage"
|
|
10
|
+
|
|
11
|
+
export type TCourseLanguageChoice = TCourseLanguage | "auto"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Lets the user fix the course language instead of leaving it to detection.
|
|
15
|
+
* Language names are written in their own language so each is recognizable
|
|
16
|
+
* whatever the interface language.
|
|
17
|
+
*/
|
|
18
|
+
export default function CourseLanguagePicker({
|
|
19
|
+
value,
|
|
20
|
+
onChange,
|
|
21
|
+
}: {
|
|
22
|
+
value: TCourseLanguageChoice
|
|
23
|
+
onChange: (value: TCourseLanguageChoice) => void
|
|
24
|
+
}) {
|
|
25
|
+
const { t } = useTranslation()
|
|
26
|
+
// Controlled so the hint only shows for Auto without remounting the select.
|
|
27
|
+
const [hintOpen, setHintOpen] = useState(false)
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Tooltip open={hintOpen && value === "auto"} onOpenChange={setHintOpen}>
|
|
31
|
+
<TooltipTrigger asChild>
|
|
32
|
+
<label className="flex items-center gap-1 text-sm text-blue-600 whitespace-nowrap">
|
|
33
|
+
<Icon name="Globe" size={14} aria-hidden="true" />
|
|
34
|
+
{t("courseLanguage.label")}:
|
|
35
|
+
<select
|
|
36
|
+
className="field-sizing-content bg-transparent text-blue-600 hover:text-blue-800 cursor-pointer rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-300"
|
|
37
|
+
value={value}
|
|
38
|
+
onChange={(e) => onChange(e.target.value as TCourseLanguageChoice)}
|
|
39
|
+
>
|
|
40
|
+
<option value="auto">{t("courseLanguage.auto")}</option>
|
|
41
|
+
<option value="en">English</option>
|
|
42
|
+
<option value="es">Español</option>
|
|
43
|
+
</select>
|
|
44
|
+
</label>
|
|
45
|
+
</TooltipTrigger>
|
|
46
|
+
<TooltipContent className="max-w-64 text-center">
|
|
47
|
+
{t("courseLanguage.autoHint")}
|
|
48
|
+
</TooltipContent>
|
|
49
|
+
</Tooltip>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -4,12 +4,25 @@ interface LanguageDetectionModalProps {
|
|
|
4
4
|
detectedLanguage: string
|
|
5
5
|
onSwitch: () => void
|
|
6
6
|
onStay: () => void
|
|
7
|
+
// Overrides for asking which language the course should be in, rather than
|
|
8
|
+
// suggesting an interface switch. Neither answer is the default there, so
|
|
9
|
+
// `equalChoices` gives both buttons the same weight.
|
|
10
|
+
title?: string
|
|
11
|
+
message?: string
|
|
12
|
+
switchLabel?: string
|
|
13
|
+
stayLabel?: string
|
|
14
|
+
equalChoices?: boolean
|
|
7
15
|
}
|
|
8
16
|
|
|
9
17
|
export default function LanguageDetectionModal({
|
|
10
18
|
detectedLanguage,
|
|
11
19
|
onSwitch,
|
|
12
20
|
onStay,
|
|
21
|
+
title,
|
|
22
|
+
message,
|
|
23
|
+
switchLabel,
|
|
24
|
+
stayLabel,
|
|
25
|
+
equalChoices = false,
|
|
13
26
|
}: LanguageDetectionModalProps) {
|
|
14
27
|
const { t, i18n } = useTranslation()
|
|
15
28
|
|
|
@@ -17,27 +30,34 @@ export default function LanguageDetectionModal({
|
|
|
17
30
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-1000">
|
|
18
31
|
<div className="bg-white p-6 rounded-xl shadow-md max-w-md w-full mx-4">
|
|
19
32
|
<h2 className="text-xl font-semibold text-center mb-4">
|
|
20
|
-
{t("languageDetection.title")}
|
|
33
|
+
{title ?? t("languageDetection.title")}
|
|
21
34
|
</h2>
|
|
22
35
|
<p className="text-gray-600 text-center mb-6">
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
{message ??
|
|
37
|
+
t("languageDetection.message", {
|
|
38
|
+
language: t(`languageDetection.languages.${detectedLanguage.toLowerCase()}`)
|
|
39
|
+
})}
|
|
26
40
|
</p>
|
|
27
41
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
28
42
|
<button
|
|
29
43
|
onClick={onSwitch}
|
|
30
|
-
className=
|
|
44
|
+
className={
|
|
45
|
+
equalChoices
|
|
46
|
+
? "flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md font-semibold hover:bg-gray-300 transition-colors"
|
|
47
|
+
: "flex-1 bg-blue-600 text-white py-2 px-4 rounded-md font-semibold hover:bg-blue-700 transition-colors"
|
|
48
|
+
}
|
|
31
49
|
>
|
|
32
|
-
{
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
{switchLabel ??
|
|
51
|
+
t("languageDetection.switchTo", {
|
|
52
|
+
language: t(`languageDetection.languages.${detectedLanguage.toLowerCase()}`)
|
|
53
|
+
})}
|
|
35
54
|
</button>
|
|
36
55
|
<button
|
|
37
56
|
onClick={onStay}
|
|
38
57
|
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md font-semibold hover:bg-gray-300 transition-colors"
|
|
39
58
|
>
|
|
40
|
-
{
|
|
59
|
+
{stayLabel ??
|
|
60
|
+
t(`languageDetection.stayIn${i18n.language.toLowerCase().split("-")[0]}`)}
|
|
41
61
|
</button>
|
|
42
62
|
</div>
|
|
43
63
|
</div>
|
|
@@ -20,6 +20,8 @@ type Props = {
|
|
|
20
20
|
setFormState: (formState: any) => void
|
|
21
21
|
onFinish: () => void
|
|
22
22
|
hideLastButton?: boolean
|
|
23
|
+
// Shown between Back and Next on every step.
|
|
24
|
+
footer?: React.ReactNode
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const StepWizard: React.FC<Props> = ({
|
|
@@ -28,6 +30,7 @@ const StepWizard: React.FC<Props> = ({
|
|
|
28
30
|
setFormState,
|
|
29
31
|
onFinish,
|
|
30
32
|
hideLastButton = false,
|
|
33
|
+
footer,
|
|
31
34
|
}) => {
|
|
32
35
|
const { t } = useTranslation()
|
|
33
36
|
const currentStep = formState.currentStep
|
|
@@ -99,20 +102,25 @@ const StepWizard: React.FC<Props> = ({
|
|
|
99
102
|
{steps.find((step) => step.slug === currentStep)?.content}
|
|
100
103
|
</div>
|
|
101
104
|
|
|
102
|
-
|
|
105
|
+
{/* Three columns keep the footer centered even when Next is hidden. On
|
|
106
|
+
narrow screens it gets its own row under Back and Next. */}
|
|
107
|
+
<div className="grid grid-cols-2 sm:grid-cols-[1fr_auto_1fr] items-center gap-2">
|
|
103
108
|
<button
|
|
104
109
|
onClick={goBack}
|
|
105
110
|
disabled={index === 0}
|
|
106
|
-
className={`text-sm text-gray-500 hover:text-gray-900 disabled:opacity-40 ${
|
|
111
|
+
className={`justify-self-start text-sm text-gray-500 hover:text-gray-900 disabled:opacity-40 ${
|
|
107
112
|
index === 0 ? "cursor-not-allowed" : "cursor-pointer"
|
|
108
113
|
}`}
|
|
109
114
|
>
|
|
110
115
|
⬅ {t("stepWizard.back")}
|
|
111
116
|
</button>
|
|
117
|
+
<div className="col-span-2 row-start-2 justify-self-center sm:col-span-1 sm:col-start-2 sm:row-start-1">
|
|
118
|
+
{footer}
|
|
119
|
+
</div>
|
|
112
120
|
{!(hideLastButton && index === totalSteps - 1) && (
|
|
113
121
|
<button
|
|
114
122
|
onClick={index === totalSteps - 1 ? onFinish : goNext}
|
|
115
|
-
className={`text-sm text-blue-600 hover:text-blue-800 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed p-2 rounded-md ${
|
|
123
|
+
className={`justify-self-end text-sm text-blue-600 hover:text-blue-800 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed p-2 rounded-md ${
|
|
116
124
|
index === totalSteps - 1 ? "bg-blue-600 text-white" : ""
|
|
117
125
|
}`}
|
|
118
126
|
>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import "../i18n";
|
|
4
|
+
import { Uploader } from "./Uploader";
|
|
5
|
+
|
|
6
|
+
const LINK = {
|
|
7
|
+
url: "https://www.youtube.com/watch?v=abc",
|
|
8
|
+
title: "Bases de datos",
|
|
9
|
+
transcript: "Hoy vamos a ver cómo se diseña una base de datos relacional",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// The real uploaders call Rigobot; these stand-ins finish straight away.
|
|
13
|
+
vi.mock("./LinkUploader", () => ({
|
|
14
|
+
default: ({ onResult }: { onResult: (link: typeof LINK) => void }) => (
|
|
15
|
+
<button onClick={() => onResult(LINK)}>mock-link</button>
|
|
16
|
+
),
|
|
17
|
+
}));
|
|
18
|
+
vi.mock("./FileUploader", () => ({
|
|
19
|
+
default: ({ onFinish }: { onFinish: () => void }) => (
|
|
20
|
+
<button onClick={onFinish}>mock-files</button>
|
|
21
|
+
),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
afterEach(cleanup);
|
|
25
|
+
|
|
26
|
+
describe("Uploader", () => {
|
|
27
|
+
it("hands pasted text over as both the content index and the seed", () => {
|
|
28
|
+
const onFinish = vi.fn();
|
|
29
|
+
render(<Uploader onFinish={onFinish} />);
|
|
30
|
+
|
|
31
|
+
fireEvent.click(screen.getByText("Write or paste a content table"));
|
|
32
|
+
fireEvent.change(screen.getByPlaceholderText("Add your text here..."), {
|
|
33
|
+
target: { value: "1. Tablas\n2. Claves" },
|
|
34
|
+
});
|
|
35
|
+
fireEvent.click(screen.getByText("Finish"));
|
|
36
|
+
|
|
37
|
+
expect(onFinish).toHaveBeenCalledWith(
|
|
38
|
+
"1. Tablas\n2. Claves",
|
|
39
|
+
"1. Tablas\n2. Claves"
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("hands a video's transcript over as the seed, outside the instructions", () => {
|
|
44
|
+
const onFinish = vi.fn();
|
|
45
|
+
render(<Uploader onFinish={onFinish} />);
|
|
46
|
+
|
|
47
|
+
fireEvent.click(screen.getByText("Share a Youtube link"));
|
|
48
|
+
fireEvent.click(screen.getByText("mock-link"));
|
|
49
|
+
|
|
50
|
+
expect(onFinish).toHaveBeenCalledWith(
|
|
51
|
+
`The following information comes from a Youtube video uploaded by the user. Use the transcript to create the course: ${JSON.stringify(LINK)}`,
|
|
52
|
+
LINK.transcript
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("gives no seed text for files, which are read from the store", () => {
|
|
57
|
+
const onFinish = vi.fn();
|
|
58
|
+
render(<Uploader onFinish={onFinish} />);
|
|
59
|
+
|
|
60
|
+
fireEvent.click(screen.getByText("Upload files"));
|
|
61
|
+
fireEvent.click(screen.getByText("mock-files"));
|
|
62
|
+
|
|
63
|
+
expect(onFinish).toHaveBeenCalledWith("");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -30,7 +30,10 @@ const TextUploader = ({ onFinish }: { onFinish: (text: string) => void }) => {
|
|
|
30
30
|
export const Uploader = ({
|
|
31
31
|
onFinish,
|
|
32
32
|
}: {
|
|
33
|
-
|
|
33
|
+
// seedText is the readable content behind `text`, used to detect the
|
|
34
|
+
// course language: the pasted text, or a video's transcript without the
|
|
35
|
+
// instructions wrapped around it. Uploaded files are read from the store.
|
|
36
|
+
onFinish: (text: string, seedText?: string) => void
|
|
34
37
|
}) => {
|
|
35
38
|
const [selectedOption, setSelectedOption] = useState<string | null>(null)
|
|
36
39
|
const { t } = useTranslation()
|
|
@@ -62,7 +65,9 @@ export const Uploader = ({
|
|
|
62
65
|
</>
|
|
63
66
|
)}
|
|
64
67
|
|
|
65
|
-
{selectedOption === "text" &&
|
|
68
|
+
{selectedOption === "text" && (
|
|
69
|
+
<TextUploader onFinish={(text) => onFinish(text, text)} />
|
|
70
|
+
)}
|
|
66
71
|
|
|
67
72
|
{selectedOption === "files" && (
|
|
68
73
|
<FileUploader
|
|
@@ -80,7 +85,8 @@ export const Uploader = ({
|
|
|
80
85
|
onFinish(
|
|
81
86
|
`The following information comes from a Youtube video uploaded by the user. Use the transcript to create the course: ${JSON.stringify(
|
|
82
87
|
link
|
|
83
|
-
)}
|
|
88
|
+
)}`,
|
|
89
|
+
link.transcript
|
|
84
90
|
)
|
|
85
91
|
}}
|
|
86
92
|
/>
|
|
@@ -121,11 +121,18 @@
|
|
|
121
121
|
"switchTo": "Yes, switch to {{language}}",
|
|
122
122
|
"stayInen": "Stay in English",
|
|
123
123
|
"stayInes": "Stay in Spanish",
|
|
124
|
+
"chooseTitle": "Course language",
|
|
125
|
+
"chooseMessage": "Which language should the course be in?",
|
|
124
126
|
"languages": {
|
|
125
127
|
"en": "English",
|
|
126
128
|
"es": "Spanish"
|
|
127
129
|
}
|
|
128
130
|
},
|
|
131
|
+
"courseLanguage": {
|
|
132
|
+
"label": "Course language",
|
|
133
|
+
"auto": "Auto",
|
|
134
|
+
"autoHint": "Uses the language of your files and your description. If they don't match, we'll ask you."
|
|
135
|
+
},
|
|
129
136
|
"login": {
|
|
130
137
|
"creatingAccount": "Creating account…",
|
|
131
138
|
"loggedInSuccessfully": "Logged in successfully",
|
|
@@ -121,11 +121,18 @@
|
|
|
121
121
|
"switchTo": "Sí, cambiar a {{language}}",
|
|
122
122
|
"stayInen": "Mantenerse en Inglés",
|
|
123
123
|
"stayInes": "Mantenerse en Español",
|
|
124
|
+
"chooseTitle": "Idioma del curso",
|
|
125
|
+
"chooseMessage": "¿En qué idioma quieres el curso?",
|
|
124
126
|
"languages": {
|
|
125
127
|
"en": "Inglés",
|
|
126
128
|
"es": "Español"
|
|
127
129
|
}
|
|
128
130
|
},
|
|
131
|
+
"courseLanguage": {
|
|
132
|
+
"label": "Idioma del curso",
|
|
133
|
+
"auto": "Automático",
|
|
134
|
+
"autoHint": "Usa el idioma de tus archivos y tu descripción. Si no coinciden, te preguntamos."
|
|
135
|
+
},
|
|
129
136
|
"login": {
|
|
130
137
|
"creatingAccount": "Creando cuenta…",
|
|
131
138
|
"loggedInSuccessfully": "Inicio de sesión exitoso",
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
decideCourseLanguage,
|
|
4
|
+
detectDescriptionLanguage,
|
|
5
|
+
detectSeedLanguage,
|
|
6
|
+
detectTextLanguage,
|
|
7
|
+
toCourseLanguage,
|
|
8
|
+
} from "./courseLanguage";
|
|
9
|
+
|
|
10
|
+
const ENGLISH =
|
|
11
|
+
"Relational databases store information in tables made of rows and columns. " +
|
|
12
|
+
"Each table describes one kind of thing, such as customers or orders, and the " +
|
|
13
|
+
"relationships between tables are expressed through keys. In this chapter you " +
|
|
14
|
+
"will learn how to write queries that filter, sort and join data, and why " +
|
|
15
|
+
"indexes make those queries fast even when the tables hold millions of rows. ";
|
|
16
|
+
|
|
17
|
+
const SPANISH =
|
|
18
|
+
"Las bases de datos relacionales guardan la información en tablas formadas " +
|
|
19
|
+
"por filas y columnas. Cada tabla describe un tipo de cosa, como clientes o " +
|
|
20
|
+
"pedidos, y las relaciones entre tablas se expresan mediante claves. En este " +
|
|
21
|
+
"capítulo aprenderás a escribir consultas que filtran, ordenan y combinan " +
|
|
22
|
+
"datos, y por qué los índices hacen que esas consultas sean rápidas. ";
|
|
23
|
+
|
|
24
|
+
const PORTUGUESE =
|
|
25
|
+
"Os bancos de dados relacionais armazenam as informações em tabelas formadas " +
|
|
26
|
+
"por linhas e colunas. Cada tabela descreve um tipo de coisa, como clientes ou " +
|
|
27
|
+
"pedidos, e as relações entre as tabelas são expressas por meio de chaves. " +
|
|
28
|
+
"Neste capítulo você vai aprender a escrever consultas que filtram, ordenam e " +
|
|
29
|
+
"combinam dados, e por que os índices tornam essas consultas rápidas. ";
|
|
30
|
+
|
|
31
|
+
const FRENCH =
|
|
32
|
+
"Les bases de données relationnelles stockent les informations dans des " +
|
|
33
|
+
"tables composées de lignes et de colonnes. Chaque table décrit un type de " +
|
|
34
|
+
"chose, comme les clients ou les commandes, et les relations entre les tables " +
|
|
35
|
+
"sont exprimées par des clés. Dans ce chapitre, vous apprendrez à écrire des " +
|
|
36
|
+
"requêtes qui filtrent, trient et combinent des données. ";
|
|
37
|
+
|
|
38
|
+
const ENGLISH_COVER =
|
|
39
|
+
"University of Northfield. School of Engineering. Department of Computer " +
|
|
40
|
+
"Science. Lecture notes for the second year. Copyright 2024, all rights " +
|
|
41
|
+
"reserved. Prepared by the teaching staff for internal use only. ";
|
|
42
|
+
|
|
43
|
+
const ENGLISH_REFERENCES =
|
|
44
|
+
"Codd, E. F. A relational model of data for large shared data banks. " +
|
|
45
|
+
"Communications of the ACM, volume 13, issue 6, pages 377 to 387. " +
|
|
46
|
+
"Date, C. J. An introduction to database systems. Addison Wesley. ";
|
|
47
|
+
|
|
48
|
+
describe("toCourseLanguage", () => {
|
|
49
|
+
it.each([
|
|
50
|
+
["es", "es"],
|
|
51
|
+
["en", "en"],
|
|
52
|
+
["EN", "en"],
|
|
53
|
+
[" es ", "es"],
|
|
54
|
+
])("accepts %j as %s", (value, expected) => {
|
|
55
|
+
expect(toCourseLanguage(value)).toBe(expected);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it.each(["fr", "pt", "es-MX", "en-US", "", "spanish"])(
|
|
59
|
+
"rejects %j",
|
|
60
|
+
(value) => {
|
|
61
|
+
expect(toCourseLanguage(value)).toBeNull();
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
it("rejects a missing value", () => {
|
|
66
|
+
expect(toCourseLanguage(undefined)).toBeNull();
|
|
67
|
+
expect(toCourseLanguage(null)).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("detectTextLanguage", () => {
|
|
72
|
+
it("detects English and Spanish text", () => {
|
|
73
|
+
expect(detectTextLanguage(ENGLISH.repeat(2))).toBe("en");
|
|
74
|
+
expect(detectTextLanguage(SPANISH.repeat(2))).toBe("es");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("reports other languages instead of passing them off as Spanish or English", () => {
|
|
78
|
+
expect(detectTextLanguage(PORTUGUESE.repeat(2))).toBe("other");
|
|
79
|
+
expect(detectTextLanguage(FRENCH.repeat(2))).toBe("other");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does not judge text with fewer than 200 letters", () => {
|
|
83
|
+
expect(detectTextLanguage("Python basics for data analysis")).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not count digits and symbols as letters", () => {
|
|
87
|
+
// Over 200 characters, but under 200 letters: too little language to judge.
|
|
88
|
+
const text =
|
|
89
|
+
"Las ventas del trimestre por región y por producto son estas: " +
|
|
90
|
+
"| 2024 | 15.30 | 7 | 0.25 | 1999 | 48.10 | 3 | 0.75 |\n".repeat(6);
|
|
91
|
+
expect(text.length).toBeGreaterThan(200);
|
|
92
|
+
expect(detectTextLanguage(text)).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("is not swayed by an English cover page on a Spanish document", () => {
|
|
96
|
+
const text = ENGLISH_COVER + SPANISH.repeat(20);
|
|
97
|
+
expect(detectTextLanguage(text)).toBe("es");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("is not swayed by a long English bibliography after a Spanish body", () => {
|
|
101
|
+
// The body fills the first five windows, so the references never vote.
|
|
102
|
+
const text = SPANISH.repeat(40) + ENGLISH_REFERENCES.repeat(200);
|
|
103
|
+
expect(detectTextLanguage(text)).toBe("es");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("does not guess when no language holds two thirds of the windows", () => {
|
|
107
|
+
// About half of the first five windows are Spanish, half English.
|
|
108
|
+
const text = SPANISH.repeat(14) + ENGLISH.repeat(40);
|
|
109
|
+
expect(detectTextLanguage(text)).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("detectSeedLanguage", () => {
|
|
114
|
+
const en = ENGLISH.repeat(2);
|
|
115
|
+
const es = SPANISH.repeat(2);
|
|
116
|
+
const pt = PORTUGUESE.repeat(2);
|
|
117
|
+
|
|
118
|
+
it("returns the language all sources share", () => {
|
|
119
|
+
expect(detectSeedLanguage([en])).toBe("en");
|
|
120
|
+
expect(detectSeedLanguage([es, es])).toBe("es");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("reports sources that disagree instead of letting the longest win", () => {
|
|
124
|
+
expect(detectSeedLanguage([en.repeat(10), es])).toBe("mixed");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("ignores sources too short to judge", () => {
|
|
128
|
+
expect(detectSeedLanguage([en, "Notas"])).toBe("en");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("settles nothing when any source is in an unsupported language", () => {
|
|
132
|
+
expect(detectSeedLanguage([en, pt])).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("settles nothing without judgeable sources", () => {
|
|
136
|
+
expect(detectSeedLanguage([])).toBeNull();
|
|
137
|
+
expect(detectSeedLanguage(["", "Notas"])).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("detectDescriptionLanguage", () => {
|
|
142
|
+
it.each([
|
|
143
|
+
"Learn TypeScript generics",
|
|
144
|
+
"Write better SQL queries",
|
|
145
|
+
"How to use pandas DataFrames",
|
|
146
|
+
"I want to learn how to build REST APIs with Flask",
|
|
147
|
+
"Teach the API documentation of our payment service",
|
|
148
|
+
])("reads %j as English", (description) => {
|
|
149
|
+
expect(detectDescriptionLanguage(description)).toBe("en");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it.each([
|
|
153
|
+
"Curso de React hooks",
|
|
154
|
+
"Quiero aprender a construir APIs REST con Flask",
|
|
155
|
+
"Álgebra lineal para machine learning",
|
|
156
|
+
"Pipelines de CI/CD con GitHub Actions",
|
|
157
|
+
"¿Cómo funciona Docker?",
|
|
158
|
+
])("reads %j as Spanish", (description) => {
|
|
159
|
+
expect(detectDescriptionLanguage(description)).toBe("es");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it.each([
|
|
163
|
+
"React hooks",
|
|
164
|
+
"Kubernetes",
|
|
165
|
+
"Data science con python course for beginners",
|
|
166
|
+
"",
|
|
167
|
+
])("cannot tell %j", (description) => {
|
|
168
|
+
expect(detectDescriptionLanguage(description)).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("decideCourseLanguage", () => {
|
|
173
|
+
it.each([
|
|
174
|
+
// The user's choice wins over everything, with or without a seed.
|
|
175
|
+
[
|
|
176
|
+
{ chosen: "es", seed: null, description: null },
|
|
177
|
+
{ kind: "use", language: "es" },
|
|
178
|
+
],
|
|
179
|
+
[
|
|
180
|
+
{ chosen: "es", seed: "en", description: "en" },
|
|
181
|
+
{ kind: "use", language: "es" },
|
|
182
|
+
],
|
|
183
|
+
[
|
|
184
|
+
{ chosen: "en", seed: "mixed", description: "es" },
|
|
185
|
+
{ kind: "use", language: "en" },
|
|
186
|
+
],
|
|
187
|
+
// Without a seed nothing is settled: the request goes out as before.
|
|
188
|
+
[{ chosen: null, seed: null, description: null }, { kind: "model" }],
|
|
189
|
+
[{ chosen: null, seed: null, description: "es" }, { kind: "model" }],
|
|
190
|
+
// The seed is used when the description agrees or cannot be judged.
|
|
191
|
+
[
|
|
192
|
+
{ chosen: null, seed: "en", description: "en" },
|
|
193
|
+
{ kind: "use", language: "en" },
|
|
194
|
+
],
|
|
195
|
+
[
|
|
196
|
+
{ chosen: null, seed: "en", description: null },
|
|
197
|
+
{ kind: "use", language: "en" },
|
|
198
|
+
],
|
|
199
|
+
[
|
|
200
|
+
{ chosen: null, seed: "es", description: "es" },
|
|
201
|
+
{ kind: "use", language: "es" },
|
|
202
|
+
],
|
|
203
|
+
[
|
|
204
|
+
{ chosen: null, seed: "es", description: null },
|
|
205
|
+
{ kind: "use", language: "es" },
|
|
206
|
+
],
|
|
207
|
+
// Any disagreement is the user's call.
|
|
208
|
+
[{ chosen: null, seed: "en", description: "es" }, { kind: "ask" }],
|
|
209
|
+
[{ chosen: null, seed: "es", description: "en" }, { kind: "ask" }],
|
|
210
|
+
[{ chosen: null, seed: "mixed", description: null }, { kind: "ask" }],
|
|
211
|
+
[{ chosen: null, seed: "mixed", description: "en" }, { kind: "ask" }],
|
|
212
|
+
] as const)("%j decides %j", (input, expected) => {
|
|
213
|
+
expect(decideCourseLanguage(input)).toEqual(expected);
|
|
214
|
+
});
|
|
215
|
+
});
|