初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,104 @@
"""角色卡解析器单元测试。"""
from __future__ import annotations
import base64
import io
import json
import struct
import zipfile
import zlib
from app.modules.imports.card_parser import parse_character_card
def _make_png_text(keyword: str, text: str) -> bytes:
"""构造含一个 tEXt chunk 的最小 PNG。"""
def chunk(ctype: bytes, data: bytes) -> bytes:
body = ctype + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
payload = keyword.encode("latin-1") + b"\x00" + text.encode("latin-1")
return b"\x89PNG\r\n\x1a\n" + chunk(b"tEXt", payload) + chunk(b"IEND", b"")
def test_parse_v2_json() -> None:
card = {"spec": "chara_card_v2", "spec_version": "2.0", "data": {"name": "狂三", "description": "desc", "personality": "腹黑"}}
parsed = parse_character_card(json.dumps(card).encode("utf-8"))
assert parsed.name == "狂三"
assert parsed.personality == "腹黑"
assert parsed.spec_version == "2.0"
def test_parse_v1_flat_normalized() -> None:
card = {"name": "V1角色", "description": "d", "personality": "p"}
parsed = parse_character_card(json.dumps(card).encode("utf-8"))
assert parsed.name == "V1角色"
assert parsed.spec_version == "1"
def test_parse_png_chara_chunk() -> None:
card = {"spec": "chara_card_v2", "spec_version": "2.0", "data": {"name": "PNG卡"}}
text = base64.b64encode(json.dumps(card).encode("utf-8")).decode("ascii")
parsed = parse_character_card(_make_png_text("chara", text))
assert parsed.name == "PNG卡"
def test_parse_png_ccv3_preferred_over_chara() -> None:
# 同时含 chara(V2) 和 ccv3(V3),优先 ccv3
card_v3 = {"spec_version": "3.0", "data": {"name": "V3卡", "nickname": "三三"}}
def chunk(ctype: bytes, data: bytes) -> bytes:
body = ctype + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
v3_text = base64.b64encode(json.dumps(card_v3).encode()).decode()
payload_chara = b"chara\x00" + base64.b64encode(json.dumps({"data": {"name": "旧"}}).encode())
payload_ccv3 = b"ccv3\x00" + v3_text.encode("latin-1")
png = b"\x89PNG\r\n\x1a\n" + chunk(b"tEXt", payload_chara) + chunk(b"tEXt", payload_ccv3) + chunk(b"IEND", b"")
parsed = parse_character_card(png)
assert parsed.name == "V3卡"
assert parsed.nickname == "三三"
def test_parse_charx_zip() -> None:
card = {"spec_version": "3.0", "data": {"name": "CHARX卡"}}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("card.json", json.dumps(card))
parsed = parse_character_card(buf.getvalue())
assert parsed.name == "CHARX卡"
def test_character_book_preserved() -> None:
card = {
"spec": "chara_card_v2",
"spec_version": "2.0",
"data": {
"name": "x",
"character_book": {"entries": [{"keys": ["a"], "content": "b"}]},
},
}
parsed = parse_character_card(json.dumps(card).encode())
assert parsed.character_book is not None
assert parsed.character_book["entries"][0]["keys"] == ["a"]
def test_alternate_greetings_and_tags() -> None:
card = {"spec_version": "2.0", "data": {"name": "x", "alternate_greetings": ["hi", "yo"], "tags": ["anime"]}}
parsed = parse_character_card(json.dumps(card).encode())
assert parsed.alternate_greetings == ["hi", "yo"]
assert parsed.tags == ["anime"]
def test_png_without_metadata_raises() -> None:
import pytest
def chunk(ctype: bytes, data: bytes) -> bytes:
body = ctype + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IEND", b"")
with pytest.raises(ValueError):
parse_character_card(png)
@@ -0,0 +1,65 @@
"""角色卡导出映射单元测试。"""
from __future__ import annotations
from app.models.character import Character
from app.models.lorebook import Lorebook, LorebookEntry
from app.modules.imports.export_mapping import character_to_card, lorebook_to_st_book
from app.repositories.json_utils import dumps
def _character(**kw: object) -> Character:
base: dict[str, object] = {
"name": "狂三",
"profile_json": "{}",
}
base.update(kw)
return Character(**base) # type: ignore[arg-type]
def test_character_to_card_basic() -> None:
char = _character(
display_name="三三",
personality_summary="腹黑",
profile_json=dumps({"background": "desc", "scenario": "sch", "tags": ["a"],
"source_meta": {"creator": "X", "character_version": "1"}}),
)
card = character_to_card(char)
assert card["spec"] == "chara_card_v3"
assert card["spec_version"] == "3.0"
data = card["data"]
assert data["name"] == "狂三"
assert data["nickname"] == "三三"
assert data["personality"] == "腹黑"
assert data["description"] == "desc"
assert data["creator"] == "X"
assert data["character_version"] == "1"
def test_no_nickname_when_same_as_name() -> None:
char = _character(name="X", display_name="X")
assert "nickname" not in character_to_card(char)["data"]
def test_personality_from_daoti_snapshot() -> None:
# 道体 personality_summary 是导出的 personality 来源(演化后的当前快照)
char = _character(personality_summary="演化后的人格")
assert character_to_card(char)["data"]["personality"] == "演化后的人格"
def test_lorebook_exported_to_st_book() -> None:
book = Lorebook(name="谱", scan_depth=4, recursive_scanning=True)
entries = [
LorebookEntry(keys_json='["猫"]', content="喜欢猫", constant=True, position="after_char"),
]
st_book = lorebook_to_st_book(book, entries)
assert st_book["name"] == "谱"
assert st_book["scan_depth"] == 4
assert st_book["recursive_scanning"] is True
assert st_book["entries"][0]["keys"] == ["猫"]
assert st_book["entries"][0]["constant"] is True
def test_no_character_book_when_lorebook_none() -> None:
char = _character()
assert "character_book" not in character_to_card(char)["data"]
@@ -0,0 +1,70 @@
"""角色卡字段映射单元测试。"""
from __future__ import annotations
from app.modules.imports.card_parser import CharacterCard
from app.modules.imports.mapping import (
card_to_daoti,
card_to_profile,
character_book_to_entries,
)
def _card(**kw: object) -> CharacterCard:
base: dict[str, object] = {"spec_version": "2.0", "name": "狂三"}
base.update(kw)
return CharacterCard(**base) # type: ignore[arg-type]
def test_profile_maps_core_fields() -> None:
card = _card(
description="desc",
scenario="sch",
first_mes="hi",
tags=["a"],
creator="X",
character_version="1",
)
profile = card_to_profile(card)
assert profile["background"] == "desc"
assert profile["scenario"] == "sch"
assert profile["first_mes"] == "hi"
assert profile["tags"] == ["a"]
assert profile["source_meta"]["creator"] == "X"
assert profile["source_meta"]["spec_version"] == "2.0"
def test_profile_preserves_system_prompt_and_extensions() -> None:
card = _card(system_prompt="SP", extensions={"foo": {"bar": 1}})
profile = card_to_profile(card)
assert profile["system_prompt"] == "SP"
assert profile["st_extensions"] == {"foo": {"bar": 1}}
def test_daoti_personality_as_initial_summary() -> None:
assert card_to_daoti(_card(personality="腹黑优雅"))["personality_summary"] == "腹黑优雅"
def test_daoti_empty_personality_is_none() -> None:
assert card_to_daoti(_card())["personality_summary"] is None
def test_book_to_entries_maps_fields() -> None:
book = {
"entries": [
{"keys": ["猫"], "content": "喜欢猫", "position": "after_char", "constant": True},
{"keys": ["时间"], "content": "时间能力", "priority": 5},
]
}
entries = character_book_to_entries(book)
assert len(entries) == 2
assert entries[0]["keys"] == ["猫"]
assert entries[0]["constant"] is True
assert entries[1]["priority"] == 5
assert entries[1]["position"] == "after_char" # 默认
def test_book_none_or_empty() -> None:
assert character_book_to_entries(None) == []
assert character_book_to_entries({}) == []
assert character_book_to_entries({"entries": []}) == []