Ctrl K
ComunidadGuías…

tests/french.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// French auto-correction (shared/french.ts): the table's rules, the line
// detector's threshold, and the two planners the editor dispatches from.
//
// The table's one promise is that it never guesses, so the tests are mostly
// about what it must NOT contain: a source that is itself a French word, a
// target that changes nothing, a duplicate that would make the object literal
// lie about its own size.

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import {
  BOUNDARIES,
  FRENCH_TABLE,
  NARROW_NBSP,
  NBSP,
  frenchCorrection,
  frenchScore,
  lineFixes,
  looksFrench,
  noteIsFrench,
  typographyFix,
  wordFix,
} from "../shared/french.ts";

/** Real French words that a naive accent table would list as sources — each
 *  is a word in its own right, with or without the mark, and the table must
 *  hold none of them. The doc comment on the table names the same set; this
 *  is the same list as an assertion. */
const REAL_WORDS = [
  "a", "ou", "la", "sur", "du", "cote", "tache", "mur", "pres", "gene", "peche", "foret", "mais",
  "pate", "traite", "fatigue", "varie", "eleve", "notre", "votre", "the", "des", "les", "de", "en", "on",
  "mat", "pale", "male", "aine", "cru", "jeune", "hale", "bailler", "chasse", "tache",
];

/** Sources with two everyday readings — `cree` is `crée` as often as `créé`,
 *  `reserve` is `réserve` or `réservé` — which a table that never guesses
 *  cannot hold either. */
const TWO_WAYS = [
  "cree", "enonce", "prefere", "resume", "reserve", "controle", "regle", "celebre", "age", "equipe", "diplome",
  "reve", "depense", "echange", "melange", "prete", "epouse", "lache", "fete", "revolte", "epice", "reforme",
  "coute", "equilibre",
];

