Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
218 lines
7.9 KiB
Python
218 lines
7.9 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/5
|
|
"""Unit tests for the keyword pooler module."""
|
|
import csv
|
|
import io
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import redirect_stderr
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from pop_fem_audit_tools.commands import pool_keywords
|
|
|
|
|
|
class TestPoolKeywords(unittest.TestCase):
|
|
"""Test cases for the keyword pooler."""
|
|
|
|
def setUp(self) -> None:
|
|
"""Create a temporary directory with two run directories."""
|
|
tmp: tempfile.TemporaryDirectory[str] \
|
|
= tempfile.TemporaryDirectory()
|
|
self.addCleanup(tmp.cleanup)
|
|
self.__dir: Path = Path(tmp.name)
|
|
self.__run1: Path = self.__dir / "run1"
|
|
self.__run2: Path = self.__dir / "run2"
|
|
self.__run1.mkdir()
|
|
self.__run2.mkdir()
|
|
self.__pool: Path = self.__dir / "pool.txt"
|
|
self.__provenance: Path = self.__dir / "provenance.csv"
|
|
|
|
@staticmethod
|
|
def __write_output(
|
|
run_dir: Path, records: list[dict[str, Any]]) -> None:
|
|
"""Write the ``output.jsonl`` file of one run.
|
|
|
|
:param run_dir: The run's archive directory.
|
|
:param records: The envelope records, in file order.
|
|
:return: None.
|
|
"""
|
|
lines: list[str] = [
|
|
json.dumps(x, ensure_ascii=False) for x in records]
|
|
(run_dir / "output.jsonl").write_text(
|
|
"\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
def __run_pool(self) -> tuple[int, str]:
|
|
"""Run the pooler with the standard error captured.
|
|
|
|
:return: A tuple of the exit status and the standard
|
|
error.
|
|
"""
|
|
stderr: io.StringIO = io.StringIO()
|
|
with redirect_stderr(stderr):
|
|
status: int = pool_keywords.main([
|
|
str(self.__run1), str(self.__run2),
|
|
str(self.__pool), str(self.__provenance)])
|
|
return status, stderr.getvalue()
|
|
|
|
def __read_pool(self) -> list[str]:
|
|
"""Read the pool text file.
|
|
|
|
:return: The keyword list, one keyword per line, with the
|
|
trailing empty line from the final newline removed.
|
|
"""
|
|
lines: list[str] = self.__pool.read_text(
|
|
encoding="utf-8").split("\n")
|
|
self.assertEqual(lines[-1], "")
|
|
return lines[:-1]
|
|
|
|
def __read_provenance(self) -> list[list[str]]:
|
|
"""Read the provenance CSV file.
|
|
|
|
:return: All rows, including the header row, in file
|
|
order.
|
|
"""
|
|
with open(self.__provenance, encoding="utf-8",
|
|
newline="") as file:
|
|
return list(csv.reader(file))
|
|
|
|
def test_pools_union_dedup_sorted(self) -> None:
|
|
"""Test the union, dedup, and lexicographic ordering, and
|
|
the plain one-keyword-per-line pool file shape."""
|
|
self.__write_output(self.__run1, [
|
|
{"id": "song-1",
|
|
"text": json.dumps({"strength": 1, "shared": 1})},
|
|
])
|
|
self.__write_output(self.__run2, [
|
|
{"id": "song-3",
|
|
"text": json.dumps({"warrior": 1, "shared": 1})},
|
|
])
|
|
status: int
|
|
stderr: str
|
|
status, stderr = self.__run_pool()
|
|
self.assertEqual(status, 0)
|
|
self.assertEqual(
|
|
self.__read_pool(), ["shared", "strength", "warrior"])
|
|
self.assertIn(
|
|
"done: 3 keywords pooled from 1+1 records", stderr)
|
|
|
|
def test_skips_error_records(self) -> None:
|
|
"""Test that records carrying an "error" field are
|
|
excluded from the pool and the record count."""
|
|
self.__write_output(self.__run1, [
|
|
{"id": "song-1",
|
|
"text": json.dumps({"strength": 1})},
|
|
{"id": "song-2", "error": "invalid_request_error"},
|
|
])
|
|
self.__write_output(self.__run2, [
|
|
{"id": "song-3", "text": json.dumps({"warrior": 1})},
|
|
])
|
|
status: int
|
|
stderr: str
|
|
status, stderr = self.__run_pool()
|
|
self.assertEqual(status, 0)
|
|
self.assertEqual(
|
|
self.__read_pool(), ["strength", "warrior"])
|
|
self.assertIn(
|
|
"done: 2 keywords pooled from 1+1 records", stderr)
|
|
|
|
def test_skips_non_json_text_records(self) -> None:
|
|
"""Test that a refusal, whose "text" does not parse as
|
|
JSON, is skipped rather than failing the run."""
|
|
self.__write_output(self.__run1, [
|
|
{"id": "song-1",
|
|
"text": json.dumps({"strength": 1})},
|
|
{"id": "song-2", "text": "I cannot help with that."},
|
|
])
|
|
self.__write_output(self.__run2, [
|
|
{"id": "song-3", "text": json.dumps({"warrior": 1})},
|
|
])
|
|
status: int
|
|
stderr: str
|
|
status, stderr = self.__run_pool()
|
|
self.assertEqual(status, 0)
|
|
self.assertEqual(
|
|
self.__read_pool(), ["strength", "warrior"])
|
|
self.assertIn(
|
|
"done: 2 keywords pooled from 1+1 records", stderr)
|
|
|
|
def test_duplicate_key_in_text_rejected(self) -> None:
|
|
"""Test that a "text" JSON object with a duplicate key
|
|
fails the run without writing any output file."""
|
|
self.__write_output(self.__run1, [
|
|
{"id": "song-1",
|
|
"text": '{"strength": 1, "strength": 2}'},
|
|
])
|
|
self.__write_output(self.__run2, [
|
|
{"id": "song-3", "text": json.dumps({"warrior": 1})},
|
|
])
|
|
status: int
|
|
stderr: str
|
|
status, stderr = self.__run_pool()
|
|
self.assertEqual(status, 1)
|
|
self.assertIn("duplicate key", stderr)
|
|
self.assertFalse(self.__pool.exists())
|
|
self.assertFalse(self.__provenance.exists())
|
|
|
|
def test_non_object_text_rejected(self) -> None:
|
|
"""Test that a "text" JSON value that is not an object
|
|
fails the run without writing any output file."""
|
|
self.__write_output(self.__run1, [
|
|
{"id": "song-1", "text": json.dumps(["strength"])},
|
|
])
|
|
self.__write_output(self.__run2, [
|
|
{"id": "song-3", "text": json.dumps({"warrior": 1})},
|
|
])
|
|
status: int
|
|
stderr: str
|
|
status, stderr = self.__run_pool()
|
|
self.assertEqual(status, 1)
|
|
self.assertIn("song-1", stderr)
|
|
self.assertFalse(self.__pool.exists())
|
|
self.assertFalse(self.__provenance.exists())
|
|
|
|
def test_provenance_content_and_ordering(self) -> None:
|
|
"""Test the provenance content and its ordering: rows
|
|
sorted by keyword lexicographically, then by run label,
|
|
then by song ID."""
|
|
self.__write_output(self.__run1, [
|
|
{"id": "song-2", "text": json.dumps({"shared": 1})},
|
|
{"id": "song-1", "text": json.dumps({"shared": 1})},
|
|
])
|
|
self.__write_output(self.__run2, [
|
|
{"id": "song-5",
|
|
"text": json.dumps({"shared": 1, "warrior": 1})},
|
|
])
|
|
status: int
|
|
status, _ = self.__run_pool()
|
|
self.assertEqual(status, 0)
|
|
rows: list[list[str]] = self.__read_provenance()
|
|
self.assertEqual(rows[1:], [
|
|
["shared", "run1", "1"],
|
|
["shared", "run1", "2"],
|
|
["shared", "run2", "5"],
|
|
["warrior", "run2", "5"],
|
|
])
|
|
|
|
def test_provenance_file_header_and_row_count(self) -> None:
|
|
"""Test that the provenance CSV file starts with the
|
|
``Keyword,Run,Song`` header row and has exactly one row
|
|
per keyword occurrence."""
|
|
self.__write_output(self.__run1, [
|
|
{"id": "song-2", "text": json.dumps({"shared": 1})},
|
|
{"id": "song-1", "text": json.dumps({"shared": 1})},
|
|
])
|
|
self.__write_output(self.__run2, [
|
|
{"id": "song-5",
|
|
"text": json.dumps({"shared": 1, "warrior": 1})},
|
|
])
|
|
status: int
|
|
status, _ = self.__run_pool()
|
|
self.assertEqual(status, 0)
|
|
rows: list[list[str]] = self.__read_provenance()
|
|
self.assertEqual(rows[0], ["Keyword", "Run", "Song"])
|
|
self.assertEqual(len(rows), 1 + 4)
|