Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
231 lines
8.5 KiB
Python
231 lines
8.5 KiB
Python
# Tools for A Feminist Audit of Pop Music.
|
|
# Copyright 2026 imacat. All rights reserved.
|
|
# Authors:
|
|
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
|
|
"""Unit tests for the LLM input exporter module."""
|
|
import io
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import redirect_stderr
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest import mock
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from pop_fem_audit_tools import config
|
|
from pop_fem_audit_tools.commands import export_llm_input
|
|
from pop_fem_audit_tools.database import Base, DataSource
|
|
from pop_fem_audit_tools.models import (
|
|
Artist,
|
|
Role,
|
|
Song,
|
|
SongArtist,
|
|
)
|
|
|
|
|
|
class TestExportLlmInput(unittest.TestCase):
|
|
"""Test cases for the LLM input exporter."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Create a temporary working store for the tests."""
|
|
tmp: tempfile.TemporaryDirectory[str] \
|
|
= tempfile.TemporaryDirectory()
|
|
self.addCleanup(tmp.cleanup)
|
|
self.__dir: Path = Path(tmp.name)
|
|
self.__output: Path = self.__dir / "llm-input.jsonl"
|
|
url: str = f"sqlite:///{self.__dir}/store.sqlite3"
|
|
config.set_settings(config.Settings(
|
|
SQLALCHEMY_DATABASE_URL=url,
|
|
ANTHROPIC_API_KEY="test-key"))
|
|
self.__ds: DataSource = DataSource()
|
|
patcher: Any = mock.patch.object(
|
|
export_llm_input, "ds", self.__ds)
|
|
patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def __seed(
|
|
self, songs: list[tuple[str, str, str | None]]) -> None:
|
|
"""Create the schema and the fixture songs.
|
|
|
|
Each song gets a single primary artist at position 0,
|
|
whose name equals the song's artist credit; the song IDs
|
|
are assigned in list order starting from 1.
|
|
|
|
:param songs: The (title, artist, lyrics) triples.
|
|
:return: None.
|
|
"""
|
|
Base.metadata.create_all(self.__ds.engine)
|
|
session: Session = self.__ds.get_db()
|
|
try:
|
|
title: str
|
|
artist: str
|
|
lyrics: str | None
|
|
for title, artist, lyrics in songs:
|
|
artist_row: Artist = Artist(name=artist)
|
|
song: Song = Song(
|
|
title=title, artist_credit=artist,
|
|
lyrics=lyrics)
|
|
session.add(song)
|
|
session.add(SongArtist(
|
|
song=song, artist=artist_row,
|
|
role=Role.PRIMARY, position=0))
|
|
session.commit()
|
|
finally:
|
|
session.close()
|
|
|
|
def __run_export(
|
|
self, extras: Path | None = None) -> tuple[int, str]:
|
|
"""Run the exporter with the standard error captured.
|
|
|
|
:param extras: The extras JSON file, or None for none.
|
|
:return: A tuple of the exit status and the standard
|
|
error.
|
|
"""
|
|
argv: list[str] = [str(self.__output)]
|
|
if extras is not None:
|
|
argv += ["--extras", str(extras)]
|
|
stderr: io.StringIO = io.StringIO()
|
|
with redirect_stderr(stderr):
|
|
status: int = export_llm_input.main(argv)
|
|
return status, stderr.getvalue()
|
|
|
|
def __write_extras(self, text: str) -> Path:
|
|
"""Write an extras file with the given raw text.
|
|
|
|
:param text: The raw file content.
|
|
:return: The path of the written extras file.
|
|
"""
|
|
path: Path = self.__dir / "extras.json"
|
|
path.write_text(text, encoding="utf-8")
|
|
return path
|
|
|
|
@staticmethod
|
|
def __read_records(path: Path) -> list[dict[str, str]]:
|
|
"""Read the JSONL records of a file.
|
|
|
|
:param path: The JSONL file.
|
|
:return: The parsed records, in file order.
|
|
"""
|
|
with open(path, encoding="utf-8") as file:
|
|
return [json.loads(line) for line in file
|
|
if line.strip() != ""]
|
|
|
|
def test_exports_songs_ordered_by_id(self) -> None:
|
|
"""Test that the songs export in ID order, one per line."""
|
|
self.__seed([
|
|
("Hello", "Adele", "hello lyrics\n"),
|
|
("Umbrella", "Rihanna", "umbrella lyrics\n")])
|
|
status: int
|
|
stderr: str
|
|
status, stderr = self.__run_export()
|
|
self.assertEqual(status, 0)
|
|
records: list[dict[str, str]] = self.__read_records(
|
|
self.__output)
|
|
self.assertEqual(records, [
|
|
{"id": "song-1", "content": "hello lyrics\n"},
|
|
{"id": "song-2", "content": "umbrella lyrics\n"}])
|
|
self.assertIn("done: 2 songs exported", stderr)
|
|
|
|
def test_preserves_non_ascii_lyrics(self) -> None:
|
|
"""Test that non-ASCII lyrics are written verbatim."""
|
|
self.__seed([("Song", "Artist", "非英文歌詞\n")])
|
|
self.assertEqual(self.__run_export()[0], 0)
|
|
self.assertEqual(
|
|
self.__output.read_text(encoding="utf-8"),
|
|
json.dumps(
|
|
{"id": "song-1", "content": "非英文歌詞\n"},
|
|
ensure_ascii=False) + "\n")
|
|
self.assertIn("非英文歌詞", self.__output.read_text(
|
|
encoding="utf-8"))
|
|
|
|
def test_no_artist_or_title_data_in_output(self) -> None:
|
|
"""Test the lyrics-only firewall: no title or artist
|
|
leaks into the output."""
|
|
self.__seed([(
|
|
"Confidential Title", "Confidential Artist",
|
|
"plain lyrics\n")])
|
|
self.assertEqual(self.__run_export()[0], 0)
|
|
content: str = self.__output.read_text(encoding="utf-8")
|
|
self.assertNotIn("Confidential Title", content)
|
|
self.assertNotIn("Confidential Artist", content)
|
|
records: list[dict[str, str]] = self.__read_records(
|
|
self.__output)
|
|
self.assertEqual(records, [
|
|
{"id": "song-1", "content": "plain lyrics\n"}])
|
|
|
|
def test_missing_lyrics_fails(self) -> None:
|
|
"""Test that a song without lyrics fails without writing
|
|
a partial file."""
|
|
self.__seed([
|
|
("Hello", "Adele", "hello lyrics\n"),
|
|
("Silent", "Nobody", None)])
|
|
status: int
|
|
stderr: str
|
|
status, stderr = self.__run_export()
|
|
self.assertEqual(status, 1)
|
|
self.assertIn(
|
|
"error: song 2 \"Silent\": no lyrics", stderr)
|
|
self.assertFalse(self.__output.exists())
|
|
|
|
def test_creates_parent_directory(self) -> None:
|
|
"""Test that the output file's parent directory is
|
|
created when missing."""
|
|
self.__seed([("Hello", "Adele", "hello lyrics\n")])
|
|
nested: Path = self.__dir / "nested" / "dir" / "out.jsonl"
|
|
status: int = export_llm_input.main([str(nested)])
|
|
self.assertEqual(status, 0)
|
|
self.assertTrue(nested.exists())
|
|
records: list[dict[str, str]] = self.__read_records(
|
|
nested)
|
|
self.assertEqual(records, [
|
|
{"id": "song-1", "content": "hello lyrics\n"}])
|
|
|
|
def test_extras_merges_lyrics_first_in_file_order(
|
|
self) -> None:
|
|
"""Test that with extras, content is a JSON object whose
|
|
first key is lyrics, followed by the extras' keys in
|
|
their file order."""
|
|
self.__seed([("Hello", "Adele", "hello lyrics\n")])
|
|
extras: Path = self.__write_extras(
|
|
'{"b": 2, "a": 1}')
|
|
status: int
|
|
stderr: str
|
|
status, stderr = self.__run_export(extras)
|
|
self.assertEqual(status, 0)
|
|
records: list[dict[str, str]] = self.__read_records(
|
|
self.__output)
|
|
self.assertEqual(len(records), 1)
|
|
content: dict[str, Any] = json.loads(
|
|
records[0]["content"])
|
|
self.assertEqual(
|
|
list(content.keys()), ["lyrics", "b", "a"])
|
|
self.assertEqual(content["lyrics"], "hello lyrics\n")
|
|
self.assertEqual(content["b"], 2)
|
|
self.assertEqual(content["a"], 1)
|
|
|
|
def test_extras_non_object_fails(self) -> None:
|
|
"""Test that a non-object extras file is rejected."""
|
|
self.__seed([("Hello", "Adele", "hello lyrics\n")])
|
|
extras: Path = self.__write_extras('[1, 2]')
|
|
status: int
|
|
stderr: str
|
|
status, stderr = self.__run_export(extras)
|
|
self.assertEqual(status, 1)
|
|
self.assertIn("error:", stderr)
|
|
self.assertFalse(self.__output.exists())
|
|
|
|
def test_extras_with_lyrics_key_fails(self) -> None:
|
|
"""Test that an extras file carrying a "lyrics" key is
|
|
rejected."""
|
|
self.__seed([("Hello", "Adele", "hello lyrics\n")])
|
|
extras: Path = self.__write_extras(
|
|
'{"lyrics": "not allowed"}')
|
|
status: int
|
|
stderr: str
|
|
status, stderr = self.__run_export(extras)
|
|
self.assertEqual(status, 1)
|
|
self.assertIn("error:", stderr)
|
|
self.assertFalse(self.__output.exists())
|