Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Tests for narrator_ai.config module."""

import os
from pathlib import Path
from unittest.mock import patch

import pytest

from narrator_ai.config import (
DEFAULT_CONFIG,
get_app_key,
get_server,
get_timeout,
load_config,
save_config,
)


def test_default_config_values():
assert DEFAULT_CONFIG["server"] == "https://openapi.jieshuo.cn"
assert DEFAULT_CONFIG["app_key"] == ""
assert DEFAULT_CONFIG["timeout"] == 30


def test_load_config_returns_defaults_when_no_file(tmp_path):
with patch("narrator_ai.config.CONFIG_FILE", tmp_path / "nonexistent.yaml"):
cfg = load_config()
assert cfg["server"] == DEFAULT_CONFIG["server"]
assert cfg["timeout"] == DEFAULT_CONFIG["timeout"]


def test_save_and_load_config(tmp_path):
config_file = tmp_path / "config.yaml"
config_dir = tmp_path
with patch("narrator_ai.config.CONFIG_FILE", config_file), \
patch("narrator_ai.config.CONFIG_DIR", config_dir):
save_config({"server": "https://test.example.com", "app_key": "test-key"})
cfg = load_config()
assert cfg["server"] == "https://test.example.com"
assert cfg["app_key"] == "test-key"
assert cfg["timeout"] == 30 # default preserved


def test_get_server_from_env():
with patch.dict(os.environ, {"NARRATOR_SERVER": "https://env.example.com"}):
assert get_server() == "https://env.example.com"


def test_get_server_strips_trailing_slash():
with patch.dict(os.environ, {"NARRATOR_SERVER": "https://example.com/"}):
Comment on lines +44 to +50
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests call get_server() without isolating the on-disk config. If a developer/CI machine has an existing ~/.narrator-ai/config.yaml (or invalid YAML), load_config() can affect or break this test even though the env var is set. Patch narrator_ai.config.CONFIG_FILE to a nonexistent path (or tmp_path) within this test to make it hermetic.

Suggested change
def test_get_server_from_env():
with patch.dict(os.environ, {"NARRATOR_SERVER": "https://env.example.com"}):
assert get_server() == "https://env.example.com"
def test_get_server_strips_trailing_slash():
with patch.dict(os.environ, {"NARRATOR_SERVER": "https://example.com/"}):
def test_get_server_from_env(tmp_path):
with patch("narrator_ai.config.CONFIG_FILE", tmp_path / "nonexistent.yaml"), \
patch.dict(os.environ, {"NARRATOR_SERVER": "https://env.example.com"}):
assert get_server() == "https://env.example.com"
def test_get_server_strips_trailing_slash(tmp_path):
with patch("narrator_ai.config.CONFIG_FILE", tmp_path / "nonexistent.yaml"), \
patch.dict(os.environ, {"NARRATOR_SERVER": "https://example.com/"}):

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +50
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same isolation issue as above: get_server() loads from CONFIG_FILE before reading env vars. Patch narrator_ai.config.CONFIG_FILE to a temp/nonexistent file in this test so it doesn't depend on the user's real config.

Suggested change
def test_get_server_strips_trailing_slash():
with patch.dict(os.environ, {"NARRATOR_SERVER": "https://example.com/"}):
def test_get_server_strips_trailing_slash(tmp_path):
with patch("narrator_ai.config.CONFIG_FILE", tmp_path / "nonexistent.yaml"), \
patch.dict(os.environ, {"NARRATOR_SERVER": "https://example.com/"}):

Copilot uses AI. Check for mistakes.
assert get_server() == "https://example.com"


def test_get_server_raises_when_not_configured(tmp_path):
with patch("narrator_ai.config.CONFIG_FILE", tmp_path / "nonexistent.yaml"), \
patch.dict(os.environ, {}, clear=True):
# DEFAULT_CONFIG has server set, so this won't raise
server = get_server()
assert server == DEFAULT_CONFIG["server"].rstrip("/")
Comment on lines +55 to +59
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name says it "raises", but it currently asserts the default server is returned and explicitly notes it won't raise. Either rename it to reflect the behavior being tested, or adjust the setup to exercise the actual error path (e.g., create a config file with server: "" and clear env so get_server() raises SystemExit).

Suggested change
with patch("narrator_ai.config.CONFIG_FILE", tmp_path / "nonexistent.yaml"), \
patch.dict(os.environ, {}, clear=True):
# DEFAULT_CONFIG has server set, so this won't raise
server = get_server()
assert server == DEFAULT_CONFIG["server"].rstrip("/")
config_file = tmp_path / "config.yaml"
config_dir = tmp_path
with patch("narrator_ai.config.CONFIG_FILE", config_file), \
patch("narrator_ai.config.CONFIG_DIR", config_dir):
save_config({"server": ""})
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(SystemExit):
get_server()

Copilot uses AI. Check for mistakes.


def test_get_app_key_from_env():
with patch.dict(os.environ, {"NARRATOR_APP_KEY": "env-key-123"}):
Comment on lines +62 to +63
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test calls get_app_key() without isolating CONFIG_FILE. If a local config file exists, it can change behavior (or fail parsing) even though the env var is set. Patch narrator_ai.config.CONFIG_FILE to a tmp/nonexistent path here for hermetic tests.

Suggested change
def test_get_app_key_from_env():
with patch.dict(os.environ, {"NARRATOR_APP_KEY": "env-key-123"}):
def test_get_app_key_from_env(tmp_path):
with patch("narrator_ai.config.CONFIG_FILE", tmp_path / "nonexistent.yaml"), \
patch.dict(os.environ, {"NARRATOR_APP_KEY": "env-key-123"}):

Copilot uses AI. Check for mistakes.
assert get_app_key() == "env-key-123"


def test_get_app_key_raises_when_not_configured(tmp_path):
with patch("narrator_ai.config.CONFIG_FILE", tmp_path / "nonexistent.yaml"), \
patch.dict(os.environ, {}, clear=True):
with pytest.raises(SystemExit):
get_app_key()


def test_get_timeout_from_env():
with patch.dict(os.environ, {"NARRATOR_TIMEOUT": "60"}):
Comment on lines +74 to +75
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test calls get_timeout() without isolating CONFIG_FILE. load_config() may read a real user config (or invalid YAML) before applying the env override, which makes the test non-hermetic. Patch narrator_ai.config.CONFIG_FILE to a tmp/nonexistent path in this test.

Suggested change
def test_get_timeout_from_env():
with patch.dict(os.environ, {"NARRATOR_TIMEOUT": "60"}):
def test_get_timeout_from_env(tmp_path):
with patch("narrator_ai.config.CONFIG_FILE", tmp_path / "nonexistent.yaml"), \
patch.dict(os.environ, {"NARRATOR_TIMEOUT": "60"}):

Copilot uses AI. Check for mistakes.
assert get_timeout() == 60


def test_get_timeout_default():
with patch.dict(os.environ, {}, clear=True), \
patch("narrator_ai.config.CONFIG_FILE", Path("/nonexistent")):
assert get_timeout() == 30
62 changes: 62 additions & 0 deletions tests/test_dubbing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Tests for narrator_ai.commands.dubbing — voice list filtering logic."""