describe("the correction table", () => {
  it("has no source that is a real French word", () => {
    for (const w of REAL_WORDS) assert.equal(FRENCH_TABLE[w], undefined, `${w} must not be a source`);
  });

  it("has no source that reads two ways", () => {
    for (const w of TWO_WAYS) assert.equal(FRENCH_TABLE[w], undefined, `${w} must not be a source`);
  });

  it("has no duplicate sources", () => {
    // An object literal silently keeps the LAST of two equal keys, so the
    // check is on the source text: every `key:` before a quoted value.
    const src = readFileSync(new URL("../shared/french.ts", import.meta.url), "utf8");
    const body = src.slice(src.indexOf("const TABLE"), src.indexOf("};", src.indexOf("const TABLE")));
    const keys = [...body.matchAll(/(?:^|[\s{,])([a-z]+):\s*"/g)].map((m) => m[1]);
    const seen = new Set<string>();
    const dupes: string[] = [];
    for (const k of keys) {
      if (seen.has(k)) dupes.push(k);
      seen.add(k);
    }
    assert.deepEqual(dupes, []);
    assert.equal(keys.length, Object.keys(FRENCH_TABLE).length);
  });

  it("is at least the size the feature promised", () => {
    // 150 was the floor asked for; the table is a few hundred past it, and
    // every entry above the floor passed the same rules as the ones below.
    const n = Object.keys(FRENCH_TABLE).length;
    assert.ok(n >= 150, `${n} entries`);
  });

  it("only maps ASCII sources to a target that actually adds something", () => {
    for (const [source, target] of Object.entries(FRENCH_TABLE)) {
      assert.match(source, /^[a-z]+$/, `${source} is not lowercase ASCII`);
      assert.notEqual(source, target, `${source} corrects to itself`);
      // Every target carries a mark the source lacked — an accent, a cedilla,
      // a ligature — which is the only kind of correction this table makes.
      assert.match(target, /[^\x00-\x7f]/, `${source} → ${target} adds no accent`);
      // …and is otherwise the same word: strip the marks and the letters agree.
      const stripped = target.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/œ/g, "oe");
      assert.equal(stripped, source, `${source} → ${target} changes more than the marks`);
    }
  });

  it("answers the owner's examples", () => {
    assert.equal(frenchCorrection("tres"), "très");
    assert.equal(frenchCorrection("etre"), "être");
    assert.equal(frenchCorrection("deja"), "déjà");
    assert.equal(frenchCorrection("francais"), "français");
    assert.equal(frenchCorrection("coeur"), "cœur");
    assert.equal(frenchCorrection("oeuvre"), "œuvre");
    assert.equal(frenchCorrection("soeur"), "sœur");
    assert.equal(frenchCorrection("ecole"), "école");
    assert.equal(frenchCorrection("etudiant"), "étudiant");
    assert.equal(frenchCorrection("hopital"), "hôpital");
    assert.equal(frenchCorrection("meme"), "même");
  });

  it("never touches the words that go both ways", () => {
    for (const w of ["a", "ou", "la", "sur", "du", "eleve", "cote", "the"]) assert.equal(frenchCorrection(w), null);
  });

  it("follows the writer's capital, and leaves capitals alone", () => {
    assert.equal(frenchCorrection("Ecole"), "École");
    assert.equal(frenchCorrection("Etat"), "État");
    assert.equal(frenchCorrection("Tres"), "Très");
    assert.equal(frenchCorrection("ETAT"), null);
    assert.equal(frenchCorrection("eTat"), null);
  });

  it("ignores anything that is not a bare word", () => {
    assert.equal(frenchCorrection("très"), null);
    assert.equal(frenchCorrection("tres2"), null);
    assert.equal(frenchCorrection(""), null);
    assert.equal(frenchCorrection("t-res"), null);
  });
});

describe("looksFrench", () => {
  it("takes three French sentences", () => {
    assert.ok(looksFrench("Je suis tres content de vous voir."));
    assert.ok(looksFrench("Elle a un coeur d'or et une patience infinie."));
    assert.ok(looksFrench("Nous avons visité l'école du village hier."));
  });

  it("counts the accents as one marker, not one per word", () => {
    // An English line quoting two accented loanwords is still English; the
    // accents alone never make a line French, and `é` — the one English
    // borrows most (café, résumé, fiancé) — is not a marker at all.
    assert.equal(frenchScore("Très bientôt"), 1);
    assert.equal(frenchScore("a naïve café résumé"), 0);
    assert.ok(looksFrench("Très bientôt, je pense"));
    assert.ok(!looksFrench("The crème brûlée was perfect"));
  });

  it("counts the elisions", () => {
    assert.equal(frenchScore("j'ai"), 1);
    assert.equal(frenchScore("c'est"), 2);
    assert.equal(frenchScore("qu’il"), 2);
    // A bare `l` is nothing, and English `don't` never yields a bare `n`.
    assert.equal(frenchScore("I don't know l"), 0);
  });

  it("refuses English, even English about a car and its son", () => {
    assert.ok(!looksFrench("This is tres chic"));
    assert.ok(!looksFrench("I put it on the car, plus a son and me"));
    assert.ok(!looksFrench("The quick brown fox jumps over the lazy dog"));
    assert.ok(!looksFrench("Beyoncé played the café"));
    assert.ok(!looksFrench("Zoë's fiancé opened a crêperie"));
  });

  it("does not count a word in capitals", () => {
    // `UN`, `LA`, `EST`, `ET` are an organisation, a city, a time zone and
    // a studio; an English meeting note carrying two of them was French.
    assert.equal(frenchScore("Meeting at 5pm EST with the UN about the hotel"), 0);
    assert.ok(!looksFrench("LA and the UN"));
    // A capital at the head of a sentence still counts.
    assert.equal(frenchScore("Je suis"), 2);
    assert.ok(looksFrench("Il est"));
  });

  it("refuses the Romance languages that share its function words, and Latin", () => {
    // `la`, `de`, `un`, `que`, `il`, `les` are Spanish, Italian, Portuguese
    // and Catalan too; one word French does not use settles the line.
    assert.ok(!looksFrench("la casa de mi madre es un hotel"));
    assert.ok(!looksFrench("il libro e la casa dei nonni"));
    assert.ok(!looksFrench("a casa da minha mãe não é um hotel"));
    assert.ok(!looksFrench("els amics de la casa"));
    assert.ok(!looksFrench("Deus est qui non est, ergo sunt"));
    // …but `tu es` is French, and `con` and `dos` are French words too.
    assert.ok(looksFrench("tu es la seule"));
    assert.ok(looksFrench("le con et le dos"));
  });

  it("refuses Arabic, Hebrew and CJK lines whatever they also carry", () => {
    assert.ok(!looksFrench("الحمد لله je suis très content"));
    assert.ok(!looksFrench("שלום et bonjour à tous"));
    assert.ok(!looksFrench("日本語 et la France"));
  });

  it("refuses code", () => {
    // A line of identifiers that happen to spell `le`, `les`, `un`, `de`
    // WOULD score — which is why the editor asks the syntax tree before it
    // asks this function. What this function promises is only that ordinary
    // code does not read as French:
    assert.ok(!looksFrench("for (let i = 0; i < n; i++) total += rows[i].value;"));
    assert.ok(!looksFrench("SELECT id, name FROM users WHERE active = 1;"));
    assert.ok(!looksFrench("git commit -m 'fix the thing'"));
  });

  it("reads the note's own frontmatter", () => {
    assert.ok(noteIsFrench("title: Notes\nlang: fr\n"));
    assert.ok(noteIsFrench("language: fr-CA"));
    assert.ok(noteIsFrench('lang: "FR"'));
    assert.ok(!noteIsFrench("lang: en"));
    assert.ok(!noteIsFrench("title: fr\n"));
    assert.ok(!noteIsFrench(""));
  });
});

describe("wordFix", () => {
  it("corrects the word ending at the caret, relative to it", () => {
    assert.deepEqual(wordFix("Je suis tres"), { from: -4, to: 0, insert: "très", word: "tres" });
    assert.deepEqual(wordFix("l'ecole"), { from: -5, to: 0, insert: "école", word: "ecole" });
  });

  it("has nothing to say when there is no word, or no correction", () => {
    assert.equal(wordFix(""), null);
    assert.equal(wordFix("Je suis "), null);
    assert.equal(wordFix("Je suis content"), null);
  });

  it("leaves TeX commands, tags, paths and addresses alone", () => {
    assert.equal(wordFix("\\etat"), null);
    assert.equal(wordFix("#etat"), null);
    assert.equal(wordFix("example.com/etat"), null);
    assert.equal(wordFix("voir https://fr.wikipedia.org/wiki/etat"), null);
    assert.equal(wordFix("www.etat"), null);
    assert.equal(wordFix("moi@etat"), null);
    assert.equal(wordFix("un_etat"), null);
    assert.equal(wordFix("3etat"), null);
    // …but an address earlier on the line, separated by a space, is over.
    assert.equal(wordFix("https://x.y/z tres")?.insert, "très");
  });

  it("names the boundaries the editor listens for", () => {
    for (const c of [" ", ".", ",", "?", ")", "»", "-", "*"]) assert.ok(BOUNDARIES.has(c), c);
    assert.ok(!BOUNDARIES.has("_"));
    assert.ok(!BOUNDARIES.has("/"));
  });
});

describe("lineFixes", () => {
  it("corrects a whole line at once, words and typography, in order", () => {
    const line = "Tres bien, je dis « tres » et alors... vraiment ?";
    assert.deepEqual(lineFixes(line), [
      { from: 0, to: 4, insert: "Très", word: "Tres" },
      { from: 19, to: 20, insert: NBSP },
      { from: 20, to: 24, insert: "très", word: "tres" },
      { from: 24, to: 25, insert: NBSP },
      { from: 35, to: 38, insert: "…" },
      { from: 47, to: 48, insert: NARROW_NBSP },
    ]);
  });

  it("keeps the per-word rules", () => {
    const fixes = lineFixes("\\etat #etat 3etat etat_x etat2 https://x.y/etat www.etat - : etat");
    assert.deepEqual(fixes, [{ from: 61, to: 65, insert: "état", word: "etat" }]);
    assert.deepEqual(lineFixes("Je suis très content"), []);
    assert.deepEqual(lineFixes(""), []);
  });
});

describe("typographyFix", () => {
  it("narrows the space before tall punctuation, after a word only", () => {
    assert.deepEqual(typographyFix("Vraiment ", "?"), { from: -1, to: 0, insert: NARROW_NBSP });
    assert.deepEqual(typographyFix("Non ", "!"), { from: -1, to: 0, insert: NARROW_NBSP });
    assert.deepEqual(typographyFix("Note ", ":"), { from: -1, to: 0, insert: NARROW_NBSP });
    assert.deepEqual(typographyFix("(oui) ", ";"), { from: -1, to: 0, insert: NARROW_NBSP });
    // A list marker's or a table pipe's space is markdown's, not prose.
    assert.equal(typographyFix("- ", ":"), null);
    assert.equal(typographyFix("| ", ":"), null);
    assert.equal(typographyFix(" ", "?"), null);
    // No space, no change: the rule converts a space, it never adds one.
    assert.equal(typographyFix("Vraiment", "?"), null);
    // Already narrow: nothing to do.
    assert.equal(typographyFix(`Vraiment${NARROW_NBSP}`, "?"), null);
  });

  it("puts no-break spaces inside guillemets", () => {
    assert.deepEqual(typographyFix("«", " "), { from: 0, to: 1, insert: NBSP });
    assert.deepEqual(typographyFix("« bonjour ", "»"), { from: -1, to: 0, insert: NBSP });
    assert.equal(typographyFix("bonjour", "»"), null);
  });

  it("turns three dots into an ellipsis, once", () => {
    assert.deepEqual(typographyFix("Alors..", "."), { from: -2, to: 1, insert: "…" });
    assert.equal(typographyFix("Alors.", "."), null);
    assert.equal(typographyFix("Alors...", "."), null);
  });
});