J'suis pas sûr qu'une autre femme soit la solution à nos problèmes.
+
diff --git a/tests/fixtures/juste-leblanc.html b/tests/fixtures/juste-leblanc.html
new file mode 100644
index 0000000..8b1a1cd
--- /dev/null
+++ b/tests/fixtures/juste-leblanc.html
@@ -0,0 +1,13 @@
+
+
+juste-leblanc
Transcrit avec noScribe vers. 0.7
Fichier audio : /Users/axelleabbadie/SynologyDrive/Scientifique/Général/Tools/Pseudobsidianization/tests/fixtures/juste-leblanc.mp3
(Début (hh꞉mm꞉ss) ꞉ 00꞉01꞉28 | Fin (hh꞉mm꞉ss) ꞉ 00꞉02꞉15 | Langue : French (fr) | Détection des locuteurs : 2 | Chevauchements de parole 1 | Horodatage : 1 | Mots de remplissage : 1 | Marquer les pauses : 1)
S00: [00:01:28] Je vais le faire moi-même. Il s'appelle Juste Leblanc.
S01 : Ah bon, il n'a pas de prénom ? (.)
S00: [00:01:34] Je viens de vous le dire, Juste Leblanc. (....) Leblanc, c'est son nom et c'est Juste son prénom. (...)
S01: Hum
S00: Monsieur Pignon, votre prénom à vous, c'est François, c'est Juste ? Eh bien, lui, c'est pareil, c'est Juste. (...) Bon, on a assez perdu de temps comme ça.
S00: [00:01:56] Ma femme a signé le roman de son nom de jeune fille, Christine Le Guérec.
S01: Ah bon ? Elle est bretonne ?
S00: Je vous en prie, restez concentrés.
S01: Excusez-moi. (.)
S00: Et vous n'oubliez pas, en fin de conversation, vous lui demandez où vous pouvez joindre Christine Le Guérec. (.) Ça sonne, je vous mets sur haut-parleur. C'est à vous.
S01: Je prends l'accent belge ?
S00: Non !
\ No newline at end of file
diff --git a/tests/fixtures/juste-leblanc.mp3 b/tests/fixtures/juste-leblanc.mp3
new file mode 100644
index 0000000..f42a0ee
Binary files /dev/null and b/tests/fixtures/juste-leblanc.mp3 differ
diff --git a/tests/unit/NoScribeHtmlParser.test.ts b/tests/unit/NoScribeHtmlParser.test.ts
new file mode 100644
index 0000000..678b54c
--- /dev/null
+++ b/tests/unit/NoScribeHtmlParser.test.ts
@@ -0,0 +1,104 @@
+import { NoScribeHtmlParser } from '../../src/parsers/NoScribeHtmlParser';
+
+const parser = new NoScribeHtmlParser();
+
+// ---- Fixture ----------------------------------------------------------------
+// Structure calquée sur une vraie sortie noScribe (Qt Rich Text HTML).
+// Les timestamps sont en millisecondes depuis le début de l'audio.
+
+const NOSCRIBE_HTML = `
+
+
+
+
+
+`;
+
+// ---- Tests ------------------------------------------------------------------
+
+describe('NoScribeHtmlParser — isNoScribeHtml', () => {
+ test('reconnaît un HTML noScribe', () => {
+ expect(NoScribeHtmlParser.isNoScribeHtml(NOSCRIBE_HTML)).toBe(true);
+ });
+
+ test('rejette un HTML ordinaire', () => {
+ expect(NoScribeHtmlParser.isNoScribeHtml('Bonjour
')).toBe(false);
+ });
+});
+
+describe('NoScribeHtmlParser — parse', () => {
+ test('ignore les paragraphes sans ancre ts_', () => {
+ const doc = parser.parse(NOSCRIBE_HTML);
+ // Les 2 paragraphes d'en-tête (titre + métadonnées) ne doivent pas produire de cue
+ // Les cues sont : pause, tour S00, tour S01, tour S00 long = 4 cues
+ expect(doc.cues.length).toBe(4);
+ });
+
+ test('extrait la pause sans locuteur', () => {
+ const doc = parser.parse(NOSCRIBE_HTML);
+ const pause = doc.cues[0];
+ expect(pause.speaker).toBeUndefined();
+ expect(pause.text).toContain('..');
+ expect(pause.startTime).toBe('00:00:00.000');
+ expect(pause.endTime).toBe('00:00:02.130');
+ });
+
+ test('extrait le locuteur depuis "Nom : texte" multi-ancres', () => {
+ const doc = parser.parse(NOSCRIBE_HTML);
+ const turn = doc.cues[1];
+ expect(turn.speaker).toBe('Expérimentateur');
+ expect(turn.text).not.toContain('Expérimentateur');
+ expect(turn.text).toContain('Ok. D\'abord');
+ });
+
+ test('extrait le locuteur depuis "Nom : texte" mono-ancre', () => {
+ const doc = parser.parse(NOSCRIBE_HTML);
+ const turn = doc.cues[2];
+ expect(turn.speaker).toBe('SC01');
+ expect(turn.text).toContain('Si je me présente');
+ expect(turn.text).not.toContain('SC01');
+ });
+
+ test('convertit les timestamps en HH:MM:SS.mmm', () => {
+ const doc = parser.parse(NOSCRIBE_HTML);
+ const turn = doc.cues[1];
+ expect(turn.startTime).toBe('00:00:02.130');
+ expect(turn.endTime).toBe('00:00:12.230');
+ });
+
+ test('timestamp long (> 27 min) correctement converti', () => {
+ const doc = parser.parse(NOSCRIBE_HTML);
+ const turn = doc.cues[3];
+ // 1668980ms = 27min 48.980s
+ expect(turn.startTime).toBe('00:27:48.980');
+ expect(turn.endTime).toBe('00:27:53.180');
+ });
+
+ test('word timestamps produits pour chaque ancre distincte', () => {
+ const doc = parser.parse(NOSCRIBE_HTML);
+ const turn = doc.cues[1]; // Expérimentateur avec 3 ancres ts_ distinctes
+ expect(turn.words.length).toBeGreaterThanOrEqual(2);
+ expect(turn.words[0].time).toBe('00:00:02.130');
+ });
+
+ test('ignore les timestamps d\'affichage [HH:MM:SS]', () => {
+ const doc = parser.parse(NOSCRIBE_HTML);
+ const turn = doc.cues[1];
+ expect(turn.text).not.toMatch(/\[\d{2}:\d{2}:\d{2}\]/);
+ });
+});
diff --git a/tests/unit/NoScribeVttParser.test.ts b/tests/unit/NoScribeVttParser.test.ts
new file mode 100644
index 0000000..edabd2b
--- /dev/null
+++ b/tests/unit/NoScribeVttParser.test.ts
@@ -0,0 +1,138 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { NoScribeVttParser } from '../../src/parsers/NoScribeVttParser';
+import { NoScribeHtmlParser } from '../../src/parsers/NoScribeHtmlParser';
+import { noScribeHtmlToMarkdown } from '../../src/parsers/TranscriptConverter';
+
+const VTT_FIXTURE = path.join(__dirname, '../fixtures/fight_club.vtt');
+const HTML_FIXTURE = path.join(__dirname, '../fixtures/juste-leblanc.html');
+
+// ---- Détection --------------------------------------------------------------
+
+describe('NoScribeVttParser — détection', () => {
+ test('reconnaît un VTT noScribe', () => {
+ const content = fs.readFileSync(VTT_FIXTURE, 'utf-8');
+ expect(NoScribeVttParser.isNoScribeVtt(content)).toBe(true);
+ });
+
+ test('rejette un VTT standard', () => {
+ expect(NoScribeVttParser.isNoScribeVtt('WEBVTT\n\n1\n00:00:01.000 --> 00:00:02.000\nBonjour\n')).toBe(false);
+ });
+
+ test('extrait le chemin audio depuis NOTE media', () => {
+ const content = fs.readFileSync(VTT_FIXTURE, 'utf-8');
+ const src = NoScribeVttParser.extractAudioSource(content);
+ expect(src).toMatch(/fight_club\.mp3$/);
+ });
+});
+
+// ---- Parsing VTT fight_club -------------------------------------------------
+
+describe('NoScribeVttParser — fight_club.vtt', () => {
+ let doc: ReturnType;
+
+ beforeAll(() => {
+ const content = fs.readFileSync(VTT_FIXTURE, 'utf-8');
+ doc = new NoScribeVttParser().parse(content);
+ });
+
+ test('produit au moins 5 cues', () => {
+ expect(doc.cues.length).toBeGreaterThanOrEqual(5);
+ });
+
+ test('détecte les locuteurs S00, S01, S02', () => {
+ const speakers = new Set(doc.cues.map((c) => c.speaker).filter(Boolean));
+ expect(speakers.has('S00')).toBe(true);
+ expect(speakers.has('S01')).toBe(true);
+ expect(speakers.has('S02')).toBe(true);
+ });
+
+ test('premier cue : S00, timestamp correct, texte non vide', () => {
+ const first = doc.cues[0];
+ expect(first.speaker).toBe('S00');
+ expect(first.startTime).toBe('00:00:00.530');
+ expect(first.text).toContain('combat');
+ });
+
+ test('les timestamps d\'affichage [HH:MM:SS] sont absents du texte', () => {
+ for (const cue of doc.cues) {
+ expect(cue.text).not.toMatch(/\[\d{2}:\d{2}:\d{2}\]/);
+ }
+ });
+
+ test('les entités HTML sont décodées', () => {
+ const allText = doc.cues.map((c) => c.text).join(' ');
+ expect(allText).not.toContain(''');
+ expect(allText).toContain("'"); // apostrophes décodées
+ });
+
+ test('les labels SXX: sont absents du texte', () => {
+ for (const cue of doc.cues) {
+ expect(cue.text).not.toMatch(/^S\d+\s*:/);
+ }
+ });
+
+ test('chaque cue a un mot pour le timestamps word-level', () => {
+ for (const cue of doc.cues) {
+ expect(cue.words.length).toBeGreaterThan(0);
+ expect(cue.words[0].time).toBe(cue.startTime);
+ }
+ });
+});
+
+// ---- Parsing HTML juste-leblanc --------------------------------------------
+
+describe('NoScribeHtmlParser — juste-leblanc.html', () => {
+ let doc: ReturnType;
+
+ beforeAll(() => {
+ const content = fs.readFileSync(HTML_FIXTURE, 'utf-8');
+ doc = new NoScribeHtmlParser().parse(content);
+ });
+
+ test('produit au moins 8 cues', () => {
+ expect(doc.cues.length).toBeGreaterThanOrEqual(8);
+ });
+
+ test('détecte S00 et S01', () => {
+ const speakers = new Set(doc.cues.map((c) => c.speaker).filter(Boolean));
+ expect(speakers.has('S00')).toBe(true);
+ expect(speakers.has('S01')).toBe(true);
+ });
+
+ test('premier cue : S00, contient "Juste Leblanc"', () => {
+ const first = doc.cues.find((c) => c.speaker === 'S00');
+ expect(first?.text).toContain('Juste Leblanc');
+ });
+
+ test('les labels SXX: sont absents du texte', () => {
+ for (const cue of doc.cues) {
+ expect(cue.text).not.toMatch(/^S\d+\s*:/);
+ }
+ });
+
+ test('les timestamps [HH:MM:SS] sont absents du texte', () => {
+ for (const cue of doc.cues) {
+ expect(cue.text).not.toMatch(/\[\d{2}:\d{2}:\d{2}\]/);
+ }
+ });
+
+ test('audio source extrait correctement', () => {
+ const content = fs.readFileSync(HTML_FIXTURE, 'utf-8');
+ expect(NoScribeHtmlParser.extractAudioSource(content)).toMatch(/juste-leblanc\.mp3$/);
+ });
+});
+
+// ---- Markdown produit -------------------------------------------------------
+
+describe('noScribeHtmlToMarkdown — juste-leblanc', () => {
+ test('format clean : pas de commentaires HTML', () => {
+ const content = fs.readFileSync(HTML_FIXTURE, 'utf-8');
+ const doc = new NoScribeHtmlParser().parse(content);
+ const md = noScribeHtmlToMarkdown(doc, 'juste-leblanc.html');
+ expect(md).toContain('pseudobs-format: html');
+ expect(md).not.toContain(' 00:00:05.800
+Bonjour, je m'appelle Marie.
+
+2
+00:00:06.100 --> 00:00:09.300
+Si je me présente ?
+
+3
+00:00:09.800 --> 00:00:11.200
+(..)
+`;
+
+const NOSCRIBE_HTML_FIXTURE = `
+
+`;
+
+describe('vttToMarkdown — format noScribe', () => {
+ let md: string;
+ beforeAll(() => { md = vttToMarkdown(new VttParser().parse(VTT_FIXTURE), 'entretien.vtt'); });
+
+ it('frontmatter pseudobs-format: vtt', () => {
+ expect(md).toContain('pseudobs-format: vtt');
+ });
+
+ it('locuteur en gras suivi du timestamp [HH:MM:SS]', () => {
+ expect(md).toContain('**Expérimentateur** [00:00:01]');
+ expect(md).toContain('**SC01** [00:00:06]');
+ });
+
+ it('texte précédé de " : " quand il y a un locuteur', () => {
+ expect(md).toContain('**Expérimentateur** [00:00:01] : Bonjour');
+ });
+
+ it('cue sans locuteur : timestamp seul', () => {
+ expect(md).toContain('[00:00:09] (..)');
+ expect(md).not.toMatch(/\*\*\*\* \[/);
+ });
+
+ it('aucun commentaire HTML dans le corps', () => {
+ const body = md.split('---').slice(2).join('---');
+ expect(body).not.toContain(' 00:00:05.800
+<00:00:01.240> Bonjour,<00:00:02.100> Marie.
+
+2
+00:00:06.100 --> 00:00:09.300
+Si je me présente ?
+`;
+
+ it('extrait les cues avec word timestamps', () => {
+ const doc = new VttParser().parse(VTT_WITH_WORDS);
+ const data = extractWordData(doc);
+ expect(data).toHaveLength(1); // cue 2 sans word timestamps exclue
+ expect(data[0].index).toBe(0);
+ expect(data[0].speaker).toBe('Expérimentateur');
+ expect(data[0].startTime).toBe('00:00:01.240');
+ expect(data[0].endTime).toBe('00:00:05.800');
+ expect(data[0].words.some((w) => w.time !== '')).toBe(true);
+ });
+
+ it('exclut les cues sans word timestamps', () => {
+ const doc = new VttParser().parse(VTT_WITH_WORDS);
+ const data = extractWordData(doc);
+ expect(data.every((c) => c.words.some((w) => w.time !== ''))).toBe(true);
+ });
+
+ it('produit un JSON sérialisable', () => {
+ const doc = new VttParser().parse(VTT_WITH_WORDS);
+ const data = extractWordData(doc);
+ expect(() => JSON.stringify(data)).not.toThrow();
+ });
+});
+
+// ---- markdownToVtt ----------------------------------------------------------
+
+describe('markdownToVtt — re-export VTT', () => {
+ const WORDS_DATA = [
+ { index: 0, startTime: '00:01:28.080', endTime: '00:01:33.536', speaker: 'S00',
+ words: [{ text: 'original', time: '00:01:28.080' }] },
+ { index: 1, startTime: '00:01:32.672', endTime: '00:01:34.816', speaker: 'S01',
+ words: [{ text: 'original', time: '00:01:32.672' }] },
+ { index: 2, startTime: '00:01:34.816', endTime: '00:01:45.920', speaker: 'S00',
+ words: [{ text: 'original', time: '00:01:34.816' }] },
+ ];
+
+ const MD_PSEUDONYMIZED = `---
+pseudobs-format: html
+pseudobs-source: "juste-leblanc.html"
+---
+
+**S00** [00:01:28] : Je vais le faire moi-même. Il s'appelle Pierre Martin.
+
+**S01** [00:01:32] : Ah bon, il n'a pas de prénom ?
+
+**S00** [00:01:34] : Je viens de vous le dire, Pierre Martin.
+`;
+
+ it('produit un WebVTT valide', () => {
+ const { vtt } = markdownToVtt(MD_PSEUDONYMIZED, WORDS_DATA);
+ expect(vtt).toMatch(/^WEBVTT/);
+ expect(vtt).toContain('00:01:28.080 --> 00:01:33.536');
+ expect(vtt).toContain('00:01:32.672 --> 00:01:34.816');
+ });
+
+ it('intègre le texte pseudonymisé', () => {
+ const { vtt } = markdownToVtt(MD_PSEUDONYMIZED, WORDS_DATA);
+ expect(vtt).toContain('Pierre Martin');
+ expect(vtt).not.toContain('Juste Leblanc');
+ });
+
+ it('préserve les locuteurs', () => {
+ const { vtt } = markdownToVtt(MD_PSEUDONYMIZED, WORDS_DATA);
+ expect(vtt).toContain('');
+ expect(vtt).toContain('');
+ });
+
+ it('utilise les timestamps du words.json, pas du Markdown', () => {
+ const { vtt } = markdownToVtt(MD_PSEUDONYMIZED, WORDS_DATA);
+ // Les ms précises viennent du words.json
+ expect(vtt).toContain('00:01:28.080');
+ expect(vtt).toContain('00:01:33.536');
+ });
+
+ it('signale une incohérence si le nombre de cues diffère', () => {
+ const { mismatch } = markdownToVtt(MD_PSEUDONYMIZED, WORDS_DATA.slice(0, 2));
+ expect(mismatch).toBe(true);
+ });
+
+ it('pas de mismatch si les comptes sont égaux', () => {
+ const { mismatch } = markdownToVtt(MD_PSEUDONYMIZED, WORDS_DATA);
+ expect(mismatch).toBe(false);
+ });
+});
diff --git a/tests/unit/VttParser.test.ts b/tests/unit/VttParser.test.ts
new file mode 100644
index 0000000..3ca6125
--- /dev/null
+++ b/tests/unit/VttParser.test.ts
@@ -0,0 +1,124 @@
+import { VttParser } from '../../src/parsers/VttParser';
+
+const parser = new VttParser();
+
+// ---- Helpers ----------------------------------------------------------------
+
+function roundTrip(input: string): string {
+ return parser.reconstruct(parser.parse(input));
+}
+
+// ---- Fixtures ---------------------------------------------------------------
+
+const VTT_SIMPLE = `WEBVTT
+
+1
+00:00:01.240 --> 00:00:05.800
+Bonjour, je m'appelle Marie Dupont.
+
+2
+00:00:06.100 --> 00:00:09.300
+Et tu habites à Lyon ?
+`;
+
+const VTT_SPEAKER = `WEBVTT
+
+1
+00:00:01.240 --> 00:00:05.800
+Bonjour, je m'appelle Marie Dupont.
+
+2
+00:00:06.100 --> 00:00:09.300
+Et tu habites à Lyon ?
+`;
+
+const VTT_WORD_TIMESTAMPS = `WEBVTT
+
+1
+00:00:01.240 --> 00:00:05.800
+<00:00:01.240> Bonjour,<00:00:02.100> je<00:00:02.300> m'appelle<00:00:03.400> Marie<00:00:04.200> Dupont.
+
+2
+00:00:06.100 --> 00:00:09.300
+<00:00:06.100> Et<00:00:06.500> tu<00:00:06.800> habites<00:00:07.200> à<00:00:07.800> Lyon<00:00:08.200> ?
+`;
+
+// ---- Tests ------------------------------------------------------------------
+
+describe('VttParser — parse', () => {
+ test('VTT simple : 2 cues sans speaker', () => {
+ const doc = parser.parse(VTT_SIMPLE);
+ expect(doc.cues).toHaveLength(2);
+ expect(doc.cues[0].startTime).toBe('00:00:01.240');
+ expect(doc.cues[0].endTime).toBe('00:00:05.800');
+ expect(doc.cues[0].text).toContain('Marie Dupont');
+ expect(doc.cues[0].speaker).toBeUndefined();
+ });
+
+ test('VTT avec speakers : extraction correcte', () => {
+ const doc = parser.parse(VTT_SPEAKER);
+ expect(doc.cues[0].speaker).toBe('SPEAKER_1');
+ expect(doc.cues[1].speaker).toBe('SPEAKER_2');
+ expect(doc.cues[0].text).toContain('Marie Dupont');
+ expect(doc.cues[0].text).not.toContain(' {
+ const doc = parser.parse(VTT_WORD_TIMESTAMPS);
+ const cue = doc.cues[0];
+ expect(cue.speaker).toBe('SPEAKER_1');
+ expect(cue.words.length).toBeGreaterThan(1);
+ expect(cue.words.some(w => w.time === '00:00:03.400')).toBe(true);
+ // Le mot "Marie" doit être associé au timestamp 00:00:03.400
+ const marie = cue.words.find(w => w.time === '00:00:03.400');
+ expect(marie?.text).toContain('Marie');
+ });
+
+ test('Header WEBVTT reconnu', () => {
+ const doc = parser.parse(VTT_SIMPLE);
+ expect(doc.cues.length).toBeGreaterThan(0);
+ });
+
+ test('Timestamps normalisés en HH:MM:SS.mmm', () => {
+ const vtt = `WEBVTT\n\n01:23.456 --> 01:27.800\nTest\n`;
+ const doc = parser.parse(vtt);
+ expect(doc.cues[0].startTime).toBe('00:01:23.456');
+ expect(doc.cues[0].endTime).toBe('00:01:27.800');
+ });
+});
+
+describe('VttParser — round-trip', () => {
+ test('VTT simple : round-trip exact', () => {
+ const doc = parser.parse(VTT_SIMPLE);
+ const out = parser.reconstruct(doc);
+ expect(out).toContain('WEBVTT');
+ expect(out).toContain('00:00:01.240 --> 00:00:05.800');
+ expect(out).toContain('Marie Dupont');
+ });
+
+ test('VTT avec speakers : speaker préservé', () => {
+ const doc = parser.parse(VTT_SPEAKER);
+ const out = parser.reconstruct(doc);
+ expect(out).toContain('');
+ expect(out).toContain('');
+ });
+
+ test('VTT word timestamps : reconstruction avec timestamps', () => {
+ const doc = parser.parse(VTT_WORD_TIMESTAMPS);
+ const out = parser.reconstruct(doc);
+ expect(out).toContain('');
+ expect(out).toContain('00:00:03.400');
+ expect(out).toContain('Marie');
+ });
+});
+
+describe('VttParser — pseudonymisation', () => {
+ test('Remplacement dans le texte reflété dans la reconstruction', () => {
+ const doc = parser.parse(VTT_SPEAKER);
+ doc.cues[0].text = doc.cues[0].text.replace('Marie Dupont', 'Sophie Martin');
+ VttParser.applyTextToWords(doc.cues[0], doc.cues[0].text);
+ const out = parser.reconstruct(doc);
+ expect(out).toContain('Sophie Martin');
+ expect(out).not.toContain('Marie Dupont');
+ });
+});
diff --git a/versions.json b/versions.json
index 159775f..9802bc5 100644
--- a/versions.json
+++ b/versions.json
@@ -9,5 +9,6 @@
"0.1.1": "1.7.2",
"0.1.2": "1.7.2",
"0.1.3": "1.7.2",
- "0.1.4": "1.7.2"
+ "0.1.4": "1.7.2",
+ "0.1.5": "1.7.2"
}
\ No newline at end of file