-
-
Notifications
You must be signed in to change notification settings - Fork 338
Added qml tests for the vertex editor geometry tool #7579
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kaustuvpokharel
wants to merge
5
commits into
master
Choose a base branch
from
vertex-pr
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+335
−0
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
f0d5c79
qml tests for the vertex editor geometry tool
kaustuvpokharel 1de558b
improveing vertex editor move/remove tests to assert exact outcomes
kaustuvpokharel cd33207
pin map extent in vertex tests so vertex moves clear the pixel in ci
kaustuvpokharel 884a251
debug points
kaustuvpokharel e08efc1
test vertex editor move on the vertex model geometry to avoid layer c…
kaustuvpokharel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,335 @@ | ||
| import QtQuick | ||
| import QtTest | ||
| import org.qfield | ||
| import org.qgis | ||
| import Theme | ||
| import "../../src/qml/geometryeditors" as GeometryEditors | ||
|
|
||
| // tests for the vertex editor tool. The vertex model itself is tested in | ||
| // test_vertexmodel.cpp, so here we only check the tool around it: init wiring, | ||
| // applyChanges and the autoSave branch, cancel, remove, and undo. | ||
| // | ||
| // canvasClicked is not tested here on purpose. It turns a screen point into a | ||
| // map coordinate through mapSettings.screenToCoordinate, which only makes sense | ||
| // with a real rendered canvas. without one the screen-to-map math is meaningless, | ||
| // so a click test would just be asserting made up numbers, so that path belongs in | ||
| // an integration (spix) test, not here. | ||
| // | ||
| // the tool reads a few things from its parent scope in the app, so we provide | ||
| // them here as plain items the same way tst_featureForm.qml does. | ||
| TestCase { | ||
| id: testCase | ||
| name: "GeometryEditorVertex" | ||
|
|
||
| property var fieldsLayer: qgisProject.mapLayersByName("Fields")[0] | ||
| property var tracksLayer: qgisProject.mapLayersByName("Tracks")[0] | ||
|
|
||
| // reset between tests. the tests assert on the vertex model geometry and do | ||
| // not commit, so this only needs to clear editing state and roll back any | ||
| // stray edit buffer. | ||
| function cleanup() { | ||
| vertexEditor.cancel(); | ||
| if (fieldsLayer.editBuffer()) { | ||
| fieldsLayer.rollBack(); | ||
| } | ||
| if (tracksLayer.editBuffer()) { | ||
| tracksLayer.rollBack(); | ||
| } | ||
| qfieldSettings.autoSave = false; | ||
| } | ||
|
|
||
| function makeFeatureModel(layer, fid) { | ||
| featureModel.currentLayer = layer; | ||
| featureModel.feature = layer.getFeature(fid); | ||
| featureModel.applyGeometryToVertexModel(); | ||
| return featureModel; | ||
| } | ||
|
|
||
| // Fields/39 is a simple polygon. its vertex model interleaves segment | ||
| // candidates with real vertices, so the real (existing) vertices sit at the | ||
| // odd indices and 1 is the first of them. | ||
| function makeFieldsModel() { | ||
| return makeFeatureModel(fieldsLayer, "39"); | ||
| } | ||
|
|
||
| // select the first real vertex and move it, so the model goes dirty like it | ||
| // would after a drag on the canvas. the layer is in meters so a meter-scale | ||
| // move is well above the tools ignore-tiny-moves threshold. | ||
| function selectAndMoveFirstVertex() { | ||
| const vertexModel = featureModel.vertexModel; | ||
| vertexModel.editingMode = VertexModel.EditVertex; | ||
| vertexModel.currentVertexIndex = 1; | ||
| const point = vertexModel.currentPoint; | ||
| vertexModel.currentPoint = GeometryUtils.point(point.x + 5, point.y + 5); | ||
| } | ||
|
|
||
| // pin a known extent and output size so mapUnitsPerPixel is deterministic on | ||
| // every platform. the vertex model ignores moves smaller than one pixel, so | ||
| // without a fixed map scale a move that registers locally can be dropped on a | ||
| // headless CI where the default map scale differs. | ||
| MapSettings { | ||
| id: mapSettingsItem | ||
| destinationCrs: CoordinateReferenceSystemUtils.fromDescription("EPSG:3857") | ||
| outputSize: Qt.size(600, 600) | ||
| extent: GeometryUtils.createRectangleFromPoints(GeometryUtils.point(1030800, 5911300), GeometryUtils.point(1031200, 5911700)) | ||
| } | ||
|
|
||
| FeatureModel { | ||
| id: featureModel | ||
| project: qgisProject | ||
|
|
||
| vertexModel: VertexModel { | ||
| id: geometryEditingVertexModel | ||
| } | ||
| } | ||
|
|
||
| GeometryEditors.VertexEditor { | ||
| id: vertexEditor | ||
| featureModel: featureModel | ||
| mapSettings: mapSettingsItem | ||
| } | ||
|
|
||
| function test_initWiresFeatureModelAndResetsCurrentVertex() { | ||
| const model = makeFieldsModel(); | ||
| model.vertexModel.currentVertexIndex = 3; | ||
|
|
||
| vertexEditor.init(model, mapSettingsItem, null, null); | ||
|
|
||
| compare(vertexEditor.featureModel, model); | ||
| // init should always start with no vertex selected | ||
| compare(model.vertexModel.currentVertexIndex, -1); | ||
| } | ||
|
|
||
| function test_blockingFollowsVertexModelDirtyState() { | ||
| const model = makeFieldsModel(); | ||
| vertexEditor.init(model, mapSettingsItem, null, null); | ||
| compare(vertexEditor.blocking, false); | ||
|
|
||
| selectAndMoveFirstVertex(); | ||
|
|
||
| // blocking is just dirty, so an unsaved edit should block | ||
| compare(model.vertexModel.dirty, true); | ||
| compare(vertexEditor.blocking, true); | ||
| } | ||
|
|
||
| // returns how many points in list a are not present (within tolerance) in | ||
| // list b. Comparing both directions makes this independent of vertex order or | ||
| // ring rotation, which a vertex move can introduce. | ||
| function pointsNotIn(a, b) { | ||
| let missing = 0; | ||
| for (let i = 0; i < a.length; ++i) { | ||
| let found = false; | ||
| for (let j = 0; j < b.length; ++j) { | ||
| if (Math.abs(a[i].x - b[j].x) < 0.001 && Math.abs(a[i].y - b[j].y) < 0.001) { | ||
| found = true; | ||
| break; | ||
| } | ||
| } | ||
| if (!found) | ||
| missing++; | ||
| } | ||
| return missing; | ||
| } | ||
|
|
||
| // reads the vertices out of a geometry's WKT as a plain array of points, so | ||
| // tests can compare geometries vertex by vertex. Uses asWkt only, which is the | ||
| // same accessor the other tests rely on. For a polygon ring the closing vertex | ||
| // repeats the first one, so we drop that duplicate to count each vertex once. | ||
| function geometryPoints(geometry) { | ||
| const points = []; | ||
| const wkt = geometry.asWkt(6); | ||
| // pull the coordinate pairs from inside the parentheses | ||
| const inner = wkt.substring(wkt.indexOf("("), wkt.lastIndexOf(")") + 1); | ||
| const pairs = inner.replace(/[()]/g, " ").trim().split(","); | ||
| for (let i = 0; i < pairs.length; ++i) { | ||
| const nums = pairs[i].trim().split(/\s+/); | ||
| if (nums.length >= 2 && nums[0] !== "") { | ||
| points.push({ | ||
| x: parseFloat(nums[0]), | ||
| y: parseFloat(nums[1]) | ||
| }); | ||
| } | ||
| } | ||
| // drop a repeated closing vertex (polygons repeat the first point at the end) | ||
| if (points.length > 1) { | ||
| const first = points[0]; | ||
| const last = points[points.length - 1]; | ||
| if (Math.abs(first.x - last.x) < 0.001 && Math.abs(first.y - last.y) < 0.001) { | ||
| points.pop(); | ||
| } | ||
| } | ||
| return points; | ||
| } | ||
|
|
||
| function test_movedVertexProducesExpectedGeometry() { | ||
| const model = makeFieldsModel(); | ||
| vertexEditor.init(model, mapSettingsItem, null, null); | ||
| const before = geometryPoints(model.vertexModel.geometry); | ||
|
|
||
| selectAndMoveFirstVertex(); | ||
|
|
||
| // a single vertex move produces the same polygon with exactly one vertex | ||
| // relocated: one original point gone, one new point present, count unchanged | ||
| const moved = model.vertexModel.geometry; | ||
| verify(!moved.isNull); | ||
| compare(moved.type, Qgis.GeometryType.Polygon); | ||
|
|
||
| const after = geometryPoints(moved); | ||
| compare(after.length, before.length); | ||
| compare(pointsNotIn(before, after), 1); | ||
| compare(pointsNotIn(after, before), 1); | ||
| } | ||
|
|
||
| function test_applyChangesWithAutoSaveOffDoesNotApply() { | ||
| qfieldSettings.autoSave = false; | ||
| const model = makeFieldsModel(); | ||
| vertexEditor.init(model, mapSettingsItem, null, null); | ||
|
|
||
| selectAndMoveFirstVertex(); | ||
| const before = model.feature.geometry.asWkt(); | ||
|
|
||
| // the next/prev/add buttons call applyChanges(autoSave). with autoSave off | ||
| // the edit stays in the model and is not pushed to the feature yet | ||
| vertexEditor.applyChanges(qfieldSettings.autoSave); | ||
|
|
||
| compare(model.feature.geometry.asWkt(), before); | ||
| compare(model.vertexModel.dirty, true); | ||
| } | ||
|
|
||
| function test_applyChangesNoOpWhenNotDirty() { | ||
| const model = makeFieldsModel(); | ||
| vertexEditor.init(model, mapSettingsItem, null, null); | ||
| compare(model.vertexModel.dirty, false); | ||
|
|
||
| const before = model.feature.geometry.asWkt(); | ||
|
|
||
| // nothing changed, so apply should do nothing | ||
| vertexEditor.applyChanges(true); | ||
|
|
||
| compare(model.feature.geometry.asWkt(), before); | ||
| } | ||
|
|
||
| function test_cancelResetsEditingModeAndClearsDirty() { | ||
| const model = makeFieldsModel(); | ||
| vertexEditor.init(model, mapSettingsItem, null, null); | ||
| selectAndMoveFirstVertex(); | ||
| compare(model.vertexModel.dirty, true); | ||
|
|
||
| vertexEditor.cancel(); | ||
|
|
||
| // cancel throws away the edit and leaves editing mode | ||
| compare(model.vertexModel.editingMode, VertexModel.NoEditing); | ||
| compare(model.vertexModel.dirty, false); | ||
| } | ||
|
|
||
| function test_removeVertexReducesCountAndSetsDirty() { | ||
| const model = makeFieldsModel(); | ||
| vertexEditor.init(model, mapSettingsItem, null, null); | ||
| const vertexModel = model.vertexModel; | ||
|
|
||
| vertexModel.editingMode = VertexModel.EditVertex; | ||
| vertexModel.currentVertexIndex = 1; | ||
| // the polygon has plenty of real vertices, well above the minimum, so | ||
| // removing one is allowed | ||
| verify(vertexModel.canRemoveVertex); | ||
| const countBefore = vertexModel.vertexCount; | ||
|
|
||
| vertexModel.removeCurrentVertex(); | ||
|
|
||
| // removing one existing vertex drops it and its paired segment candidate, so | ||
| // the row count goes down by exactly two, and the edit is marked dirty | ||
| compare(vertexModel.vertexCount, countBefore - 2); | ||
| compare(vertexModel.dirty, true); | ||
| } | ||
|
|
||
| function test_canRemoveVertexFalseWithoutEditMode() { | ||
| const model = makeFieldsModel(); | ||
| vertexEditor.init(model, mapSettingsItem, null, null); | ||
|
|
||
| // canRemoveVertex only holds while a vertex is being edited, never before | ||
| compare(model.vertexModel.editingMode, VertexModel.NoEditing); | ||
| compare(model.vertexModel.canRemoveVertex, false); | ||
| } | ||
|
|
||
| function test_undoRestoresGeometryAfterMove() { | ||
| const model = makeFieldsModel(); | ||
| vertexEditor.init(model, mapSettingsItem, null, null); | ||
| const vertexModel = model.vertexModel; | ||
|
|
||
| const before = vertexModel.currentPoint; | ||
| selectAndMoveFirstVertex(); | ||
| verify(vertexModel.canUndo); | ||
|
|
||
| vertexModel.undoHistory(); | ||
|
|
||
| // undo should put the moved vertex back where it started | ||
| vertexModel.currentVertexIndex = 1; | ||
| const restored = vertexModel.currentPoint; | ||
| compare(restored.x, before.x); | ||
| compare(restored.y, before.y); | ||
| } | ||
|
|
||
| function test_undoIsNoOpWithEmptyHistory() { | ||
| const model = makeFieldsModel(); | ||
| vertexEditor.init(model, mapSettingsItem, null, null); | ||
|
|
||
| // nothing has been edited, so there is nothing to undo | ||
| compare(model.vertexModel.canUndo, false); | ||
| model.vertexModel.undoHistory(); | ||
| compare(model.vertexModel.dirty, false); | ||
| } | ||
|
|
||
| function test_movedVertexProducesExpectedGeometryOnLine() { | ||
| // same path on a line feature, to cover the line geometry branch | ||
| const model = makeFeatureModel(tracksLayer, "1"); | ||
| vertexEditor.init(model, mapSettingsItem, null, null); | ||
| const vertexModel = model.vertexModel; | ||
|
|
||
| const before = geometryPoints(vertexModel.geometry); | ||
|
|
||
| // on a line the first real vertex is also at an odd index | ||
| vertexModel.editingMode = VertexModel.EditVertex; | ||
| vertexModel.currentVertexIndex = 1; | ||
| const point = vertexModel.currentPoint; | ||
| vertexModel.currentPoint = GeometryUtils.point(point.x + 5, point.y + 5); | ||
| compare(vertexModel.dirty, true); | ||
|
|
||
| // the vertex model geometry reflects the move directly, one vertex relocated | ||
| const moved = vertexModel.geometry; | ||
| verify(!moved.isNull); | ||
| compare(moved.type, Qgis.GeometryType.Line); | ||
|
|
||
| const after = geometryPoints(moved); | ||
| compare(after.length, before.length); | ||
| compare(pointsNotIn(before, after), 1); | ||
| compare(pointsNotIn(after, before), 1); | ||
| } | ||
|
kaustuvpokharel marked this conversation as resolved.
|
||
|
|
||
| // scope objects the tool expects from the app | ||
| Item { | ||
| id: mainWindowItem | ||
| } | ||
|
|
||
| Item { | ||
| id: qfieldSettings | ||
| property bool autoSave: false | ||
| } | ||
|
|
||
| Item { | ||
| id: coordinateLocator | ||
| property var currentCoordinate: GeometryUtils.point(0, 0) | ||
| property string positionInformation: "" | ||
| property string topSnappingResult: "" | ||
| property bool positionLocked: false | ||
| } | ||
|
|
||
| Item { | ||
| id: projectInfo | ||
| property string cloudUserInformation: "" | ||
| } | ||
|
|
||
| Item { | ||
| id: positionSource | ||
| property string positionInformation: "" | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.