diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..35f59e7 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + name: Tests (${{ matrix.os }}, py${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.11", "3.13"] + env: + QT_QPA_PLATFORM: offscreen + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Qt runtime libraries + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libpulse0 libpulse-mainloop-glib0 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt pytest + - name: Run tests + run: python -m pytest -v diff --git a/.gitignore b/.gitignore index 2fd984d..ec98a7f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ *.srt *.csv *.txt +!requirements*.txt +!characters.example.txt *.vat *.exe @@ -39,3 +41,8 @@ Thumbs.db *.backup *.spec __pycache__/ + +# Per-user state written by the app +settings.json +.pytest_cache/ +.pytest_tmp/ diff --git a/README.md b/README.md index 1427cb1..a505448 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ - **Editable Note column:** Fill per-line notes either from right panel Note input or directly in the table. - **Player controls:** Built-in play/pause button, timeline slider, and current/total time display. - **Coding template options:** Optional inheritance of Listener/Target from the previous line. -- **Autosave + restore:** Periodic autosave with optional restore prompt on startup. -- **Unsaved-change protection:** Save/discard/cancel prompt when closing. +- **Autosave + restore:** Every 15 seconds unsaved work is copied to a hidden recovery file (`..autosave.vat`) next to the project, or next to the subtitles before the first save. Your project file is only written when you save. After a crash, the next session offers to restore the recovery file. +- **Unsaved-change protection:** Save/discard/cancel prompt when closing. Choosing not to save discards the changes and the recovery file. - **Safer writes:** Manual save and autosave use atomic write replacement to reduce data corruption risk. - **CSV import/export:** Save and load annotation progress in UTF-8 BOM CSV. @@ -39,7 +39,7 @@ 1. **Clone the Repository** ``` git clone https://github.com/MediaCompLab/ciga-annotator.git - cd ciga/ciga-annotator + cd ciga-annotator ``` 2. **Install Dependencies** @@ -49,22 +49,26 @@ ## Getting Started -1. **Run the Application** +1. **Run the Application** (from the repository root) ``` - python src/main.py + python run.py ``` If you are on Windows and get a Qt DLL load error in a Conda terminal, run with your system Python explicitly, for example: ``` - c:/python313/python.exe src/main.py + c:/python313/python.exe run.py ``` 2. **Select Files** - Select your video and SRT files. Character file is optional. + Select your video and SRT files. Character file is optional; see `characters.example.txt`. If provided, each line can be: - `CharacterName` - `CharacterName,ShortcutKey` + Character names cannot contain commas, because commas separate characters in the exported CSV + and in CIGA's input. Without a character file, the annotator keeps its list in `characters.txt` + next to the application. + 3. **Start Annotation** Click "Start Annotation" to begin. The player auto-pauses at subtitle boundaries for coding. @@ -85,6 +89,15 @@ 5. Use `Inherit Listener/Target from previous line` to reduce repeated coding in dialogue runs. 6. Use `N` (or `Next Uncoded` button) to jump through remaining work quickly. +## Development + +``` +pip install -r requirements-dev.txt +python -m pytest +``` + +To build the Windows executable: `python build.py`. + ## Contributing Contributions are welcome! To contribute: diff --git a/assets/logo_black.png b/assets/logo_black.png new file mode 100644 index 0000000..828fd0d Binary files /dev/null and b/assets/logo_black.png differ diff --git a/assets/logo_white.png b/assets/logo_white.png new file mode 100644 index 0000000..bff1d34 Binary files /dev/null and b/assets/logo_white.png differ diff --git a/build.py b/build.py index 68fc7fb..874f97b 100644 --- a/build.py +++ b/build.py @@ -1,5 +1,4 @@ import PyInstaller.__main__ -from pathlib import Path import shutil import os @@ -14,9 +13,9 @@ '--windowed', '--onefile', '--clean', - '--icon=../logo_black.png', - f'--add-data=../logo_black.png{os.pathsep}.', - f'--add-data=../logo_white.png{os.pathsep}.', + '--icon=assets/logo_black.png', + f'--add-data=assets/logo_black.png{os.pathsep}.', + f'--add-data=assets/logo_white.png{os.pathsep}.', '--collect-all=PySide6', '--collect-all=shiboken6', ]) diff --git a/characters.txt b/characters.example.txt similarity index 100% rename from characters.txt rename to characters.example.txt diff --git a/extract.py b/extract.py deleted file mode 100644 index 51ae161..0000000 --- a/extract.py +++ /dev/null @@ -1,39 +0,0 @@ -import re - -with open('annotator.py', 'r', encoding='utf-8') as f: - text = f.read() - -# Match VideoAnnotator class until def main() -start = text.find('class VideoAnnotator(') -end = text.find('def main():') - -anim_text = text[start:end] - -imports = """import csv -import json -import os -import tempfile -from pathlib import Path -from PySide6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QVBoxLayout, QLabel, - QFileDialog, QPushButton, QLineEdit, QHBoxLayout, QTableWidget, - QTableWidgetItem, QSizePolicy, QMessageBox, QHeaderView, - QSplitter, QAbstractItemView, QSlider -) -from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput -from PySide6.QtMultimediaWidgets import QVideoWidget -from PySide6.QtCore import Qt, QUrl, Slot, Signal, QEvent, QTimer -from PySide6.QtGui import QAction, QColor, QKeySequence - -from src.core.app_settings import AppSettings -from src.ui.settings_dialog import SettingsDialog -from src.ui.char_dialog import ManageCharactersDialog -from src.core.parsers import parse_srt, milliseconds_to_srt_time -from src.core.characters import read_characters, save_characters -from src.core.csv_utils import write_rows_to_csv_atomic - -""" - -with open('src/ui/annotator_window.py', 'w', encoding='utf-8') as f: - f.write(imports + anim_text) - diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..77f6e5e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +pythonpath = . +testpaths = tests +# Same as CIGA: keep pytest's temporary directories inside the checkout, because an +# unreadable entry in the system temp directory makes every tmp_path test error. +addopts = --basetemp=.pytest_tmp +tmp_path_retention_policy = none diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..8ea235b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest +pyinstaller diff --git a/requirements.txt b/requirements.txt index 188a72c..9ec65da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ PySide6>=6.10,<7 chardet>=5,<8 -pyinstaller diff --git a/settings.json b/settings.json deleted file mode 100644 index c9697c2..0000000 --- a/settings.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "auto_pause": false, - "inherit_listener": false, - "inherit_target": false, - "only_uncoded": false, - "hotkeys": { - "play_pause": "Space", - "next_uncoded": "N", - "prev_subtitle": "Up", - "next_subtitle": "Down", - "seek_back": "Left", - "seek_forward": "Right", - "role_speakers": "Ctrl+1", - "role_listeners": "Ctrl+2", - "role_targets": "Ctrl+3", - "cycle_role": "Ctrl+Q", - "focus_search": "Ctrl+F", - "shortcuts_help": "F1" - } -} \ No newline at end of file diff --git a/src/core/app_settings.py b/src/core/app_settings.py index daa8b51..887af5e 100644 --- a/src/core/app_settings.py +++ b/src/core/app_settings.py @@ -1,8 +1,8 @@ +import copy import json import os import sys from pathlib import Path -from PySide6.QtCore import Qt def get_base_dir(): if getattr(sys, 'frozen', False): @@ -35,7 +35,9 @@ def get_base_dir(): class AppSettings: def __init__(self): - self.settings = DEFAULT_SETTINGS.copy() + # Deep copy: a shallow copy shares the nested "hotkeys" dict, so rebinding a + # key would also rewrite DEFAULT_SETTINGS and break "Reset to Default". + self.settings = copy.deepcopy(DEFAULT_SETTINGS) self.load() def load(self): @@ -43,11 +45,11 @@ def load(self): try: with open(SETTINGS_FILE, "r", encoding="utf-8") as f: loaded = json.load(f) - # Merge loaded into default to preserve missing keys - self.settings.update(loaded) - if "hotkeys" in loaded: - for k, v in loaded["hotkeys"].items(): - self.settings["hotkeys"][k] = v + # Merge loaded into the defaults so missing keys (including individual + # hotkeys) keep their default values. + loaded_hotkeys = loaded.pop("hotkeys", {}) + self.settings.update(loaded) + self.settings["hotkeys"].update(loaded_hotkeys) except Exception as e: print(f"Failed to load settings: {e}") diff --git a/src/core/characters.py b/src/core/characters.py index fe0e9f5..bb1d04d 100644 --- a/src/core/characters.py +++ b/src/core/characters.py @@ -8,6 +8,19 @@ def get_base_dir(): else: return Path(__file__).parent.parent.parent +def validate_character_name(name): + """Return an error message for an unusable character name, or None if it is valid. + + Commas separate characters in exported CSV cells and in CIGA's speaker/listener + columns, so a name containing one would silently become two characters. + """ + if not name or not name.strip(): + return "Character name cannot be empty." + if ',' in name: + return "Character names cannot contain commas; commas separate characters in the exported CSV." + return None + + def read_characters(char_file): if not char_file or not os.path.exists(char_file): return [] diff --git a/src/core/parsers.py b/src/core/parsers.py index fc18b95..2ece5f4 100644 --- a/src/core/parsers.py +++ b/src/core/parsers.py @@ -1,43 +1,62 @@ import re import chardet -def parse_srt(srt_file): - print('srt_file', srt_file) - pattern = re.compile( - r'(\d+)\s+([\d:,]+)\s*-->\s*([\d:,]+)\s*(.*?)\s*(?=\n\d+|\Z)', - re.DOTALL - ) - - with open(srt_file, 'rb') as f: - raw_data = f.read() +# "00:00:01,000 --> 00:00:02,000", with "." also accepted as the millisecond separator +# and anything after the end time (cue settings such as "X1:40") ignored. +TIMING_PATTERN = re.compile( + r'^\s*(\d+:\d{1,2}:\d{1,2}[,.]\d+)\s*-->\s*(\d+:\d{1,2}:\d{1,2}[,.]\d+)' +) +BLOCK_SEPARATOR = re.compile(r'\n[ \t]*\n') - result = chardet.detect(raw_data) - encoding = result['encoding'] - if not encoding: - encoding = 'utf-8' +def _decode(raw_data): + encoding = chardet.detect(raw_data)['encoding'] or 'utf-8' try: content = raw_data.decode(encoding) except (UnicodeDecodeError, LookupError): content = raw_data.decode('latin-1') + return content.lstrip('').replace('\r\n', '\n').replace('\r', '\n') - content = content.replace('\r\n', '\n').replace('\r', '\n') - matches = re.findall(pattern, content) - subtitles = [] - for match in matches: - index = int(match[0]) - start_time = srt_time_to_milliseconds(match[1]) - end_time = srt_time_to_milliseconds(match[2]) - text = match[3].replace('\n', ' ').strip() - subtitles.append({ +def _parse_block(block, fallback_index): + """Parse one cue: an optional index line, the timing line, then the text lines.""" + lines = block.strip('\n').split('\n') + for position, line in enumerate(lines[:2]): + timing = TIMING_PATTERN.match(line) + if not timing: + continue + index = fallback_index + if position == 1 and lines[0].strip().isdigit(): + index = int(lines[0].strip()) + text = ' '.join(part.strip() for part in lines[position + 1:] if part.strip()) + return { 'index': index, - 'start_time': start_time, - 'end_time': end_time, - 'text': text - }) + 'start_time': srt_time_to_milliseconds(timing.group(1)), + 'end_time': srt_time_to_milliseconds(timing.group(2)), + 'text': text, + } + return None + + +def parse_srt(srt_file): + """Parse an SRT file into cues. + + Cues are split on blank lines rather than on "a newline followed by digits", + so a text line that starts with a number stays part of its cue. + """ + with open(srt_file, 'rb') as f: + content = _decode(f.read()) + + subtitles = [] + for block in BLOCK_SEPARATOR.split(content): + if not block.strip(): + continue + cue = _parse_block(block, len(subtitles) + 1) + if cue is not None: + subtitles.append(cue) return subtitles + def srt_time_to_milliseconds(srt_time): try: h, m, s_ms = srt_time.split(':') diff --git a/src/core/project.py b/src/core/project.py new file mode 100644 index 0000000..c432104 --- /dev/null +++ b/src/core/project.py @@ -0,0 +1,82 @@ +"""Project (.vat) file storage and crash-recovery autosave. + +Autosave never writes to the user's project file. It writes a hidden sibling file +that records which project it belongs to, so choosing "don't save" really discards +changes, and an explicit save or discard removes the autosave. +""" +import json +import os +import tempfile +from pathlib import Path + +# Key stored in an autosave file: absolute path of the project it recovers, or "" +# when the session had never been saved to a project. +AUTOSAVE_OF_KEY = "autosave_of" + + +def autosave_path_for(vat_file, srt_file): + """Hidden autosave location: next to the project if there is one, else the subtitles.""" + anchor = Path(vat_file) if vat_file else Path(srt_file) + return str(anchor.parent / f".{anchor.name}.autosave.vat") + + +def _same_path(a, b): + return os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b)) + + +def recovery_target(autosave_file, project_data, srt_file): + """Project a recovered autosave should be saved to, or None if it cannot be trusted. + + Returns the recorded project path ("" for a session never saved to a project) only + when that project's autosave location is this very file. An autosave carried along + by copying or moving a project folder still names the original project, and saving + the recovered work there would overwrite a different project. + """ + if not isinstance(project_data, dict): + return None + owner = project_data.get(AUTOSAVE_OF_KEY) + if not isinstance(owner, str): + return None + if not _same_path(autosave_path_for(owner, srt_file), autosave_file): + return None + return owner + + +def write_project_file(file_path, project_data): + """Write JSON atomically; on any failure the previous file is left untouched. + + Errors (serialization or I/O) propagate so callers decide how to report them. + """ + target = Path(file_path) + target.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)) + os.close(fd) + try: + with open(tmp_path, 'w', encoding='utf-8') as f: + json.dump(project_data, f, indent=4) + os.replace(tmp_path, target) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + + +def read_project_file(file_path): + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + + +def autosave_is_recoverable(autosave_path, vat_file): + """An autosave is worth offering only if it exists and is newer than its project.""" + if not os.path.exists(autosave_path): + return False + if vat_file and os.path.exists(vat_file): + return os.path.getmtime(autosave_path) > os.path.getmtime(vat_file) + return True + + +def remove_file_quietly(file_path): + """Delete a file if present. Used for autosaves, whose loss is never fatal.""" + try: + os.remove(file_path) + except FileNotFoundError: + pass diff --git a/src/main.py b/src/main.py index 1495b40..6c4997b 100644 --- a/src/main.py +++ b/src/main.py @@ -9,10 +9,8 @@ def get_resource_path(filename): if hasattr(sys, "_MEIPASS"): return os.path.join(sys._MEIPASS, filename) - else: - current_dir = os.path.dirname(os.path.abspath(__file__)) - ciga_root = os.path.dirname(os.path.dirname(current_dir)) - return os.path.join(ciga_root, filename) + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return os.path.join(repo_root, "assets", filename) def main(): app = QApplication(sys.argv) @@ -28,11 +26,21 @@ def main(): app.main_window = MainWindow() app.main_window.show() + def close_annotator(): + """Close the current annotator; False if the user cancelled at the save prompt.""" + annotator = getattr(app, 'video_annotator', None) + if annotator is None: + return True + if not annotator.close(): + return False + app.removeEventFilter(annotator) + app.video_annotator = None + return True + def start_annotation(video_file, srt_file, char_file, vat_file): - if hasattr(app, 'video_annotator') and app.video_annotator: - app.removeEventFilter(app.video_annotator) - app.video_annotator.close() - + if not close_annotator(): + return + app.video_annotator = VideoAnnotator(video_file, srt_file, char_file, vat_file) # Hook up "New Project" signal from main window to reset app.video_annotator.request_new_project.connect(reset_to_main_window) @@ -42,10 +50,8 @@ def start_annotation(video_file, srt_file, char_file, vat_file): app.installEventFilter(app.video_annotator) def reset_to_main_window(): - app.removeEventFilter(app.video_annotator) - app.video_annotator.close() - app.video_annotator = None - app.main_window.show() + if close_annotator(): + app.main_window.show() app.main_window.start_annotation.connect(start_annotation) sys.exit(app.exec()) diff --git a/src/ui/annotator_window.py b/src/ui/annotator_window.py index ea11a58..1dff359 100644 --- a/src/ui/annotator_window.py +++ b/src/ui/annotator_window.py @@ -1,13 +1,9 @@ import csv -import json -import os -import sys -import tempfile from pathlib import Path from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QLabel, QFileDialog, QPushButton, QLineEdit, QHBoxLayout, QTableWidget, - QTableWidgetItem, QSizePolicy, QMessageBox, QHeaderView, + QTableWidgetItem, QMessageBox, QHeaderView, QSplitter, QAbstractItemView, QSlider, QInputDialog ) from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput @@ -17,10 +13,13 @@ from src.core.app_settings import AppSettings from src.ui.settings_dialog import SettingsDialog -from src.ui.char_dialog import ManageCharactersDialog from src.core.parsers import parse_srt, milliseconds_to_srt_time -from src.core.characters import read_characters, save_characters +from src.core.characters import get_base_dir, read_characters, save_characters, validate_character_name from src.core.csv_utils import write_rows_to_csv_atomic +from src.core.project import ( + AUTOSAVE_OF_KEY, autosave_is_recoverable, autosave_path_for, read_project_file, recovery_target, + remove_file_quietly, write_project_file, +) class VideoAnnotator(QMainWindow): request_new_project = Signal() @@ -37,14 +36,14 @@ def __init__(self, video_file, srt_file, char_file, vat_file=""): self.vat_file = vat_file self.is_dirty = False + self._recovered_from_autosave = False self.annotations = {} self.custom_columns = ['Note'] if self.vat_file: # Load project details from .vat JSON try: - with open(self.vat_file, 'r', encoding='utf-8') as f: - project_data = json.load(f) + project_data = read_project_file(self.vat_file) video_file = project_data.get('video_file', '') srt_file = project_data.get('srt_file', '') self.char_file = project_data.get('char_file', '') @@ -116,20 +115,25 @@ def __init__(self, video_file, srt_file, char_file, vat_file=""): else: project_data['char_file'] = "" - with open(self.vat_file, 'w', encoding='utf-8') as f: - json.dump(project_data, f, indent=4) + write_project_file(self.vat_file, project_data) + self.is_dirty = True + + if AUTOSAVE_OF_KEY in project_data: + # Recovered from an autosave: keep working on the project it belongs + # to, never on the hidden autosave file. If its recorded owner does + # not match where it lives (a copied folder), continue as an unsaved + # session so Save asks for a location instead of overwriting another + # project. + target = recovery_target(self.vat_file, project_data, srt_file) + self.vat_file = target or "" self.is_dirty = True + self._recovered_from_autosave = True except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load project file: {e}") QTimer.singleShot(0, self.request_new_project.emit) return else: if not char_file: - def get_base_dir(): - if getattr(sys, 'frozen', False): - return Path(sys.executable).parent - else: - return Path(__file__).parent.parent.parent char_file = str(get_base_dir() / "characters.txt") self.char_file = char_file self.characters = read_characters(self.char_file) @@ -137,10 +141,7 @@ def get_base_dir(): self.video_file = video_file self.srt_file = srt_file - if self.vat_file: - self.setWindowTitle(f"CIGA Annotator - {Path(self.vat_file).name}") - else: - self.setWindowTitle(f"CIGA Annotator - {Path(self.srt_file).name}") + self.update_window_title() self.subtitles = parse_srt(self.srt_file) if not self.subtitles: @@ -154,9 +155,9 @@ def get_base_dir(): self.active_role = 'speakers' self._updating_table = False self.slider_is_dragging = False - - srt_base = Path(self.srt_file).name - self.autosave_path = str(Path(self.srt_file).parent / f".{srt_base}.autosave.vat") + # Autosave files this session wrote or recovered from; all are removed once the + # work is saved or explicitly discarded (the location moves on "Save As"). + self._autosave_files = {self.autosave_path} # Video Section first to ensure seek_slider exists before duration_changed triggers self.play_pause_btn = QPushButton("Pause") @@ -351,6 +352,14 @@ def table_key_press(event): self.mediaPlayer.play() self.jump_to_subtitle(self.current_subtitle_index) + @property + def autosave_path(self): + return autosave_path_for(self.vat_file, self.srt_file) + + def update_window_title(self): + name = Path(self.vat_file).name if self.vat_file else Path(self.srt_file).name + self.setWindowTitle(f"CIGA Annotator - {name}") + def create_menu_bar(self): menu_bar = self.menuBar() file_menu = menu_bar.addMenu("File") @@ -551,6 +560,13 @@ def on_character_table_item_changed(self, item): # If they typed in Name col for new character if col == 0: + error = validate_character_name(text) + if error: + QMessageBox.warning(self, "Invalid Name", error) + self._updating_table = True + item.setText("") + self._updating_table = False + return name = text # auto-assign shortcut used_keys = {str(c.get('key', '')).upper() for c in self.characters if c.get('key')} @@ -572,8 +588,11 @@ def on_character_table_item_changed(self, item): else: # Editing existing if col == 0: - if not text: - # Restore previous if emptied, or maybe delete? Let's just restore. + error = validate_character_name(text) + if error: + # Emptied or invalid: restore the previous name. + if text: + QMessageBox.warning(self, "Invalid Name", error) self._updating_table = True item.setText(self.characters[row]['name']) self._updating_table = False @@ -1101,57 +1120,55 @@ def open_project_dialog(self): self.request_open_project.emit(vat_file) def save_project(self): + """Save to the current project, asking for a location first if there is none. + + Returns True only if the project was written. + """ self.record_annotation(self.current_subtitle_index, clear_selections=False) - if hasattr(self, 'vat_file') and self.vat_file: - self._write_project_file(self.vat_file) - self.is_dirty = False - self.update_progress_status() - else: - self.save_project_as_dialog() + if not self.vat_file: + return self.save_project_as_dialog() + return self._save_to(self.vat_file) def save_project_as_dialog(self): self.record_annotation(self.current_subtitle_index, clear_selections=False) save_path, _ = QFileDialog.getSaveFileName(self, "Save Project", "project.vat", "VAT Projects (*.vat)") - if save_path: - self.vat_file = save_path - self._write_project_file(self.vat_file) - self.is_dirty = False - self.update_progress_status() - - def _write_project_file(self, file_path): + if not save_path: + return False + return self._save_to(save_path) + + def _save_to(self, file_path): try: - # We must use paths relative to the vat file where possible, or just store absolute. - # Storing absolute is easier but less portable. Let's use pure filenames if in same dir, or absolute. - vat_dir = Path(file_path).parent - - def rel_path(p): - if not p: return "" - try: - return str(Path(p).relative_to(vat_dir)) - except ValueError: - return str(Path(p).absolute()) - - project_data = { - "video_file": rel_path(self.video_file), - "srt_file": rel_path(self.srt_file), - "char_file": rel_path(self.char_file), - "characters": self.characters, - "custom_columns": getattr(self, 'custom_columns', ['Note']), - "annotations": self.annotations - } - target = Path(file_path) - target.parent.mkdir(parents=True, exist_ok=True) - fd, tmp_path = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)) - os.close(fd) - try: - with open(tmp_path, 'w', encoding='utf-8') as f: - json.dump(project_data, f, indent=4) - os.replace(tmp_path, file_path) - finally: - if os.path.exists(tmp_path): - os.remove(tmp_path) + write_project_file(file_path, self._project_data(file_path)) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to save project: {e}") + return False + self.vat_file = file_path + self.is_dirty = False + self.discard_autosave() + self.update_window_title() + self.update_progress_status() + return True + + def _project_data(self, file_path): + # Paths are stored relative to the project file where possible, so a project + # folder can be moved; files on another drive stay absolute. + vat_dir = Path(file_path).parent + + def rel_path(p): + if not p: return "" + try: + return str(Path(p).relative_to(vat_dir)) + except ValueError: + return str(Path(p).absolute()) + + return { + "video_file": rel_path(self.video_file), + "srt_file": rel_path(self.srt_file), + "char_file": rel_path(self.char_file), + "characters": self.characters, + "custom_columns": getattr(self, 'custom_columns', ['Note']), + "annotations": self.annotations + } def export_csv_dialog(self): self.record_annotation(self.current_subtitle_index, clear_selections=False) @@ -1193,10 +1210,6 @@ def export_csv(self, file_path): if spk or lst or tgt or has_custom: rows.append(row_data) - if rows: - fieldnames = ['line', 'start_time', 'end_time', 'speakers', 'listeners', 'targets'] + getattr(self, 'custom_columns', ['Note']) - # write_rows_to_csv_atomic assumes dicts with standard keys, but we can override it if we write it locally here. - # Actually write_rows_to_csv_atomic in utils checks keys of dicts. Let's just use it and rely on its dict handling. if rows: fieldnames = ['line', 'start_time', 'end_time', 'speakers', 'listeners', 'targets'] + getattr(self, 'custom_columns', ['Note']) else: @@ -1287,35 +1300,54 @@ def update_progress_status(self): ) def autosave_annotations(self): + """Write a crash-recovery copy; the user's project file is only written on save.""" if not self.is_dirty: return - - # Determine the target autosave path - save_target = self.vat_file if hasattr(self, 'vat_file') and self.vat_file else self.autosave_path - + path = self.autosave_path try: self.record_annotation(self.current_subtitle_index, clear_selections=False) - self._write_project_file(save_target) - except Exception: - # Autosave should never interrupt annotation flow. - pass + project_data = self._project_data(path) + project_data[AUTOSAVE_OF_KEY] = str(Path(self.vat_file).absolute()) if self.vat_file else "" + write_project_file(path, project_data) + self._autosave_files.add(path) + except Exception as e: + # Autosave must never interrupt annotation, but a failure should be visible. + self.statusBar().showMessage(f"Autosave failed: {e}", 10000) + + def discard_autosave(self): + """Remove the crash-recovery copies once the work is saved or discarded.""" + for path in getattr(self, '_autosave_files', set()): + try: + remove_file_quietly(path) + except OSError as e: + self.statusBar().showMessage(f"Could not remove autosave {path}: {e}", 10000) + if hasattr(self, '_autosave_files'): + self._autosave_files.clear() def try_restore_autosave(self): - # We don't need to restore autosave if we are explicitly opening a vat project, - # since the vat project is the autosave target itself if it exists. - if hasattr(self, 'vat_file') and self.vat_file: + if self._recovered_from_autosave: return - - if not os.path.exists(self.autosave_path): + path = self.autosave_path + if not autosave_is_recoverable(path, self.vat_file): + return + # Offer only an autosave recorded for this very project (or unsaved session); + # one carried in by copying a project folder names the original project. + try: + owner = recovery_target(path, read_project_file(path), self.srt_file) + except (OSError, ValueError): + owner = None # unreadable or malformed: nothing trustworthy to restore + if owner is None: return reply = QMessageBox.question( self, "Restore Autosave", - f"Found autosave project:\n{self.autosave_path}\n\nRestore it now?", + f"Found unsaved work from a previous session:\n{path}\n\nRestore it now?", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: - self.request_open_project.emit(self.autosave_path) + # This window is closed to open the recovered one; keep the file it reads. + self._autosave_files.discard(path) + self.request_open_project.emit(path) def closeEvent(self, event): if self.is_dirty: @@ -1328,17 +1360,13 @@ def closeEvent(self, event): if reply == QMessageBox.Cancel: event.ignore() return - if reply == QMessageBox.Yes: - if hasattr(self, 'vat_file') and self.vat_file: - self.save_project() - else: - save_path, _ = QFileDialog.getSaveFileName(self, "Save Project", "project.vat", "VAT Projects (*.vat)") - if not save_path: - event.ignore() - return - self.vat_file = save_path - self.save_project() - self.autosave_timer.stop() + if reply == QMessageBox.Yes and not self.save_project(): + event.ignore() + return + # Saved, discarded, or nothing to save: the recovery copy is no longer needed. + self.discard_autosave() + if hasattr(self, 'autosave_timer'): + self.autosave_timer.stop() event.accept() @Slot(str) diff --git a/src/ui/char_dialog.py b/src/ui/char_dialog.py deleted file mode 100644 index 1213257..0000000 --- a/src/ui/char_dialog.py +++ /dev/null @@ -1,60 +0,0 @@ -from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QTableWidget, - QTableWidgetItem, QPushButton, QDialogButtonBox, QHeaderView -) - -class ManageCharactersDialog(QDialog): - def __init__(self, characters, parent=None): - super().__init__(parent) - self.setWindowTitle("Manage Characters & Shortcuts") - self.resize(300, 400) - self.characters = characters.copy() - - self.layout = QVBoxLayout(self) - self.table = QTableWidget(len(self.characters), 2) - self.table.setHorizontalHeaderLabels(["Name", "Shortcut (e.g. 1, Q)"]) - self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) - - for row, char in enumerate(self.characters): - self.table.setItem(row, 0, QTableWidgetItem(char['name'])) - self.table.setItem(row, 1, QTableWidgetItem(char.get('key', ''))) - - self.layout.addWidget(self.table) - - btn_layout = QHBoxLayout() - self.add_btn = QPushButton("Add New Row") - self.add_btn.clicked.connect(self.add_row) - self.del_btn = QPushButton("Delete Selected Row") - self.del_btn.clicked.connect(self.delete_row) - btn_layout.addWidget(self.add_btn) - btn_layout.addWidget(self.del_btn) - self.layout.addLayout(btn_layout) - - self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self) - self.buttons.accepted.connect(self.accept) - self.buttons.rejected.connect(self.reject) - self.layout.addWidget(self.buttons) - - def add_row(self): - row = self.table.rowCount() - self.table.insertRow(row) - self.table.setItem(row, 0, QTableWidgetItem("")) - self.table.setItem(row, 1, QTableWidgetItem("")) - - def delete_row(self): - row = self.table.currentRow() - if row >= 0: - self.table.removeRow(row) - - def get_data(self): - data = [] - for row in range(self.table.rowCount()): - name_item = self.table.item(row, 0) - key_item = self.table.item(row, 1) - name = name_item.text().strip() if name_item else "" - key = key_item.text().strip().upper() if key_item else "" - if len(key) > 1: - key = key[0] - if name: - data.append({'name': name, 'key': key if key else None}) - return data diff --git a/tests/test_annotator_window.py b/tests/test_annotator_window.py new file mode 100644 index 0000000..e8c8d6f --- /dev/null +++ b/tests/test_annotator_window.py @@ -0,0 +1,229 @@ +"""Save/autosave/restore behavior of the real annotator window, run offscreen. + +Dialogs are replaced with scripted answers so nothing blocks; the video file is an +empty placeholder, which only makes the media player report an error. +""" +import json +import os +import shutil + +import pytest + +pytest.importorskip("PySide6.QtMultimedia") +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox # noqa: E402 + +from src.core.project import AUTOSAVE_OF_KEY # noqa: E402 +from src.ui.annotator_window import VideoAnnotator # noqa: E402 + +SRT = "1\n00:00:01,000 --> 00:00:02,000\nHello\n\n2\n00:00:03,000 --> 00:00:04,000\nWorld\n" + + +@pytest.fixture(scope="module") +def qapp(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def answers(monkeypatch): + """Scripted dialog answers; tests set answers['question'] etc. as needed.""" + script = {"question": QMessageBox.No, "save_path": ""} + shown = [] + for name in ("critical", "warning", "information"): + monkeypatch.setattr(QMessageBox, name, staticmethod(lambda *a, _n=name, **k: shown.append((_n, a[2] if len(a) > 2 else "")))) + monkeypatch.setattr(QMessageBox, "question", staticmethod(lambda *a, **k: script["question"])) + monkeypatch.setattr(QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (script["save_path"], ""))) + script["shown"] = shown + return script + + +@pytest.fixture +def media(tmp_path): + (tmp_path / "ep1.srt").write_text(SRT, encoding="utf-8") + (tmp_path / "ep1.mp4").write_bytes(b"") + (tmp_path / "chars.txt").write_text("Sheldon,1\nPenny,2\n", encoding="utf-8") + return tmp_path + + +def _saved_project(media): + vat = media / "study.vat" + vat.write_text(json.dumps({ + "video_file": "ep1.mp4", "srt_file": "ep1.srt", "char_file": "chars.txt", + "characters": [{"name": "Sheldon", "key": "1"}], "custom_columns": ["Note"], + "annotations": {}, + }), encoding="utf-8") + return vat + + +def _open(vat="", media=None): + if vat: + return VideoAnnotator("", "", "", str(vat)) + return VideoAnnotator(str(media / "ep1.mp4"), str(media / "ep1.srt"), str(media / "chars.txt"), "") + + +def _code_current_line(window, speaker="Sheldon"): + window.current_speakers = [speaker] + window.is_dirty = True + + +def _close(window): + window.close() + window.deleteLater() + + +def test_autosave_never_touches_the_project_file(qapp, answers, media): + vat = _saved_project(media) + before = vat.read_text(encoding="utf-8") + window = _open(vat) + _code_current_line(window) + + window.autosave_annotations() + + autosave = media / ".study.vat.autosave.vat" + assert vat.read_text(encoding="utf-8") == before + data = json.loads(autosave.read_text(encoding="utf-8")) + assert data[AUTOSAVE_OF_KEY] == str(vat.absolute()) + assert data["annotations"]["0"]["speakers"] == ["Sheldon"] + window.is_dirty = False + _close(window) + + +def test_saving_writes_the_project_and_removes_the_autosave(qapp, answers, media): + vat = _saved_project(media) + window = _open(vat) + _code_current_line(window) + window.autosave_annotations() + + assert window.save_project() is True + + assert json.loads(vat.read_text(encoding="utf-8"))["annotations"]["0"]["speakers"] == ["Sheldon"] + assert not (media / ".study.vat.autosave.vat").exists() + assert window.is_dirty is False + _close(window) + + +def test_declining_to_save_on_close_discards_changes_and_autosave(qapp, answers, media): + vat = _saved_project(media) + before = vat.read_text(encoding="utf-8") + window = _open(vat) + _code_current_line(window) + window.autosave_annotations() + answers["question"] = QMessageBox.No + + assert window.close() is True + + assert vat.read_text(encoding="utf-8") == before + assert not (media / ".study.vat.autosave.vat").exists() + + +def test_cancelling_close_keeps_the_window_and_autosave(qapp, answers, media): + window = _open(media=media) + _code_current_line(window) + window.autosave_annotations() + answers["question"] = QMessageBox.Cancel + + assert window.close() is False + assert (media / ".ep1.srt.autosave.vat").exists() + window.is_dirty = False + _close(window) + + +def test_failed_save_as_keeps_the_window_open(qapp, answers, media): + window = _open(media=media) + _code_current_line(window) + answers["question"] = QMessageBox.Yes + answers["save_path"] = "" # user cancels the Save As dialog + + assert window.close() is False + window.is_dirty = False + _close(window) + + +def test_recovered_session_saves_to_its_project_not_the_autosave(qapp, answers, media): + vat = _saved_project(media) + window = _open(vat) + _code_current_line(window) + window.autosave_annotations() + window.is_dirty = False # simulate a crash: the window never got to save + window.deleteLater() + autosave = media / ".study.vat.autosave.vat" + + recovered = _open(autosave) + + assert recovered.vat_file == str(vat.absolute()) + assert recovered.is_dirty is True + assert recovered.annotations[0]["speakers"] == ["Sheldon"] + assert recovered.save_project() is True + assert json.loads(vat.read_text(encoding="utf-8"))["annotations"]["0"]["speakers"] == ["Sheldon"] + assert not autosave.exists() + _close(recovered) + + +def test_unsaved_session_autosave_sits_next_to_the_subtitles(qapp, answers, media): + window = _open(media=media) + _code_current_line(window) + + window.autosave_annotations() + + data = json.loads((media / ".ep1.srt.autosave.vat").read_text(encoding="utf-8")) + assert data[AUTOSAVE_OF_KEY] == "" + window.is_dirty = False + _close(window) + assert not (media / ".ep1.srt.autosave.vat").exists() + + +def test_character_names_with_commas_are_rejected(qapp, answers, media): + window = _open(media=media) + table = window.character_table + new_row = len(window.characters) + names_before = [c["name"] for c in window.characters] + + table.item(new_row, 0).setText("Cooper, Sheldon") + + assert [c["name"] for c in window.characters] == names_before + assert any(kind == "warning" for kind, _ in answers["shown"]) + _close(window) + + +def test_autosave_copied_from_another_project_is_not_offered(qapp, answers, media, monkeypatch): + original = _saved_project(media) + window = _open(original) + _code_current_line(window) + window.autosave_annotations() + window.is_dirty = False + window.deleteLater() + # Copy the project folder, hidden autosave included, then open the copy. + copy_dir = media.parent / "copied_project" + shutil.copytree(media, copy_dir) + copy_vat = copy_dir / "study.vat" + os.utime(copy_vat, (1000, 1000)) + offered = [] + answers["question"] = QMessageBox.Yes + monkeypatch.setattr(QMessageBox, "question", staticmethod(lambda *a, **k: offered.append(a[2]) or QMessageBox.Yes)) + + copied = _open(copy_vat) + requested = [] + copied.request_open_project.connect(requested.append) + copied.try_restore_autosave() + + assert offered == [] and requested == [] + copied.is_dirty = False + _close(copied) + + +def test_opening_a_mismatched_autosave_never_targets_the_other_project(qapp, answers, media): + original = _saved_project(media) + window = _open(original) + _code_current_line(window) + window.autosave_annotations() + window.is_dirty = False + window.deleteLater() + copy_dir = media.parent / "copied_project2" + shutil.copytree(media, copy_dir) + + recovered = _open(copy_dir / ".study.vat.autosave.vat") + + assert recovered.vat_file == "" # unsaved: Save asks where, instead of writing the original + recovered.is_dirty = False + _close(recovered) diff --git a/tests/test_app_settings.py b/tests/test_app_settings.py new file mode 100644 index 0000000..3b4927a --- /dev/null +++ b/tests/test_app_settings.py @@ -0,0 +1,39 @@ +import json + +import pytest + +import src.core.app_settings as app_settings + + +@pytest.fixture +def settings_file(tmp_path, monkeypatch): + path = tmp_path / "settings.json" + monkeypatch.setattr(app_settings, "SETTINGS_FILE", str(path)) + return path + + +def test_rebinding_a_hotkey_leaves_the_defaults_untouched(settings_file): + settings = app_settings.AppSettings() + + settings.set_hotkey("play_pause", "P") + + assert settings.get_hotkey("play_pause") == "P" + assert app_settings.DEFAULT_SETTINGS["hotkeys"]["play_pause"] == "Space" + + +def test_loading_a_settings_file_leaves_the_defaults_untouched(settings_file): + settings_file.write_text(json.dumps({"auto_pause": False, "hotkeys": {"next_uncoded": "M"}}), encoding="utf-8") + + settings = app_settings.AppSettings() + + assert settings.get("auto_pause") is False + assert settings.get_hotkey("next_uncoded") == "M" + assert settings.get_hotkey("play_pause") == "Space" + assert app_settings.DEFAULT_SETTINGS["hotkeys"]["next_uncoded"] == "N" + assert app_settings.DEFAULT_SETTINGS["auto_pause"] is True + + +def test_settings_persist_between_instances(settings_file): + app_settings.AppSettings().set("inherit_listener", True) + + assert app_settings.AppSettings().get("inherit_listener") is True diff --git a/tests/test_characters.py b/tests/test_characters.py new file mode 100644 index 0000000..129b55a --- /dev/null +++ b/tests/test_characters.py @@ -0,0 +1,28 @@ +import pytest + +from src.core.characters import read_characters, save_characters, validate_character_name + + +def test_round_trips_names_and_shortcuts(tmp_path): + path = str(tmp_path / "characters.txt") + characters = [{"name": "Monica", "key": "1"}, {"name": "Joey", "key": None}] + + save_characters(path, characters) + + assert read_characters(path) == characters + + +def test_missing_file_yields_no_characters(tmp_path): + assert read_characters(str(tmp_path / "absent.txt")) == [] + + +@pytest.mark.parametrize("name", ["Sheldon", "Dr. Cooper", "Mary Cooper"]) +def test_accepts_plain_names(name): + assert validate_character_name(name) is None + + +@pytest.mark.parametrize("name", ["", " ", "Cooper, Sheldon"]) +def test_rejects_empty_names_and_commas(name): + # Commas separate characters in exported CSV cells and in CIGA's input, + # so a name containing one would be split into two characters. + assert validate_character_name(name) diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 0000000..1f515f8 --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,92 @@ +import pytest + +from src.core.parsers import milliseconds_to_srt_time, parse_srt, srt_time_to_milliseconds + + +def _write(tmp_path, text, encoding="utf-8"): + path = tmp_path / "subtitles.srt" + path.write_bytes(text.encode(encoding)) + return str(path) + + +def test_parses_basic_cues(tmp_path): + srt = _write(tmp_path, "1\n00:00:01,000 --> 00:00:02,500\nHello\n\n2\n00:00:03,000 --> 00:00:04,000\nWorld\n") + + subs = parse_srt(srt) + + assert subs == [ + {"index": 1, "start_time": 1000, "end_time": 2500, "text": "Hello"}, + {"index": 2, "start_time": 3000, "end_time": 4000, "text": "World"}, + ] + + +def test_keeps_continuation_lines_that_start_with_a_digit(tmp_path): + srt = _write(tmp_path, "1\n00:00:01,000 --> 00:00:02,000\nI owe you\n20 dollars.\n\n2\n00:00:03,000 --> 00:00:04,000\nNext\n") + + subs = parse_srt(srt) + + assert [s["text"] for s in subs] == ["I owe you 20 dollars.", "Next"] + + +def test_accepts_dot_millisecond_separator(tmp_path): + srt = _write(tmp_path, "1\n00:00:05.000 --> 00:00:06.250\nDot separated\n") + + subs = parse_srt(srt) + + assert len(subs) == 1 + assert (subs[0]["start_time"], subs[0]["end_time"]) == (5000, 6250) + + +def test_handles_crlf_bom_and_extra_blank_lines(tmp_path): + text = "1\r\n00:00:01,000 --> 00:00:02,000\r\nFirst\r\n\r\n\r\n2\r\n00:00:03,000 --> 00:00:04,000\r\nSecond\r\n" + srt = _write(tmp_path, text) + + subs = parse_srt(srt) + + assert [(s["index"], s["text"]) for s in subs] == [(1, "First"), (2, "Second")] + + +def test_ignores_cue_settings_after_the_timing(tmp_path): + srt = _write(tmp_path, "1\n00:00:01,000 --> 00:00:02,000 X1:40 X2:600 Y1:20 Y2:50\nPositioned\n") + + assert parse_srt(srt)[0]["text"] == "Positioned" + + +def test_numbers_cues_without_an_index_line(tmp_path): + srt = _write(tmp_path, "00:00:01,000 --> 00:00:02,000\nNo index\n\n00:00:03,000 --> 00:00:04,000\nAlso none\n") + + assert [(s["index"], s["text"]) for s in parse_srt(srt)] == [(1, "No index"), (2, "Also none")] + + +def test_keeps_cues_with_empty_text(tmp_path): + srt = _write(tmp_path, "1\n00:00:01,000 --> 00:00:02,000\n\n2\n00:00:03,000 --> 00:00:04,000\nAfter\n") + + assert [s["text"] for s in parse_srt(srt)] == ["", "After"] + + +def test_skips_blocks_without_timing(tmp_path): + srt = _write(tmp_path, "garbage header\n\n1\n00:00:01,000 --> 00:00:02,000\nKept\n") + + assert [s["text"] for s in parse_srt(srt)] == ["Kept"] + + +def test_decodes_non_utf8_files(tmp_path): + srt = _write(tmp_path, "1\n00:00:01,000 --> 00:00:02,000\n你好,世界。这是一段比较长的中文字幕,用来测试编码检测。\n", encoding="gb18030") + + assert parse_srt(srt)[0]["text"].startswith("你好") + + +@pytest.mark.parametrize("value, expected", [ + ("00:00:01,000", 1000), + ("01:02:03,456", 3723456), + ("00:00:01.5", 1500), + ("0:00:01,000", 1000), + ("bad", 0), +]) +def test_srt_time_to_milliseconds(value, expected): + assert srt_time_to_milliseconds(value) == expected + + +def test_milliseconds_round_trip(): + assert milliseconds_to_srt_time(3723456) == "01:02:03,456" + assert srt_time_to_milliseconds(milliseconds_to_srt_time(3723456)) == 3723456 diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..d1b2d00 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,104 @@ +import json +import os +from pathlib import Path + +import pytest + +from src.core import project + + +def test_autosave_of_an_unsaved_session_sits_next_to_the_subtitles(tmp_path): + srt = tmp_path / "ep1.srt" + + assert Path(project.autosave_path_for("", str(srt))) == tmp_path / ".ep1.srt.autosave.vat" + + +def test_autosave_of_a_saved_project_sits_next_to_the_project(tmp_path): + vat = tmp_path / "work" / "study.vat" + + path = project.autosave_path_for(str(vat), str(tmp_path / "ep1.srt")) + + assert Path(path) == tmp_path / "work" / ".study.vat.autosave.vat" + assert Path(path) != vat + + +def test_write_then_read_round_trips(tmp_path): + target = tmp_path / "nested" / "p.vat" + data = {"annotations": {"0": {"speakers": ["A"]}}, "characters": []} + + project.write_project_file(str(target), data) + + assert project.read_project_file(str(target)) == data + + +def test_failed_write_keeps_the_previous_file_and_leaves_no_temp_files(tmp_path): + target = tmp_path / "p.vat" + project.write_project_file(str(target), {"version": 1}) + + with pytest.raises(TypeError): + project.write_project_file(str(target), {"bad": object()}) + + assert json.loads(target.read_text(encoding="utf-8")) == {"version": 1} + assert sorted(os.listdir(tmp_path)) == ["p.vat"] + + +def test_autosave_newer_than_project(tmp_path): + vat, autosave = tmp_path / "p.vat", tmp_path / ".p.vat.autosave.vat" + vat.write_text("{}") + autosave.write_text("{}") + os.utime(vat, (1000, 1000)) + os.utime(autosave, (2000, 2000)) + + assert project.autosave_is_recoverable(str(autosave), str(vat)) + assert not project.autosave_is_recoverable(str(vat), str(autosave)) + + +def test_autosave_without_a_project_is_recoverable(tmp_path): + autosave = tmp_path / ".ep1.srt.autosave.vat" + autosave.write_text("{}") + + assert project.autosave_is_recoverable(str(autosave), "") + assert not project.autosave_is_recoverable(str(tmp_path / "missing"), "") + + +def test_remove_file_quietly(tmp_path): + path = tmp_path / "x" + path.write_text("x") + + project.remove_file_quietly(str(path)) + project.remove_file_quietly(str(path)) + + assert not path.exists() + + +def test_recovery_target_accepts_matching_metadata(tmp_path): + vat = tmp_path / "study.vat" + srt = tmp_path / "ep1.srt" + autosave = project.autosave_path_for(str(vat), str(srt)) + + assert project.recovery_target(autosave, {project.AUTOSAVE_OF_KEY: str(vat)}, str(srt)) == str(vat) + + +def test_recovery_target_accepts_an_unsaved_session(tmp_path): + srt = tmp_path / "ep1.srt" + autosave = project.autosave_path_for("", str(srt)) + + assert project.recovery_target(autosave, {project.AUTOSAVE_OF_KEY: ""}, str(srt)) == "" + + +def test_recovery_target_rejects_an_autosave_pointing_at_another_project(tmp_path): + # A copied project folder carries an autosave whose metadata still names the + # original project; saving there would overwrite the wrong project. + original = tmp_path / "original" / "study.vat" + copy = tmp_path / "copy" / "study.vat" + srt = tmp_path / "copy" / "ep1.srt" + autosave_in_copy = project.autosave_path_for(str(copy), str(srt)) + + assert project.recovery_target(autosave_in_copy, {project.AUTOSAVE_OF_KEY: str(original)}, str(srt)) is None + + +@pytest.mark.parametrize("data", [{}, {project.AUTOSAVE_OF_KEY: None}, {project.AUTOSAVE_OF_KEY: 3}, []]) +def test_recovery_target_rejects_malformed_metadata(tmp_path, data): + srt = tmp_path / "ep1.srt" + + assert project.recovery_target(project.autosave_path_for("", str(srt)), data, str(srt)) is None