Add the pool-keywords subcommand for the tagging runs' keywords
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user