feat: structured screens for SIGN_MODE_TEXTUAL (#13434)

## Description

Refs: #11970

Changes target of `SIGN_MODE_TEXTUAL` rendering to be a structured datatype
instead of lines of ASCII text. This avoids the complexities of in-band, signaling and
allows more capable signing devices not to be hindered by the limitations of those less
capable.

---

### Author Checklist

*All items are required. Please add a note to the item if the item is not applicable and
please add links to any relevant follow up issues.*

I have...

- [X] included the correct [type prefix](https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json) in the PR title
- [X] added `!` to the type prefix if API or client breaking change
- [X] targeted the correct branch (see [PR Targeting](https://github.com/cosmos/cosmos-sdk/blob/main/CONTRIBUTING.md#pr-targeting))
- [X] provided a link to the relevant issue or specification
- [X] followed the guidelines for [building modules](https://github.com/cosmos/cosmos-sdk/blob/main/docs/building-modules)
- [X] included the necessary unit and integration [tests](https://github.com/cosmos/cosmos-sdk/blob/main/CONTRIBUTING.md#testing)
- [ ] added a changelog entry to `CHANGELOG.md`
- [X] included comments for [documenting Go code](https://blog.golang.org/godoc)
- [X] updated the relevant documentation or specification
- [X] reviewed "Files changed" and left comments if necessary
- [x] confirmed all CI checks have passed
NOTE: changelog intentionally omitted - we'll add an entry when
#11970 is complete.

### Reviewers Checklist

*All items are required. Please add a note if the item is not applicable and please add
your handle next to the items reviewed if you only reviewed selected items.*

I have...

- [ ] confirmed the correct [type prefix](https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json) in the PR title
- [ ] confirmed `!` in the type prefix if API or client breaking change
- [ ] confirmed all author checklist items have been addressed 
- [ ] reviewed state machine logic
- [ ] reviewed API design and naming
- [ ] reviewed documentation is accurate
- [ ] reviewed tests and test coverage
- [ ] manually tested (if applicable)
This commit is contained in:
Jim Larson
2022-10-15 08:04:36 +00:00
committed by GitHub
parent ebe0f5c6ee
commit d9f3eb027d
16 changed files with 331 additions and 218 deletions
+3 -9
View File
@@ -23,17 +23,15 @@ var intValues = []protoreflect.Value{
func BenchmarkIntValueRendererFormat(b *testing.B) {
ctx := context.Background()
ivr := new(intValueRenderer)
buf := new(bytes.Buffer)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for _, value := range intValues {
if err := ivr.Format(ctx, value, buf); err != nil {
if _, err := ivr.Format(ctx, value); err != nil {
b.Fatal(err)
}
}
buf.Reset()
}
}
@@ -52,17 +50,15 @@ var decimalValues = []protoreflect.Value{
func BenchmarkDecimalValueRendererFormat(b *testing.B) {
ctx := context.Background()
dvr := new(decValueRenderer)
buf := new(bytes.Buffer)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for _, value := range intValues {
if err := dvr.Format(ctx, value, buf); err != nil {
if _, err := dvr.Format(ctx, value); err != nil {
b.Fatal(err)
}
}
buf.Reset()
}
}
@@ -81,16 +77,14 @@ var byteValues = []protoreflect.Value{
func BenchmarkBytesValueRendererFormat(b *testing.B) {
ctx := context.Background()
bvr := new(bytesValueRenderer)
buf := new(bytes.Buffer)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for _, value := range byteValues {
if err := bvr.Format(ctx, value, buf); err != nil {
if _, err := bvr.Format(ctx, value); err != nil {
b.Fatal(err)
}
}
buf.Reset()
}
}
+8 -8
View File
@@ -3,7 +3,7 @@ package valuerenderer
import (
"context"
"encoding/hex"
"io"
"fmt"
"strings"
"google.golang.org/protobuf/reflect/protoreflect"
@@ -19,16 +19,16 @@ type bytesValueRenderer struct{}
var _ ValueRenderer = bytesValueRenderer{}
func (vr bytesValueRenderer) Format(ctx context.Context, v protoreflect.Value, w io.Writer) error {
_, err := io.WriteString(w, strings.ToUpper(hex.EncodeToString(v.Bytes())))
return err
func (vr bytesValueRenderer) Format(ctx context.Context, v protoreflect.Value) ([]Screen, error) {
text := strings.ToUpper(hex.EncodeToString(v.Bytes()))
return []Screen{{Text: text}}, nil
}
func (vr bytesValueRenderer) Parse(_ context.Context, r io.Reader) (protoreflect.Value, error) {
formatted, err := io.ReadAll(r)
if err != nil {
return protoreflect.ValueOfBytes([]byte{}), err
func (vr bytesValueRenderer) Parse(_ context.Context, screens []Screen) (protoreflect.Value, error) {
if len(screens) != 1 {
return protoreflect.ValueOfBytes([]byte{}), fmt.Errorf("expected single screen: %v", screens)
}
formatted := screens[0].Text
data, err := hex.DecodeString(string(formatted))
if err != nil {
+4 -6
View File
@@ -5,7 +5,6 @@ import (
"encoding/base64"
"encoding/json"
"os"
"strings"
"testing"
"cosmossdk.io/tx/textual/valuerenderer"
@@ -31,14 +30,13 @@ func TestBytesJsonTestCases(t *testing.T) {
valrend, err := textual.GetValueRenderer(fieldDescriptorFromName("BYTES"))
require.NoError(t, err)
b := new(strings.Builder)
err = valrend.Format(context.Background(), protoreflect.ValueOfBytes(data), b)
screens, err := valrend.Format(context.Background(), protoreflect.ValueOfBytes(data))
require.NoError(t, err)
require.Equal(t, tc.hex, b.String())
require.Equal(t, 1, len(screens))
require.Equal(t, tc.hex, screens[0].Text)
// Round trip
r := strings.NewReader(tc.hex)
val, err := valrend.Parse(context.Background(), r)
val, err := valrend.Parse(context.Background(), screens)
require.NoError(t, err)
require.Equal(t, tc.base64, base64.StdEncoding.EncodeToString(val.Bytes()))
}
+6 -9
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"github.com/stretchr/testify/require"
@@ -33,13 +32,11 @@ func mockCoinMetadataQuerier(ctx context.Context, denom string) (*bankv1beta1.Me
}
func TestMetadataQuerier(t *testing.T) {
b := new(strings.Builder)
// Errors on nil metadata querier
textual := valuerenderer.NewTextual(nil)
vr, err := textual.GetValueRenderer(fieldDescriptorFromName("COIN"))
require.NoError(t, err)
err = vr.Format(context.Background(), protoreflect.ValueOf((&basev1beta1.Coin{}).ProtoReflect()), b)
_, err = vr.Format(context.Background(), protoreflect.ValueOf((&basev1beta1.Coin{}).ProtoReflect()))
require.Error(t, err)
// Errors if metadata querier returns an error
@@ -49,9 +46,9 @@ func TestMetadataQuerier(t *testing.T) {
})
vr, err = textual.GetValueRenderer(fieldDescriptorFromName("COIN"))
require.NoError(t, err)
err = vr.Format(context.Background(), protoreflect.ValueOf((&basev1beta1.Coin{}).ProtoReflect()), b)
_, err = vr.Format(context.Background(), protoreflect.ValueOf((&basev1beta1.Coin{}).ProtoReflect()))
require.ErrorIs(t, err, expErr)
err = vr.Format(context.Background(), protoreflect.ValueOf(NewGenericList([]*basev1beta1.Coin{{}})), b)
_, err = vr.Format(context.Background(), protoreflect.ValueOf(NewGenericList([]*basev1beta1.Coin{{}})))
require.ErrorIs(t, err, expErr)
}
@@ -70,8 +67,7 @@ func TestCoinJsonTestcases(t *testing.T) {
t.Run(tc.Text, func(t *testing.T) {
if tc.Proto != nil {
ctx := context.WithValue(context.Background(), mockCoinMetadataKey(tc.Proto.Denom), tc.Metadata)
b := new(strings.Builder)
err = vr.Format(ctx, protoreflect.ValueOf(tc.Proto.ProtoReflect()), b)
screens, err := vr.Format(ctx, protoreflect.ValueOf(tc.Proto.ProtoReflect()))
if tc.Error {
require.Error(t, err)
@@ -79,7 +75,8 @@ func TestCoinJsonTestcases(t *testing.T) {
}
require.NoError(t, err)
require.Equal(t, tc.Text, b.String())
require.Equal(t, 1, len(screens))
require.Equal(t, tc.Text, screens[0].Text)
}
// TODO Add parsing tests
+10 -13
View File
@@ -3,7 +3,6 @@ package valuerenderer
import (
"context"
"fmt"
"io"
"sort"
"strings"
@@ -28,9 +27,9 @@ type coinsValueRenderer struct {
var _ ValueRenderer = coinsValueRenderer{}
func (vr coinsValueRenderer) Format(ctx context.Context, v protoreflect.Value, w io.Writer) error {
func (vr coinsValueRenderer) Format(ctx context.Context, v protoreflect.Value) ([]Screen, error) {
if vr.coinMetadataQuerier == nil {
return fmt.Errorf("expected non-nil coin metadata querier")
return nil, fmt.Errorf("expected non-nil coin metadata querier")
}
// Check whether we have a Coin or some Coins.
@@ -45,17 +44,16 @@ func (vr coinsValueRenderer) Format(ctx context.Context, v protoreflect.Value, w
coins[i] = coin
metadatas[i], err = vr.coinMetadataQuerier(ctx, coin.Denom)
if err != nil {
return err
return nil, err
}
}
formatted, err := formatCoins(coins, metadatas)
if err != nil {
return err
return nil, err
}
_, err = w.Write([]byte(formatted))
return err
return []Screen{{Text: formatted}}, nil
}
// If it's a single Coin:
case protoreflect.Message:
@@ -64,23 +62,22 @@ func (vr coinsValueRenderer) Format(ctx context.Context, v protoreflect.Value, w
metadata, err := vr.coinMetadataQuerier(ctx, coin.Denom)
if err != nil {
return err
return nil, err
}
formatted, err := formatCoin(coin, metadata)
if err != nil {
return err
return nil, err
}
_, err = w.Write([]byte(formatted))
return err
return []Screen{{Text: formatted}}, nil
}
default:
return fmt.Errorf("got invalid type %t for coins", v.Interface())
return nil, fmt.Errorf("got invalid type %t for coins", v.Interface())
}
}
func (vr coinsValueRenderer) Parse(_ context.Context, r io.Reader) (protoreflect.Value, error) {
func (vr coinsValueRenderer) Parse(_ context.Context, screens []Screen) (protoreflect.Value, error) {
// ref: https://github.com/cosmos/cosmos-sdk/issues/13153
panic("implement me, see #13153")
}
+3 -4
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"os"
"strings"
"testing"
bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1"
@@ -35,9 +34,8 @@ func TestCoinsJsonTestcases(t *testing.T) {
ctx = context.WithValue(ctx, mockCoinMetadataKey(coin.Denom), tc.Metadata[coin.Denom])
}
b := new(strings.Builder)
listValue := NewGenericList(tc.Proto)
err = vr.Format(ctx, protoreflect.ValueOf(listValue), b)
screens, err := vr.Format(ctx, protoreflect.ValueOf(listValue))
if tc.Error {
require.Error(t, err)
@@ -45,7 +43,8 @@ func TestCoinsJsonTestcases(t *testing.T) {
}
require.NoError(t, err)
require.Equal(t, tc.Text, b.String())
require.Equal(t, 1, len(screens))
require.Equal(t, tc.Text, screens[0].Text)
}
// TODO Add parsing tests
+4 -7
View File
@@ -3,7 +3,6 @@ package valuerenderer
import (
"context"
"fmt"
"io"
"strings"
"google.golang.org/protobuf/reflect/protoreflect"
@@ -21,17 +20,15 @@ type decValueRenderer struct{}
var _ ValueRenderer = decValueRenderer{}
func (vr decValueRenderer) Format(_ context.Context, v protoreflect.Value, w io.Writer) error {
func (vr decValueRenderer) Format(_ context.Context, v protoreflect.Value) ([]Screen, error) {
formatted, err := formatDecimal(v.String())
if err != nil {
return err
return nil, err
}
_, err = io.WriteString(w, formatted)
return err
return []Screen{{Text: formatted}}, nil
}
func (vr decValueRenderer) Parse(_ context.Context, r io.Reader) (protoreflect.Value, error) {
func (vr decValueRenderer) Parse(_ context.Context, screens []Screen) (protoreflect.Value, error) {
panic("implement me")
}
+10 -12
View File
@@ -3,7 +3,6 @@ package valuerenderer
import (
"context"
"fmt"
"io"
"regexp"
"strconv"
"strings"
@@ -65,19 +64,19 @@ func formatSeconds(seconds int64, nanos int32) string {
}
// Format implements the ValueRenderer interface.
func (dr durationValueRenderer) Format(_ context.Context, v protoreflect.Value, w io.Writer) error {
func (dr durationValueRenderer) Format(_ context.Context, v protoreflect.Value) ([]Screen, error) {
// Reify the reflected message as a proto Duration
msg := v.Message().Interface()
duration, ok := msg.(*dpb.Duration)
if !ok {
return fmt.Errorf("expected Duration, got %T", msg)
return nil, fmt.Errorf("expected Duration, got %T", msg)
}
// Bypass use of time.Duration, as the range is more limited than that of dpb.Duration.
// (Too bad the companies that produced both technologies didn't coordinate better!)
if err := duration.CheckValid(); err != nil {
return err
return nil, err
}
negative := false
@@ -115,26 +114,25 @@ func (dr durationValueRenderer) Format(_ context.Context, v protoreflect.Value,
s = "-" + s
}
_, err := w.Write([]byte(s))
return err
return []Screen{{Text: s}}, nil
}
var durRegexp = regexp.MustCompile(`^(-)?(?:([0-9]+) days?)?(?:, )?(?:([0-9]+) hours?)?(?:, )?(?:([0-9]+) minutes?)?(?:, )?(?:([0-9]+)(?:\.([0-9]+))? seconds?)?$`)
// Parse implements the ValueRenderer interface.
func (dr durationValueRenderer) Parse(_ context.Context, r io.Reader) (protoreflect.Value, error) {
bz, err := io.ReadAll(r)
if err != nil {
return protoreflect.Value{}, err
func (dr durationValueRenderer) Parse(_ context.Context, screens []Screen) (protoreflect.Value, error) {
if len(screens) != 1 {
return protoreflect.Value{}, fmt.Errorf("expected single screen: %v", screens)
}
parts := durRegexp.FindStringSubmatch(string(bz))
parts := durRegexp.FindStringSubmatch(screens[0].Text)
if parts == nil {
return protoreflect.Value{}, fmt.Errorf("bad duration format: %s", string(bz))
return protoreflect.Value{}, fmt.Errorf("bad duration format: %s", screens[0].Text)
}
negative := parts[1] != ""
var days, hours, minutes, seconds, nanos int64
var err error
if parts[2] != "" {
days, err = strconv.ParseInt(parts[2], 10, 64)
+5 -6
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"cosmossdk.io/tx/textual/valuerenderer"
@@ -34,19 +33,19 @@ func TestDurationJSON(t *testing.T) {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
rend := valuerenderer.NewDurationValueRenderer()
var screens []valuerenderer.Screen
if tc.Proto != nil {
wr := new(strings.Builder)
err = rend.Format(context.Background(), protoreflect.ValueOf(tc.Proto.ProtoReflect()), wr)
screens, err = rend.Format(context.Background(), protoreflect.ValueOf(tc.Proto.ProtoReflect()))
if tc.Error {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.Text, wr.String())
require.Equal(t, 1, len(screens))
require.Equal(t, tc.Text, screens[0].Text)
}
rd := strings.NewReader(tc.Text)
val, err := rend.Parse(context.Background(), rd)
val, err := rend.Parse(context.Background(), screens)
if tc.Error {
require.Error(t, err)
return
+4 -7
View File
@@ -3,7 +3,6 @@ package valuerenderer
import (
"context"
"fmt"
"io"
"strings"
"google.golang.org/protobuf/reflect/protoreflect"
@@ -19,17 +18,15 @@ type intValueRenderer struct{}
var _ ValueRenderer = intValueRenderer{}
func (vr intValueRenderer) Format(_ context.Context, v protoreflect.Value, w io.Writer) error {
func (vr intValueRenderer) Format(_ context.Context, v protoreflect.Value) ([]Screen, error) {
formatted, err := formatInteger(v.String())
if err != nil {
return err
return nil, err
}
_, err = io.WriteString(w, formatted)
return err
return []Screen{{Text: formatted}}, nil
}
func (vr intValueRenderer) Parse(_ context.Context, r io.Reader) (protoreflect.Value, error) {
func (vr intValueRenderer) Parse(_ context.Context, screens []Screen) (protoreflect.Value, error) {
panic("implement me")
}
+7 -10
View File
@@ -3,7 +3,6 @@ package valuerenderer
import (
"context"
"fmt"
"io"
"time"
"google.golang.org/protobuf/reflect/protoreflect"
@@ -20,12 +19,12 @@ func NewTimestampValueRenderer() ValueRenderer {
}
// Format implements the ValueRenderer interface.
func (vr timestampValueRenderer) Format(_ context.Context, v protoreflect.Value, w io.Writer) error {
func (vr timestampValueRenderer) Format(_ context.Context, v protoreflect.Value) ([]Screen, error) {
// Reify the reflected message as a proto Timestamp
msg := v.Message().Interface()
timestamp, ok := msg.(*tspb.Timestamp)
if !ok {
return fmt.Errorf("expected Timestamp, got %T", msg)
return nil, fmt.Errorf("expected Timestamp, got %T", msg)
}
// Convert proto timestamp to a Go Time.
@@ -33,18 +32,16 @@ func (vr timestampValueRenderer) Format(_ context.Context, v protoreflect.Value,
// Format the Go Time as RFC 3339.
s := t.Format(time.RFC3339Nano)
w.Write([]byte(s))
return nil
return []Screen{{Text: s}}, nil
}
// Parse implements the ValueRenderer interface.
func (vr timestampValueRenderer) Parse(_ context.Context, r io.Reader) (protoreflect.Value, error) {
func (vr timestampValueRenderer) Parse(_ context.Context, screens []Screen) (protoreflect.Value, error) {
// Parse the RFC 3339 input as a Go Time.
bz, err := io.ReadAll(r)
if err != nil {
return protoreflect.Value{}, err
if len(screens) != 1 {
return protoreflect.Value{}, fmt.Errorf("expected single screen: %v", screens)
}
t, err := time.Parse(time.RFC3339Nano, string(bz))
t, err := time.Parse(time.RFC3339Nano, screens[0].Text)
if err != nil {
return protoreflect.Value{}, err
}
+6 -23
View File
@@ -4,9 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"testing"
"time"
@@ -45,19 +43,19 @@ func TestTimestampJsonTestcases(t *testing.T) {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
rend := valuerenderer.NewTimestampValueRenderer()
var screens []valuerenderer.Screen
if tc.Proto != nil {
wr := new(strings.Builder)
err = rend.Format(context.Background(), protoreflect.ValueOf(tc.Proto.ProtoReflect()), wr)
screens, err = rend.Format(context.Background(), protoreflect.ValueOf(tc.Proto.ProtoReflect()))
if tc.Error {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.Text, wr.String())
require.Equal(t, 1, len(screens))
require.Equal(t, tc.Text, screens[0].Text)
}
rd := strings.NewReader(tc.Text)
val, err := rend.Parse(context.Background(), rd)
val, err := rend.Parse(context.Background(), screens)
if tc.Error {
require.Error(t, err)
return
@@ -73,21 +71,6 @@ func TestTimestampJsonTestcases(t *testing.T) {
func TestTimestampBadFormat(t *testing.T) {
rend := valuerenderer.NewTimestampValueRenderer()
wr := new(strings.Builder)
err := rend.Format(context.Background(), protoreflect.ValueOf(dur.New(time.Hour).ProtoReflect()), wr)
_, err := rend.Format(context.Background(), protoreflect.ValueOf(dur.New(time.Hour).ProtoReflect()))
require.Error(t, err)
}
type badReader struct{}
var _ io.Reader = badReader{}
func (br badReader) Read(p []byte) (int, error) {
return 0, fmt.Errorf("reader error")
}
func TestTimestampBadParse_reader(t *testing.T) {
rend := valuerenderer.NewTimestampValueRenderer()
_, err := rend.Parse(context.Background(), badReader{})
require.ErrorContains(t, err, "reader error")
}
+19 -3
View File
@@ -2,11 +2,24 @@ package valuerenderer
import (
"context"
"io"
"google.golang.org/protobuf/reflect/protoreflect"
)
// Screen is the abstract unit of Textual rendering.
type Screen struct {
// Text is the text to display - a sequence of Unicode code points.
Text string
// Indent is the indentation level of the screen.
// Zero indicates top-level. Should be less than 16.
Indent int
// Expert indicates that the screen should only be displayed
// via an opt-in from the user.
Expert bool
}
// ValueRenderer defines an interface to produce formatted output for all
// protobuf types as well as parse a string into those protobuf types.
//
@@ -15,6 +28,9 @@ import (
// here, so that optionally more value renderers could be built, for example, a
// separate one for a different language.
type ValueRenderer interface {
Format(context.Context, protoreflect.Value, io.Writer) error
Parse(context.Context, io.Reader) (protoreflect.Value, error)
// Format should render the value to a text plus annotation.
Format(context.Context, protoreflect.Value) ([]Screen, error)
// Parse should be the inverse of Format.
Parse(context.Context, []Screen) (protoreflect.Value, error)
}
+12 -17
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
@@ -33,11 +32,10 @@ func TestFormatInteger(t *testing.T) {
if err == nil {
r, err := textual.GetValueRenderer(fieldDescriptorFromName("UINT64"))
require.NoError(t, err)
b := new(strings.Builder)
err = r.Format(context.Background(), protoreflect.ValueOf(i), b)
screens, err := r.Format(context.Background(), protoreflect.ValueOf(i))
require.NoError(t, err)
require.Equal(t, tc[1], b.String())
require.Equal(t, 1, len(screens))
require.Equal(t, tc[1], screens[0].Text)
}
// Parse test case strings as protobuf uint32
@@ -45,11 +43,10 @@ func TestFormatInteger(t *testing.T) {
if err == nil {
r, err := textual.GetValueRenderer(fieldDescriptorFromName("UINT32"))
require.NoError(t, err)
b := new(strings.Builder)
err = r.Format(context.Background(), protoreflect.ValueOf(i), b)
screens, err := r.Format(context.Background(), protoreflect.ValueOf(i))
require.NoError(t, err)
require.Equal(t, tc[1], b.String())
require.Equal(t, 1, len(screens))
require.Equal(t, tc[1], screens[0].Text)
}
// Parse test case strings as sdk.Ints
@@ -57,11 +54,10 @@ func TestFormatInteger(t *testing.T) {
if ok {
r, err := textual.GetValueRenderer(fieldDescriptorFromName("SDKINT"))
require.NoError(t, err)
b := new(strings.Builder)
err = r.Format(context.Background(), protoreflect.ValueOf(tc[0]), b)
screens, err := r.Format(context.Background(), protoreflect.ValueOf(tc[0]))
require.NoError(t, err)
require.Equal(t, tc[1], b.String())
require.Equal(t, 1, len(screens))
require.Equal(t, tc[1], screens[0].Text)
}
}
}
@@ -81,11 +77,10 @@ func TestFormatDecimal(t *testing.T) {
t.Run(tc[0], func(t *testing.T) {
r, err := textual.GetValueRenderer(fieldDescriptorFromName("SDKDEC"))
require.NoError(t, err)
b := new(strings.Builder)
err = r.Format(context.Background(), protoreflect.ValueOf(tc[0]), b)
screens, err := r.Format(context.Background(), protoreflect.ValueOf(tc[0]))
require.NoError(t, err)
require.Equal(t, tc[1], b.String())
require.Equal(t, 1, len(screens))
require.Equal(t, tc[1], screens[0].Text)
})
}
}