from typer.testing import CliRunner

from narrator_ai.commands.dubbing import DUBBING_LIST, app

runner = CliRunner()


def test_dubbing_list_not_empty():
assert len(DUBBING_LIST) > 0


def test_dubbing_list_has_required_fields():
for voice in DUBBING_LIST:
assert "name" in voice
assert "id" in voice
assert "type" in voice
assert "tag" in voice


def test_dubbing_list_json_output():
result = runner.invoke(app, ["list", "--json"])
assert result.exit_code == 0
import json
data = json.loads(result.output)
assert isinstance(data, list)
assert len(data) > 0


def test_dubbing_list_filter_by_lang():
result = runner.invoke(app, ["list", "--lang", "英语", "--json"])
assert result.exit_code == 0
import json
data = json.loads(result.output)
assert all(v["type"] == "英语" for v in data)
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI filter logic uses substring matching (lang in d["type"]), but this test asserts strict equality. To match the implemented behavior and avoid brittle failures if type ever contains additional descriptors, assert lang in v["type"] (or otherwise align the assertion with the command’s semantics).

Suggested change
assert all(v["type"] == "英语" for v in data)
assert all("英语" in v["type"] for v in data)

Copilot uses AI. Check for mistakes.


def test_dubbing_list_filter_by_tag():
result = runner.invoke(app, ["list", "--tag", "通用男声", "--json"])
assert result.exit_code == 0
import json
data = json.loads(result.output)
assert all(v["tag"] == "通用男声" for v in data)
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The command filters tags with substring matching (tag in d["tag"]), but this test asserts strict equality. Align the assertion with the actual filter semantics (e.g., tag in v["tag"]) to reduce brittleness if tag strings expand in the future.

Suggested change
assert all(v["tag"] == "通用男声" for v in data)
assert all("通用男声" in v["tag"] for v in data)

Copilot uses AI. Check for mistakes.


def test_dubbing_languages_json():
result = runner.invoke(app, ["languages", "--json"])
assert result.exit_code == 0
import json
data = json.loads(result.output)
assert isinstance(data, list)
assert any(item["language"] == "普通话" for item in data)


def test_dubbing_tags_json():
result = runner.invoke(app, ["tags", "--json"])
assert result.exit_code == 0
import json
data = json.loads(result.output)
assert isinstance(data, list)
assert len(data) > 0
51 changes: 51 additions & 0 deletions tests/test_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Tests for narrator_ai.output module."""

import json
from io import StringIO
from unittest.mock import patch
Comment on lines +4 to +5
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StringIO and patch are imported but never used. With Ruff linting enabled (F401), this will fail CI; remove the unused imports or use them in the tests.

Suggested change
from io import StringIO
from unittest.mock import patch

Copilot uses AI. Check for mistakes.

from narrator_ai.output import print_json, print_error, print_success, print_info


def test_print_json_dict(capsys):
print_json({"key": "value", "num": 42})
out = json.loads(capsys.readouterr().out)
assert out["key"] == "value"
assert out["num"] == 42


def test_print_json_list(capsys):
print_json([1, 2, 3])
out = json.loads(capsys.readouterr().out)
assert out == [1, 2, 3]


def test_print_json_unicode(capsys):
print_json({"name": "测试"})
out = json.loads(capsys.readouterr().out)
assert out["name"] == "测试"


def test_print_error_with_code(capsys):
print_error("something failed", code=404)
err = capsys.readouterr().err
assert "404" in err
assert "something failed" in err


def test_print_error_without_code(capsys):
print_error("generic error")
err = capsys.readouterr().err
assert "generic error" in err


def test_print_success(capsys):
print_success("done!")
err = capsys.readouterr().err
assert "done!" in err


def test_print_info(capsys):
print_info("processing...")
err = capsys.readouterr().err
assert "processing..." in err
Loading