forgot to add stuff
This commit is contained in:
23
.github/pull_request_template.md
vendored
Normal file
23
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
Describe what changed and why.
|
||||||
|
|
||||||
|
## Spec Link (Required For Feature Changes)
|
||||||
|
|
||||||
|
- Spec file in `docs/specs/`: <!-- e.g. docs/specs/2026-04-15-speed-adjustment.md -->
|
||||||
|
|
||||||
|
## Acceptance Criteria Checklist
|
||||||
|
|
||||||
|
- [ ] Acceptance criteria reviewed against the linked spec
|
||||||
|
- [ ] User-visible behavior verified for this change
|
||||||
|
- [ ] Backward compatibility impact assessed
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- [ ] `./scripts/validate-all.sh` passes locally
|
||||||
|
- [ ] Added/updated tests for changed behavior
|
||||||
|
|
||||||
|
## Risk And Rollback
|
||||||
|
|
||||||
|
- Risk level: Low / Medium / High
|
||||||
|
- Rollback plan:
|
||||||
58
.github/workflows/validate-all.yml
vendored
Normal file
58
.github/workflows/validate-all.yml
vendored
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
name: Validate All
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-all:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: |
|
||||||
|
frontend/package-lock.json
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Enforce feature spec policy (PR only)
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: ./scripts/check-feature-spec.sh
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Run validate-all
|
||||||
|
env:
|
||||||
|
SKIP_BACKEND_IMPORT_SMOKE: '1'
|
||||||
|
run: ./scripts/validate-all.sh
|
||||||
|
|
||||||
|
- name: Collect diagnostics on failure
|
||||||
|
if: failure()
|
||||||
|
run: ./scripts/collect-diagnostics.sh
|
||||||
|
|
||||||
|
- name: Upload diagnostics artifact
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: diagnostics
|
||||||
|
path: .diagnostics
|
||||||
@ -18,7 +18,7 @@ Features are grouped by priority. Check off items as they are implemented.
|
|||||||
|
|
||||||
- [x] [#006] **Volume / gain control** — per-selection or global audio gain slider. Every editor has this. Descript users constantly complain it's missing. Backend: `ffmpeg -af volume=Xdb`.
|
- [x] [#006] **Volume / gain control** — per-selection or global audio gain slider. Every editor has this. Descript users constantly complain it's missing. Backend: `ffmpeg -af volume=Xdb`.
|
||||||
|
|
||||||
- [ ] [#007] **Speed adjustment** — slow down or speed up a selection or the whole clip. Backend: `ffmpeg -filter:v setpts` + `atempo`. Common use case: slightly speed up boring sections.
|
- [ ] [#007] **Speed adjustment (4th zone type)** — add speed zones as the fourth editable timeline/transcript zone type (after cut, mute, gain), allowing slow/fast playback per range or globally. Backend: `ffmpeg -filter:v setpts` + `atempo`. Common use case: slightly speed up boring sections.
|
||||||
|
|
||||||
- [ ] [#008] **Cut preview** — before committing a delete, play what the audio will sound like with that section removed (pre-listen across the edit point). Pure frontend using Web Audio API — splice the AudioBuffer and play the join.
|
- [ ] [#008] **Cut preview** — before committing a delete, play what the audio will sound like with that section removed (pre-listen across the edit point). Pure frontend using Web Audio API — splice the AudioBuffer and play the join.
|
||||||
|
|
||||||
|
|||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
57
backend/tests/test_cache_utils.py
Normal file
57
backend/tests/test_cache_utils.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.utils import cache as cache_utils
|
||||||
|
|
||||||
|
|
||||||
|
class CacheUtilsTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self._old_cache_dir = cache_utils.CACHE_DIR
|
||||||
|
cache_utils.CACHE_DIR = Path(self._tmp_dir.name) / "cache"
|
||||||
|
|
||||||
|
self._work_dir = Path(self._tmp_dir.name) / "work"
|
||||||
|
self._work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._src_file = self._work_dir / "sample.txt"
|
||||||
|
self._src_file.write_text("hello", encoding="utf-8")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
cache_utils.CACHE_DIR = self._old_cache_dir
|
||||||
|
self._tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_get_file_hash_returns_none_for_missing_file(self) -> None:
|
||||||
|
missing = self._work_dir / "missing.txt"
|
||||||
|
self.assertIsNone(cache_utils.get_file_hash(missing))
|
||||||
|
|
||||||
|
def test_save_and_load_round_trip(self) -> None:
|
||||||
|
payload = {"value": 123, "ok": True}
|
||||||
|
saved = cache_utils.save_to_cache(self._src_file, payload, model="m1", operation="transcribe")
|
||||||
|
self.assertTrue(saved)
|
||||||
|
|
||||||
|
loaded = cache_utils.load_from_cache(self._src_file, model="m1", operation="transcribe")
|
||||||
|
self.assertEqual(payload, loaded)
|
||||||
|
|
||||||
|
def test_load_from_cache_respects_max_age(self) -> None:
|
||||||
|
payload = {"value": 999}
|
||||||
|
self.assertTrue(cache_utils.save_to_cache(self._src_file, payload, operation="transcribe"))
|
||||||
|
|
||||||
|
time.sleep(0.02)
|
||||||
|
expired = cache_utils.load_from_cache(self._src_file, operation="transcribe", max_age=0.001)
|
||||||
|
self.assertIsNone(expired)
|
||||||
|
|
||||||
|
def test_clear_cache_deletes_files(self) -> None:
|
||||||
|
self.assertTrue(cache_utils.save_to_cache(self._src_file, {"a": 1}, operation="transcribe"))
|
||||||
|
self.assertTrue(cache_utils.save_to_cache(self._src_file, {"a": 2}, operation="summarize"))
|
||||||
|
|
||||||
|
deleted_count = cache_utils.clear_cache()
|
||||||
|
self.assertGreaterEqual(deleted_count, 1)
|
||||||
|
|
||||||
|
size_bytes, file_count = cache_utils.get_cache_size()
|
||||||
|
self.assertEqual(size_bytes, 0)
|
||||||
|
self.assertEqual(file_count, 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
451
backend/tests/test_router_contracts.py
Normal file
451
backend/tests/test_router_contracts.py
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from backend.main import app
|
||||||
|
from routers import audio as audio_router
|
||||||
|
|
||||||
|
|
||||||
|
class RouterContractTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.client = TestClient(app)
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
audio_router._waveform_cache.clear()
|
||||||
|
|
||||||
|
def test_health_endpoint(self) -> None:
|
||||||
|
res = self.client.get("/health")
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.json(), {"status": "ok"})
|
||||||
|
|
||||||
|
def test_file_endpoint_full_content(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
file_path = Path(tmp) / "sample.wav"
|
||||||
|
file_path.write_bytes(b"abcdefghij")
|
||||||
|
|
||||||
|
res = self.client.get("/file", params={"path": str(file_path)})
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.content, b"abcdefghij")
|
||||||
|
self.assertEqual(res.headers.get("accept-ranges"), "bytes")
|
||||||
|
|
||||||
|
def test_file_endpoint_range_request(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
file_path = Path(tmp) / "sample.wav"
|
||||||
|
file_path.write_bytes(b"abcdefghij")
|
||||||
|
|
||||||
|
res = self.client.get(
|
||||||
|
"/file",
|
||||||
|
params={"path": str(file_path)},
|
||||||
|
headers={"Range": "bytes=2-5"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 206)
|
||||||
|
self.assertEqual(res.content, b"cdef")
|
||||||
|
self.assertEqual(res.headers.get("content-range"), "bytes 2-5/10")
|
||||||
|
|
||||||
|
def test_file_endpoint_missing_file(self) -> None:
|
||||||
|
res = self.client.get("/file", params={"path": "/tmp/does-not-exist.wav"})
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 404)
|
||||||
|
self.assertIn("File not found", res.json()["detail"])
|
||||||
|
|
||||||
|
@patch("routers.audio.subprocess.run")
|
||||||
|
def test_audio_waveform_cache_miss_then_hit(self, mock_subprocess_run) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
media_file = Path(tmp) / "input.mp4"
|
||||||
|
media_file.write_bytes(b"fake-media")
|
||||||
|
|
||||||
|
def fake_ffmpeg(cmd, capture_output, text):
|
||||||
|
out_path = Path(cmd[-1])
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_bytes(b"fake-wav")
|
||||||
|
return SimpleNamespace(returncode=0, stderr="")
|
||||||
|
|
||||||
|
mock_subprocess_run.side_effect = fake_ffmpeg
|
||||||
|
|
||||||
|
res1 = self.client.get("/audio/waveform", params={"path": str(media_file)})
|
||||||
|
self.assertEqual(res1.status_code, 200)
|
||||||
|
self.assertTrue(res1.headers.get("content-type", "").startswith("audio/wav"))
|
||||||
|
|
||||||
|
res2 = self.client.get("/audio/waveform", params={"path": str(media_file)})
|
||||||
|
self.assertEqual(res2.status_code, 200)
|
||||||
|
self.assertTrue(res2.headers.get("content-type", "").startswith("audio/wav"))
|
||||||
|
|
||||||
|
self.assertEqual(mock_subprocess_run.call_count, 1)
|
||||||
|
|
||||||
|
@patch("routers.audio.subprocess.run")
|
||||||
|
def test_audio_waveform_ffmpeg_failure_returns_500(self, mock_subprocess_run) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
media_file = Path(tmp) / "input.mp4"
|
||||||
|
media_file.write_bytes(b"fake-media")
|
||||||
|
|
||||||
|
mock_subprocess_run.return_value = SimpleNamespace(returncode=1, stderr="ffmpeg failed")
|
||||||
|
|
||||||
|
res = self.client.get("/audio/waveform", params={"path": str(media_file)})
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 500)
|
||||||
|
self.assertIn("Failed to extract audio", res.json()["detail"])
|
||||||
|
|
||||||
|
@patch("routers.ai.detect_filler_words")
|
||||||
|
def test_ai_filler_removal_contract(self, mock_detect_filler_words) -> None:
|
||||||
|
mock_detect_filler_words.return_value = {
|
||||||
|
"wordIndices": [2, 5],
|
||||||
|
"fillerWords": [
|
||||||
|
{"index": 2, "word": "um", "reason": "filler"},
|
||||||
|
{"index": 5, "word": "uh", "reason": "filler"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"transcript": "Hello um world uh",
|
||||||
|
"words": [
|
||||||
|
{"index": 0, "word": "Hello"},
|
||||||
|
{"index": 1, "word": "um"},
|
||||||
|
{"index": 2, "word": "world"},
|
||||||
|
],
|
||||||
|
"provider": "ollama",
|
||||||
|
"model": "llama3",
|
||||||
|
}
|
||||||
|
res = self.client.post("/ai/filler-removal", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertIn("wordIndices", res.json())
|
||||||
|
mock_detect_filler_words.assert_called_once()
|
||||||
|
|
||||||
|
@patch("routers.ai.detect_filler_words")
|
||||||
|
def test_ai_filler_removal_error_returns_500(self, mock_detect_filler_words) -> None:
|
||||||
|
mock_detect_filler_words.side_effect = RuntimeError("ai-filler-fail")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"transcript": "Hello world",
|
||||||
|
"words": [{"index": 0, "word": "Hello"}],
|
||||||
|
"provider": "ollama",
|
||||||
|
}
|
||||||
|
res = self.client.post("/ai/filler-removal", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 500)
|
||||||
|
self.assertEqual(res.json()["detail"], "ai-filler-fail")
|
||||||
|
|
||||||
|
@patch("routers.ai.create_clip_suggestion")
|
||||||
|
def test_ai_create_clip_contract(self, mock_create_clip_suggestion) -> None:
|
||||||
|
mock_create_clip_suggestion.return_value = {
|
||||||
|
"title": "Best Moment",
|
||||||
|
"startWordIndex": 10,
|
||||||
|
"endWordIndex": 40,
|
||||||
|
"startTime": 12.3,
|
||||||
|
"endTime": 48.8,
|
||||||
|
"reason": "Strong hook",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"transcript": "Long transcript...",
|
||||||
|
"words": [{"index": 0, "word": "hello"}],
|
||||||
|
"provider": "ollama",
|
||||||
|
"target_duration": 45,
|
||||||
|
}
|
||||||
|
res = self.client.post("/ai/create-clip", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.json()["title"], "Best Moment")
|
||||||
|
mock_create_clip_suggestion.assert_called_once()
|
||||||
|
|
||||||
|
@patch("routers.ai.create_clip_suggestion")
|
||||||
|
def test_ai_create_clip_error_returns_500(self, mock_create_clip_suggestion) -> None:
|
||||||
|
mock_create_clip_suggestion.side_effect = RuntimeError("ai-clip-fail")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"transcript": "Hello world",
|
||||||
|
"words": [{"index": 0, "word": "hello"}],
|
||||||
|
"provider": "ollama",
|
||||||
|
}
|
||||||
|
res = self.client.post("/ai/create-clip", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 500)
|
||||||
|
self.assertEqual(res.json()["detail"], "ai-clip-fail")
|
||||||
|
|
||||||
|
@patch("routers.ai.AIProvider.list_ollama_models")
|
||||||
|
def test_ai_ollama_models_contract(self, mock_list_ollama_models) -> None:
|
||||||
|
mock_list_ollama_models.return_value = ["llama3", "qwen2.5"]
|
||||||
|
|
||||||
|
res = self.client.get("/ai/ollama-models?base_url=http://localhost:11434")
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.json(), {"models": ["llama3", "qwen2.5"]})
|
||||||
|
mock_list_ollama_models.assert_called_once_with("http://localhost:11434")
|
||||||
|
|
||||||
|
@patch("routers.ai.AIProvider.list_ollama_models")
|
||||||
|
def test_ai_ollama_models_unhandled_error_returns_500(self, mock_list_ollama_models) -> None:
|
||||||
|
mock_list_ollama_models.side_effect = RuntimeError("ollama-unreachable")
|
||||||
|
|
||||||
|
local_client = TestClient(app, raise_server_exceptions=False)
|
||||||
|
res = local_client.get("/ai/ollama-models")
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 500)
|
||||||
|
|
||||||
|
@patch("routers.transcribe.transcribe_audio")
|
||||||
|
def test_transcribe_success(self, mock_transcribe) -> None:
|
||||||
|
mock_transcribe.return_value = {"words": [], "segments": [], "language": "en"}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"file_path": "/tmp/input.wav",
|
||||||
|
"model": "base",
|
||||||
|
"use_gpu": False,
|
||||||
|
"use_cache": True,
|
||||||
|
}
|
||||||
|
res = self.client.post("/transcribe", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.json(), {"words": [], "segments": [], "language": "en"})
|
||||||
|
mock_transcribe.assert_called_once()
|
||||||
|
|
||||||
|
@patch("routers.transcribe.diarize_and_label")
|
||||||
|
@patch("routers.transcribe.transcribe_audio")
|
||||||
|
def test_transcribe_with_diarization(self, mock_transcribe, mock_diarize) -> None:
|
||||||
|
mock_transcribe.return_value = {"words": [{"word": "hi", "start": 0.0, "end": 0.2}], "segments": []}
|
||||||
|
mock_diarize.return_value = {"words": [{"word": "hi", "start": 0.0, "end": 0.2, "speaker": "SPEAKER_00"}], "segments": []}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"file_path": "/tmp/input.wav",
|
||||||
|
"model": "base",
|
||||||
|
"diarize": True,
|
||||||
|
"hf_token": "hf_xxx",
|
||||||
|
"num_speakers": 2,
|
||||||
|
}
|
||||||
|
res = self.client.post("/transcribe", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertIn("words", res.json())
|
||||||
|
mock_transcribe.assert_called_once()
|
||||||
|
mock_diarize.assert_called_once()
|
||||||
|
|
||||||
|
@patch("routers.transcribe.transcribe_audio")
|
||||||
|
def test_transcribe_file_not_found_returns_404(self, mock_transcribe) -> None:
|
||||||
|
mock_transcribe.side_effect = FileNotFoundError("missing")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"file_path": "/tmp/missing.wav",
|
||||||
|
"model": "base",
|
||||||
|
}
|
||||||
|
res = self.client.post("/transcribe", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 404)
|
||||||
|
self.assertIn("File not found", res.json()["detail"])
|
||||||
|
|
||||||
|
@patch("routers.transcribe.transcribe_audio")
|
||||||
|
def test_transcribe_runtime_failure_returns_500(self, mock_transcribe) -> None:
|
||||||
|
mock_transcribe.side_effect = RuntimeError("boom")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"file_path": "/tmp/in.wav",
|
||||||
|
"model": "base",
|
||||||
|
}
|
||||||
|
res = self.client.post("/transcribe", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 500)
|
||||||
|
self.assertEqual(res.json()["detail"], "boom")
|
||||||
|
|
||||||
|
@patch("routers.captions.generate_srt")
|
||||||
|
def test_captions_plain_response(self, mock_generate_srt) -> None:
|
||||||
|
mock_generate_srt.return_value = "1\n00:00:00,000 --> 00:00:01,000\nHello\n"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"words": [{"word": "Hello", "start": 0.0, "end": 1.0}],
|
||||||
|
"format": "srt",
|
||||||
|
}
|
||||||
|
res = self.client.post("/captions", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertIn("Hello", res.text)
|
||||||
|
mock_generate_srt.assert_called_once()
|
||||||
|
|
||||||
|
@patch("routers.captions.save_captions")
|
||||||
|
@patch("routers.captions.generate_srt")
|
||||||
|
def test_captions_save_output_path(self, mock_generate_srt, mock_save) -> None:
|
||||||
|
mock_generate_srt.return_value = "1\n00:00:00,000 --> 00:00:01,000\nHello\n"
|
||||||
|
mock_save.return_value = "/tmp/out.srt"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"words": [{"word": "Hello", "start": 0.0, "end": 1.0}],
|
||||||
|
"format": "srt",
|
||||||
|
"output_path": "/tmp/out.srt",
|
||||||
|
}
|
||||||
|
res = self.client.post("/captions", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.json(), {"status": "ok", "output_path": "/tmp/out.srt"})
|
||||||
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
|
def test_captions_unknown_format_returns_400(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"words": [{"word": "Hello", "start": 0.0, "end": 1.0}],
|
||||||
|
"format": "txt",
|
||||||
|
}
|
||||||
|
res = self.client.post("/captions", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
self.assertIn("Unknown format", res.json()["detail"])
|
||||||
|
|
||||||
|
@patch("routers.captions.generate_srt")
|
||||||
|
def test_captions_internal_error_returns_500(self, mock_generate_srt) -> None:
|
||||||
|
mock_generate_srt.side_effect = RuntimeError("caption-fail")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"words": [{"word": "Hello", "start": 0.0, "end": 1.0}],
|
||||||
|
"format": "srt",
|
||||||
|
}
|
||||||
|
res = self.client.post("/captions", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 500)
|
||||||
|
self.assertEqual(res.json()["detail"], "caption-fail")
|
||||||
|
|
||||||
|
@patch("routers.audio.is_deepfilter_available")
|
||||||
|
@patch("routers.audio.clean_audio")
|
||||||
|
def test_audio_clean_contract(self, mock_clean_audio, mock_is_deepfilter_available) -> None:
|
||||||
|
mock_clean_audio.return_value = "/tmp/cleaned.wav"
|
||||||
|
mock_is_deepfilter_available.return_value = True
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"input_path": "/tmp/in.wav",
|
||||||
|
"output_path": "/tmp/cleaned.wav",
|
||||||
|
}
|
||||||
|
res = self.client.post("/audio/clean", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
body = res.json()
|
||||||
|
self.assertEqual(body["status"], "ok")
|
||||||
|
self.assertEqual(body["output_path"], "/tmp/cleaned.wav")
|
||||||
|
self.assertEqual(body["engine"], "deepfilternet")
|
||||||
|
|
||||||
|
@patch("routers.audio.clean_audio")
|
||||||
|
def test_audio_clean_error_returns_500(self, mock_clean_audio) -> None:
|
||||||
|
mock_clean_audio.side_effect = RuntimeError("clean-fail")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"input_path": "/tmp/in.wav",
|
||||||
|
"output_path": "/tmp/cleaned.wav",
|
||||||
|
}
|
||||||
|
res = self.client.post("/audio/clean", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 500)
|
||||||
|
self.assertEqual(res.json()["detail"], "clean-fail")
|
||||||
|
|
||||||
|
@patch("routers.audio.detect_silence_ranges")
|
||||||
|
def test_audio_detect_silence_contract(self, mock_detect_silence_ranges) -> None:
|
||||||
|
mock_detect_silence_ranges.return_value = [{"start": 1.2, "end": 2.1, "duration": 0.9}]
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"input_path": "/tmp/in.wav",
|
||||||
|
"min_silence_ms": 500,
|
||||||
|
"silence_db": -35.0,
|
||||||
|
}
|
||||||
|
res = self.client.post("/audio/detect-silence", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
body = res.json()
|
||||||
|
self.assertEqual(body["status"], "ok")
|
||||||
|
self.assertEqual(body["count"], 1)
|
||||||
|
self.assertEqual(len(body["ranges"]), 1)
|
||||||
|
|
||||||
|
@patch("routers.audio.detect_silence_ranges")
|
||||||
|
def test_audio_detect_silence_error_returns_500(self, mock_detect_silence_ranges) -> None:
|
||||||
|
mock_detect_silence_ranges.side_effect = RuntimeError("silence-fail")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"input_path": "/tmp/in.wav",
|
||||||
|
"min_silence_ms": 500,
|
||||||
|
"silence_db": -35.0,
|
||||||
|
}
|
||||||
|
res = self.client.post("/audio/detect-silence", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 500)
|
||||||
|
self.assertEqual(res.json()["detail"], "silence-fail")
|
||||||
|
|
||||||
|
@patch("routers.audio.is_deepfilter_available")
|
||||||
|
def test_audio_capabilities_contract(self, mock_is_deepfilter_available) -> None:
|
||||||
|
mock_is_deepfilter_available.return_value = False
|
||||||
|
|
||||||
|
res = self.client.get("/audio/capabilities")
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.json(), {"deepfilternet_available": False})
|
||||||
|
|
||||||
|
@patch("routers.export.export_stream_copy")
|
||||||
|
def test_export_fast_contract(self, mock_export_stream_copy) -> None:
|
||||||
|
mock_export_stream_copy.return_value = "/tmp/out.mp4"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"input_path": "/tmp/in.mp4",
|
||||||
|
"output_path": "/tmp/out.mp4",
|
||||||
|
"keep_segments": [{"start": 0.0, "end": 2.0}],
|
||||||
|
"mode": "fast",
|
||||||
|
"captions": "none",
|
||||||
|
}
|
||||||
|
res = self.client.post("/export", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.json(), {"status": "ok", "output_path": "/tmp/out.mp4"})
|
||||||
|
mock_export_stream_copy.assert_called_once()
|
||||||
|
|
||||||
|
@patch("routers.export.save_captions")
|
||||||
|
@patch("routers.export.generate_srt")
|
||||||
|
@patch("routers.export.export_stream_copy")
|
||||||
|
def test_export_sidecar_caption_contract(self, mock_export_stream_copy, mock_generate_srt, mock_save_captions) -> None:
|
||||||
|
mock_export_stream_copy.return_value = "/tmp/out.mp4"
|
||||||
|
mock_generate_srt.return_value = "1\n00:00:00,000 --> 00:00:01,000\nHello\n"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"input_path": "/tmp/in.mp4",
|
||||||
|
"output_path": "/tmp/out.mp4",
|
||||||
|
"keep_segments": [{"start": 0.0, "end": 2.0}],
|
||||||
|
"mode": "fast",
|
||||||
|
"captions": "sidecar",
|
||||||
|
"words": [{"word": "Hello", "start": 0.0, "end": 1.0}],
|
||||||
|
"deleted_indices": [],
|
||||||
|
}
|
||||||
|
res = self.client.post("/export", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
body = res.json()
|
||||||
|
self.assertEqual(body["status"], "ok")
|
||||||
|
self.assertEqual(body["output_path"], "/tmp/out.mp4")
|
||||||
|
self.assertEqual(body["srt_path"], "/tmp/out.srt")
|
||||||
|
mock_save_captions.assert_called_once()
|
||||||
|
|
||||||
|
def test_export_missing_segments_returns_400(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"input_path": "/tmp/in.mp4",
|
||||||
|
"output_path": "/tmp/out.mp4",
|
||||||
|
"keep_segments": [],
|
||||||
|
"mode": "fast",
|
||||||
|
"captions": "none",
|
||||||
|
}
|
||||||
|
res = self.client.post("/export", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
self.assertIn("No segments to export", res.json()["detail"])
|
||||||
|
|
||||||
|
@patch("routers.export.export_stream_copy")
|
||||||
|
def test_export_runtime_error_returns_500(self, mock_export_stream_copy) -> None:
|
||||||
|
mock_export_stream_copy.side_effect = RuntimeError("export-fail")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"input_path": "/tmp/in.mp4",
|
||||||
|
"output_path": "/tmp/out.mp4",
|
||||||
|
"keep_segments": [{"start": 0.0, "end": 2.0}],
|
||||||
|
"mode": "fast",
|
||||||
|
"captions": "none",
|
||||||
|
}
|
||||||
|
res = self.client.post("/export", json=payload)
|
||||||
|
|
||||||
|
self.assertEqual(res.status_code, 500)
|
||||||
|
self.assertEqual(res.json()["detail"], "export-fail")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
18
docs/specs/README.md
Normal file
18
docs/specs/README.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Feature Specs
|
||||||
|
|
||||||
|
Place one feature spec document in this folder for each feature or major behavior change.
|
||||||
|
|
||||||
|
Use [docs/spec-template.md](../spec-template.md) as the canonical template.
|
||||||
|
|
||||||
|
Recommended naming format:
|
||||||
|
|
||||||
|
- `YYYY-MM-DD-short-feature-name.md`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `2026-04-15-gain-zones-and-visibility-filters.md`
|
||||||
|
- `2026-04-16-speed-adjustment.md`
|
||||||
|
|
||||||
|
CI policy:
|
||||||
|
|
||||||
|
- Pull requests that change app code are expected to include at least one changed spec file in this folder.
|
||||||
237
frontend/src/components/ZoneEditor.tsx
Normal file
237
frontend/src/components/ZoneEditor.tsx
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useEditorStore } from '../store/editorStore';
|
||||||
|
import { Trash2, Scissors, Volume2, SlidersHorizontal } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ZoneEditor() {
|
||||||
|
const [viewMode, setViewMode] = useState<'all' | 'cut' | 'mute' | 'gain'>('all');
|
||||||
|
|
||||||
|
const {
|
||||||
|
cutRanges,
|
||||||
|
muteRanges,
|
||||||
|
gainRanges,
|
||||||
|
globalGainDb,
|
||||||
|
setGlobalGainDb,
|
||||||
|
removeCutRange,
|
||||||
|
removeMuteRange,
|
||||||
|
removeGainRange,
|
||||||
|
updateGainRange,
|
||||||
|
} = useEditorStore();
|
||||||
|
|
||||||
|
const totalZones = cutRanges.length + muteRanges.length + gainRanges.length;
|
||||||
|
|
||||||
|
const getZoneTypeColor = (type: 'cut' | 'mute' | 'gain') => {
|
||||||
|
switch (type) {
|
||||||
|
case 'cut':
|
||||||
|
return 'border-red-500/40 bg-red-500/5';
|
||||||
|
case 'mute':
|
||||||
|
return 'border-orange-500/40 bg-orange-500/5';
|
||||||
|
case 'gain':
|
||||||
|
return 'border-amber-500/40 bg-amber-500/5';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded bg-editor-accent/30" />
|
||||||
|
Zone Editor
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-editor-text-muted">
|
||||||
|
Manage all timeline zones ({totalZones} total)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<div className="flex items-center gap-1 rounded bg-editor-surface border border-editor-border p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('all')}
|
||||||
|
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||||
|
viewMode === 'all'
|
||||||
|
? 'bg-editor-accent text-white'
|
||||||
|
: 'text-editor-text-muted hover:text-editor-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('cut')}
|
||||||
|
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||||
|
viewMode === 'cut'
|
||||||
|
? 'bg-red-500/30 text-red-500'
|
||||||
|
: 'text-editor-text-muted hover:text-editor-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Cut
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('mute')}
|
||||||
|
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||||
|
viewMode === 'mute'
|
||||||
|
? 'bg-orange-500/30 text-orange-500'
|
||||||
|
: 'text-editor-text-muted hover:text-editor-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Mute
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('gain')}
|
||||||
|
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||||
|
viewMode === 'gain'
|
||||||
|
? 'bg-amber-500/30 text-amber-500'
|
||||||
|
: 'text-editor-text-muted hover:text-editor-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Gain
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalZones === 0 ? (
|
||||||
|
<div className="p-4 rounded border border-dashed border-editor-border text-center">
|
||||||
|
<p className="text-xs text-editor-text-muted">
|
||||||
|
No zones yet. Create zones from the toolbar or by highlighting words.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Cut Zones */}
|
||||||
|
{(viewMode === 'all' || viewMode === 'cut') && cutRanges.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-semibold text-red-500/80 flex items-center gap-2">
|
||||||
|
<Scissors className="w-3.5 h-3.5" />
|
||||||
|
Cut Zones ({cutRanges.length})
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{cutRanges.map((range) => (
|
||||||
|
<div
|
||||||
|
key={range.id}
|
||||||
|
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group ${getZoneTypeColor('cut')}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{range.start.toFixed(2)}s – {range.end.toFixed(2)}s
|
||||||
|
</div>
|
||||||
|
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeCutRange(range.id)}
|
||||||
|
className="p-1 rounded hover:bg-red-500/20 text-red-500/70 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
title="Delete cut zone"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mute Zones */}
|
||||||
|
{(viewMode === 'all' || viewMode === 'mute') && muteRanges.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-semibold text-orange-500/80 flex items-center gap-2">
|
||||||
|
<Volume2 className="w-3.5 h-3.5" />
|
||||||
|
Mute Zones ({muteRanges.length})
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{muteRanges.map((range) => (
|
||||||
|
<div
|
||||||
|
key={range.id}
|
||||||
|
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group ${getZoneTypeColor('mute')}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{range.start.toFixed(2)}s – {range.end.toFixed(2)}s
|
||||||
|
</div>
|
||||||
|
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeMuteRange(range.id)}
|
||||||
|
className="p-1 rounded hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
title="Delete mute zone"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gain Zones */}
|
||||||
|
{(viewMode === 'all' || viewMode === 'gain') && gainRanges.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-semibold text-amber-500/80 flex items-center gap-2">
|
||||||
|
<SlidersHorizontal className="w-3.5 h-3.5" />
|
||||||
|
Gain Zones ({gainRanges.length})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Gain Slider */}
|
||||||
|
<div className="px-2 py-2 rounded border border-amber-500/20 bg-amber-500/5 space-y-2">
|
||||||
|
<label className="text-xs text-editor-text-muted font-medium">Global Gain</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={-24}
|
||||||
|
max={24}
|
||||||
|
step={0.5}
|
||||||
|
value={globalGainDb}
|
||||||
|
onChange={(e) => setGlobalGainDb(Number(e.target.value))}
|
||||||
|
className="flex-1 h-1.5"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={-24}
|
||||||
|
max={24}
|
||||||
|
step={0.5}
|
||||||
|
value={globalGainDb}
|
||||||
|
onChange={(e) => setGlobalGainDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
|
||||||
|
className="w-14 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-amber-500/80 font-medium w-6 text-right">dB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{gainRanges.map((range) => (
|
||||||
|
<div
|
||||||
|
key={range.id}
|
||||||
|
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group ${getZoneTypeColor('gain')}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{range.start.toFixed(2)}s – {range.end.toFixed(2)}s
|
||||||
|
</div>
|
||||||
|
<div className="text-editor-text-muted text-[10px]">
|
||||||
|
{range.gainDb > 0 ? '+' : ''}{range.gainDb.toFixed(1)} dB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={-24}
|
||||||
|
max={24}
|
||||||
|
step={0.5}
|
||||||
|
value={range.gainDb}
|
||||||
|
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
|
||||||
|
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
|
title="Gain dB"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => removeGainRange(range.id)}
|
||||||
|
className="p-1 rounded hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
title="Delete gain zone"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/store/editorStore.test.ts
Normal file
32
frontend/src/store/editorStore.test.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { useEditorStore } from './editorStore';
|
||||||
|
|
||||||
|
|
||||||
|
describe('editorStore basics', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useEditorStore.getState().reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps global gain to valid bounds', () => {
|
||||||
|
const state = useEditorStore.getState();
|
||||||
|
|
||||||
|
state.setGlobalGainDb(100);
|
||||||
|
expect(useEditorStore.getState().globalGainDb).toBe(24);
|
||||||
|
|
||||||
|
state.setGlobalGainDb(-100);
|
||||||
|
expect(useEditorStore.getState().globalGainDb).toBe(-24);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds gain range to store', () => {
|
||||||
|
const state = useEditorStore.getState();
|
||||||
|
|
||||||
|
state.addGainRange(1.2, 2.4, 3.5);
|
||||||
|
|
||||||
|
const ranges = useEditorStore.getState().gainRanges;
|
||||||
|
expect(ranges.length).toBe(1);
|
||||||
|
expect(ranges[0].start).toBe(1.2);
|
||||||
|
expect(ranges[0].end).toBe(2.4);
|
||||||
|
expect(ranges[0].gainDb).toBe(3.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
scripts/check-feature-spec.sh
Executable file
63
scripts/check-feature-spec.sh
Executable file
@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[check-feature-spec] %s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
BASE_SHA="${BASE_SHA:-}"
|
||||||
|
if [[ -z "$BASE_SHA" ]]; then
|
||||||
|
if git rev-parse --verify origin/main >/dev/null 2>&1; then
|
||||||
|
BASE_SHA="$(git merge-base origin/main HEAD)"
|
||||||
|
else
|
||||||
|
log "No BASE_SHA and origin/main unavailable; skipping check."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git rev-parse --verify "$BASE_SHA" >/dev/null 2>&1; then
|
||||||
|
log "BASE_SHA '$BASE_SHA' not found; skipping check."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
changed_files="$(git diff --name-only "$BASE_SHA"...HEAD)"
|
||||||
|
if [[ -z "$changed_files" ]]; then
|
||||||
|
log "No changed files; nothing to enforce."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
code_changed=0
|
||||||
|
spec_changed=0
|
||||||
|
|
||||||
|
while IFS= read -r path; do
|
||||||
|
[[ -z "$path" ]] && continue
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
frontend/src/*|backend/*|src-tauri/src/*|shared/project-schema.json)
|
||||||
|
code_changed=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$path" in
|
||||||
|
docs/specs/*.md)
|
||||||
|
spec_changed=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$changed_files"
|
||||||
|
|
||||||
|
if [[ "$code_changed" -eq 0 ]]; then
|
||||||
|
log "No app code changes detected; spec file not required."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$spec_changed" -eq 1 ]]; then
|
||||||
|
log "Spec requirement satisfied."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Code changes detected without spec update in docs/specs/."
|
||||||
|
log "Add or update at least one spec file using docs/spec-template.md."
|
||||||
|
exit 1
|
||||||
Reference in New Issue
Block a user