diff --git a/app/test/testformeditors.cpp b/app/test/testformeditors.cpp index c1b52f420..013b5f5c9 100644 --- a/app/test/testformeditors.cpp +++ b/app/test/testformeditors.cpp @@ -449,151 +449,204 @@ void TestFormEditors::testRelationsWidgetPresence() QVERIFY( relationReferencesCount == 1 ); } -void TestFormEditors::testValueRelationsEditor() {} -// { -// /* Test project: project_value_relations -// * It has value relations sets up followingly: -// * -// * - Main Layer has VR to: -// * - sub layer -// * - subsub layer ( with filter expression that subsub is categorized based on sub ) -// * - another layer ( key is not fid, but textual ) -// */ - -// QString projectDir = TestUtils::testDataDir() + "/project_value_relations"; -// QString projectName = "proj.qgz"; - -// QVERIFY( QgsProject::instance()->read( projectDir + "/" + projectName ) ); - -// QgsMapLayer *mainL = QgsProject::instance()->mapLayersByName( QStringLiteral( "main" ) ).at( 0 ); -// QgsVectorLayer *mainLayer = static_cast( mainL ); - -// QVERIFY( mainLayer && mainLayer->isValid() ); - -// QgsMapLayer *subL = QgsProject::instance()->mapLayersByName( QStringLiteral( "sub" ) ).at( 0 ); -// QgsVectorLayer *subLayer = static_cast( subL ); - -// QVERIFY( subLayer && subLayer->isValid() ); - -// QgsMapLayer *subsubL = QgsProject::instance()->mapLayersByName( QStringLiteral( "subsub" ) ).at( 0 ); -// QgsVectorLayer *subsubLayer = static_cast( subsubL ); - -// QVERIFY( subsubLayer && subsubLayer->isValid() ); - -// QgsMapLayer *anotherL = QgsProject::instance()->mapLayersByName( QStringLiteral( "another" ) ).at( 0 ); -// QgsVectorLayer *anotherLayer = static_cast( anotherL ); - -// QVERIFY( anotherLayer && anotherLayer->isValid() ); - -// // test ValueRelationsFeaturesModel (drawer model) and ValueRelationController - -// QgsFeature f = mainLayer->getFeature( 1 ); -// FeatureLayerPair pair( f, mainLayer ); - -// AttributeController controller; -// controller.setFeatureLayerPair( pair ); - -// const TabItem *tab = controller.tabItem( 0 ); -// QVector items = tab->formItems(); - -// QVERIFY( items.length() == 5 ); - -// // order: 0 - fid, 1 - Name, 2 - subfk, 3 - anotherfk, 4 - subsubfk - -// // ------- FIELD SubFK: drawer model loads all sub-layer features on demand - -// const FormItem *subFkItem = controller.formItem( items.at( 2 ) ); - -// ValueRelationFeaturesModel subVRModel; -// QSignalSpy subSpy( &subVRModel, &LayerFeaturesModel::fetchingResultsChanged ); - -// subVRModel.setConfig( subFkItem->editorWidgetConfig() ); - -// // No features before explicit load (lazy loading) -// QCOMPARE( subVRModel.rowCount(), 0 ); - -// subVRModel.reloadFeatures(); -// subSpy.wait(); -// QCOMPARE( subVRModel.rowCount(), subLayer->dataProvider()->featureCount() ); -// QCOMPARE( subVRModel.layer()->id(), subLayer->id() ); - -// // KeyColumn and ValueColumn roles are present -// QVERIFY( subVRModel.data( subVRModel.index( 0, 0 ), ValueRelationFeaturesModel::KeyColumn ).isValid() ); -// QVERIFY( subVRModel.data( subVRModel.index( 0, 0 ), ValueRelationFeaturesModel::ValueColumn ).isValid() ); +void TestFormEditors::testValueRelationConversions() +{ + /* Tests qgisFormatToArray() and arrayToQgisFormat() for null, single, + * and multi values. + * + * AllowMulti=false: + * QGIS value -> keys: "1" -> ["1"] + * keys -> QGIS value: ["42"] -> "42" + * + * AllowMulti=true: + * QGIS value -> keys: "{1,3,4}" -> ["1","3","4"] + * keys -> QGIS value: ["1","3","4"] -> "{1,3,4}"; ["1"] -> "{1}" + */ + QgsProject::instance()->removeAllMapLayers(); -// // ------- FIELD SubSubFK: form-scoped FilterExpression + drill-down + QgsVectorLayer *layer = TestUtils::createVRLookupLayer( 5 ); + QVERIFY( layer && layer->isValid() ); + QgsProject::instance()->addMapLayer( layer ); -// const FormItem *subsubFkItem = controller.formItem( items.at( 4 ) ); + // Single value test + ValueRelationController controller; + controller.setConfig( + { + { QStringLiteral( "Layer" ), layer->id() }, + { QStringLiteral( "Key" ), QStringLiteral( "key" ) }, + { QStringLiteral( "Value" ), QStringLiteral( "label" ) }, + { QStringLiteral( "AllowMulti" ), false } + } ); -// ValueRelationFeaturesModel subsubVRModel; -// QSignalSpy subsubSpy( &subsubVRModel, &LayerFeaturesModel::fetchingResultsChanged ); -// subsubVRModel.setConfig( subsubFkItem->editorWidgetConfig() ); -// subsubVRModel.setPair( pair ); // form scope resolves current_value() in the filter -// subsubVRModel.reloadFeatures(); + // null/empty : empty list + QCOMPARE( controller.qgisFormatToArray( QVariant() ), QStringList() ); + QCOMPARE( controller.qgisFormatToArray( QStringLiteral( "" ) ), QStringList() ); -// subsubSpy.wait(); -// QCOMPARE( subsubVRModel.layer()->id(), subsubLayer->id() ); + // single value "1" : ["1"] + QCOMPARE( controller.qgisFormatToArray( QStringLiteral( "1" ) ), + QStringList( { QStringLiteral( "1" ) } ) ); -// // With the pair set the form-scoped filter must restrict the result set -// QCOMPARE( subsubVRModel.rowCount(), 2 ); + // empty keys : "" + QCOMPARE( controller.arrayToQgisFormat( {} ), QString() ); -// // Filter expression is present and valid in the request -// QgsFeatureRequest request; -// subsubVRModel.setupFeatureRequest( request ); -// QVERIFY( !request.filterExpression()->operator QString().isEmpty() ); -// QVERIFY( request.filterExpression()->isValid() ); + // single key : plain value + QCOMPARE( controller.arrayToQgisFormat( { QStringLiteral( "42" ) } ), + QStringLiteral( "42" ) ); -// // Search combined with the filter expression -// subsubVRModel.setSearchExpression( QStringLiteral( "2" ) ); -// subsubSpy.wait(); -// QCOMPARE( subsubVRModel.rowCount(), 1 ); + // Multi value test + controller.setConfig( + { + { QStringLiteral( "Layer" ), layer->id() }, + { QStringLiteral( "Key" ), QStringLiteral( "key" ) }, + { QStringLiteral( "Value" ), QStringLiteral( "label" ) }, + { QStringLiteral( "AllowMulti" ), true } + } ); -// // featureTitle returns the value column -// { -// QModelIndex idx = subsubVRModel.index( 0, 0 ); -// FeatureLayerPair tempPair = subsubVRModel.data( idx, FeaturesModel::FeaturePair ).value(); -// QCOMPARE( subsubVRModel.featureTitle( tempPair ), QStringLiteral( "A2" ) ); -// } + // null : empty list + QCOMPARE( controller.qgisFormatToArray( QVariant() ), QStringList() ); -// // ------- FIELD AnotherFK: helper-based conversions and invalidation + // "{1,3,4}" : ["1","3","4"] + QCOMPARE( controller.qgisFormatToArray( QStringLiteral( "{1,3,4}" ) ), + QStringList( { QStringLiteral( "1" ), QStringLiteral( "3" ), QStringLiteral( "4" ) } ) ); -// const FormItem *anotherFkItem = controller.formItem( items.at( 3 ) ); + // "{1}" : ["1"] + QCOMPARE( controller.qgisFormatToArray( QStringLiteral( "{1}" ) ), + QStringList( { QStringLiteral( "1" ) } ) ); -// ValueRelationFeaturesModel anotherVRModel; -// QSignalSpy anotherSpy( &anotherVRModel, &LayerFeaturesModel::fetchingResultsChanged ); -// anotherVRModel.setConfig( anotherFkItem->editorWidgetConfig() ); -// anotherVRModel.reloadFeatures(); -// anotherSpy.wait(); -// QCOMPARE( anotherVRModel.rowCount(), anotherLayer->dataProvider()->featureCount() ); -// QCOMPARE( anotherVRModel.layer()->id(), anotherLayer->id() ); + // empty keys : "" + QCOMPARE( controller.arrayToQgisFormat( {} ), QString() ); -// // ValueRelationController handles conversions (static) and lookups (instance). -// // The "another" layer uses text keys; we look up a single known key "B". -// ValueRelationController anotherHelper; -// anotherHelper.setConfig( anotherFkItem->editorWidgetConfig() ); + // ["1","3","4"] : "{1,3,4}" + QCOMPARE( controller.arrayToQgisFormat( { QStringLiteral( "1" ), QStringLiteral( "3" ), QStringLiteral( "4" ) } ), + QStringLiteral( "{1,3,4}" ) ); -// // Single key lookup: pick the first key from the already-loaded model so the -// // test is not sensitive to the exact fixture values. -// QVERIFY( anotherVRModel.rowCount() > 0 ); -// const QVariant firstKey = anotherVRModel.data( anotherVRModel.index( 0, 0 ), ValueRelationFeaturesModel::KeyColumn ); -// QVERIFY( firstKey.isValid() ); + // ["1"] : "{1}" + QCOMPARE( controller.arrayToQgisFormat( { QStringLiteral( "1" ) } ), + QStringLiteral( "{1}" ) ); -// QSignalSpy lookupSpy( &anotherHelper, &ValueRelationController::displayValuesReady ); -// anotherHelper.lookupDisplayValues( firstKey ); -// QVERIFY( lookupSpy.wait() ); -// QCOMPARE( lookupSpy.last().at( 0 ).toList().size(), 1 ); + QgsProject::instance()->removeAllMapLayers(); +} -// // Static: round-trip QGIS format (type-independent, no layer access) -// QCOMPARE( anotherHelper.convertToQgisFormat( { QStringLiteral( "B" ), QStringLiteral( "C" ) } ), -// QStringLiteral( "{B,C}" ) ); -// QCOMPARE( anotherHelper.convertFromQgisFormat( QStringLiteral( "{B,C}" ), true ), -// QStringList( { QStringLiteral( "B" ), QStringLiteral( "C" ) } ) ); +void TestFormEditors::testValueRelationControllerLookup() +{ + /* Tests async display-text lookup for ValueRelationController: + * + * baseConfig controller (no FilterExpression): + * 1. Missing key -> presentRawValue fires; invalidateSelection does NOT fire; displayText="" + * 2. lookupDisplayTextOnHotreload without FilterExpression -> returns early, no signals + * 3. Basic lookup: lookupDisplayTextOnValueChanged("1") -> "Cat1-A" + * + * filterConfig controller (FilterExpression set): + * 4. lookupDisplayTextOnHotreload, key valid in context -> displayText updated ("Cat1-A") + * 5. lookupDisplayTextOnHotreload, key no longer in context -> invalidateSelection fires + * + * Cases 1-2 run before case 3 so that mDisplayText is "" when case 1 checks + * displaySpy.isEmpty() -> the setDisplayText guard skips emission for equal values. + * + * Lookup layer: + * key=1 label="Cat1-A" category=1 + * key=2 label="Cat1-B" category=1 + * key=3 label="Cat2-A" category=2 + * key=4 label="Cat2-B" category=2 + * + * Filter expression: "category" = current_value('cat') + * Form feature cat=1 -> keys 1,2 reachable; cat=2 -> keys 3,4 reachable. + */ + QgsProject::instance()->removeAllMapLayers(); + + // create the lookup layer + QgsVectorLayer *lookupLayer = new QgsVectorLayer( + QStringLiteral( "None?field=key:integer&field=label:string&field=category:integer" ), + QStringLiteral( "vr_lookup" ), + QStringLiteral( "memory" ) + ); + QVERIFY( lookupLayer && lookupLayer->isValid() ); + + const QStringList labels = { QStringLiteral( "Cat1-A" ), QStringLiteral( "Cat1-B" ), + QStringLiteral( "Cat2-A" ), QStringLiteral( "Cat2-B" ) + }; + QgsFeatureList features; + for ( int i = 0; i < 4; ++i ) + { + QgsFeature feature( lookupLayer->fields() ); + feature.setAttribute( QStringLiteral( "key" ), i + 1 ); + feature.setAttribute( QStringLiteral( "label" ), labels.at( i ) ); + feature.setAttribute( QStringLiteral( "category" ), i < 2 ? 1 : 2 ); + features << feature; + } + lookupLayer->dataProvider()->addFeatures( features ); + QgsProject::instance()->addMapLayer( lookupLayer ); + + // create the form layer, that provides fields for building form-scope features + QgsVectorLayer *formLayer = new QgsVectorLayer( + QStringLiteral( "None?field=cat:integer" ), + QStringLiteral( "vr_form" ), + QStringLiteral( "memory" ) + ); + QVERIFY( formLayer && formLayer->isValid() ); + QgsProject::instance()->addMapLayer( formLayer ); + + const QVariantMap baseConfig = + { + { QStringLiteral( "Layer" ), lookupLayer->id() }, + { QStringLiteral( "Key" ), QStringLiteral( "key" ) }, + { QStringLiteral( "Value" ), QStringLiteral( "label" ) }, + { QStringLiteral( "AllowMulti" ), false } + }; -// // Invalidation: helper with no FilterExpression must NOT emit invalidate -// // (the "another" layer config has no filter expression) -// QSignalSpy helperInvalidateSpy( &anotherHelper, &ValueRelationController::invalidate ); -// QSignalSpy helperResultSpy( &anotherHelper, &ValueRelationController::displayValuesReady ); -// anotherHelper.lookupDisplayValues( QStringLiteral( "NONEXISTENT_KEY" ) ); -// QVERIFY( helperResultSpy.wait() ); -// QVERIFY( helperInvalidateSpy.isEmpty() ); -// } + QVariantMap filterConfig = baseConfig; + filterConfig[ QStringLiteral( "FilterExpression" ) ] = + QStringLiteral( "\"category\" = current_value('cat')" ); + + ValueRelationController baseController; + baseController.setConfig( baseConfig ); + + QSignalSpy rawSpy( &baseController, &ValueRelationController::presentRawValue ); + QSignalSpy invalidateSpy( &baseController, &ValueRelationController::invalidateSelection ); + QSignalSpy displaySpy( &baseController, &ValueRelationController::displayTextChanged ); + + // 1. Missing key, no filter -> presentRawValue; NOT invalidateSelection + baseController.lookupDisplayTextOnValueChanged( QStringLiteral( "99999" ) ); + QVERIFY( rawSpy.wait( 5000 ) ); + QVERIFY( displaySpy.isEmpty() ); // mDisplayText was already ""; setDisplayText skips emission + QVERIFY( invalidateSpy.isEmpty() ); + QCOMPARE( baseController.displayText(), QString() ); + + // 2. Hotreload without FilterExpression -> early return, no signals + rawSpy.clear(); + displaySpy.clear(); + baseController.lookupDisplayTextOnHotreload( QStringLiteral( "1" ), QgsFeature() ); + QVERIFY( displaySpy.isEmpty() ); + + // 3. Basic lookup: key "1" -> "Cat1-A" + displaySpy.clear(); + baseController.lookupDisplayTextOnValueChanged( QStringLiteral( "1" ) ); + QVERIFY( displaySpy.wait( 5000 ) ); + QCOMPARE( baseController.displayText(), QStringLiteral( "Cat1-A" ) ); + + ValueRelationController filterController; + filterController.setConfig( filterConfig ); + + QSignalSpy filterDisplaySpy( &filterController, &ValueRelationController::displayTextChanged ); + QSignalSpy filterInvalidateSpy( &filterController, &ValueRelationController::invalidateSelection ); + + // 4. Hotreload with filter, key valid in context -> displayText updated + // Form context: cat=1 -> key 1 ("Cat1-A") is reachable (category=1) + QgsFeature formFeature( formLayer->fields() ); + formFeature.setAttribute( QStringLiteral( "cat" ), 1 ); + formFeature.setValid( true ); + filterController.lookupDisplayTextOnHotreload( QStringLiteral( "1" ), formFeature ); + QVERIFY( filterDisplaySpy.wait( 5000 ) ); + QCOMPARE( filterController.displayText(), QStringLiteral( "Cat1-A" ) ); + + // 5. Hotreload with filter, key not in context -> invalidateSelection + // Form context: cat=2 -> key 1 ("Cat1-A") is NOT reachable (category=1 ≠ 2) + filterDisplaySpy.clear(); + QgsFeature formFeature2( formLayer->fields() ); + formFeature2.setAttribute( QStringLiteral( "cat" ), 2 ); + formFeature2.setValid( true ); + filterController.lookupDisplayTextOnHotreload( QStringLiteral( "1" ), formFeature2 ); + QVERIFY( filterInvalidateSpy.wait( 5000 ) ); + + QgsProject::instance()->removeAllMapLayers(); +} diff --git a/app/test/testformeditors.h b/app/test/testformeditors.h index e53bc49e5..382eb3eee 100644 --- a/app/test/testformeditors.h +++ b/app/test/testformeditors.h @@ -24,7 +24,8 @@ class TestFormEditors : public QObject void testRelationsEditor(); void testRelationsReferenceEditor(); void testRelationsWidgetPresence(); - void testValueRelationsEditor(); + void testValueRelationConversions(); + void testValueRelationControllerLookup(); }; #endif // TESTFORMEDITORS_H diff --git a/app/test/testmodels.cpp b/app/test/testmodels.cpp index 9be05b5ec..48682ace1 100644 --- a/app/test/testmodels.cpp +++ b/app/test/testmodels.cpp @@ -179,304 +179,260 @@ void TestModels::testLayerFeaturesModelSorted() QCOMPARE( model.data( model.index( 8, 0 ), FeaturesModel::ModelRoles::FeatureId ), 100000000 ); } -void TestModels::testValueRelationFeaturesModel() +void TestModels::testValueRelationOrdering() { - // Tests the drawer model: lazy loading, new KeyColumn/ValueColumn roles, - // filter expressions and search — all without form-pair dependency. - - const QString projectDir = TestUtils::testDataDir() + "/project_value_relations"; - QVERIFY( QgsProject::instance()->read( projectDir + "/proj.qgz" ) ); - - QgsMapLayer *subsubL = QgsProject::instance()->mapLayersByName( QStringLiteral( "subsub" ) ).at( 0 ); - QgsVectorLayer *subsubLayer = static_cast( subsubL ); - QVERIFY( subsubLayer && subsubLayer->isValid() ); + /* Tests the four ordering permutations against a layer whose insertion order + * is neither key-sorted nor label-sorted: + * + * key->label: 1->Alpha 2->Delta 3->Gamma 4->Beta + * + * 1. OrderByKey asc -> 1(Alpha), 2(Delta), 3(Gamma), 4(Beta) + * 2. OrderByKey desc -> 4(Beta), 3(Gamma), 2(Delta), 1(Alpha) + * 3. OrderByValue asc -> 1(Alpha), 4(Beta), 2(Delta), 3(Gamma) + * 4. OrderByField "label" asc -> same as 3, but different code path + * 5. OrderByField desc -> 3(Gamma), 2(Delta), 4(Beta), 1(Alpha) + */ + QgsProject::instance()->removeAllMapLayers(); + + QgsVectorLayer *layer = TestUtils::createVROrderingLayer(); + QVERIFY( layer && layer->isValid() ); + QCOMPARE( static_cast( layer->featureCount() ), 4 ); + QgsProject::instance()->addMapLayer( layer ); const QVariantMap baseConfig = { - { QStringLiteral( "Layer" ), QStringLiteral( "subsub_df9d0ba0_2ec8_4a2c_9f96_84576e37c126" ) }, - { QStringLiteral( "Key" ), QStringLiteral( "fid" ) }, - { QStringLiteral( "Value" ), QStringLiteral( "Name" ) }, + { QStringLiteral( "Layer" ), layer->id() }, + { QStringLiteral( "Key" ), QStringLiteral( "key" ) }, + { QStringLiteral( "Value" ), QStringLiteral( "label" ) } }; - // ── 1. No auto-load: setConfig must not trigger populate ────────────── - { - ValueRelationFeaturesModel model; - QSignalSpy spy( &model, &LayerFeaturesModel::fetchingResultsChanged ); - - model.setConfig( baseConfig ); - - // Give the event loop a moment; no async work should have started. - QTest::qWait( 100 ); - QVERIFY( spy.isEmpty() ); - QCOMPARE( model.rowCount(), 0 ); - QCOMPARE( model.layer()->id(), subsubLayer->id() ); - } + ValueRelationFeaturesModel model; + model.setConfig( baseConfig ); + QCOMPARE( model.rowCount(), 0 ); // setConfig should not populate the model + QSignalSpy spy( &model, &LayerFeaturesModel::fetchingResultsChanged ); - // ── 2. Explicit populate loads features; KeyColumn / ValueColumn roles ─ + auto keyAt = [&]( int row ) + { + return model.data( model.index( row, 0 ), ValueRelationFeaturesModel::KeyColumn ).toString(); + }; + auto valAt = [&]( int row ) { - ValueRelationFeaturesModel model; - QSignalSpy spy( &model, &LayerFeaturesModel::fetchingResultsChanged ); + return model.data( model.index( row, 0 ), ValueRelationFeaturesModel::ValueColumn ).toString(); + }; - model.setConfig( baseConfig ); + auto reload = [&]( const QVariantMap & config ) + { + spy.clear(); + model.setConfig( config ); model.reloadFeatures(); - spy.wait(); + while ( spy.count() < 2 ) + QVERIFY( spy.wait( 5000 ) ); + }; - QCOMPARE( model.rowCount(), 9 ); + // 1. OrderByKey ascending + QVariantMap config = baseConfig; + config[ QStringLiteral( "OrderByKey" ) ] = true; + config[ QStringLiteral( "OrderByDescending" ) ] = false; + reload( config ); + QCOMPARE( model.rowCount(), 4 ); + QCOMPARE( keyAt( 0 ), QStringLiteral( "1" ) ); + QCOMPARE( keyAt( 1 ), QStringLiteral( "2" ) ); + QCOMPARE( keyAt( 2 ), QStringLiteral( "3" ) ); + QCOMPARE( keyAt( 3 ), QStringLiteral( "4" ) ); + + // 2. OrderByKey descending + config[ QStringLiteral( "OrderByDescending" ) ] = true; + reload( config ); + QCOMPARE( model.rowCount(), 4 ); + QCOMPARE( keyAt( 0 ), QStringLiteral( "4" ) ); + QCOMPARE( keyAt( 1 ), QStringLiteral( "3" ) ); + QCOMPARE( keyAt( 2 ), QStringLiteral( "2" ) ); + QCOMPARE( keyAt( 3 ), QStringLiteral( "1" ) ); + + // 3. OrderByValue ascending + config = baseConfig; + config[ QStringLiteral( "OrderByValue" ) ] = true; + config[ QStringLiteral( "OrderByDescending" ) ] = false; + reload( config ); + QCOMPARE( model.rowCount(), 4 ); + QCOMPARE( valAt( 0 ), QStringLiteral( "Alpha" ) ); + QCOMPARE( valAt( 1 ), QStringLiteral( "Beta" ) ); + QCOMPARE( valAt( 2 ), QStringLiteral( "Delta" ) ); + QCOMPARE( valAt( 3 ), QStringLiteral( "Gamma" ) ); + + // 4. OrderByField with an explicit field name — same result as 3, different code path + config = baseConfig; + config[ QStringLiteral( "OrderByField" ) ] = true; + config[ QStringLiteral( "OrderByFieldName" ) ] = QStringLiteral( "label" ); + config[ QStringLiteral( "OrderByDescending" ) ] = false; + reload( config ); + QCOMPARE( model.rowCount(), 4 ); + QCOMPARE( valAt( 0 ), QStringLiteral( "Alpha" ) ); + QCOMPARE( valAt( 1 ), QStringLiteral( "Beta" ) ); + QCOMPARE( valAt( 2 ), QStringLiteral( "Delta" ) ); + QCOMPARE( valAt( 3 ), QStringLiteral( "Gamma" ) ); + + // 5. OrderByField descending + config[ QStringLiteral( "OrderByDescending" ) ] = true; + reload( config ); + QCOMPARE( model.rowCount(), 4 ); + QCOMPARE( valAt( 0 ), QStringLiteral( "Gamma" ) ); + QCOMPARE( valAt( 1 ), QStringLiteral( "Delta" ) ); + QCOMPARE( valAt( 2 ), QStringLiteral( "Beta" ) ); + QCOMPARE( valAt( 3 ), QStringLiteral( "Alpha" ) ); + + QgsProject::instance()->removeAllMapLayers(); +} - // KeyColumn returns the raw key-field attribute - QCOMPARE( model.data( model.index( 0, 0 ), ValueRelationFeaturesModel::KeyColumn ), QVariant( 1 ) ); - QCOMPARE( model.data( model.index( 8, 0 ), ValueRelationFeaturesModel::KeyColumn ), QVariant( 100000000 ) ); +void TestModels::testValueRelationSearch() +{ + /* Tests that search applies only to the value (label) column, not the key. + * + * Layer: + * key=1 label="Alpha" — key contains "1", label does not + * key=2 label="Val1" — label contains "1", key does not + * key=100 label="Gamma" — key contains "1", label does not + * + * Search "1" must return only "Val1" — proving that key=1 and key=100 are + * ignored because buildSearchExpression() filters on the value field only. + */ + QgsProject::instance()->removeAllMapLayers(); + + auto *layer = new QgsVectorLayer( + QStringLiteral( "None?field=key:integer&field=label:string" ), + QStringLiteral( "vr_search" ), + QStringLiteral( "memory" ) + ); + QVERIFY( layer && layer->isValid() ); - // ValueColumn returns the display-label attribute - // (insert order from the fixture: first entry is not "A1" without sorting) - QVERIFY( !model.data( model.index( 0, 0 ), ValueRelationFeaturesModel::ValueColumn ).toString().isEmpty() ); + struct Row { int key; QString label; }; + const QList rows = { {1, QStringLiteral( "Alpha" )}, {2, QStringLiteral( "Val1" )}, {100, QStringLiteral( "Gamma" )} }; + + QgsFeatureList features; + for ( const auto &row : rows ) + { + QgsFeature f( layer->fields() ); + f.setAttribute( QStringLiteral( "key" ), row.key ); + f.setAttribute( QStringLiteral( "label" ), row.label ); + features << f; } + layer->dataProvider()->addFeatures( features ); + QCOMPARE( layer->featureCount(), ( long long ) 3 ); + QgsProject::instance()->addMapLayer( layer ); - // ── 3. OrderByValue sorts by the value field ─────────────────────────── + const QVariantMap config = { - QVariantMap config = baseConfig; - config[ QStringLiteral( "OrderByValue" ) ] = true; + { QStringLiteral( "Layer" ), layer->id() }, + { QStringLiteral( "Key" ), QStringLiteral( "key" ) }, + { QStringLiteral( "Value" ), QStringLiteral( "label" ) }, + { QStringLiteral( "OrderByValue" ), true } + }; - ValueRelationFeaturesModel model; - QSignalSpy spy( &model, &LayerFeaturesModel::fetchingResultsChanged ); + ValueRelationFeaturesModel model; + QSignalSpy spy( &model, &LayerFeaturesModel::fetchingResultsChanged ); + auto waitForReload = [&]() + { + while ( spy.count() < 2 ) + QVERIFY( spy.wait( 5000 ) ); + }; - model.setConfig( config ); - model.reloadFeatures(); - spy.wait(); + model.setConfig( config ); - QCOMPARE( model.rowCount(), 9 ); - QCOMPARE( model.data( model.index( 0, 0 ), ValueRelationFeaturesModel::ValueColumn ), QLatin1String( "A1" ) ); - QCOMPARE( model.data( model.index( 1, 0 ), ValueRelationFeaturesModel::ValueColumn ), QLatin1String( "A2" ) ); - QCOMPARE( model.data( model.index( 8, 0 ), ValueRelationFeaturesModel::ValueColumn ), QLatin1String( "VERYBIG" ) ); - } + // Initial load: all 3 features + model.reloadFeatures(); + waitForReload(); + QCOMPARE( model.rowCount(), 3 ); - // ── 4. Search expression filters loaded results ──────────────────────── - { - QVariantMap config = baseConfig; - config[ QStringLiteral( "OrderByValue" ) ] = true; + // Search "1": only "Val1" matches (value column); key=1 and key=100 are ignored + spy.clear(); + model.setSearchExpression( QStringLiteral( "1" ) ); + waitForReload(); + QCOMPARE( model.rowCount(), 1 ); + QCOMPARE( model.data( model.index( 0, 0 ), ValueRelationFeaturesModel::ValueColumn ).toString(), + QStringLiteral( "Val1" ) ); - ValueRelationFeaturesModel model; - QSignalSpy spy( &model, &LayerFeaturesModel::fetchingResultsChanged ); + // Clear search: all 3 features again + spy.clear(); + model.setSearchExpression( QString() ); + waitForReload(); + QCOMPARE( model.rowCount(), 3 ); - model.setConfig( config ); - model.reloadFeatures(); - spy.wait(); + // Search with no match + spy.clear(); + model.setSearchExpression( QStringLiteral( "xyz" ) ); + waitForReload(); + QCOMPARE( model.rowCount(), 0 ); - model.setSearchExpression( QStringLiteral( "D" ) ); - spy.wait(); + QgsProject::instance()->removeAllMapLayers(); +} - QCOMPARE( model.rowCount(), 2 ); - QCOMPARE( model.data( model.index( 0, 0 ), ValueRelationFeaturesModel::ValueColumn ), QLatin1String( "D1" ) ); - QCOMPARE( model.data( model.index( 1, 0 ), ValueRelationFeaturesModel::ValueColumn ), QLatin1String( "D2" ) ); - } +void TestModels::testValueRelationHotreload() +{ + /* Tests that calling reloadFeatures() after modifying the underlying layer + * triggers hot reload -> reloading after the drawer is already open + * Also tests auto-reload: LayerFeaturesModel connects featureAdded to populate(), + * so adding a feature during an editing session triggers an async reload. + */ + QgsProject::instance()->removeAllMapLayers(); + + QgsVectorLayer *layer = TestUtils::createVRLookupLayer( 3 ); + QVERIFY( layer && layer->isValid() ); + QgsProject::instance()->addMapLayer( layer ); - // ── 5. Static FilterExpression restricts results ─────────────────────── + const QVariantMap config = { - QVariantMap config = baseConfig; - config[ QStringLiteral( "OrderByValue" ) ] = true; - config[ QStringLiteral( "FilterExpression" ) ] = QStringLiteral( "subFk = 1" ); + { QStringLiteral( "Layer" ), layer->id() }, + { QStringLiteral( "Key" ), QStringLiteral( "key" ) }, + { QStringLiteral( "Value" ), QStringLiteral( "label" ) }, + { QStringLiteral( "OrderByKey" ), true } + }; - ValueRelationFeaturesModel model; - QSignalSpy spy( &model, &LayerFeaturesModel::fetchingResultsChanged ); + ValueRelationFeaturesModel model; + QSignalSpy spy( &model, &LayerFeaturesModel::fetchingResultsChanged ); + auto waitForReload = [&]() + { + while ( spy.count() < 2 ) + QVERIFY( spy.wait( 5000 ) ); + }; - model.setConfig( config ); - model.reloadFeatures(); - spy.wait(); + model.setConfig( config ); - QCOMPARE( model.rowCount(), 2 ); - QCOMPARE( model.data( model.index( 0, 0 ), ValueRelationFeaturesModel::ValueColumn ), QLatin1String( "A1" ) ); - QCOMPARE( model.data( model.index( 1, 0 ), ValueRelationFeaturesModel::ValueColumn ), QLatin1String( "A2" ) ); - } -} + // Initial load: 3 features + model.reloadFeatures(); + waitForReload(); + QCOMPARE( model.rowCount(), 3 ); -void TestModels::testValueRelationController() {} -// { -// // The conversion helpers are non-static Q_INVOKABLE methods; an unconfigured -// // helper is sufficient since they use no instance state. -// ValueRelationController conv; - -// // ── convertFromQgisFormat ───────────────────────────────────────────── -// // Multi-value: parse QGIS "{...}" wire format -// QCOMPARE( conv.convertFromQgisFormat( QStringLiteral( "{1,2,3}" ), true ), -// QStringList( { QStringLiteral( "1" ), QStringLiteral( "2" ), QStringLiteral( "3" ) } ) ); - -// // Single-value: treat as plain value, not list syntax -// QCOMPARE( conv.convertFromQgisFormat( QVariant( 42 ), false ), -// QStringList( { QStringLiteral( "42" ) } ) ); - -// // Null/empty input produces empty list -// QVERIFY( conv.convertFromQgisFormat( QVariant(), false ).isEmpty() ); -// QVERIFY( conv.convertFromQgisFormat( QVariant(), true ).isEmpty() ); -// QVERIFY( conv.convertFromQgisFormat( QStringLiteral( "" ), true ).isEmpty() ); - -// // Large integer key (no scientific-notation rounding) -// QCOMPARE( conv.convertFromQgisFormat( QStringLiteral( "{100000000}" ), true ), -// QStringList( { QStringLiteral( "100000000" ) } ) ); - -// // ── convertToQgisFormat ─────────────────────────────────────────────── -// QCOMPARE( conv.convertToQgisFormat( { QStringLiteral( "1" ), QStringLiteral( "2" ), QStringLiteral( "3" ) } ), -// QStringLiteral( "{1,2,3}" ) ); -// QCOMPARE( conv.convertToQgisFormat( { QStringLiteral( "42" ) } ), QStringLiteral( "{42}" ) ); -// QCOMPARE( conv.convertToQgisFormat( {} ), QStringLiteral( "{}" ) ); - -// // Round-trip -// { -// const QString original = QStringLiteral( "{7,8,100000000}" ); -// QCOMPARE( conv.convertToQgisFormat( conv.convertFromQgisFormat( original, true ) ), original ); -// } - -// // ── Instance method: lookupDisplayValues ────────────────────────────── -// const QString projectDir = TestUtils::testDataDir() + "/project_value_relations"; -// QVERIFY( QgsProject::instance()->read( projectDir + "/proj.qgz" ) ); - -// // Single-value config (AllowMulti=false, the default) -// const QVariantMap singleConfig = -// { -// { QStringLiteral( "Layer" ), QStringLiteral( "subsub_df9d0ba0_2ec8_4a2c_9f96_84576e37c126" ) }, -// { QStringLiteral( "Key" ), QStringLiteral( "fid" ) }, -// { QStringLiteral( "Value" ), QStringLiteral( "Name" ) }, -// }; - -// // Multi-value config (AllowMulti=true) for {…} wire format -// const QVariantMap multiConfig = -// { -// { QStringLiteral( "Layer" ), QStringLiteral( "subsub_df9d0ba0_2ec8_4a2c_9f96_84576e37c126" ) }, -// { QStringLiteral( "Key" ), QStringLiteral( "fid" ) }, -// { QStringLiteral( "Value" ), QStringLiteral( "Name" ) }, -// { QStringLiteral( "AllowMulti" ), true }, -// }; - -// ValueRelationController helper; -// helper.setConfig( singleConfig ); -// QSignalSpy resultSpy( &helper, &ValueRelationController::displayValuesReady ); - -// // Helper to extract the latest result from the spy -// auto latestResult = [&]() -> QVariantList { -// return resultSpy.last().at( 0 ).toList(); -// }; - -// // Single-value lookup: key 1 → one display label -// helper.lookupDisplayValues( QVariant( 1 ) ); -// QVERIFY( resultSpy.wait() ); -// QCOMPARE( latestResult().size(), 1 ); -// QVERIFY( !latestResult().at( 0 ).toString().isEmpty() ); - -// // Null field value: empty result emitted synchronously (no async work started). -// // QSignalSpy::wait() expects one *new* emission after the call, but this one -// // fires before wait() returns, so check the count directly instead. -// { -// const int countBefore = resultSpy.count(); -// helper.lookupDisplayValues( QVariant() ); -// QCOMPARE( resultSpy.count(), countBefore + 1 ); -// QVERIFY( latestResult().isEmpty() ); -// } - -// // Large FID key does not lose precision -// helper.lookupDisplayValues( QVariant( 100000000 ) ); -// QVERIFY( resultSpy.wait() ); -// QCOMPARE( latestResult().size(), 1 ); -// QCOMPARE( latestResult().at( 0 ).toString(), QStringLiteral( "VERYBIG" ) ); - -// // Multi-value lookup: "{1,2}" parsed with AllowMulti=true → two results -// { -// ValueRelationController multiHelper; -// QSignalSpy multiSpy( &multiHelper, &ValueRelationController::displayValuesReady ); -// multiHelper.setConfig( multiConfig ); -// multiHelper.lookupDisplayValues( QStringLiteral( "{1,2}" ) ); -// QVERIFY( multiSpy.wait() ); -// QCOMPARE( multiSpy.last().at( 0 ).toList().size(), 2 ); -// } -// } - -void TestModels::testValueRelationControllerInvalidation() {} -// { -// // Invalidation must fire when a FilterExpression is present and the lookup -// // returns nothing (value became unavailable due to a context change). -// // Without a FilterExpression, invalidation must NOT fire even for a missing key. - -// const QString projectDir = TestUtils::testDataDir() + "/project_value_relations"; -// QVERIFY( QgsProject::instance()->read( projectDir + "/proj.qgz" ) ); - -// QgsMapLayer *mainL = QgsProject::instance()->mapLayersByName( QStringLiteral( "main" ) ).at( 0 ); -// QgsVectorLayer *mainLayer = static_cast( mainL ); -// QVERIFY( mainLayer && mainLayer->isValid() ); - -// // ── No FilterExpression: no invalidate, even for a bogus key ────────── -// { -// const QVariantMap config = -// { -// { QStringLiteral( "Layer" ), QStringLiteral( "subsub_df9d0ba0_2ec8_4a2c_9f96_84576e37c126" ) }, -// { QStringLiteral( "Key" ), QStringLiteral( "fid" ) }, -// { QStringLiteral( "Value" ), QStringLiteral( "Name" ) }, -// }; - -// ValueRelationController helper; -// QSignalSpy invalidateSpy( &helper, &ValueRelationController::invalidate ); -// QSignalSpy resultSpy( &helper, &ValueRelationController::displayValuesReady ); -// helper.setConfig( config ); - -// helper.lookupDisplayValues( QVariant( 9999 ) ); // key that does not exist -// QVERIFY( resultSpy.wait() ); // wait for async to complete -// QVERIFY( invalidateSpy.isEmpty() ); // must NOT invalidate -// } - -// // ── With FilterExpression: invalidate when value is outside filtered set -// { -// // subFk=1 restricts subsub to only fid 1 and 2. -// // If the stored value is fid=5 (which has subFk=3), the lookup returns nothing -// // → should emit invalidate. -// const QVariantMap config = -// { -// { QStringLiteral( "Layer" ), QStringLiteral( "subsub_df9d0ba0_2ec8_4a2c_9f96_84576e37c126" ) }, -// { QStringLiteral( "Key" ), QStringLiteral( "fid" ) }, -// { QStringLiteral( "Value" ), QStringLiteral( "Name" ) }, -// { QStringLiteral( "FilterExpression" ), QStringLiteral( "subFk = 1" ) }, -// }; - -// ValueRelationController helper; -// QSignalSpy invalidateSpy( &helper, &ValueRelationController::invalidate ); -// QSignalSpy resultSpy( &helper, &ValueRelationController::displayValuesReady ); -// helper.setConfig( config ); - -// // fid=5 is not in the subFk=1 subset → should trigger invalidate -// helper.lookupDisplayValues( QVariant( 5 ) ); -// QVERIFY( resultSpy.wait() ); -// QCOMPARE( invalidateSpy.count(), 1 ); -// } - -// // ── With FilterExpression: no invalidate when value IS in filtered set ─ -// { -// const QVariantMap config = -// { -// { QStringLiteral( "Layer" ), QStringLiteral( "subsub_df9d0ba0_2ec8_4a2c_9f96_84576e37c126" ) }, -// { QStringLiteral( "Key" ), QStringLiteral( "fid" ) }, -// { QStringLiteral( "Value" ), QStringLiteral( "Name" ) }, -// { QStringLiteral( "FilterExpression" ), QStringLiteral( "subFk = 1" ) }, -// }; - -// ValueRelationController helper; -// QSignalSpy spy( &helper, &ValueRelationController::invalidate ); -// helper.setConfig( config ); - -// // From the fixture the "subFk=1" subset contains fid 1 and 2 (A1/A2 or similar). -// // We look up all features with that filter to find a valid key, then use it. -// ValueRelationFeaturesModel probeModel; -// QSignalSpy probeSpy( &probeModel, &LayerFeaturesModel::fetchingResultsChanged ); -// probeModel.setConfig( config ); -// probeModel.reloadFeatures(); -// probeSpy.wait(); -// QVERIFY( probeModel.rowCount() > 0 ); - -// const QVariant validKey = probeModel.data( probeModel.index( 0, 0 ), ValueRelationFeaturesModel::KeyColumn ); - -// QSignalSpy invalidateSpy( &helper, &ValueRelationController::invalidate ); -// QSignalSpy resultSpy( &helper, &ValueRelationController::displayValuesReady ); -// helper.lookupDisplayValues( validKey ); -// QVERIFY( resultSpy.wait() ); -// QVERIFY( !resultSpy.last().at( 0 ).toList().isEmpty() ); -// QVERIFY( invalidateSpy.isEmpty() ); -// } -// } + // Add a feature directly to the provider + QgsFeature newFeature( layer->fields() ); + newFeature.setAttribute( QStringLiteral( "key" ), 4 ); + newFeature.setAttribute( QStringLiteral( "label" ), QStringLiteral( "Label 4" ) ); + QVERIFY( layer->dataProvider()->addFeatures( QgsFeatureList() << newFeature ) ); + + // Manual hot reload: reloadFeatures() must pick up the added feature + spy.clear(); + model.reloadFeatures(); + waitForReload(); + QCOMPARE( model.rowCount(), 4 ); + QCOMPARE( model.data( model.index( 3, 0 ), ValueRelationFeaturesModel::KeyColumn ).toString(), + QStringLiteral( "4" ) ); + + // Auto-reload via featureAdded signal: + // startEditing + addFeature fires featureAdded, which LayerFeaturesModel connects to populate(). + // addFeature and commitChanges can each trigger a populate cycle (2 emissions each); + // wait until fetching has stopped (last emission is false) to avoid reading rowCount mid-flight. + spy.clear(); + layer->startEditing(); + QgsFeature editFeature( layer->fields() ); + editFeature.setAttribute( QStringLiteral( "key" ), 5 ); + editFeature.setAttribute( QStringLiteral( "label" ), QStringLiteral( "Label 5" ) ); + QVERIFY( layer->addFeature( editFeature ) ); + QVERIFY( layer->commitChanges() ); + while ( spy.isEmpty() || spy.last().at( 0 ).toBool() ) + QVERIFY( spy.wait( 5000 ) ); + QCOMPARE( model.rowCount(), 5 ); + + QgsProject::instance()->removeAllMapLayers(); +} void TestModels::testProjectsModel() { diff --git a/app/test/testmodels.h b/app/test/testmodels.h index fa5436655..a6ea309cf 100644 --- a/app/test/testmodels.h +++ b/app/test/testmodels.h @@ -23,9 +23,9 @@ class TestModels : public QObject void testStaticFeaturesModel(); void testLayerFeaturesModel(); void testLayerFeaturesModelSorted(); - void testValueRelationFeaturesModel(); - void testValueRelationController(); - void testValueRelationControllerInvalidation(); + void testValueRelationOrdering(); + void testValueRelationSearch(); + void testValueRelationHotreload(); void testProjectsModel(); void testProjectsProxyModel(); diff --git a/app/test/testutils.cpp b/app/test/testutils.cpp index 3c65c7ccb..96e684201 100644 --- a/app/test/testutils.cpp +++ b/app/test/testutils.cpp @@ -21,6 +21,7 @@ #include "qgsvectorlayer.h" #include "qgsproject.h" +#include "qgsfeature.h" #include "qgslayertree.h" #include "qgslayertreelayer.h" @@ -379,6 +380,61 @@ void TestUtils::testIsValidUrl() QVERIFY( !InputUtils::isValidUrl( "" ) ); // empty url is considered valid by QUrl but not by us } +QgsVectorLayer *TestUtils::createVRLookupLayer( int count ) +{ + auto *layer = new QgsVectorLayer( + QStringLiteral( "None?field=key:integer&field=label:string" ), + QStringLiteral( "vr_lookup" ), + QStringLiteral( "memory" ) + ); + if ( !layer || !layer->isValid() ) + return nullptr; + + QgsFeatureList features; + features.reserve( count ); + for ( int i = 1; i <= count; ++i ) + { + QgsFeature f( layer->fields() ); + f.setAttribute( QStringLiteral( "key" ), i ); + f.setAttribute( QStringLiteral( "label" ), QStringLiteral( "Label %1" ).arg( i ) ); + features << f; + } + layer->dataProvider()->addFeatures( features ); + return layer; +} + +QgsVectorLayer *TestUtils::createVROrderingLayer() +{ + auto *layer = new QgsVectorLayer( + QStringLiteral( "None?field=key:integer&field=label:string" ), + QStringLiteral( "vr_ordering" ), + QStringLiteral( "memory" ) + ); + if ( !layer || !layer->isValid() ) + return nullptr; + + struct Row { int key; QString label; }; + const QList rows = + { + {3, QStringLiteral( "Gamma" )}, + {1, QStringLiteral( "Alpha" )}, + {4, QStringLiteral( "Beta" )}, + {2, QStringLiteral( "Delta" )} + }; + + QgsFeatureList features; + features.reserve( rows.size() ); + for ( const auto &row : rows ) + { + QgsFeature f( layer->fields() ); + f.setAttribute( QStringLiteral( "key" ), row.key ); + f.setAttribute( QStringLiteral( "label" ), row.label ); + features << f; + } + layer->dataProvider()->addFeatures( features ); + return layer; +} + bool TestUtils::testExifPositionMetadataExists( const QString &imageSource ) { if ( !QFileInfo::exists( imageSource ) ) diff --git a/app/test/testutils.h b/app/test/testutils.h index 8643248fa..a5819f10c 100644 --- a/app/test/testutils.h +++ b/app/test/testutils.h @@ -49,6 +49,19 @@ namespace TestUtils QgsProject *loadPlanesTestProject(); + /** + * Creates an in-memory no-geometry lookup layer with \a count features. + * Fields: key (integer), label (string). + */ + QgsVectorLayer *createVRLookupLayer( int count ); + + /** + * Creates an in-memory no-geometry lookup layer for ordering tests. + * Features are inserted in an order that is neither key-sorted nor label-sorted: + * key=3 label="Gamma", key=1 label="Alpha", key=4 label="Beta", key=2 label="Delta" + */ + QgsVectorLayer *createVROrderingLayer(); + /** * Generates files and folders in rootPath based on json structure. * \param structure is a json instance, each object is considered as folder. Each folder can have a key named "files"