package html import ( "encoding/json" "math/rand" "reflect" "testing" "unicode/utf8" "github.com/bnema/vev-vt/core" "github.com/stretchr/testify/require" ) func TestAppendJSONStringMatchesEncodingJSON(t *testing.T) { cases := []string{ "", " ", "A", `"`, `\`, "<", ">", "&", "\x00", "\x1f", "\x7f", "\b\f\n\r\t", "\u2028", "\u2029", "界", "😀", "e\u0301", "a&c\"d\\e\u2028f", } for r := rune(0); r < 0x300; r++ { cases = append(cases, string(r)) } for _, text := range cases { want, err := json.Marshal(text) require.NoError(t, err) require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text) } } // Invalid UTF-8 never reaches the encoder (cells are validated). Should it, // the output must stay valid JSON decoding to the same text encoding/json // would produce, whatever escaping style the toolchain's encoder uses. func TestAppendJSONStringInvalidUTF8StaysValidJSON(t *testing.T) { for _, text := range []string{"\xff", "a\xc3", "\xed\xa0\x80", "\xe2\x80", "ok\xf0\x9f"} { want, err := json.Marshal(text) require.NoError(t, err) var wantText, gotText string require.NoError(t, json.Unmarshal(want, &wantText)) require.NoError(t, json.Unmarshal(appendJSONString(nil, text), &gotText), "%q", text) require.Equal(t, wantText, gotText, "%q", text) } } func FuzzAppendJSONString(f *testing.F) { for _, seed := range []string{"", "a&c", "\u2028\u2029", "界😀", "\x00\x1f\x7f"} { f.Add(seed) } f.Fuzz(func(t *testing.T, text string) { if !utf8.ValidString(text) { t.Skip("cell text is always valid UTF-8") } want, err := json.Marshal(text) require.NoError(t, err) require.Equal(t, string(want), string(appendJSONString(nil, text))) }) } func randomColor(rng *rand.Rand) Color { switch rng.Intn(3) { case 0: return Color{Kind: ColorDefault} case 1: return Color{Kind: ColorIndexed, Index: uint8(rng.Intn(256))} } return Color{Kind: ColorRGB, RGB: RGB{R: uint8(rng.Intn(256)), G: uint8(rng.Intn(256)), B: uint8(rng.Intn(256))}} } func TestAppendUpdateJSONMatchesEncodingJSON(t *testing.T) { rng := rand.New(rand.NewSource(1)) texts := []string{" ", "A", "<", "&", `"`, `\`, "界", "😀", "\x01", "\u2028"} for range 300 { update := Update{ SchemaVersion: UpdateSchemaVersion, Width: rng.Intn(500), Height: rng.Intn(500), Snapshot: rng.Intn(2) == 0, Cursor: Cursor{ Row: rng.Intn(100), Column: rng.Intn(100), Visible: rng.Intn(2) == 0, Style: CursorStyle(rng.Intn(7)), StyleSet: rng.Intn(2) == 0, }, } if rng.Intn(5) != 0 { update.Rows = make([]RowUpdate, rng.Intn(4)) for i := range update.Rows { update.Rows[i].Row = rng.Intn(1000) if rng.Intn(5) != 0 { update.Rows[i].Cells = make([]CellUpdate, rng.Intn(5)) for j := range update.Rows[i].Cells { update.Rows[i].Cells[j] = CellUpdate{Column: rng.Intn(300), Width: 1 + rng.Intn(2), Text: texts[rng.Intn(len(texts))], Style: rng.Intn(10)} } } } } if rng.Intn(5) != 0 { update.Styles = make([]Style, rng.Intn(4)) for i := range update.Styles { update.Styles[i] = Style{ Bold: rng.Intn(2) == 0, Italic: rng.Intn(2) == 0, Inverse: rng.Intn(2) == 0, Dim: rng.Intn(2) == 0, Blink: rng.Intn(2) == 0, Strikethrough: rng.Intn(2) == 0, Underline: rng.Intn(2) == 0, UnderlineStyle: core.UnderlineStyle(rng.Intn(6)), Foreground: randomColor(rng), Background: randomColor(rng), UnderlineColor: randomColor(rng), } } } want, err := json.Marshal(update) require.NoError(t, err) got, err := appendUpdateJSON(nil, update) require.NoError(t, err) require.Equal(t, string(want), string(got)) } } func TestAppendUpdateJSONRejectsInvalidColorKind(t *testing.T) { update := Update{Styles: []Style{{Foreground: Color{Kind: 9}}}} _, err := appendUpdateJSON(nil, update) require.ErrorContains(t, err, "invalid color kind 9") } func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) { frame := core.NewFrame(6, 2) style := core.DefaultStyle() style.Bold = true style.HasForegroundRGB, style.ForegroundRGB = true, core.RGB{R: 1, G: 2, B: 3} style.Background = 200 frame.Set(0, 0, core.Cell{Rune: '<', Style: style}) frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()}) frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) frame.Set(3, 0, core.Cell{Rune: '😀', Style: core.DefaultStyle()}) frame.Set(4, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) frame.Set(5, 0, core.Cell{Rune: '&', Style: core.DefaultStyle()}) frame.Set(0, 1, core.Cell{Rune: '"', Style: style}) renderer, err := New(Options{}) require.NoError(t, err) prepared, err := renderer.Prepare(frame, nil, true, Cursor{Column: 2, Visible: true, Style: 3, StyleSet: true}) require.NoError(t, err) want, err := json.Marshal(prepared.tx.update) require.NoError(t, err) require.Equal(t, string(want), string(prepared.JSON())) } // fillAll sets every field reachable from v to a distinctive non-zero value // (negative for signed integers) so a field added to the update types without // a matching encoder change shows up as a JSON mismatch. func fillAll(t *testing.T, v reflect.Value, colorKind ColorKind, counter *int) { t.Helper() *counter++ n := *counter if v.Type() == reflect.TypeOf(Color{}) { v.FieldByName("Kind").SetUint(uint64(colorKind)) v.FieldByName("Index").SetUint(uint64(100 + n%100)) fillAll(t, v.FieldByName("RGB"), colorKind, counter) return } switch v.Kind() { case reflect.Bool: v.SetBool(true) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: v.SetInt(-int64(n) - 1) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: v.SetUint(uint64(1 + n%120)) case reflect.String: v.SetString("t<&>\"\\界\u2028" + string(rune('a'+n%26))) case reflect.Slice: v.Set(reflect.MakeSlice(v.Type(), 2, 2)) for i := range 2 { fillAll(t, v.Index(i), colorKind, counter) } case reflect.Struct: for i := range v.NumField() { require.True(t, v.Field(i).CanSet(), "%s.%s must be settable", v.Type(), v.Type().Field(i).Name) fillAll(t, v.Field(i), colorKind, counter) } default: t.Fatalf("fillAll: unsupported kind %s in %s", v.Kind(), v.Type()) } } func requireNoZeroFields(t *testing.T, v reflect.Value) { t.Helper() switch v.Kind() { case reflect.Struct: for i := range v.NumField() { requireNoZeroFields(t, v.Field(i)) } case reflect.Slice: require.NotZero(t, v.Len()) for i := range v.Len() { requireNoZeroFields(t, v.Index(i)) } default: require.False(t, v.IsZero(), "zero %s left in filled value", v.Type()) } } // TestEncoderCoversEveryUpdateField fails when a field is added to (or removed // from) the update types without updating appendUpdateJSON and this test. func TestEncoderCoversEveryUpdateField(t *testing.T) { for typ, fields := range map[reflect.Type]int{ reflect.TypeOf(Update{}): 7, reflect.TypeOf(RowUpdate{}): 2, reflect.TypeOf(CellUpdate{}): 4, reflect.TypeOf(Style{}): 11, reflect.TypeOf(Cursor{}): 5, reflect.TypeOf(RGB{}): 3, reflect.TypeOf(Color{}): 3, } { require.Equal(t, fields, typ.NumField(), "%s field count changed: update appendUpdateJSON", typ) } for _, kind := range []ColorKind{ColorDefault, ColorIndexed, ColorRGB} { var update Update counter := 0 fillAll(t, reflect.ValueOf(&update).Elem(), kind, &counter) update.Cursor.Style = 6 // keep within the documented DECSCUSR range if kind != ColorDefault { requireNoZeroFields(t, reflect.ValueOf(update)) } want, err := json.Marshal(update) require.NoError(t, err) got, err := appendUpdateJSON(nil, update) require.NoError(t, err) require.Equal(t, string(want), string(got), "color kind %d", kind) } }