39 Commits
Author SHA1 Message Date
imacatandClaude Fable 5 a4f8ac719a Require valid JSON with escaped quotes in the definition files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 06:15:14 +08:00
imacatandClaude Fable 5 74b3844b9a Forbid Markdown code fences in the definition-file outputs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 06:15:14 +08:00
imacatandClaude Fable 5 2755b63a37 Record the song-775 refusal ablation and rule it unanalyzable
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 00:42:24 +08:00
imacatandClaude Fable 5 4286e62f12 Add watermark normalization to fetch-lyrics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 00:42:06 +08:00
imacatandClaude Fable 5 cbc91047b5 Add the natural-coding definition files with single-pass convergence
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 23:23:25 +08:00
imacatandClaude Fable 5 aa65f6a3b3 Settle women-power as the a-priori screen label term
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 22:41:42 +08:00
imacatandClaude Fable 5 d155d1ba21 Reduce run-llm to a pure batch executor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:31:24 +08:00
imacatandClaude Fable 5 5e509ffcad Record the natural-coding pipeline design in the project documents
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:22:52 +08:00
imacatandClaude Fable 5 4eb9f50238 Gather the CLI command modules into a commands sub-package
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:34:50 +08:00
imacatandClaude Fable 5 814bfa8910 Add the export-llm-input subcommand for the lyrics-only LLM input
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:34:36 +08:00
imacatandClaude Fable 5 179de0ccd7 Regenerate the artists report with the Wikidata snapshot applied
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:34:36 +08:00
imacatandClaude Fable 5 edb582daf9 Rename the Markdown documents to dash-connected names
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:34:36 +08:00
imacatandClaude Fable 5 21c5ae5481 Drop the engine preparation from build-db
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:34:36 +08:00
imacatandClaude Fable 5 383670ab2b Move the invariant checks into the importers and drop find_violations
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:34:36 +08:00
imacatandClaude Fable 5 c59a023095 Package the capture-layer application into a CaptureImporter class
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:34:36 +08:00
imacatandClaude Fable 5 10dc4cb693 Repackage the build-db import steps into job classes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:34:36 +08:00
imacatandClaude Fable 5 449675e7a2 Capture the song lyrics from the pilot study and the public APIs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:34:36 +08:00
imacatandClaude Fable 5 a7806c0c9d Align the fetch-lyrics summary with fetch-artists via shared utils
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:34:26 +08:00
imacatandClaude Fable 5 105664e93b Remove the missing-lyrics report from fetch-lyrics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:15:49 +08:00
imacatandClaude Fable 5 1220c21807 Fall back to the original artist credit when fetching lyrics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:15:49 +08:00
imacatandClaude Fable 5 86914de850 Remove the retired artist overrides layer and the mixed artist type
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:15:49 +08:00
imacat 8463cfc906 Keep the artist snapshot sorted by name 2026-08-04 15:15:49 +08:00
imacat ef4112c61e Show resolved count and elapsed time in the fetch-artists summary 2026-08-04 15:15:49 +08:00
imacat 1eaebeb5fb Resolve artists on Wikidata via exact-match SPARQL with song corroboration 2026-08-04 15:15:49 +08:00
imacat 6fc4b7db17 Restore official artist name spellings in the canonical table 2026-08-04 15:15:49 +08:00
imacat c94b6f2589 Record the complete-Wikidata-then-capture workflow decision 2026-08-04 15:15:49 +08:00
imacat b3ac12c88d Export human-readable songs and artists reports to the derived data layer 2026-08-04 15:15:49 +08:00
imacat 5b2c192827 Split artist credits with documented exceptions and normalize artist names 2026-08-04 15:15:49 +08:00
imacat 991ab5d93a Merge same-song chart rows via a canonical artist credit table 2026-08-04 15:15:49 +08:00
imacat 78cf5e6ce0 Store the source chart CSV with its original CRLF line endings 2026-08-04 15:15:49 +08:00
imacat aa78a54d12 Align the source chart CSV with the archived Billboard year-end charts 2026-08-04 15:15:49 +08:00
imacatandClaude Fable 5 3d472d5c15 Reorganize data/ into source, captures, and manual layers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:15:49 +08:00
imacatandClaude Fable 5 2f048dee36 Make every subcommand input an explicit CLI argument or option
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:13:31 +08:00
imacatandClaude Fable 5 a3062e848c Refactor internal data passing to dataclasses and enums, and remove unneeded defensive code
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:13:31 +08:00
imacatandClaude Fable 5 1b2d46cb42 Add the data layer with the build-db, fetch-lyrics, and fetch-artists subcommands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:12:22 +08:00
imacatandClaude Fable 5 f2ccca60b5 Use the unified pydantic-settings configuration in run_llm
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 14:03:37 +08:00
imacatandClaude Fable 5 db7b5f6679 Record the data storage architecture and phase-1 plan in the project documents
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 14:03:37 +08:00
imacatandClaude Fable 5 98b665537f Add the tools sub-project with the run_llm batch runner
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 14:03:28 +08:00
imacatandClaude Fable 5 9a089f6c6b Initialize project structure, workflow rules, and research plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:51:10 +08:00
13 changed files with 183 additions and 10 deletions
+27 -5
View File
@@ -99,8 +99,8 @@
## 2026-08-02 ## 2026-08-02
- **`import-lyrics` 不設為子命令,pilot 歌詞改以私人腳本 - **`import-lyrics` 不設為子命令,pilot 歌詞改以 `excludes/`
匯入**。理由:pilot 捕捉檔不隨論文發布,子命令 私人腳本匯入**。理由:pilot 捕捉檔不隨論文發布,子命令
形式會在發布的 CLI 裡留下讀者無法執行的死命令——要交待的 形式會在發布的 CLI 裡留下讀者無法執行的死命令——要交待的
是「沿用 pilot 捕捉」的事實(記於 lyrics-provenance.csv 與 是「沿用 pilot 捕捉」的事實(記於 lyrics-provenance.csv 與
論文方法節),不是工具本身;工具移出專案,發布管線即 論文方法節),不是工具本身;工具移出專案,發布管線即
@@ -178,8 +178,8 @@
來源公開可稽核,快照乾淨、overrides 縮小;論文方法節揭露 來源公開可稽核,快照乾淨、overrides 縮小;論文方法節揭露
「捕捉前經研究者查證補完」。性別以公開自我認同為準, 「捕捉前經研究者查證補完」。性別以公開自我認同為準,
推測不確定且無佐證者列疑慮清單;查證屬資料策展而非 推測不確定且無佐證者列疑慮清單;查證屬資料策展而非
分析,以 Claude Code 輔助、不走分析 APIQS 批次 分析,以 Claude Code 輔助、不走分析 APIQS 批次
私人工作檔留存,不隨論文發布。 `excludes/`私人工作檔,不隨論文發布
## 2026-08-04 ## 2026-08-04
@@ -227,7 +227,7 @@
- **Markdown 檔名一律以 dash 連接**decision-log.md、 - **Markdown 檔名一律以 dash 連接**decision-log.md、
research-plan.md、project-structure.md 等;與 data/ 層 research-plan.md、project-structure.md 等;與 data/ 層
CSV 檔名慣例一致),定義檔命名慣例同步改為 CSV 檔名慣例一致),定義檔命名慣例同步改為
`prompts/<task>-v<N>.md`(如 screen-v1.md),版本庫外的 `prompts/<task>-v<N>.md`(如 screen-v1.md),`excludes/`
私人工作檔一併改名;`conference_abstract.md` 改名並搬入 私人工作檔一併改名;`conference_abstract.md` 改名並搬入
`paper/`——它是本次年會實際送出的摘要,與全文同屬投稿 `paper/`——它是本次年會實際送出的摘要,與全文同屬投稿
血脈,不是 `docs/` 的內部工作文件。全 repo 指涉同步更新。 血脈,不是 `docs/` 的內部工作文件。全 repo 指涉同步更新。
@@ -374,3 +374,25 @@
層;(3) 複雜仲裁機械無以自證其益(見前條)。驗證改為 層;(3) 複雜仲裁機械無以自證其益(見前條)。驗證改為
確定性格式檢查(完整分割、組名唯一、cap ≤ 50),違規 確定性格式檢查(完整分割、組名唯一、cap ≤ 50),違規
依協定修訂定義檔重跑。 依協定修訂定義檔重跑。
## 2026-08-05
- **歌詞捕捉加浮水印/誤碼正規化**:全語料普查(程式窮舉
非 ASCII)發現歌詞站浮水印與 cp1252 誤碼——同形字(西里爾
е 160 處/78 檔、希臘 ό 6 處)、異體空格(U+2005 211 處、
U+205F 80 處、U+200A 6 處)、零寬字元(U+200B 3 處)、C1
誤碼(U+0091/92/93/94/97 共 58 處;先前手修之 U+0085 同
類);受影響 81/883 檔全部來自 pilot 匯入,新抓取檔乾淨。
正規化規則四條(C1 依 cp1252 本義還原、同形字還原、異體
空格→ASCII 空格、零寬刪除)實作於 fetch-lyrics 的
normalize_lyrics(),寫入時套用;既有語料以同一函式一次性
套用。倒放行等歌曲本體內容不動——策展界線:清傳輸雜質,
不改內容。
- **Song 775 拒答機制查明與裁定為無法分析**:tag 首跑僅
此一首遭模型拒答。以隔離子代理做消融測試(四變體)
證實觸發源是歌詞中的倒放拼寫行(觸發混淆文字防護),
與歌詞中的種族蔑稱無關、與零寬字元亦無關(移除
U+200B 而保留倒放行仍拒答)。裁定:倒放行是歌曲的
忠實內容,保留不動——語料忠實優先於可分析性;該首
視為無法分析,缺漏歸因於 Anthropic 平台防護,非本
研究語料的問題。
+1 -1
View File
@@ -73,7 +73,7 @@ pop-fem-audit/
│ └── methodology.md # 方法細節(全文方法節底稿; │ └── methodology.md # 方法細節(全文方法節底稿;
│ # 映射分析方法須在看結果前寫定) │ # 映射分析方法須在看結果前寫定)
└── paper/ └── paper/
├── abstract.md # 摘要 ├── conference-abstract.md # pilot 摘要(投稿版)
└── full-paper.md # 全文 └── full-paper.md # 全文
``` ```
+7 -3
View File
@@ -7,7 +7,7 @@
- **研究敘事**:全文為獨立完整的正式研究(2016–2025 十年語料 - **研究敘事**:全文為獨立完整的正式研究(2016–2025 十年語料
共 1000 筆榜單紀錄,凍結程序後全量執行),**不與先導研究 共 1000 筆榜單紀錄,凍結程序後全量執行),**不與先導研究
做比較**;全文數字一律以正式研究 paper/conference-abstract.md做比較**;全文數字一律以正式研究
結果為準。先導研究僅作為 codebook v0 與假說的內部來源, 結果為準。先導研究僅作為 codebook v0 與假說的內部來源,
記於決策日誌,不進入論文敘事。 記於決策日誌,不進入論文敘事。
- **執行原則**:主會話只做討論;所有分析由 deterministic script - **執行原則**:主會話只做討論;所有分析由 deterministic script
@@ -72,8 +72,8 @@
候選歌曲的歌手另做深度背景(族裔以公開自我認同為準、音樂 候選歌曲的歌手另做深度背景(族裔以公開自我認同為準、音樂
場景),script 輔助、人工核定。 場景),script 輔助、人工核定。
- **Pilot 歌詞沿用(私人匯入,不進發布管線)**:先導研究 - **Pilot 歌詞沿用(私人匯入,不進發布管線)**:先導研究
捕捉檔(lyrics.json684 首,20182025)以私人腳本 捕捉檔(lyrics.json684 首,20182025)以 `excludes/`
匯入歌詞快取——只取識別欄位與歌詞本文,以 私人腳本匯入歌詞快取——只取識別欄位與歌詞本文,以
(year, rank) 精確匹配 song_id。匯入工具不屬於專案交付物 (year, rank) 精確匹配 song_id。匯入工具不屬於專案交付物
(讀者拿不到其輸入),讀者的重現路徑純粹是 `fetch-lyrics` (讀者拿不到其輸入),讀者的重現路徑純粹是 `fetch-lyrics`
沿用之**事實**記於 `data/captures/lyrics-provenance.csv`(進 git): 沿用之**事實**記於 `data/captures/lyrics-provenance.csv`(進 git):
@@ -165,10 +165,14 @@
與「貶低其他女性/以男性指標定義權力」。 與「貶低其他女性/以男性指標定義權力」。
- 流程:v0 → 校準樣本試編 → 修訂 → 凍結 v1 → 全量編碼; - 流程:v0 → 校準樣本試編 → 修訂 → 凍結 v1 → 全量編碼;
中途修訂須記決策日誌並回檢已編歌曲。 中途修訂須記決策日誌並回檢已編歌曲。
- Positionality statement 寫入論文(53 歲、長年婦運者、資深
工程師、離開學術圈多年、因整理榜單而起)。
## 其他已定案事項 ## 其他已定案事項
- 歌詞受版權保護:完整歌詞不進 git(`data/captures/lyrics/` gitignored), - 歌詞受版權保護:完整歌詞不進 git(`data/captures/lyrics/` gitignored),
論文與 repo 只留分類所引摘錄。 論文與 repo 只留分類所引摘錄。
- 工程支援(寫 script、style check、審稿)用公司訂閱的
Claude Code;進論文的分析 token 由個人 API 帳號支付。
- Fable 5 不用於 pipeline:成本高、thinking 無法關閉且不可稽核、 - Fable 5 不用於 pipeline:成本高、thinking 無法關閉且不可稽核、
無 temperature 控制,且會混淆「盲點是提示問題」的核心主張。 無 temperature 控制,且會混淆「盲點是提示問題」的核心主張。
+4
View File
@@ -14,6 +14,10 @@ Rules:
- For each keyword, quote 1 to 3 verbatim lines of the lyrics - For each keyword, quote 1 to 3 verbatim lines of the lyrics
that ground it; do not give a keyword you cannot ground. that ground it; do not give a keyword you cannot ground.
Do not wrap the output in a Markdown code fence.
The output must be valid JSON; escape any double quote
inside a string as \".
Output a single JSON object mapping each keyword to its list Output a single JSON object mapping each keyword to its list
of quotes, and nothing else: of quotes, and nothing else:
+4
View File
@@ -17,6 +17,10 @@ Rules:
by hyphens, that best names the theme it gathers. Group by hyphens, that best names the theme it gathers. Group
names must be unique. names must be unique.
Do not wrap the output in a Markdown code fence.
The output must be valid JSON; escape any double quote
inside a string as \".
Output a single JSON object mapping each group name to the Output a single JSON object mapping each group name to the
list of its member keywords, and nothing else: list of its member keywords, and nothing else:
+4
View File
@@ -20,6 +20,10 @@ Rules:
by hyphens, that best names the theme it gathers. Group by hyphens, that best names the theme it gathers. Group
names must be unique. names must be unique.
Do not wrap the output in a Markdown code fence.
The output must be valid JSON; escape any double quote
inside a string as \".
Output a single JSON object mapping each group name to the Output a single JSON object mapping each group name to the
list of its member keywords, and nothing else: list of its member keywords, and nothing else:
+4
View File
@@ -21,6 +21,10 @@ Rules:
the lyrics that ground it; do not assign a keyword you the lyrics that ground it; do not assign a keyword you
cannot ground. cannot ground.
Do not wrap the output in a Markdown code fence.
The output must be valid JSON; escape any double quote
inside a string as \".
Output a single JSON object mapping each assigned keyword to Output a single JSON object mapping each assigned keyword to
its list of quotes (an empty object when no keyword applies), its list of quotes (an empty object when no keyword applies),
and nothing else: and nothing else:
+4
View File
@@ -30,6 +30,10 @@ Rules:
lines of the lyrics; quote them. lines of the lyrics; quote them.
- Do not add any keyword that is not listed. - Do not add any keyword that is not listed.
Do not wrap the output in a Markdown code fence.
The output must be valid JSON; escape any double quote
inside a string as \".
Output a single JSON object mapping each kept keyword to your Output a single JSON object mapping each kept keyword to your
list of quotes, and nothing else. A dropped keyword is simply list of quotes, and nothing else. A dropped keyword is simply
left out; output an empty object when nothing is kept: left out; output an empty object when nothing is kept:
+4
View File
@@ -12,6 +12,10 @@ Rules:
lines of the lyrics that ground the judgment; if you cannot lines of the lyrics that ground the judgment; if you cannot
ground it in a quote, the answer is no. ground it in a quote, the answer is no.
Do not wrap the output in a Markdown code fence.
The output must be valid JSON; escape any double quote
inside a string as \".
Output a single JSON array and nothing else: the quoted Output a single JSON array and nothing else: the quoted
lines when the song expresses the theme, or an empty array lines when the song expresses the theme, or an empty array
when it does not: when it does not:
+4
View File
@@ -21,6 +21,10 @@ Rules:
lines of the lyrics that ground the judgment; if you cannot lines of the lyrics that ground the judgment; if you cannot
ground it in a quote, the answer is no. ground it in a quote, the answer is no.
Do not wrap the output in a Markdown code fence.
The output must be valid JSON; escape any double quote
inside a string as \".
Output a single JSON array and nothing else: the quoted Output a single JSON array and nothing else: the quoted
lines when the song expresses the theme, or an empty array lines when the song expresses the theme, or an empty array
when it does not: when it does not:
@@ -57,6 +57,49 @@ SLEEP_SECONDS: float = 1.0
"""The delay between consecutive HTTP requests, in seconds.""" """The delay between consecutive HTTP requests, in seconds."""
def _build_normalization() -> dict[int, str | None]:
"""Build the lyrics normalization translation table.
:return: The codepoint-to-replacement mapping, a replacement
of None meaning removal.
"""
table: dict[int, str | None] = {}
codepoint: int
for codepoint in range(0x80, 0xa0):
try:
table[codepoint] = bytes([codepoint]).decode("cp1252")
except UnicodeDecodeError:
table[codepoint] = None
table[0x0435] = "e"
table[0x03cc] = "ó"
for codepoint in (0x2005, 0x205f, 0x200a):
table[codepoint] = " "
for codepoint in (0x200b, 0x200c, 0x200d, 0xfeff):
table[codepoint] = None
return table
NORMALIZATION: dict[int, str | None] = _build_normalization()
"""The codepoint-to-replacement mapping applied to fetched
lyrics: cp1252-mojibake restoration for U+0080-U+009F (with the
five byte values undefined in cp1252 removed), homoglyph
restoration for the Cyrillic "e" and the Greek "o" with tonos,
ASCII-space restoration for exotic space variants, and removal
of zero-width characters. A replacement of None removes the
codepoint."""
def normalize_lyrics(text: str) -> str:
"""Restore or remove watermark and mojibake characters.
:param text: The lyrics text as fetched from an API.
:return: The text with the codepoints in
:data:`NORMALIZATION` replaced or removed; every other
character is unchanged.
"""
return text.translate(NORMALIZATION)
def parse_args(argv: list[str] | None) -> argparse.Namespace: def parse_args(argv: list[str] | None) -> argparse.Namespace:
"""Parse the command-line arguments. """Parse the command-line arguments.
@@ -187,6 +230,9 @@ def save_lyrics(lyrics_dir: Path, song_id: int,
The cache directory is created when missing. The cache directory is created when missing.
The lyrics text is normalized with :func:`normalize_lyrics`
before being written.
:param lyrics_dir: The lyrics cache directory. :param lyrics_dir: The lyrics cache directory.
:param song_id: The song ID. :param song_id: The song ID.
:param lyrics: The lyrics text. :param lyrics: The lyrics text.
@@ -195,7 +241,7 @@ def save_lyrics(lyrics_dir: Path, song_id: int,
""" """
lyrics_dir.mkdir(parents=True, exist_ok=True) lyrics_dir.mkdir(parents=True, exist_ok=True)
(lyrics_dir / f"{song_id}.txt").write_text( (lyrics_dir / f"{song_id}.txt").write_text(
lyrics, encoding="utf-8") normalize_lyrics(lyrics), encoding="utf-8")
def append_provenance(path: Path, song_id: int, def append_provenance(path: Path, song_id: int,
+73
View File
@@ -317,6 +317,79 @@ class TestFetchLyrics(unittest.TestCase):
self.assertEqual([x[0] for x in rows[1:]], self.assertEqual([x[0] for x in rows[1:]],
["1", "2", "2"]) ["1", "2", "2"])
def test_normalize_cp1252_mojibake(self) -> None:
"""Test that cp1252 mojibake codepoints are restored."""
self.assertEqual(
fetch_lyrics.normalize_lyrics("wait…"),
"wait…")
self.assertEqual(
fetch_lyrics.normalize_lyrics(
"‘quote’"),
"quote")
self.assertEqual(
fetch_lyrics.normalize_lyrics(
"“quote”"),
"“quote”")
self.assertEqual(
fetch_lyrics.normalize_lyrics("dash—line"),
"dash—line")
def test_normalize_undefined_cp1252_removed(self) -> None:
"""Test that undefined cp1252 byte values are removed."""
text: str = ("abcdef")
self.assertEqual(
fetch_lyrics.normalize_lyrics(text), "abcdef")
def test_normalize_homoglyphs(self) -> None:
"""Test that watermark homoglyphs are restored."""
self.assertEqual(
fetch_lyrics.normalize_lyrics("likе that"),
"like that")
self.assertEqual(
fetch_lyrics.normalize_lyrics("lό que soy"),
"ló que soy")
def test_normalize_space_variants(self) -> None:
"""Test that exotic space variants become ASCII space."""
self.assertEqual(
fetch_lyrics.normalize_lyrics(
"abcd"),
"a b c d")
def test_normalize_zero_width_removed(self) -> None:
"""Test that zero-width characters are removed."""
text: str = (
"abcde")
self.assertEqual(
fetch_lyrics.normalize_lyrics(text), "abcde")
def test_normalize_ascii_unchanged(self) -> None:
"""Test that plain ASCII text passes through unchanged."""
text: str = "Hello, it's me\n"
self.assertEqual(
fetch_lyrics.normalize_lyrics(text), text)
def test_normalize_legitimate_non_ascii_unchanged(self) -> None:
"""Test that legitimate non-ASCII content is unchanged."""
text: str = "¿cómo estás? 안녕하세요\n"
self.assertEqual(
fetch_lyrics.normalize_lyrics(text), text)
def test_fetched_lyrics_saved_normalized(self) -> None:
"""Test that a fetched lyric is normalized before saving."""
self.__seed([("Hello", "Adele")])
with mock.patch(
"urllib.request.urlopen",
side_effect=[self.__response(
{"lyrics": "wait… likе"
" thatnow\n"})]):
status: int = self.__run_fetch()[0]
self.assertEqual(status, 0)
self.assertEqual(
(self.__lyrics / "1.txt")
.read_text(encoding="utf-8"),
"wait… like that now\n")
def test_no_store_fails(self) -> None: def test_no_store_fails(self) -> None:
"""Test that a missing working store fails the run.""" """Test that a missing working store fails the run."""
urlopen: mock.Mock urlopen: mock.Mock