diff --git a/rfx.go b/rfx.go index 96b9551..0bf4a5f 100644 --- a/rfx.go +++ b/rfx.go @@ -651,13 +651,42 @@ func (v *rfx) Keys() []string { if !v.valid() { return nil } - if v.td.Kind == reflect.Struct { - out := make([]string, len(v.td.fields)) - for i := range v.td.fields { - out[i] = v.td.fields[i].Name + cur, ok := (*v).normalize() + if !ok { + return nil + } + + switch cur.td.Kind { + case reflect.Struct: + out := make([]string, len(cur.td.fields)) + for i := range cur.td.fields { + out[i] = cur.td.fields[i].Name + } + return out + + case reflect.Map: + // 不必判 IsNil: MapKeys() 对 nil map 返回空切片而不是 panic, + // 于是这里得到 []string{} —— 与旧实现一致。提前返回 nil 会让 + // DeepEqual 和 JSON 序列化(`[]` vs `null`)出现差异。 + m := valueAt(cur.td, cur.ptr) + keys := m.MapKeys() + out := make([]string, len(keys)) + // 字符串键直接取, 不必绕 fmt.Sprint —— 后者要先把 key 装箱成 + // interface 再走一遍格式化, 实测 3 个键就差一倍。 + // 非字符串键仍旧交给 fmt.Sprint: k.String() 对非字符串 Value 返回的是 + // "" 这种占位串, 不是键本身。 + if m.Type().Key().Kind() == reflect.String { + for i, k := range keys { + out[i] = k.String() + } + } else { + for i, k := range keys { + out[i] = fmt.Sprint(k.Interface()) + } } return out } + return v.slow().Keys() } diff --git a/rfx_keys_test.go b/rfx_keys_test.go new file mode 100644 index 0000000..631d24e --- /dev/null +++ b/rfx_keys_test.go @@ -0,0 +1,87 @@ +package reflux + +import ( + "reflect" + "sort" + "testing" +) + +// Keys() 的 map 分支现在按键类型分支: 字符串键直接取, 其余仍走 fmt.Sprint。 +// 这一组守的是"分支之后行为不变" —— 尤其是非字符串键不能退化成 +// k.String() 的 "" 占位串。 + +type keyStr string + +func TestKeysMatchesLegacy(t *testing.T) { + type inner struct{ A, B string } + + cases := []struct { + name string + val func() any + }{ + {"string 键 map", func() any { return &map[string]int{"b": 2, "a": 1, "c": 3} }}, + {"具名 string 键 map", func() any { return &map[keyStr]int{"b": 2, "a": 1} }}, + {"int 键 map", func() any { return &map[int]string{2: "b", 1: "a", 10: "j"} }}, + {"int64 键 map", func() any { return &map[int64]string{7: "x"} }}, + {"float 键 map", func() any { return &map[float64]string{1.5: "x"} }}, + {"bool 键 map", func() any { return &map[bool]string{true: "t", false: "f"} }}, + {"空 map", func() any { return &map[string]int{} }}, + {"nil map", func() any { var m map[string]int; return &m }}, + {"struct", func() any { return &inner{} }}, + {"切片(两边都不支持)", func() any { return &[]string{"a"} }}, + {"map 里的 map", func() any { return &map[string]any{"sub": map[string]any{"x": 1}} }}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + want := newLegacy(c.val()).Keys() + got := New(c.val()).Keys() + // map 迭代顺序随机, 比较前排序 + sort.Strings(want) + sort.Strings(got) + if !reflect.DeepEqual(want, got) { + t.Fatalf("Keys() 不一致\n 旧 = %#v\n 新 = %#v", want, got) + } + }) + } +} + +// 经路径取到的 map / interface 包着的 map 也要正确 +func TestKeysThroughPath(t *testing.T) { + doc := &struct { + Meta map[string]string + Any any + Ptr *map[string]string + }{ + Meta: map[string]string{"k1": "v", "k2": "v"}, + Any: map[string]string{"a1": "v"}, + } + m := map[string]string{"p1": "v"} + doc.Ptr = &m + + r, l := New(doc), newLegacy(doc) + for _, p := range []string{"Meta", "Any", "Ptr"} { + t.Run(p, func(t *testing.T) { + want, got := l.Get(p).Keys(), r.Get(p).Keys() + sort.Strings(want) + sort.Strings(got) + if !reflect.DeepEqual(want, got) { + t.Fatalf("Get(%q).Keys() 不一致\n 旧 = %#v\n 新 = %#v", p, want, got) + } + if len(got) == 0 { + t.Fatalf("Get(%q).Keys() 不该为空", p) + } + }) + } +} + +// 非字符串键必须是键本身的值, 不能是 "" 这种占位串 +func TestKeysNonStringKeysAreReal(t *testing.T) { + got := New(&map[int]string{42: "x"}).Keys() + if len(got) != 1 || got[0] != "42" { + t.Fatalf("int 键的 Keys() = %#v, 期望 [\"42\"]", got) + } + if got := New(&map[bool]string{true: "x"}).Keys(); len(got) != 1 || got[0] != "true" { + t.Fatalf("bool 键的 Keys() = %#v, 期望 [\"true\"]", got) + } +}