ui: add typed value conversion errors

Replace boolean output-parameter converters with UIValueResult so conversion failures can provide numeric codes and optional diagnostics without exposing partially converted values.
Expose observable converter error state from UIDataBind, UIProperty, and UIValueBinding.
Reject invalid widget input without changing the model or propagating it to sibling widgets, while keeping programmatic model updates authoritative.
Migrate ecode's custom output parser converter, expand binding tests, and document the data-handling architecture and next form-validation
phase.
This commit is contained in:
Martín Lucas Golini
2026-08-09 00:52:00 -03:00
parent 01d5614a71
commit b6cdc83818
11 changed files with 1227 additions and 680 deletions
@@ -3,6 +3,7 @@
#include <eepp/system/filesystem.hpp>
#include <eepp/ui/uiapplication.hpp>
#include <eepp/ui/uicheckbox.hpp>
#include <eepp/ui/uitextinput.hpp>
#include <eepp/ui/uivaluebinding.hpp>
using namespace EE;
@@ -90,3 +91,79 @@ UTEST( UIValueBinding, synchronizesBothDirectionsAndHandlesEndpointLifetimes ) {
eeDelete( widget );
EXPECT_FALSE( static_cast<bool>( binding ) );
}
UTEST( UIValueBinding, validatesWidgetProposalsButAcceptsAuthoritativeObservableValues ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIValueBinding Validation Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto widget = UITextInput::New();
ObservableValue<std::string> value( "initial" );
UIValueConverter<std::string> nonEmpty(
[]( const CSS::PropertyDefinition*,
const std::string& candidate ) -> UIValueResult<std::string> {
return candidate.empty() ? UIValueResult<std::string>::error( 300, "empty value" )
: UIValueResult<std::string>::success( candidate );
},
UIValueConverter<std::string>::converterString().fromValue );
auto binding = bindValue( value, widget, nonEmpty, "text", Event::OnTextChanged );
widget->setText( "" );
EXPECT_TRUE( value.get() == "initial" );
EXPECT_FALSE( binding.isValid() );
EXPECT_EQ( *binding.validationState()->code(), 300u );
widget->setText( "valid" );
EXPECT_TRUE( value.get() == "valid" );
EXPECT_TRUE( binding.isValid() );
value = "";
EXPECT_TRUE( widget->getText().empty() );
EXPECT_TRUE( binding.isValid() );
eeDelete( widget );
EXPECT_TRUE( binding.isValid() );
}
UTEST( UIValueBinding, convertsFormattedModelValues ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIValueBinding Converter Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto widget = UITextInput::New();
ObservableValue<double> amount( 10.0 );
UIValueConverter<double> euro(
[]( const CSS::PropertyDefinition*, const std::string& text ) -> UIValueResult<double> {
const std::string prefix( "€" );
double value;
if ( text.rfind( prefix, 0 ) != 0 ||
!String::fromString( value, text.substr( prefix.size() ) ) )
return UIValueResult<double>::error( 400, "expected an EUR amount" );
if ( value < 0 )
return UIValueResult<double>::error( 401 );
return value;
},
[]( const CSS::PropertyDefinition*, double value ) {
return UIValueResult<std::string>( "€" + String::fromDouble( value ) );
} );
auto binding = bindValue( amount, widget, euro, "text", Event::OnTextChanged );
EXPECT_TRUE( widget->getText() == "€10" );
widget->setText( "€25" );
EXPECT_EQ( amount.get(), 25.0 );
EXPECT_TRUE( binding.isValid() );
widget->setText( "USD 30" );
EXPECT_EQ( amount.get(), 25.0 );
EXPECT_EQ( *binding.validationState()->code(), 400u );
widget->setText( "€-5" );
EXPECT_EQ( amount.get(), 25.0 );
EXPECT_EQ( *binding.validationState()->code(), 401u );
amount = -5;
EXPECT_TRUE( widget->getText() == "€-5" );
EXPECT_TRUE( binding.isValid() );
eeDelete( widget );
}
+133 -3
View File
@@ -3,10 +3,45 @@
#include <eepp/ui/uiapplication.hpp>
#include <eepp/ui/uicheckbox.hpp>
#include <eepp/ui/uiproperty.hpp>
#include <eepp/ui/uitextinput.hpp>
using namespace EE;
using namespace EE::UI;
UTEST( UIValueValidation, supportsCodesAndOptionalDiagnostics ) {
auto success = UIValueValidationResult::success();
EXPECT_TRUE( static_cast<bool>( success ) );
EXPECT_FALSE( success.code.has_value() );
EXPECT_FALSE( success.debugMessage.has_value() );
auto coded = UIValueValidationResult::error( 42 );
EXPECT_FALSE( static_cast<bool>( coded ) );
EXPECT_TRUE( coded.code.has_value() );
EXPECT_EQ( *coded.code, 42u );
EXPECT_FALSE( coded.debugMessage.has_value() );
auto diagnosed = UIValueValidationResult::error( 43, "technical detail" );
EXPECT_EQ( *diagnosed.code, 43u );
EXPECT_TRUE( *diagnosed.debugMessage == "technical detail" );
auto diagnosticOnly = UIValueValidationResult::error( std::string( "only diagnostic" ) );
EXPECT_FALSE( diagnosticOnly.code.has_value() );
EXPECT_TRUE( *diagnosticOnly.debugMessage == "only diagnostic" );
}
UTEST( UIValueValidation, observesOnlyDistinctResultChanges ) {
UIValueValidationState state;
int notifications = 0;
auto connection = state.observe( [&]( const UIValueValidationResult& ) { ++notifications; } );
state.set( UIValueValidationResult::error( 7 ) );
state.set( UIValueValidationResult::error( 7 ) );
state.set( UIValueValidationResult::error( 7, "detail" ) );
state.clear();
EXPECT_EQ( notifications, 3 );
EXPECT_TRUE( state.isValid() );
}
UTEST( UIProperty, defaultConstructionOwnsUsableValue ) {
UIProperty<int> property;
EXPECT_EQ( property.value(), 0 );
@@ -69,9 +104,9 @@ UTEST( UIProperty, stringConcatenationPropagatesForStandardAndEEStrings ) {
UTEST( UIDataBind, defaultStringConverterReadsWidgetValue ) {
auto converter = UIDataBind<std::string>::converterDefault();
std::string value;
EXPECT_TRUE( converter.toValue( nullptr, value, "widget value" ) );
EXPECT_TRUE( value == "widget value" );
auto value = converter.toValue( nullptr, "widget value" );
EXPECT_TRUE( value );
EXPECT_TRUE( *value.value == "widget value" );
}
UTEST( UIDataBind, lateBoundWidgetReceivesValueAndCanDieFirst ) {
@@ -119,3 +154,98 @@ UTEST( UIDataBind, supportsMultipleWidgetsAndDisconnectsWhenBindingDiesFirst ) {
eeDelete( firstWidget );
eeDelete( secondWidget );
}
UTEST( UIDataBind, validatesWidgetProposalsButAcceptsAuthoritativeModelValues ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIDataBind Validation Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
std::string value( "initial" );
auto firstWidget = UITextInput::New();
auto secondWidget = UITextInput::New();
UIValueConverter<std::string> converter(
[]( const CSS::PropertyDefinition*,
const std::string& candidate ) -> UIValueResult<std::string> {
return candidate == "invalid" ? UIValueResult<std::string>::error( 100 )
: UIValueResult<std::string>::success( candidate );
},
UIValueConverter<std::string>::converterString().fromValue );
UIDataBind<std::string> binding( &value, UnorderedSet<UIWidget*>{ firstWidget, secondWidget },
converter, "text", Event::OnTextChanged );
firstWidget->setText( "invalid" );
EXPECT_TRUE( value == "initial" );
EXPECT_TRUE( firstWidget->getText() == "invalid" );
EXPECT_TRUE( secondWidget->getText() == "initial" );
EXPECT_FALSE( binding.isValid() );
EXPECT_EQ( *binding.validationState().code(), 100u );
auto result = binding.set( std::string( "invalid" ) );
EXPECT_TRUE( static_cast<bool>( result ) );
EXPECT_TRUE( value == "invalid" );
EXPECT_TRUE( binding.isValid() );
EXPECT_TRUE( binding.set( std::string( "initial" ) ) );
firstWidget->setText( "initial" );
firstWidget->setText( "invalid" );
eeDelete( firstWidget );
firstWidget = nullptr;
EXPECT_TRUE( binding.isValid() );
firstWidget = UITextInput::New();
binding.bind( firstWidget );
firstWidget->setText( "valid" );
EXPECT_TRUE( value == "valid" );
EXPECT_TRUE( secondWidget->getText() == "valid" );
EXPECT_TRUE( binding.isValid() );
eeDelete( firstWidget );
eeDelete( secondWidget );
}
UTEST( UIDataBind, defaultNumericConversionFailurePreservesModel ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIDataBind Parse Validation Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto widget = UITextInput::New();
int value = 5;
UIDataBind<int> binding( &value, widget, UIDataBind<int>::converterDefault(), "text",
Event::OnTextChanged );
widget->setText( "not a number" );
EXPECT_EQ( value, 5 );
EXPECT_FALSE( binding.isValid() );
EXPECT_EQ( *binding.validationState().code(),
static_cast<Uint32>( UIValueValidationError::ConversionFailed ) );
eeDelete( widget );
}
UTEST( UIDataBind, failedModelToWidgetConversionDoesNotApplyPartialText ) {
UIApplication app(
WindowSettings( 320, 240, "eepp - UIDataBind Conversion Test", WindowStyle::Default,
WindowBackend::Default, 32 ),
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
auto widget = UITextInput::New();
int value = 1;
UIValueConverter<int> converter(
[]( const CSS::PropertyDefinition*, const std::string& ) {
return UIValueResult<int>::success( 1 );
},
[]( const CSS::PropertyDefinition*, const int& converted ) {
return converted == 2 ? UIValueResult<std::string>::error( 200 )
: UIValueResult<std::string>::success( "partial" );
} );
UIDataBind<int> binding( &value, widget, converter, "text", Event::OnTextChanged );
EXPECT_TRUE( widget->getText() == "partial" );
auto result = binding.set( 2 );
EXPECT_FALSE( static_cast<bool>( result ) );
EXPECT_TRUE( widget->getText() == "partial" );
EXPECT_EQ( value, 2 );
EXPECT_FALSE( binding.set( 2 ) );
EXPECT_FALSE( binding.isValid() );
eeDelete( widget );
}
+9 -11
View File
@@ -127,20 +127,18 @@ class UICustomOutputParserWindow : public UIWindow {
UIDropDownList* cpTypeddl = find<UIDropDownList>( "custom_parser_type" );
UIDataBind<ProjectOutputParserTypes>::Converter projectOutputParserTypesConverter(
[]( const PropertyDefinition* property, ProjectOutputParserTypes& val,
const std::string& str ) -> bool {
[]( const PropertyDefinition* property, const std::string& str ) {
auto v = StyleSheetProperty( property, str ).asString();
Uint32 idx;
if ( String::fromString( idx, v ) && idx >= 0 && idx <= 2 ) {
val = (ProjectOutputParserTypes)idx;
return true;
}
return false;
if ( String::fromString( idx, v ) && idx <= 2 )
return UIValueResult<ProjectOutputParserTypes>::success(
static_cast<ProjectOutputParserTypes>( idx ) );
return UIValueResult<ProjectOutputParserTypes>::error(
static_cast<Uint32>( UIValueValidationError::ConversionFailed ) );
},
[cpTypeddl]( const PropertyDefinition*, std::string& str,
const ProjectOutputParserTypes& val ) -> bool {
str = cpTypeddl->getListBox()->getItem( (Uint32)val )->getText();
return true;
[cpTypeddl]( const PropertyDefinition*, const ProjectOutputParserTypes& val ) {
return UIValueResult<std::string>::success(
cpTypeddl->getListBox()->getItem( static_cast<Uint32>( val ) )->getText() );
} );
mDataBindHolder += UIDataBind<ProjectOutputParserTypes>::New(