package rules import ( "strings" "testing" ) func TestConvertRules_BasicMihomo(t *testing.T) { content := `DOMAIN,example.com,PROXY DOMAIN-SUFFIX,google.com,DIRECT DOMAIN-KEYWORD,facebook,PROXY IP-CIDR,10.0.0.0/8,DIRECT,no-resolve GEOIP,CN,DIRECT MATCH,PROXY` result := ConvertRules(content, TargetMihomo) if result.Parsed != 6 { t.Errorf("expected 6 parsed, got %d", result.Parsed) } if result.Emitted != 6 { t.Errorf("expected 6 emitted, got %d", result.Emitted) } if !strings.Contains(result.Content, "DOMAIN,example.com,PROXY") { t.Errorf("missing DOMAIN rule: %s", result.Content) } if !strings.Contains(result.Content, "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve") { t.Errorf("missing IP-CIDR with option: %s", result.Content) } if !strings.Contains(result.Content, "MATCH,PROXY") { t.Errorf("missing MATCH rule: %s", result.Content) } } func TestConvertRules_SurgeTarget(t *testing.T) { content := "DOMAIN-SUFFIX,google.com,PROXY" result := ConvertRules(content, TargetSurge) if !strings.Contains(result.Content, "DOMAIN-SUFFIX,google.com,PROXY") { t.Errorf("expected DOMAIN-SUFFIX for surge: %s", result.Content) } } func TestConvertRules_QXTarget(t *testing.T) { content := `DOMAIN,example.com,PROXY DOMAIN-SUFFIX,google.com,DIRECT DOMAIN-KEYWORD,facebook,PROXY IP-CIDR,10.0.0.0/8,DIRECT,no-resolve IP-CIDR6,2001:db8::/32,DIRECT PROCESS-NAME,chrome,DIRECT DST-PORT,443,PROXY MATCH,PROXY` result := ConvertRules(content, TargetQX) // QX uses HOST/HOST-SUFFIX/HOST-KEYWORD aliases if !strings.Contains(result.Content, "HOST,example.com,PROXY") { t.Errorf("expected HOST for QX: %s", result.Content) } if !strings.Contains(result.Content, "HOST-SUFFIX,google.com,DIRECT") { t.Errorf("expected HOST-SUFFIX for QX: %s", result.Content) } if !strings.Contains(result.Content, "HOST-KEYWORD,facebook,PROXY") { t.Errorf("expected HOST-KEYWORD for QX: %s", result.Content) } if !strings.Contains(result.Content, "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve") { t.Errorf("expected IP-CIDR for QX: %s", result.Content) } if !strings.Contains(result.Content, "IP6-CIDR,2001:db8::/32,DIRECT") { t.Errorf("expected IP6-CIDR for QX: %s", result.Content) } if !strings.Contains(result.Content, "PROCESS-NAME,chrome,DIRECT") { t.Errorf("expected PROCESS-NAME for QX: %s", result.Content) } if !strings.Contains(result.Content, "DEST-PORT,443,PROXY") { t.Errorf("expected DEST-PORT for QX: %s", result.Content) } if !strings.Contains(result.Content, "FINAL,PROXY") { t.Errorf("expected FINAL for QX: %s", result.Content) } } func TestConvertRules_LoonTarget(t *testing.T) { content := "DOMAIN,example.com,PROXY" result := ConvertRules(content, TargetLoon) if !strings.Contains(result.Content, "DOMAIN,example.com,PROXY") { t.Errorf("expected DOMAIN for loon: %s", result.Content) } } func TestConvertRules_YamlPayloadFormat(t *testing.T) { content := `payload: - DOMAIN,example.com,PROXY - DOMAIN-SUFFIX,google.com,DIRECT - MATCH,PROXY` result := ConvertRules(content, TargetMihomo) if result.Parsed != 3 { t.Errorf("expected 3 parsed, got %d", result.Parsed) } if !strings.Contains(result.Content, "DOMAIN,example.com,PROXY") { t.Errorf("missing DOMAIN rule from yaml: %s", result.Content) } } func TestConvertRules_RulesYamlFormat(t *testing.T) { content := `rules: - DOMAIN,example.com,PROXY - DOMAIN-SUFFIX,google.com,DIRECT` result := ConvertRules(content, TargetMihomo) if result.Parsed != 2 { t.Errorf("expected 2 parsed, got %d", result.Parsed) } } func TestConvertRules_EmptyContent(t *testing.T) { result := ConvertRules("", TargetMihomo) if result.Parsed != 0 { t.Errorf("expected 0 parsed, got %d", result.Parsed) } if result.Content != "" { t.Errorf("expected empty content, got %s", result.Content) } } func TestConvertRules_CommentsAndEmptyLines(t *testing.T) { content := `# This is a comment ; semicolon comment DOMAIN,example.com,PROXY # another comment MATCH,PROXY` result := ConvertRules(content, TargetMihomo) if result.Parsed != 2 { t.Errorf("expected 2 parsed, got %d", result.Parsed) } } func TestConvertRules_QuotedLines(t *testing.T) { content := `"DOMAIN,example.com,PROXY" 'DOMAIN-SUFFIX,google.com,DIRECT'` result := ConvertRules(content, TargetMihomo) if result.Parsed != 2 { t.Errorf("expected 2 parsed, got %d", result.Parsed) } } func TestConvertRules_UnknownKind(t *testing.T) { content := "UNKNOWN-RULE,example.com,PROXY" result := ConvertRules(content, TargetMihomo) if result.Parsed != 0 { t.Errorf("expected 0 parsed for unknown kind, got %d", result.Parsed) } } func TestConvertRules_KindAliases(t *testing.T) { tests := []struct { input string expected string }{ {"HOST,example.com,PROXY", "DOMAIN,example.com,PROXY"}, {"HOST-SUFFIX,google.com,DIRECT", "DOMAIN-SUFFIX,google.com,DIRECT"}, {"HOST-KEYWORD,facebook,PROXY", "DOMAIN-KEYWORD,facebook,PROXY"}, {"IPCIDR,10.0.0.0/8,DIRECT", "IP-CIDR,10.0.0.0/8,DIRECT"}, {"IP6CIDR,2001:db8::/32,DIRECT", "IP-CIDR6,2001:db8::/32,DIRECT"}, {"PROCESS,chrome,DIRECT", "PROCESS-NAME,chrome,DIRECT"}, {"DEST-PORT,443,PROXY", "DST-PORT,443,PROXY"}, {"FINAL,PROXY", "MATCH,PROXY"}, } for _, tc := range tests { result := ConvertRules(tc.input, TargetMihomo) if !strings.Contains(result.Content, tc.expected) { t.Errorf("for input %q, expected %q in output, got %q", tc.input, tc.expected, result.Content) } } } func TestConvertRules_MatchWithPolicy(t *testing.T) { result := ConvertRules("MATCH,PROXY", TargetMihomo) if !strings.Contains(result.Content, "MATCH,PROXY") { t.Errorf("expected MATCH,PROXY: %s", result.Content) } } func TestConvertRules_MatchWithPolicyAndOptions(t *testing.T) { result := ConvertRules("MATCH,PROXY,no-resolve", TargetMihomo) if !strings.Contains(result.Content, "MATCH,PROXY,no-resolve") { t.Errorf("expected MATCH with options: %s", result.Content) } } func TestConvertRules_QXMatchWithPolicy(t *testing.T) { result := ConvertRules("MATCH,PROXY", TargetQX) if !strings.Contains(result.Content, "FINAL,PROXY") { t.Errorf("expected FINAL,PROXY for QX: %s", result.Content) } } func TestConvertRules_RuleWithNoValue(t *testing.T) { // DOMAIN with no value -> should be skipped (parseRuleLine returns nil) result := ConvertRules("DOMAIN,", TargetMihomo) if result.Parsed != 0 { t.Errorf("expected 0 parsed for DOMAIN with no value, got %d", result.Parsed) } } func TestConvertRules_MatchWithOptions(t *testing.T) { content := "MATCH,PROXY,extended-matching" result := ConvertRules(content, TargetMihomo) if !strings.Contains(result.Content, "MATCH,PROXY,extended-matching") { t.Errorf("expected MATCH with options: %s", result.Content) } } func TestConvertRules_RuleWithOptions(t *testing.T) { content := "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve,extended-matching" result := ConvertRules(content, TargetMihomo) // no-resolve and extended-matching are options; DIRECT is the policy if !strings.Contains(result.Content, "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve,extended-matching") { t.Errorf("expected options preserved: %s", result.Content) } } func TestConvertRules_QXFiltersNonNoResolveOptions(t *testing.T) { content := "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve,extended-matching" result := ConvertRules(content, TargetQX) // QX only keeps no-resolve, drops extended-matching if !strings.Contains(result.Content, "IP-CIDR,10.0.0.0/8,DIRECT,no-resolve") { t.Errorf("expected no-resolve kept for QX: %s", result.Content) } if strings.Contains(result.Content, "extended-matching") { t.Errorf("expected extended-matching dropped for QX: %s", result.Content) } } func TestConvertRules_NoPolicy(t *testing.T) { content := "DOMAIN,example.com" result := ConvertRules(content, TargetMihomo) if result.Parsed != 1 { t.Errorf("expected 1 parsed, got %d", result.Parsed) } if !strings.Contains(result.Content, "DOMAIN,example.com") { t.Errorf("expected DOMAIN,example.com: %s", result.Content) } } func TestParseRuleLine_Empty(t *testing.T) { if parseRuleLine("") != nil { t.Error("expected nil for empty line") } } func TestSplitCsv_Simple(t *testing.T) { parts := splitCsv("a,b,c") if len(parts) != 3 || parts[0] != "a" || parts[1] != "b" || parts[2] != "c" { t.Errorf("expected [a b c], got %v", parts) } } func TestSplitCsv_WithQuotes(t *testing.T) { parts := splitCsv(`a,"b,c",d`) if len(parts) != 3 || parts[0] != "a" || parts[1] != "b,c" || parts[2] != "d" { t.Errorf("expected [a b,c d], got %v", parts) } } func TestSplitCsv_SingleQuotes(t *testing.T) { parts := splitCsv(`a,'b,c',d`) if len(parts) != 3 || parts[0] != "a" || parts[1] != "b,c" || parts[2] != "d" { t.Errorf("expected [a b,c d], got %v", parts) } } func TestSplitCsv_EmptyFields(t *testing.T) { parts := splitCsv("a,,c") // Empty middle field is trimmed to "" and should be included if len(parts) != 3 { t.Errorf("expected 3 parts, got %d: %v", len(parts), parts) } } func TestSplitCsv_TrailingEmpty(t *testing.T) { parts := splitCsv("a,b,") // Trailing empty should not be included if len(parts) != 2 { t.Errorf("expected 2 parts, got %d: %v", len(parts), parts) } } func TestQxKind_KnownAndUnknown(t *testing.T) { if qxKind("DOMAIN") != "HOST" { t.Error("expected HOST for DOMAIN") } if qxKind("DOMAIN-SUFFIX") != "HOST-SUFFIX" { t.Error("expected HOST-SUFFIX") } if qxKind("IP-CIDR6") != "IP6-CIDR" { t.Error("expected IP6-CIDR") } // Unknown kind returns itself if qxKind("UNKNOWN") != "UNKNOWN" { t.Error("expected UNKNOWN to pass through") } } func TestExtractRuleLines_OnlyComments(t *testing.T) { lines := extractRuleLines("# comment\n; another\n") if len(lines) != 0 { t.Errorf("expected 0 lines, got %d", len(lines)) } } func TestExtractRuleLines_YamlWithComments(t *testing.T) { content := `payload: - DOMAIN,example.com,PROXY # comment - MATCH,DIRECT` lines := extractRuleLines(content) if len(lines) != 2 { t.Errorf("expected 2 lines, got %d: %v", len(lines), lines) } } func TestConvertRules_GeoSite(t *testing.T) { content := "GEOSITE,category-ads,DIRECT" result := ConvertRules(content, TargetMihomo) if !strings.Contains(result.Content, "GEOSITE,category-ads,DIRECT") { t.Errorf("expected GEOSITE rule: %s", result.Content) } }