# 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())