mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-01 23:15:27 +08:00
388 lines
12 KiB
Go
388 lines
12 KiB
Go
package util
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"ds2api/internal/config"
|
||
)
|
||
|
||
// ─── EstimateTokens edge cases ───────────────────────────────────────
|
||
|
||
func TestEstimateTokensEmpty(t *testing.T) {
|
||
if got := EstimateTokens(""); got != 0 {
|
||
t.Fatalf("expected 0 for empty string, got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestEstimateTokensShortASCII(t *testing.T) {
|
||
got := EstimateTokens("ab")
|
||
if got != 1 {
|
||
t.Fatalf("expected 1 for 2 ascii chars, got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestEstimateTokensLongASCII(t *testing.T) {
|
||
got := EstimateTokens(strings.Repeat("x", 100))
|
||
if got != 25 {
|
||
t.Fatalf("expected 25 for 100 ascii chars, got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestEstimateTokensChinese(t *testing.T) {
|
||
got := EstimateTokens("你好世界")
|
||
if got < 1 {
|
||
t.Fatalf("expected at least 1 token for Chinese text, got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestEstimateTokensMixed(t *testing.T) {
|
||
got := EstimateTokens("Hello 你好世界")
|
||
if got < 2 {
|
||
t.Fatalf("expected at least 2 tokens for mixed text, got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestEstimateTokensSingleByte(t *testing.T) {
|
||
got := EstimateTokens("x")
|
||
if got != 1 {
|
||
t.Fatalf("expected 1 for single char (minimum), got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestEstimateTokensSingleChinese(t *testing.T) {
|
||
got := EstimateTokens("你")
|
||
if got != 1 {
|
||
t.Fatalf("expected 1 for single Chinese char, got %d", got)
|
||
}
|
||
}
|
||
|
||
// ─── ToBool edge cases ───────────────────────────────────────────────
|
||
|
||
func TestToBoolTrue(t *testing.T) {
|
||
if !ToBool(true) {
|
||
t.Fatal("expected true")
|
||
}
|
||
}
|
||
|
||
func TestToBoolFalse(t *testing.T) {
|
||
if ToBool(false) {
|
||
t.Fatal("expected false")
|
||
}
|
||
}
|
||
|
||
func TestToBoolNonBool(t *testing.T) {
|
||
if ToBool("true") {
|
||
t.Fatal("expected false for string 'true'")
|
||
}
|
||
if ToBool(1) {
|
||
t.Fatal("expected false for int 1")
|
||
}
|
||
if ToBool(nil) {
|
||
t.Fatal("expected false for nil")
|
||
}
|
||
}
|
||
|
||
// ─── IntFrom edge cases ─────────────────────────────────────────────
|
||
|
||
func TestIntFromFloat64(t *testing.T) {
|
||
if got := IntFrom(float64(42.5)); got != 42 {
|
||
t.Fatalf("expected 42 for float64(42.5), got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestIntFromInt(t *testing.T) {
|
||
if got := IntFrom(int(42)); got != 42 {
|
||
t.Fatalf("expected 42, got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestIntFromInt64(t *testing.T) {
|
||
if got := IntFrom(int64(42)); got != 42 {
|
||
t.Fatalf("expected 42, got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestIntFromString(t *testing.T) {
|
||
if got := IntFrom("42"); got != 0 {
|
||
t.Fatalf("expected 0 for string, got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestIntFromNil(t *testing.T) {
|
||
if got := IntFrom(nil); got != 0 {
|
||
t.Fatalf("expected 0 for nil, got %d", got)
|
||
}
|
||
}
|
||
|
||
// ─── WriteJSON ───────────────────────────────────────────────────────
|
||
|
||
func TestWriteJSON(t *testing.T) {
|
||
rec := httptest.NewRecorder()
|
||
WriteJSON(rec, 200, map[string]any{"key": "value"})
|
||
if rec.Code != 200 {
|
||
t.Fatalf("expected 200, got %d", rec.Code)
|
||
}
|
||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||
t.Fatalf("expected application/json content type, got %q", ct)
|
||
}
|
||
var body map[string]any
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||
t.Fatalf("decode error: %v", err)
|
||
}
|
||
if body["key"] != "value" {
|
||
t.Fatalf("unexpected body: %#v", body)
|
||
}
|
||
}
|
||
|
||
func TestWriteJSONStatusCodes(t *testing.T) {
|
||
for _, code := range []int{200, 201, 400, 404, 500} {
|
||
rec := httptest.NewRecorder()
|
||
WriteJSON(rec, code, map[string]any{"status": code})
|
||
if rec.Code != code {
|
||
t.Fatalf("expected %d, got %d", code, rec.Code)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── MessagesPrepare edge cases ──────────────────────────────────────
|
||
|
||
func TestMessagesPrepareEmpty(t *testing.T) {
|
||
got := MessagesPrepare(nil)
|
||
if got != "" {
|
||
t.Fatalf("expected empty for nil messages, got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestMessagesPrepareMergesConsecutiveSameRole(t *testing.T) {
|
||
messages := []map[string]any{
|
||
{"role": "user", "content": "Hello"},
|
||
{"role": "user", "content": "World"},
|
||
}
|
||
got := MessagesPrepare(messages)
|
||
if !strings.HasPrefix(got, "<|begin▁of▁sentence|>") {
|
||
t.Fatalf("expected user marker at the start, got %q", got)
|
||
}
|
||
if !strings.Contains(got, "Hello") || !strings.Contains(got, "World") {
|
||
t.Fatalf("expected both messages, got %q", got)
|
||
}
|
||
// Should be merged into a single user turn with one marker at the start.
|
||
count := strings.Count(got, "<|User|>")
|
||
if count != 1 {
|
||
t.Fatalf("expected one User marker for the merged pair, got %d occurrences", count)
|
||
}
|
||
// User messages no longer have end_of_sentence markers in the official format.
|
||
// The merged pair should have zero end_of_sentence markers (user turn only).
|
||
if count := strings.Count(got, "<|end▁of▁sentence|>"); count != 0 {
|
||
t.Fatalf("expected zero sentence terminators for user-only merge, got %d occurrences", count)
|
||
}
|
||
}
|
||
|
||
func TestMessagesPrepareAssistantMarkers(t *testing.T) {
|
||
messages := []map[string]any{
|
||
{"role": "user", "content": "Hi"},
|
||
{"role": "assistant", "content": "Hello!"},
|
||
}
|
||
got := MessagesPrepare(messages)
|
||
if !strings.Contains(got, "<|Assistant|>") {
|
||
t.Fatalf("expected assistant marker, got %q", got)
|
||
}
|
||
if !strings.Contains(got, "<|end▁of▁sentence|>") {
|
||
t.Fatalf("expected end of sentence marker, got %q", got)
|
||
}
|
||
if strings.Count(got, "<|end▁of▁sentence|>") != 1 {
|
||
t.Fatalf("expected one end_of_sentence (assistant only), got %q", got)
|
||
}
|
||
if !strings.Contains(got, "<|Assistant|>Hello!<|end▁of▁sentence|>") {
|
||
t.Fatalf("expected assistant EOS suffix, got %q", got)
|
||
}
|
||
if strings.Contains(got, "<think>") || strings.Contains(got, "</think>") {
|
||
t.Fatalf("did not expect think tags in prompt, got %q", got)
|
||
}
|
||
if strings.Contains(got, "<system_instructions>") {
|
||
t.Fatalf("did not expect legacy system marker, got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestMessagesPrepareUnknownRole(t *testing.T) {
|
||
messages := []map[string]any{
|
||
{"role": "user", "content": "Hello"},
|
||
{"role": "unknown_role", "content": "Unknown"},
|
||
}
|
||
got := MessagesPrepare(messages)
|
||
if !strings.Contains(got, "Unknown") {
|
||
t.Fatalf("expected unknown role content, got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestMessagesPrepareMarkdownImageReplaced(t *testing.T) {
|
||
messages := []map[string]any{
|
||
{"role": "user", "content": "Look at this: "},
|
||
}
|
||
got := MessagesPrepare(messages)
|
||
if strings.Contains(got, "![alt]") {
|
||
t.Fatalf("expected markdown image to be replaced, got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestMessagesPrepareNilContent(t *testing.T) {
|
||
messages := []map[string]any{
|
||
{"role": "user", "content": nil},
|
||
}
|
||
got := MessagesPrepare(messages)
|
||
if got != "null" {
|
||
t.Logf("nil content handled as: %q", got)
|
||
}
|
||
}
|
||
|
||
// ─── normalizeContent edge cases ─────────────────────────────────────
|
||
|
||
func TestNormalizeContentString(t *testing.T) {
|
||
got := normalizeContent("hello")
|
||
if got != "hello" {
|
||
t.Fatalf("expected 'hello', got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestNormalizeContentArray(t *testing.T) {
|
||
got := normalizeContent([]any{
|
||
map[string]any{"type": "text", "text": "line1"},
|
||
map[string]any{"type": "text", "text": "line2"},
|
||
})
|
||
if got != "line1\nline2" {
|
||
t.Fatalf("expected 'line1\\nline2', got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestNormalizeContentArrayWithContentField(t *testing.T) {
|
||
got := normalizeContent([]any{
|
||
map[string]any{"type": "text", "content": "from-content"},
|
||
})
|
||
if got != "from-content" {
|
||
t.Fatalf("expected 'from-content', got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestNormalizeContentArraySkipsImage(t *testing.T) {
|
||
got := normalizeContent([]any{
|
||
map[string]any{"type": "image_url", "image_url": "https://example.com/img.png"},
|
||
map[string]any{"type": "text", "text": "caption"},
|
||
})
|
||
if strings.Contains(got, "image") {
|
||
t.Fatalf("expected image skipped, got %q", got)
|
||
}
|
||
if got != "caption" {
|
||
t.Fatalf("expected 'caption', got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestNormalizeContentArrayNonMapItems(t *testing.T) {
|
||
got := normalizeContent([]any{"string item", 42})
|
||
if got != "" {
|
||
t.Fatalf("expected empty for non-map items, got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestNormalizeContentJSON(t *testing.T) {
|
||
got := normalizeContent(map[string]any{"key": "value"})
|
||
if !strings.Contains(got, `"key":"value"`) {
|
||
t.Fatalf("expected JSON serialized, got %q", got)
|
||
}
|
||
}
|
||
|
||
// ─── ConvertClaudeToDeepSeek edge cases ──────────────────────────────
|
||
|
||
func TestConvertClaudeToDeepSeekDefaultModel(t *testing.T) {
|
||
store := config.LoadStore()
|
||
req := map[string]any{
|
||
"messages": []any{map[string]any{"role": "user", "content": "Hi"}},
|
||
}
|
||
out := ConvertClaudeToDeepSeek(req, store)
|
||
if out["model"] == "" {
|
||
t.Fatal("expected default model")
|
||
}
|
||
}
|
||
|
||
func TestConvertClaudeToDeepSeekWithStopSequences(t *testing.T) {
|
||
store := config.LoadStore()
|
||
req := map[string]any{
|
||
"model": "claude-sonnet-4-5",
|
||
"messages": []any{map[string]any{"role": "user", "content": "Hi"}},
|
||
"stop_sequences": []any{"\n\n"},
|
||
}
|
||
out := ConvertClaudeToDeepSeek(req, store)
|
||
if out["stop"] == nil {
|
||
t.Fatal("expected stop field from stop_sequences")
|
||
}
|
||
}
|
||
|
||
func TestConvertClaudeToDeepSeekWithTemperature(t *testing.T) {
|
||
store := config.LoadStore()
|
||
req := map[string]any{
|
||
"model": "claude-sonnet-4-5",
|
||
"messages": []any{map[string]any{"role": "user", "content": "Hi"}},
|
||
"temperature": 0.7,
|
||
"top_p": 0.9,
|
||
}
|
||
out := ConvertClaudeToDeepSeek(req, store)
|
||
if out["temperature"] != 0.7 {
|
||
t.Fatalf("expected temperature 0.7, got %v", out["temperature"])
|
||
}
|
||
if out["top_p"] != 0.9 {
|
||
t.Fatalf("expected top_p 0.9, got %v", out["top_p"])
|
||
}
|
||
}
|
||
|
||
func TestConvertClaudeToDeepSeekNoSystem(t *testing.T) {
|
||
store := config.LoadStore()
|
||
req := map[string]any{
|
||
"model": "claude-sonnet-4-5",
|
||
"messages": []any{map[string]any{"role": "user", "content": "Hi"}},
|
||
}
|
||
out := ConvertClaudeToDeepSeek(req, store)
|
||
msgs, _ := out["messages"].([]any)
|
||
if len(msgs) != 1 {
|
||
t.Fatalf("expected 1 message without system, got %d", len(msgs))
|
||
}
|
||
}
|
||
|
||
func TestConvertClaudeToDeepSeekOpusUsesGlobalAlias(t *testing.T) {
|
||
store := config.LoadStore()
|
||
req := map[string]any{
|
||
"model": "claude-opus-4-6",
|
||
"messages": []any{map[string]any{"role": "user", "content": "Hi"}},
|
||
}
|
||
out := ConvertClaudeToDeepSeek(req, store)
|
||
if out["model"] != "deepseek-v4-pro" {
|
||
t.Fatalf("expected opus to use global alias, got %q", out["model"])
|
||
}
|
||
}
|
||
|
||
func TestConvertClaudeToDeepSeekUsesExplicitModelAlias(t *testing.T) {
|
||
t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"model_aliases":{"claude-sonnet-4-6":"deepseek-v4-pro-search"}}`)
|
||
store := config.LoadStore()
|
||
req := map[string]any{
|
||
"model": "claude-sonnet-4-6",
|
||
"messages": []any{map[string]any{"role": "user", "content": "Hi"}},
|
||
}
|
||
out := ConvertClaudeToDeepSeek(req, store)
|
||
if out["model"] != "deepseek-v4-pro-search" {
|
||
t.Fatalf("expected explicit alias override, got %q", out["model"])
|
||
}
|
||
}
|
||
|
||
func TestConvertClaudeToDeepSeekUsesExplicitNoThinkingModelAlias(t *testing.T) {
|
||
t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"model_aliases":{"claude-sonnet-4-6":"deepseek-v4-pro-search"}}`)
|
||
store := config.LoadStore()
|
||
req := map[string]any{
|
||
"model": "claude-sonnet-4-6-nothinking",
|
||
"messages": []any{map[string]any{"role": "user", "content": "Hi"}},
|
||
}
|
||
out := ConvertClaudeToDeepSeek(req, store)
|
||
if out["model"] != "deepseek-v4-pro-search-nothinking" {
|
||
t.Fatalf("expected explicit alias override with nothinking suffix, got %q", out["model"])
|
||
}
|
||
}
|