Transforma a etapa "colar decisões" numa tela de lapidação: a sugestão da IA chega carregada e o editor afina frase a frase o que é ênfase e o que fica fora. Essa marcação é o norte da etapa 6 — só as frases com ênfase recebem zoom e legenda dinâmica; as demais ficam com legenda comum. O campo de colar o JSON sobe para a etapa 4, então a numeração das etapas não muda e a etapa 6 segue intacta. Backend (fcpxml/phrase_review.py): - build_phrase_review funde o _voice_timeline.json com as actions da IA - trim por frase que anda em fronteira de palavra; corte parcial da IA chega como trim em vez de ser arredondado fora - phrase_review_to_actions volta a cuts/zooms + emphasis_spans - merge_saved_decisions reaplica só as decisões salvas sobre uma revisão remontada da análise atual, para reprocessar a voz não ficar mascarado - resolve_source acha a mídia: o voice timeline guarda só o nome do arquivo App (SwiftUI): - layout de sala de edição: preview em cima, inspector à direita, timeline atravessando embaixo com seis trilhas rotuladas - preview enquadra no formato de entrega lido do .fcpxml (fonte horizontal, projeto vertical), com alternância para a mídia original - reprodução pula os trechos removidos e para no fim do trecho - zoom manual por trecho marcado, sem guardar escala: a forma vem das configurações de Análise de Voz no render - emoção da fala exposta por frase Correções encontradas no caminho: - VideoPlayer (AVKit) aborta em runtime no app compilado por swiftc; trocado por AVPlayerLayer (ver Engine/docs/05_EXPERIENCIAS.md #22) - teste que ainda afirmava o default zoom scale=1.3 removido do parser (#21) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
480 lines
19 KiB
Python
480 lines
19 KiB
Python
"""Tests for the phrase review model (voice timeline + AI actions → editable script)."""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from fcpxml.phrase_review import (
|
|
TRACK_BACKSTAGE,
|
|
TRACK_SCRIPT,
|
|
ZOOM_SCALE_BY_LEVEL,
|
|
build_phrase_review,
|
|
load_phrase_review,
|
|
merge_saved_decisions,
|
|
phrase_review_to_actions,
|
|
resolve_source,
|
|
review_paths,
|
|
save_phrase_review,
|
|
snap_to_words,
|
|
)
|
|
|
|
|
|
def _words(spans, emphasis=0.0):
|
|
return [
|
|
{
|
|
"text": f"w{i}",
|
|
"start": start,
|
|
"end": end,
|
|
"energy": 0.5,
|
|
"emphasis": emphasis,
|
|
}
|
|
for i, (start, end) in enumerate(spans)
|
|
]
|
|
|
|
|
|
def _timeline(segments):
|
|
return {"source": "/tmp/take.mov", "speakers": ["SPEAKER_00"], "segments": segments}
|
|
|
|
|
|
def _segment(start, end, text="linha", peak=0.1, take_boundary=False, words=None):
|
|
return {
|
|
"start": start,
|
|
"end": end,
|
|
"text": text,
|
|
"speaker": "SPEAKER_00",
|
|
"peak_emphasis": peak,
|
|
"take_boundary": take_boundary,
|
|
"gap_before": 0.0,
|
|
"words": words if words is not None else _words([(start, end)]),
|
|
}
|
|
|
|
|
|
class TestBuildFromAcoustics:
|
|
def test_emphasis_levels_follow_peak_thresholds(self):
|
|
review = build_phrase_review(
|
|
_timeline(
|
|
[
|
|
_segment(0, 1, peak=0.10),
|
|
_segment(1, 2, peak=0.30),
|
|
_segment(2, 3, peak=0.50),
|
|
_segment(3, 4, peak=0.90),
|
|
]
|
|
)
|
|
)
|
|
assert [p["emphasis"] for p in review["phrases"]] == [0, 1, 2, 3]
|
|
|
|
def test_every_phrase_starts_active_without_actions(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 1), _segment(1, 2)]))
|
|
assert all(p["active"] for p in review["phrases"])
|
|
assert all(p["track"] == TRACK_SCRIPT for p in review["phrases"])
|
|
|
|
def test_carries_text_speaker_and_words(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 2, text=" olá ")]))
|
|
phrase = review["phrases"][0]
|
|
assert phrase["text"] == "olá"
|
|
assert phrase["speaker"] == "SPEAKER_00"
|
|
assert phrase["words"][0]["text"] == "w0"
|
|
assert review["duration"] == 2.0
|
|
|
|
|
|
class TestCutsDeactivate:
|
|
def test_fully_cut_phrase_is_inactive(self):
|
|
review = build_phrase_review(
|
|
_timeline([_segment(0, 2), _segment(2, 4)]),
|
|
{"actions": [{"kind": "cut", "start": 0, "end": 2, "reason": "gaguejou"}]},
|
|
)
|
|
assert review["phrases"][0]["active"] is False
|
|
assert review["phrases"][0]["reason"] == "gaguejou"
|
|
assert review["phrases"][1]["active"] is True
|
|
|
|
def test_small_overlap_keeps_the_phrase(self):
|
|
# 0.2s off a 2s line is a trim, not a removal.
|
|
review = build_phrase_review(
|
|
_timeline([_segment(1, 3, words=_words([(1, 1.2), (1.2, 3)]))]),
|
|
{"actions": [{"kind": "cut", "start": 0.5, "end": 1.2}]},
|
|
)
|
|
assert review["phrases"][0]["active"] is True
|
|
|
|
def test_majority_overlap_deactivates(self):
|
|
review = build_phrase_review(
|
|
_timeline([_segment(0, 2)]),
|
|
{"actions": [{"kind": "cut", "start": 0, "end": 1.5}]},
|
|
)
|
|
assert review["phrases"][0]["active"] is False
|
|
|
|
def test_inactive_after_take_boundary_is_backstage(self):
|
|
review = build_phrase_review(
|
|
_timeline([_segment(10, 12, take_boundary=True)]),
|
|
{"actions": [{"kind": "cut", "start": 10, "end": 12}]},
|
|
)
|
|
assert review["phrases"][0]["track"] == TRACK_BACKSTAGE
|
|
|
|
|
|
class TestTrimFromPartialCuts:
|
|
def test_head_cut_becomes_a_trim_snapped_to_a_word(self):
|
|
review = build_phrase_review(
|
|
_timeline([_segment(1, 4, words=_words([(1, 1.4), (1.4, 4)]))]),
|
|
{"actions": [{"kind": "cut", "start": 0.8, "end": 1.35}]},
|
|
)
|
|
phrase = review["phrases"][0]
|
|
assert phrase["active"] is True
|
|
assert phrase["trim_start"] == 1.4 # snapped to the second word's start
|
|
assert phrase["trim_end"] == 4.0
|
|
|
|
def test_tail_cut_becomes_a_trim(self):
|
|
review = build_phrase_review(
|
|
_timeline([_segment(0, 3, words=_words([(0, 2.5), (2.5, 3)]))]),
|
|
{"actions": [{"kind": "cut", "start": 2.6, "end": 3.5}]},
|
|
)
|
|
phrase = review["phrases"][0]
|
|
assert phrase["trim_start"] == 0.0
|
|
assert phrase["trim_end"] == 2.5
|
|
|
|
def test_untouched_phrase_trims_to_its_own_bounds(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 2)]))
|
|
phrase = review["phrases"][0]
|
|
assert (phrase["trim_start"], phrase["trim_end"]) == (0.0, 2.0)
|
|
|
|
|
|
class TestAIDirectionWins:
|
|
def test_zoom_action_sets_the_level_over_the_heuristic(self):
|
|
review = build_phrase_review(
|
|
_timeline([_segment(0, 2, peak=0.05)]),
|
|
{
|
|
"actions": [
|
|
{
|
|
"kind": "zoom",
|
|
"start": 0.5,
|
|
"end": 0.9,
|
|
"params": {"scale": 1.5},
|
|
"reason": "virada da história",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
phrase = review["phrases"][0]
|
|
assert phrase["emphasis"] == 3
|
|
assert phrase["reason"] == "virada da história"
|
|
|
|
def test_text_action_marks_emphasis(self):
|
|
review = build_phrase_review(
|
|
_timeline([_segment(0, 2, peak=0.0)]),
|
|
{
|
|
"actions": [
|
|
{
|
|
"kind": "text",
|
|
"start": 0.5,
|
|
"end": 1.0,
|
|
"params": {"content": "3x mais rápido"},
|
|
}
|
|
]
|
|
},
|
|
)
|
|
assert review["phrases"][0]["emphasis"] == 2
|
|
|
|
def test_highest_level_wins_when_several_actions_overlap(self):
|
|
review = build_phrase_review(
|
|
_timeline([_segment(0, 4)]),
|
|
{
|
|
"actions": [
|
|
{"kind": "zoom", "start": 0.2, "end": 0.5, "params": {"scale": 1.15}},
|
|
{"kind": "zoom", "start": 2.0, "end": 2.4, "params": {"scale": 1.5}},
|
|
]
|
|
},
|
|
)
|
|
assert review["phrases"][0]["emphasis"] == 3
|
|
|
|
def test_malformed_rows_are_reported_not_fatal(self):
|
|
review = build_phrase_review(
|
|
_timeline([_segment(0, 2)]),
|
|
{"actions": [{"kind": "voar", "start": 0, "end": 1}]},
|
|
)
|
|
assert len(review["errors"]) == 1
|
|
assert review["phrases"][0]["active"] is True
|
|
|
|
|
|
class TestBackToActions:
|
|
def test_inactive_phrase_becomes_a_cut(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 2), _segment(2, 4)]))
|
|
review["phrases"][0]["active"] = False
|
|
result = phrase_review_to_actions(review)
|
|
cuts = [a for a in result["actions"] if a["kind"] == "cut"]
|
|
assert len(cuts) == 1
|
|
assert (cuts[0]["start"], cuts[0]["end"]) == (0.0, 2.0)
|
|
|
|
def test_emphasis_becomes_a_zoom_and_a_span(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 2)]))
|
|
review["phrases"][0]["emphasis"] = 2
|
|
result = phrase_review_to_actions(review)
|
|
zooms = [a for a in result["actions"] if a["kind"] == "zoom"]
|
|
assert zooms[0]["params"]["scale"] == ZOOM_SCALE_BY_LEVEL[2]
|
|
assert result["emphasis_spans"] == [
|
|
{"start": 0.0, "end": 2.0, "level": 2, "text": "linha"}
|
|
]
|
|
|
|
def test_level_zero_produces_nothing(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 2)]))
|
|
review["phrases"][0]["emphasis"] = 0
|
|
result = phrase_review_to_actions(review)
|
|
assert result["actions"] == []
|
|
assert result["emphasis_spans"] == []
|
|
|
|
def test_trim_becomes_head_and_tail_cuts(self):
|
|
review = build_phrase_review(
|
|
_timeline([_segment(0, 4, words=_words([(0, 1), (1, 3), (3, 4)]))])
|
|
)
|
|
review["phrases"][0]["trim_start"] = 1.0
|
|
review["phrases"][0]["trim_end"] = 3.0
|
|
result = phrase_review_to_actions(review)
|
|
spans = [(a["start"], a["end"]) for a in result["actions"] if a["kind"] == "cut"]
|
|
assert spans == [(0.0, 1.0), (3.0, 4.0)]
|
|
|
|
def test_inactive_phrase_is_cut_whole_ignoring_its_trim(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 4)]))
|
|
review["phrases"][0].update({"active": False, "trim_start": 1.0, "trim_end": 3.0})
|
|
result = phrase_review_to_actions(review)
|
|
assert [(a["start"], a["end"]) for a in result["actions"]] == [(0.0, 4.0)]
|
|
|
|
def test_zoom_follows_the_trimmed_span(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 4)]))
|
|
review["phrases"][0].update({"emphasis": 1, "trim_start": 1.0, "trim_end": 3.0})
|
|
result = phrase_review_to_actions(review)
|
|
zoom = next(a for a in result["actions"] if a["kind"] == "zoom")
|
|
assert (zoom["start"], zoom["end"]) == (1.0, 3.0)
|
|
|
|
def test_impossible_trim_is_ignored(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 4)]))
|
|
review["phrases"][0].update({"trim_start": 3.0, "trim_end": 1.0})
|
|
result = phrase_review_to_actions(review)
|
|
assert result["actions"] == []
|
|
|
|
def test_emphasis_out_of_range_is_clamped(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 2)]))
|
|
review["phrases"][0]["emphasis"] = 99
|
|
result = phrase_review_to_actions(review)
|
|
assert result["actions"][0]["params"]["scale"] == ZOOM_SCALE_BY_LEVEL[3]
|
|
|
|
def test_rows_that_make_no_sense_are_skipped(self):
|
|
result = phrase_review_to_actions(
|
|
{"phrases": ["nope", {"start": 5, "end": 1}, {"start": 0, "end": 1}]}
|
|
)
|
|
assert result["actions"] == []
|
|
|
|
|
|
class TestManualZooms:
|
|
def test_manual_zoom_becomes_an_action_without_a_scale(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 10)]))
|
|
review["zooms"] = [{"start": 2.0, "end": 4.0}]
|
|
result = phrase_review_to_actions(review)
|
|
zoom = next(a for a in result["actions"] if a["kind"] == "zoom")
|
|
assert (zoom["start"], zoom["end"]) == (2.0, 4.0)
|
|
# Sem scale: o aplicador usa o zoom_scale configurado pelo usuário.
|
|
assert "scale" not in zoom["params"]
|
|
|
|
def test_zoom_shorter_than_the_ramp_is_refused(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 10)]))
|
|
review["zooms"] = [{"start": 2.0, "end": 2.1}]
|
|
assert phrase_review_to_actions(review)["actions"] == []
|
|
|
|
def test_manual_zoom_coexists_with_phrase_emphasis(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 10)]))
|
|
review["phrases"][0]["emphasis"] = 2
|
|
review["zooms"] = [{"start": 2.0, "end": 4.0}]
|
|
zooms = [a for a in phrase_review_to_actions(review)["actions"] if a["kind"] == "zoom"]
|
|
assert len(zooms) == 2
|
|
|
|
def test_malformed_zoom_rows_are_skipped(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 10)]))
|
|
review["zooms"] = ["nope", {"start": 5}, {"start": 4, "end": 1}]
|
|
assert phrase_review_to_actions(review)["actions"] == []
|
|
|
|
def test_saved_zooms_are_restored(self):
|
|
review = merge_saved_decisions(
|
|
build_phrase_review(_timeline([_segment(0, 10)])),
|
|
{"phrases": [], "zooms": [{"start": 1.0, "end": 3.0}]},
|
|
)
|
|
assert review["zooms"] == [{"start": 1.0, "end": 3.0}]
|
|
|
|
def test_new_review_starts_with_no_manual_zooms(self):
|
|
assert build_phrase_review(_timeline([_segment(0, 2)]))["zooms"] == []
|
|
|
|
|
|
class TestRoundTrip:
|
|
def test_review_survives_actions_and_back(self):
|
|
timeline = _timeline(
|
|
[_segment(0, 2, peak=0.9), _segment(2, 4), _segment(4, 6, peak=0.5)]
|
|
)
|
|
first = build_phrase_review(timeline)
|
|
first["phrases"][1]["active"] = False
|
|
actions = phrase_review_to_actions(first)
|
|
|
|
second = build_phrase_review(timeline, actions)
|
|
assert [p["active"] for p in second["phrases"]] == [True, False, True]
|
|
assert [p["emphasis"] for p in second["phrases"]] == [3, 0, 2]
|
|
|
|
|
|
class TestSnapToWords:
|
|
def test_snaps_to_the_nearest_start(self):
|
|
words = _words([(1.0, 1.5), (1.5, 2.0)])
|
|
assert snap_to_words(1.6, words, 1.6, "in") == 1.5
|
|
|
|
def test_snaps_to_the_nearest_end(self):
|
|
words = _words([(1.0, 1.5), (1.5, 2.0)])
|
|
assert snap_to_words(1.9, words, 1.9, "out") == 2.0
|
|
|
|
def test_falls_back_without_word_timings(self):
|
|
assert snap_to_words(1.2, [], 3.4, "in") == 3.4
|
|
|
|
|
|
class TestResolveSource:
|
|
def test_finds_the_media_beside_its_timeline(self, tmp_path):
|
|
media = tmp_path / "take.mov"
|
|
media.write_bytes(b"0")
|
|
timeline = tmp_path / "take_voice_timeline.json"
|
|
assert resolve_source("take.mov", str(timeline)) == str(media)
|
|
|
|
def test_falls_back_to_the_project_folder(self, tmp_path):
|
|
media_dir = tmp_path / "midia"
|
|
media_dir.mkdir()
|
|
media = media_dir / "take.mov"
|
|
media.write_bytes(b"0")
|
|
timeline = tmp_path / "json" / "take_voice_timeline.json"
|
|
assert resolve_source("take.mov", str(timeline), [str(media_dir)]) == str(media)
|
|
|
|
def test_absolute_path_is_used_as_is(self, tmp_path):
|
|
media = tmp_path / "take.mov"
|
|
media.write_bytes(b"0")
|
|
assert resolve_source(str(media), "") == str(media)
|
|
|
|
def test_missing_media_resolves_to_empty(self, tmp_path):
|
|
assert resolve_source("take.mov", str(tmp_path / "x_voice_timeline.json")) == ""
|
|
|
|
def test_stale_absolute_path_still_finds_the_file_by_name(self, tmp_path):
|
|
# The fixture's source is an absolute path that no longer exists (the
|
|
# everyday case: the project moved). Falling back to the file name next
|
|
# to the timeline is what keeps the preview working after a move.
|
|
media = tmp_path / "take.mov"
|
|
media.write_bytes(b"0")
|
|
assert resolve_source("/tmp/gone/take.mov", str(tmp_path / "t.json")) == str(media)
|
|
|
|
def test_review_carries_the_resolved_path(self, tmp_path):
|
|
media = tmp_path / "take.mov"
|
|
media.write_bytes(b"0")
|
|
review = build_phrase_review(
|
|
{**_timeline([_segment(0, 1)]), "source": "take.mov"},
|
|
voice_timeline_path=str(tmp_path / "take_voice_timeline.json"),
|
|
)
|
|
assert review["source_path"] == str(media)
|
|
|
|
def test_review_without_media_reports_no_path(self, tmp_path):
|
|
review = build_phrase_review(
|
|
_timeline([_segment(0, 1)]),
|
|
voice_timeline_path=str(tmp_path / "take_voice_timeline.json"),
|
|
)
|
|
assert review["source_path"] == ""
|
|
|
|
|
|
class TestEmotion:
|
|
def test_segment_emotion_reaches_the_phrase(self):
|
|
segment = _segment(0, 2)
|
|
segment["emotion"] = "excited"
|
|
segment["emotion_confidence"] = 0.72
|
|
review = build_phrase_review(_timeline([segment]))
|
|
assert review["phrases"][0]["emotion"] == "excited"
|
|
assert review["phrases"][0]["emotion_confidence"] == 0.72
|
|
|
|
def test_defaults_to_neutral_when_absent(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 2)]))
|
|
assert review["phrases"][0]["emotion"] == "neutral"
|
|
assert review["phrases"][0]["emotion_confidence"] == 0.0
|
|
|
|
def test_availability_comes_from_the_analysis_layers(self):
|
|
assert build_phrase_review(_timeline([_segment(0, 1)]))["emotion_available"] is False
|
|
timeline = {**_timeline([_segment(0, 1)]), "layers": {"emotion": True}}
|
|
assert build_phrase_review(timeline)["emotion_available"] is True
|
|
|
|
|
|
class TestMergeSavedDecisions:
|
|
def test_saved_decisions_win_over_the_derivation(self):
|
|
timeline = _timeline([_segment(0, 2, peak=0.9), _segment(2, 4)])
|
|
saved = {
|
|
"phrases": [
|
|
{"index": 0, "start": 0.0, "emphasis": 0, "active": False,
|
|
"track": TRACK_BACKSTAGE, "text": "corrigido"},
|
|
]
|
|
}
|
|
review = merge_saved_decisions(build_phrase_review(timeline), saved)
|
|
first = review["phrases"][0]
|
|
assert (first["emphasis"], first["active"]) == (0, False)
|
|
assert first["track"] == TRACK_BACKSTAGE
|
|
assert first["text"] == "corrigido"
|
|
assert review["phrases"][1]["active"] is True
|
|
|
|
def test_fresh_analysis_fields_are_not_overwritten(self):
|
|
segment = _segment(0, 2, peak=0.9)
|
|
segment["emotion"] = "tense"
|
|
review = merge_saved_decisions(
|
|
build_phrase_review(_timeline([segment])),
|
|
{"phrases": [{"index": 0, "start": 0.0, "emphasis": 1}]},
|
|
)
|
|
assert review["phrases"][0]["emotion"] == "tense"
|
|
assert review["phrases"][0]["peak_emphasis"] == 0.9
|
|
|
|
def test_decision_is_dropped_when_the_line_moved(self):
|
|
review = merge_saved_decisions(
|
|
build_phrase_review(_timeline([_segment(10, 12, peak=0.9)])),
|
|
{"phrases": [{"index": 0, "start": 0.0, "active": False}]},
|
|
)
|
|
assert review["phrases"][0]["active"] is True
|
|
|
|
def test_saved_trim_is_restored(self):
|
|
review = merge_saved_decisions(
|
|
build_phrase_review(_timeline([_segment(0, 4)])),
|
|
{"phrases": [{"index": 0, "start": 0.0, "trim_start": 1.0, "trim_end": 3.0}]},
|
|
)
|
|
assert (review["phrases"][0]["trim_start"], review["phrases"][0]["trim_end"]) == (1.0, 3.0)
|
|
|
|
def test_impossible_saved_trim_is_ignored(self):
|
|
review = merge_saved_decisions(
|
|
build_phrase_review(_timeline([_segment(0, 4)])),
|
|
{"phrases": [{"index": 0, "start": 0.0, "trim_start": 9.0, "trim_end": 12.0}]},
|
|
)
|
|
assert (review["phrases"][0]["trim_start"], review["phrases"][0]["trim_end"]) == (0.0, 4.0)
|
|
|
|
def test_no_saved_review_is_a_no_op(self):
|
|
review = build_phrase_review(_timeline([_segment(0, 2)]))
|
|
assert merge_saved_decisions(review, None) is review
|
|
|
|
|
|
class TestPersistence:
|
|
def test_paths_are_named_after_the_timeline(self, tmp_path):
|
|
timeline_path = tmp_path / "take_voice_timeline.json"
|
|
review_path, actions_path = review_paths(str(timeline_path))
|
|
assert review_path.name == "take_phrase_review.json"
|
|
assert actions_path.name == "take_phrase_actions.json"
|
|
|
|
def test_save_writes_both_files_and_load_reads_it_back(self, tmp_path):
|
|
timeline_path = tmp_path / "take_voice_timeline.json"
|
|
review = build_phrase_review(_timeline([_segment(0, 2)]))
|
|
review["phrases"][0]["emphasis"] = 3
|
|
|
|
review_path, actions_path = save_phrase_review(str(timeline_path), review)
|
|
assert review_path.is_file() and actions_path.is_file()
|
|
|
|
written = json.loads(actions_path.read_text(encoding="utf-8"))
|
|
assert written["actions"][0]["kind"] == "zoom"
|
|
|
|
assert load_phrase_review(str(timeline_path))["phrases"][0]["emphasis"] == 3
|
|
|
|
def test_load_returns_none_when_absent_or_broken(self, tmp_path):
|
|
timeline_path = tmp_path / "take_voice_timeline.json"
|
|
assert load_phrase_review(str(timeline_path)) is None
|
|
|
|
review_path, _ = review_paths(str(timeline_path))
|
|
review_path.write_text("{ not json", encoding="utf-8")
|
|
assert load_phrase_review(str(timeline_path)) is None
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|