예제 소스
암호화폐 자동매매를 위한 CNN 시각 정책망과 VLM · 파일 59개
test_hf_img_unit.py
"""
test_hf_img.py 헬퍼 및 HF 호출 로직 단위 테스트 (네트워크 없음).
통합 실행 시 HTTP 400 model_not_supported 는 Hugging Face 계정에서 켠
Inference Provider 와 --model / HF_VISION_MODEL 조합 문제이며,
이 파일의 테스트는 그 경우를 “모킹한 400 응답”으로만 재현합니다.
실행: 프로젝트 루트에서 python -m unittest discover -s tests -v
실 Router + 이미지: tests/test_hf_img_integration.py (HF_INTEGRATION_TEST=1)
"""
from __future__ import annotations
import base64
import json
import re
import unittest
from io import BytesIO
from unittest.mock import MagicMock, patch
# 프로젝트 루트의 test_hf_img 모듈
import test_hf_img as hf
def _payload_from_mock(mock_urlopen: MagicMock) -> dict:
req = mock_urlopen.call_args[0][0]
return json.loads(req.data.decode("utf-8"))
def _image_url_from_payload(payload: dict) -> str:
parts = payload["messages"][0]["content"]
self0 = parts[0]
assert self0["type"] == "image_url"
return self0["image_url"]["url"]
def _fake_urlopen_cm(body: dict) -> MagicMock:
"""urlopen() 이 with 문에서 쓰는 컨텍스트 매니저를 흉내 냄."""
raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
resp = MagicMock()
resp.read.return_value = raw
cm = MagicMock()
cm.__enter__.return_value = resp
cm.__exit__.return_value = None
return cm
class TestPngToJpeg(unittest.TestCase):
def test_jpg_magic_and_non_empty(self) -> None:
try:
from PIL import Image
except ImportError:
self.skipTest("Pillow 없음")
buf = BytesIO()
Image.new("RGB", (4, 4), color=(128, 64, 32)).save(buf, format="PNG")
png = buf.getvalue()
self.assertTrue(png.startswith(b"\x89PNG\r\n\x1a\n"))
jpg = hf._png_bytes_to_jpg_bytes(png)
self.assertTrue(jpg.startswith(b"\xff\xd8"), "JPEG SOI")
self.assertGreater(len(jpg), 100)
class TestParseOpinion(unittest.TestCase):
def test_colon_form(self) -> None:
op, _ = hf._parse_opinion("의견: 매수\n근거: 테스트")
self.assertEqual(op, "매수")
def test_fullwidth_colon(self) -> None:
op, _ = hf._parse_opinion("의견:유지\n근거")
self.assertEqual(op, "유지")
def test_fallback_keyword(self) -> None:
op, _ = hf._parse_opinion("현재는 매도 쪽으로 보입니다.")
self.assertEqual(op, "매도")
def test_empty(self) -> None:
op, full = hf._parse_opinion("")
self.assertIsNone(op)
self.assertEqual(full, "")
def test_whitespace_only(self) -> None:
op, full = hf._parse_opinion(" \n\t ")
self.assertIsNone(op)
def test_no_match_returns_none(self) -> None:
op, full = hf._parse_opinion("차트만 있고 의견 문구가 없습니다.")
self.assertIsNone(op)
self.assertEqual(full, "차트만 있고 의견 문구가 없습니다.")
def test_opinion_precedence_over_fallback(self) -> None:
"""첫 줄 의견이 있으면 키워드 휴리스틱보다 우선."""
text = "의견: 유지\n근거: 단기 매수 신호도 보이나 불확실"
op, _ = hf._parse_opinion(text)
self.assertEqual(op, "유지")
def test_quoted_verdict(self) -> None:
op, _ = hf._parse_opinion(
'따라서 질문의 옵션 중 "매도"가 가장 논리적으로 보입니다.'
)
self.assertEqual(op, "매도")
def test_long_text_conclusion_at_end(self) -> None:
"""긴 본문 뒤 결론(구버전은 앞 200자만 검사해 실패)."""
long_prefix = "분석 내용. " * 80
op, _ = hf._parse_opinion(long_prefix + "따라서 매도 쪽이 타당합니다.")
self.assertEqual(op, "매도")
def test_zonghe_heading(self) -> None:
op, _ = hf._parse_opinion("**2. 종합 판단:**\n따라서 매도가 맞습니다.")
self.assertEqual(op, "매도")
class TestBuildPrompt(unittest.TestCase):
def test_contains_ticker(self) -> None:
p = hf._build_analysis_prompt("KRW-BTC")
self.assertIn("KRW-BTC", p)
self.assertIn("매수", p)
class TestHfVisionChatMocked(unittest.TestCase):
@patch("urllib.request.urlopen")
def test_returns_string_content(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _fake_urlopen_cm(
{"choices": [{"message": {"content": "의견: 유지\n근거: 안전"}}]}
)
out = hf._hf_vision_chat(
token="t",
image_bytes=b"\xff\xd8\xff", # JPEG-ish for mime
user_text="hi",
model="m/M",
timeout_sec=5,
)
self.assertIn("유지", out)
mock_urlopen.assert_called_once()
req = mock_urlopen.call_args[0][0]
self.assertEqual(req.get_header("Authorization"), "Bearer t")
payload = json.loads(req.data.decode("utf-8"))
self.assertEqual(payload["model"], "m/M")
self.assertEqual(payload["messages"][0]["role"], "user")
@patch("urllib.request.urlopen")
def test_png_mime_in_payload(self, mock_urlopen: MagicMock) -> None:
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
mock_urlopen.return_value = _fake_urlopen_cm(
{"choices": [{"message": {"content": "ok"}}]}
)
hf._hf_vision_chat(
token="x",
image_bytes=png,
user_text="q",
model="vision/x",
timeout_sec=5,
)
payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8"))
parts = payload["messages"][0]["content"]
url = parts[0]["image_url"]["url"]
self.assertIn("data:image/png;base64,", url)
@patch("urllib.request.urlopen")
def test_list_content_parts(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _fake_urlopen_cm(
{
"choices": [
{
"message": {
"content": [
{"type": "text", "text": "의견: 매도"},
{"type": "text", "text": "\n둘째"},
]
}
}
]
}
)
out = hf._hf_vision_chat(
token="t",
image_bytes=b"\xff\xd8\xff",
user_text="q",
model="m",
timeout_sec=5,
)
self.assertIn("매도", out)
@patch("urllib.request.urlopen")
def test_http_error_message(self, mock_urlopen: MagicMock) -> None:
import urllib.error
fp = BytesIO(
b'{"error":{"message":"model_not_supported","type":"invalid_request_error"}}'
)
err = urllib.error.HTTPError(
hf.HF_CHAT_URL, 400, "Bad Request", hdrs={}, fp=fp
)
mock_urlopen.side_effect = err
with self.assertRaises(RuntimeError) as ctx:
hf._hf_vision_chat(
token="t",
image_bytes=b"\xff\xd8\xff",
user_text="q",
model="Bad/Model",
timeout_sec=5,
)
self.assertIn("400", str(ctx.exception))
self.assertIn("Bad/Model", str(ctx.exception))
@patch("urllib.request.urlopen")
def test_http_400_router_model_not_supported_body(self, mock_urlopen: MagicMock) -> None:
"""터미널에 나온 Router 400 JSON 형태를 그대로 모킹."""
import urllib.error
body = {
"error": {
"message": (
"The requested model 'Qwen/Qwen2.5-VL-7B-Instruct' "
"is not supported by any provider you have enabled."
),
"type": "invalid_request_error",
"param": "model",
"code": "model_not_supported",
}
}
fp = BytesIO(json.dumps(body).encode("utf-8"))
err = urllib.error.HTTPError(
hf.HF_CHAT_URL, 400, "Bad Request", hdrs={}, fp=fp
)
mock_urlopen.side_effect = err
with self.assertRaises(RuntimeError) as ctx:
hf._hf_vision_chat(
token="********",
image_bytes=b"\xff\xd8\xff\xe0\x00\x10JFIF",
user_text="분석",
model="Qwen/Qwen2.5-VL-7B-Instruct",
timeout_sec=30,
)
msg = str(ctx.exception)
self.assertIn("HTTP 400", msg)
self.assertIn("model_not_supported", msg)
self.assertIn("provider you have enabled", msg)
self.assertIn("Inference Providers", msg)
@patch("urllib.request.urlopen")
def test_http_400_not_a_chat_model_hint(self, mock_urlopen: MagicMock) -> None:
import urllib.error
body = {
"error": {
"message": "The requested model 'google/gemma-4-E4B-it' is not a chat model.",
"type": "invalid_request_error",
"param": "model",
"code": "model_not_supported",
}
}
fp = BytesIO(json.dumps(body).encode("utf-8"))
err = urllib.error.HTTPError(
hf.HF_CHAT_URL, 400, "Bad Request", hdrs={}, fp=fp
)
mock_urlopen.side_effect = err
with self.assertRaises(RuntimeError) as ctx:
hf._hf_vision_chat(
token="********",
image_bytes=b"\xff\xd8\xff",
user_text="hi",
model="google/gemma-4-E4B-it",
timeout_sec=5,
)
msg = str(ctx.exception)
self.assertIn("not a chat model", msg.lower())
self.assertIn("Conversational", msg)
@patch("urllib.request.urlopen")
def test_urlopen_receives_timeout_kwarg(self, mock_urlopen: MagicMock) -> None:
mock_urlopen.return_value = _fake_urlopen_cm(
{"choices": [{"message": {"content": "ok"}}]}
)
hf._hf_vision_chat(
token="t",
image_bytes=b"\xff\xd8\xff",
user_text="x",
model="m",
timeout_sec=77,
)
_, kwargs = mock_urlopen.call_args
self.assertEqual(kwargs.get("timeout"), 77)
@patch("urllib.request.urlopen")
def test_urllib_error_wrapped(self, mock_urlopen: MagicMock) -> None:
import urllib.error
mock_urlopen.side_effect = urllib.error.URLError("no route")
with self.assertRaises(RuntimeError) as ctx:
hf._hf_vision_chat(
token="t",
image_bytes=b"\xff\xd8\xff",
user_text="x",
model="m",
timeout_sec=5,
)
self.assertIn("연결 실패", str(ctx.exception))
class TestHfRequestPayloadContract(unittest.TestCase):
"""Router로 나가는 POST 본문이 OpenAI 호환 멀티모달 형태인지 검증."""
def setUp(self) -> None:
self._patcher = patch("urllib.request.urlopen")
self.mock_urlopen = self._patcher.start()
self.mock_urlopen.return_value = _fake_urlopen_cm(
{"choices": [{"message": {"content": "의견: 유지"}}]}
)
def tearDown(self) -> None:
self._patcher.stop()
def test_request_url_method_headers(self) -> None:
hf._hf_vision_chat(
token="********",
image_bytes=b"\xff\xd8\xff\xe0",
user_text="hello",
model="org/model-id",
timeout_sec=5,
)
req = self.mock_urlopen.call_args[0][0]
self.assertEqual(req.full_url, hf.HF_CHAT_URL)
self.assertEqual(req.get_method(), "POST")
self.assertEqual(req.get_header("Content-type"), "application/json")
self.assertEqual(req.get_header("Accept"), "application/json")
self.assertEqual(req.get_header("Authorization"), "Bearer secret")
def test_payload_openai_multimodal_shape(self) -> None:
hf._hf_vision_chat(
token="t",
image_bytes=b"\xff\xd8\xff",
user_text="프롬프트",
model="my/vision",
timeout_sec=5,
)
p = _payload_from_mock(self.mock_urlopen)
self.assertEqual(p["model"], "my/vision")
self.assertEqual(p["max_tokens"], hf.DEFAULT_HF_MAX_TOKENS)
self.assertEqual(p["temperature"], 0.2)
self.assertEqual(len(p["messages"]), 1)
self.assertEqual(p["messages"][0]["role"], "user")
parts = p["messages"][0]["content"]
self.assertEqual(len(parts), 2)
self.assertEqual(parts[0]["type"], "image_url")
self.assertIn("url", parts[0]["image_url"])
self.assertEqual(parts[1]["type"], "text")
self.assertEqual(parts[1]["text"], "프롬프트")
def test_jpeg_data_url_roundtrips_bytes(self) -> None:
raw = b"\xff\xd8\xff\xe0\x00\x10JFIF" + b"\xab" * 32
hf._hf_vision_chat(
token="t",
image_bytes=raw,
user_text="q",
model="m",
timeout_sec=5,
)
url = _image_url_from_payload(_payload_from_mock(self.mock_urlopen))
m = re.match(r"^data:(image/jpeg);base64,(.+)$", url)
self.assertIsNotNone(m)
decoded = base64.standard_b64decode(m.group(2))
self.assertEqual(decoded, raw)
def test_unicode_user_text_json_safe(self) -> None:
hf._hf_vision_chat(
token="t",
image_bytes=b"\xff\xd8\xff",
user_text="티커: KRW-비트코인 🔍",
model="m",
timeout_sec=5,
)
p = _payload_from_mock(self.mock_urlopen)
self.assertIn("비트코인", p["messages"][0]["content"][1]["text"])
class TestHfResponseEdgeCases(unittest.TestCase):
def setUp(self) -> None:
self.p = patch("urllib.request.urlopen")
self.mock = self.p.start()
def tearDown(self) -> None:
self.p.stop()
def test_top_level_error_in_json_raises(self) -> None:
self.mock.return_value = _fake_urlopen_cm({"error": {"message": "rate limited"}})
with self.assertRaises(RuntimeError) as ctx:
hf._hf_vision_chat(
token="t",
image_bytes=b"\xff\xd8\xff",
user_text="q",
model="m",
timeout_sec=5,
)
self.assertIn("HF API error", str(ctx.exception))
def test_empty_choices_raises(self) -> None:
self.mock.return_value = _fake_urlopen_cm({"choices": []})
with self.assertRaises(RuntimeError) as ctx:
hf._hf_vision_chat(
token="t",
image_bytes=b"\xff\xd8\xff",
user_text="q",
model="m",
timeout_sec=5,
)
self.assertIn("choices 없음", str(ctx.exception))
def test_invalid_json_body_raises(self) -> None:
resp = MagicMock()
resp.read.return_value = b"not json {"
cm = MagicMock()
cm.__enter__.return_value = resp
self.mock.return_value = cm
with self.assertRaises(RuntimeError) as ctx:
hf._hf_vision_chat(
token="t",
image_bytes=b"\xff\xd8\xff",
user_text="q",
model="m",
timeout_sec=5,
)
self.assertIn("JSON 파싱 실패", str(ctx.exception))
def test_list_content_skips_non_text(self) -> None:
self.mock.return_value = _fake_urlopen_cm(
{
"choices": [
{
"message": {
"content": [
{"type": "image_url", "image_url": {"url": "x"}},
{"type": "text", "text": "의견: 매수"},
]
}
}
]
}
)
out = hf._hf_vision_chat(
token="t",
image_bytes=b"\xff\xd8\xff",
user_text="q",
model="m",
timeout_sec=5,
)
self.assertEqual(out, "의견: 매수")
def test_content_none_raises_with_debug(self) -> None:
self.mock.return_value = _fake_urlopen_cm(
{"choices": [{"message": {"content": None}}]}
)
with self.assertRaises(RuntimeError) as ctx:
hf._hf_vision_chat(
token="t",
image_bytes=b"\xff\xd8\xff",
user_text="q",
model="m",
timeout_sec=5,
)
self.assertIn("텍스트를 찾을 수 없습니다", str(ctx.exception))
def test_reasoning_content_when_content_null_qwen_style(self) -> None:
"""Qwen3 등: content null, reasoning_content 에 본문."""
self.mock.return_value = _fake_urlopen_cm(
{
"choices": [
{
"message": {
"content": None,
"reasoning_content": "의견: 유지\n근거: 단색 차트.",
}
}
]
}
)
out = hf._hf_vision_chat(
token="t",
image_bytes=b"\xff\xd8\xff",
user_text="q",
model="Qwen/Qwen3.5-397B-A17B",
timeout_sec=5,
)
self.assertIn("유지", out)
class TestDefaultVisionModelEnv(unittest.TestCase):
def test_default_is_non_empty_string(self) -> None:
self.assertIsInstance(hf.DEFAULT_VISION_MODEL, str)
self.assertTrue(len(hf.DEFAULT_VISION_MODEL.strip()) > 0)
if __name__ == "__main__":
unittest.main()