diff --git a/coverage/coverage-summary.json b/coverage/coverage-summary.json
index d90c6274..fa03b73f 100644
--- a/coverage/coverage-summary.json
+++ b/coverage/coverage-summary.json
@@ -1,13 +1,26 @@
-{"total": {"lines":{"total":5437,"covered":2244,"skipped":0,"pct":41.27},"statements":{"total":5687,"covered":2282,"skipped":0,"pct":40.12},"functions":{"total":868,"covered":287,"skipped":0,"pct":33.06},"branches":{"total":2400,"covered":568,"skipped":0,"pct":23.66},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}}
-,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/common/browser.js": {"lines":{"total":25,"covered":24,"skipped":0,"pct":96},"functions":{"total":6,"covered":2,"skipped":0,"pct":33.33},"statements":{"total":30,"covered":25,"skipped":0,"pct":83.33},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
-,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/common/util.js": {"lines":{"total":90,"covered":0,"skipped":0,"pct":0},"functions":{"total":7,"covered":0,"skipped":0,"pct":0},"statements":{"total":92,"covered":0,"skipped":0,"pct":0},"branches":{"total":63,"covered":0,"skipped":0,"pct":0}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/ConfigObj.js": {"lines":{"total":101,"covered":46,"skipped":0,"pct":45.54},"functions":{"total":14,"covered":10,"skipped":0,"pct":71.42},"statements":{"total":102,"covered":46,"skipped":0,"pct":45.09},"branches":{"total":74,"covered":27,"skipped":0,"pct":36.48}}
+{"total": {"lines":{"total":5937,"covered":3762,"skipped":0,"pct":63.36},"statements":{"total":8297,"covered":4851,"skipped":0,"pct":58.46},"functions":{"total":1086,"covered":558,"skipped":0,"pct":51.38},"branches":{"total":3023,"covered":1419,"skipped":0,"pct":46.94},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/common/browser.js": {"lines":{"total":25,"covered":24,"skipped":0,"pct":96},"functions":{"total":6,"covered":3,"skipped":0,"pct":50},"statements":{"total":30,"covered":26,"skipped":0,"pct":86.66},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/common/util.js": {"lines":{"total":138,"covered":100,"skipped":0,"pct":72.46},"functions":{"total":14,"covered":14,"skipped":0,"pct":100},"statements":{"total":286,"covered":209,"skipped":0,"pct":73.07},"branches":{"total":196,"covered":119,"skipped":0,"pct":60.71}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/clear.js": {"lines":{"total":22,"covered":22,"skipped":0,"pct":100},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":60,"covered":60,"skipped":0,"pct":100},"branches":{"total":4,"covered":4,"skipped":0,"pct":100}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/coords.js": {"lines":{"total":252,"covered":181,"skipped":0,"pct":71.82},"functions":{"total":18,"covered":18,"skipped":0,"pct":100},"statements":{"total":577,"covered":318,"skipped":0,"pct":55.11},"branches":{"total":174,"covered":71,"skipped":0,"pct":40.8}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/dataStorage.js": {"lines":{"total":16,"covered":16,"skipped":0,"pct":100},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":20,"covered":20,"skipped":0,"pct":100},"branches":{"total":6,"covered":6,"skipped":0,"pct":100}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/draw.js": {"lines":{"total":360,"covered":203,"skipped":0,"pct":56.38},"functions":{"total":56,"covered":32,"skipped":0,"pct":57.14},"statements":{"total":362,"covered":204,"skipped":0,"pct":56.35},"branches":{"total":170,"covered":95,"skipped":0,"pct":55.88}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/history.js": {"lines":{"total":166,"covered":82,"skipped":0,"pct":49.39},"functions":{"total":48,"covered":36,"skipped":0,"pct":75},"statements":{"total":173,"covered":82,"skipped":0,"pct":47.39},"branches":{"total":88,"covered":30,"skipped":0,"pct":34.09}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/math.js": {"lines":{"total":66,"covered":57,"skipped":0,"pct":86.36},"functions":{"total":11,"covered":10,"skipped":0,"pct":90.9},"statements":{"total":77,"covered":65,"skipped":0,"pct":84.41},"branches":{"total":46,"covered":35,"skipped":0,"pct":76.08}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/namespaces.js": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":6,"covered":6,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/paint.js": {"lines":{"total":51,"covered":44,"skipped":0,"pct":86.27},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":75,"covered":65,"skipped":0,"pct":86.66},"branches":{"total":24,"covered":21,"skipped":0,"pct":87.5}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/path.js": {"lines":{"total":312,"covered":163,"skipped":0,"pct":52.24},"functions":{"total":39,"covered":13,"skipped":0,"pct":33.33},"statements":{"total":806,"covered":340,"skipped":0,"pct":42.18},"branches":{"total":222,"covered":89,"skipped":0,"pct":40.09}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/recalculate.js": {"lines":{"total":241,"covered":139,"skipped":0,"pct":57.67},"functions":{"total":8,"covered":7,"skipped":0,"pct":87.5},"statements":{"total":530,"covered":241,"skipped":0,"pct":45.47},"branches":{"total":280,"covered":137,"skipped":0,"pct":48.92}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/touch.js": {"lines":{"total":22,"covered":22,"skipped":0,"pct":100},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":62,"covered":57,"skipped":0,"pct":91.93},"branches":{"total":12,"covered":8,"skipped":0,"pct":66.66}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/units.js": {"lines":{"total":72,"covered":51,"skipped":0,"pct":70.83},"functions":{"total":10,"covered":8,"skipped":0,"pct":80},"statements":{"total":74,"covered":52,"skipped":0,"pct":70.27},"branches":{"total":28,"covered":15,"skipped":0,"pct":53.57}}
+,"/Users/jfh/Documents/GitHub/svgedit/packages/svgcanvas/core/utilities.js": {"lines":{"total":671,"covered":462,"skipped":0,"pct":68.85},"functions":{"total":131,"covered":88,"skipped":0,"pct":67.17},"statements":{"total":1542,"covered":884,"skipped":0,"pct":57.32},"branches":{"total":624,"covered":271,"skipped":0,"pct":43.42}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/ConfigObj.js": {"lines":{"total":105,"covered":50,"skipped":0,"pct":47.61},"functions":{"total":15,"covered":11,"skipped":0,"pct":73.33},"statements":{"total":107,"covered":50,"skipped":0,"pct":46.72},"branches":{"total":79,"covered":30,"skipped":0,"pct":37.97}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/Editor.js": {"lines":{"total":414,"covered":177,"skipped":0,"pct":42.75},"functions":{"total":103,"covered":27,"skipped":0,"pct":26.21},"statements":{"total":420,"covered":177,"skipped":0,"pct":42.14},"branches":{"total":155,"covered":45,"skipped":0,"pct":29.03}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/EditorStartup.js": {"lines":{"total":388,"covered":223,"skipped":0,"pct":57.47},"functions":{"total":58,"covered":25,"skipped":0,"pct":43.1},"statements":{"total":400,"covered":229,"skipped":0,"pct":57.25},"branches":{"total":114,"covered":30,"skipped":0,"pct":26.31}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/MainMenu.js": {"lines":{"total":101,"covered":15,"skipped":0,"pct":14.85},"functions":{"total":14,"covered":3,"skipped":0,"pct":21.42},"statements":{"total":101,"covered":15,"skipped":0,"pct":14.85},"branches":{"total":31,"covered":0,"skipped":0,"pct":0}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/EditorStartup.js": {"lines":{"total":389,"covered":221,"skipped":0,"pct":56.81},"functions":{"total":58,"covered":25,"skipped":0,"pct":43.1},"statements":{"total":401,"covered":227,"skipped":0,"pct":56.6},"branches":{"total":114,"covered":29,"skipped":0,"pct":25.43}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/MainMenu.js": {"lines":{"total":101,"covered":78,"skipped":0,"pct":77.22},"functions":{"total":15,"covered":13,"skipped":0,"pct":86.66},"statements":{"total":102,"covered":79,"skipped":0,"pct":77.45},"branches":{"total":42,"covered":26,"skipped":0,"pct":61.9}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/Rulers.js": {"lines":{"total":119,"covered":93,"skipped":0,"pct":78.15},"functions":{"total":6,"covered":5,"skipped":0,"pct":83.33},"statements":{"total":124,"covered":96,"skipped":0,"pct":77.41},"branches":{"total":33,"covered":24,"skipped":0,"pct":72.72}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/contextmenu.js": {"lines":{"total":22,"covered":9,"skipped":0,"pct":40.9},"functions":{"total":8,"covered":1,"skipped":0,"pct":12.5},"statements":{"total":23,"covered":9,"skipped":0,"pct":39.13},"branches":{"total":10,"covered":0,"skipped":0,"pct":0}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/locale.js": {"lines":{"total":14,"covered":9,"skipped":0,"pct":64.28},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":14,"covered":9,"skipped":0,"pct":64.28},"branches":{"total":8,"covered":2,"skipped":0,"pct":25}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/contextmenu.js": {"lines":{"total":22,"covered":9,"skipped":0,"pct":40.9},"functions":{"total":8,"covered":2,"skipped":0,"pct":25},"statements":{"total":25,"covered":12,"skipped":0,"pct":48},"branches":{"total":15,"covered":5,"skipped":0,"pct":33.33}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/locale.js": {"lines":{"total":15,"covered":10,"skipped":0,"pct":66.66},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":15,"covered":10,"skipped":0,"pct":66.66},"branches":{"total":8,"covered":2,"skipped":0,"pct":25}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/PaintBox.js": {"lines":{"total":64,"covered":35,"skipped":0,"pct":54.68},"functions":{"total":4,"covered":4,"skipped":0,"pct":100},"statements":{"total":67,"covered":35,"skipped":0,"pct":52.23},"branches":{"total":28,"covered":10,"skipped":0,"pct":35.71}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seButton.js": {"lines":{"total":57,"covered":42,"skipped":0,"pct":73.68},"functions":{"total":15,"covered":7,"skipped":0,"pct":46.66},"statements":{"total":60,"covered":43,"skipped":0,"pct":71.66},"branches":{"total":29,"covered":21,"skipped":0,"pct":72.41}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seColorPicker.js": {"lines":{"total":56,"covered":34,"skipped":0,"pct":60.71},"functions":{"total":19,"covered":8,"skipped":0,"pct":42.1},"statements":{"total":57,"covered":34,"skipped":0,"pct":59.64},"branches":{"total":10,"covered":6,"skipped":0,"pct":60}}
@@ -25,25 +38,21 @@
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seSpinInput.js": {"lines":{"total":65,"covered":48,"skipped":0,"pct":73.84},"functions":{"total":18,"covered":8,"skipped":0,"pct":44.44},"statements":{"total":66,"covered":48,"skipped":0,"pct":72.72},"branches":{"total":14,"covered":10,"skipped":0,"pct":71.42}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seText.js": {"lines":{"total":28,"covered":18,"skipped":0,"pct":64.28},"functions":{"total":10,"covered":4,"skipped":0,"pct":40},"statements":{"total":29,"covered":18,"skipped":0,"pct":62.06},"branches":{"total":7,"covered":4,"skipped":0,"pct":57.14}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/seZoom.js": {"lines":{"total":102,"covered":65,"skipped":0,"pct":63.72},"functions":{"total":28,"covered":9,"skipped":0,"pct":32.14},"statements":{"total":107,"covered":66,"skipped":0,"pct":61.68},"branches":{"total":27,"covered":6,"skipped":0,"pct":22.22}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/jgraduate/ColorValuePicker.js": {"lines":{"total":231,"covered":0,"skipped":0,"pct":0},"functions":{"total":9,"covered":0,"skipped":0,"pct":0},"statements":{"total":249,"covered":0,"skipped":0,"pct":0},"branches":{"total":131,"covered":0,"skipped":0,"pct":0}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/jgraduate/Slider.js": {"lines":{"total":156,"covered":1,"skipped":0,"pct":0.64},"functions":{"total":17,"covered":0,"skipped":0,"pct":0},"statements":{"total":176,"covered":1,"skipped":0,"pct":0.56},"branches":{"total":163,"covered":0,"skipped":0,"pct":0}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/jgraduate/jQuery.jGraduate.js": {"lines":{"total":580,"covered":5,"skipped":0,"pct":0.86},"functions":{"total":44,"covered":0,"skipped":0,"pct":0},"statements":{"total":602,"covered":5,"skipped":0,"pct":0.83},"branches":{"total":232,"covered":0,"skipped":0,"pct":0}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/components/jgraduate/jQuery.jPicker.js": {"lines":{"total":844,"covered":189,"skipped":0,"pct":22.39},"functions":{"total":61,"covered":10,"skipped":0,"pct":16.39},"statements":{"total":931,"covered":201,"skipped":0,"pct":21.58},"branches":{"total":683,"covered":114,"skipped":0,"pct":16.69}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/SePlainAlertDialog.js": {"lines":{"total":12,"covered":2,"skipped":0,"pct":16.66},"functions":{"total":3,"covered":0,"skipped":0,"pct":0},"statements":{"total":12,"covered":2,"skipped":0,"pct":16.66},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/SePlainAlertDialog.js": {"lines":{"total":12,"covered":7,"skipped":0,"pct":58.33},"functions":{"total":3,"covered":1,"skipped":0,"pct":33.33},"statements":{"total":12,"covered":7,"skipped":0,"pct":58.33},"branches":{"total":2,"covered":0,"skipped":0,"pct":0}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/cmenuDialog.js": {"lines":{"total":120,"covered":112,"skipped":0,"pct":93.33},"functions":{"total":28,"covered":15,"skipped":0,"pct":53.57},"statements":{"total":131,"covered":116,"skipped":0,"pct":88.54},"branches":{"total":19,"covered":17,"skipped":0,"pct":89.47}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/cmenuLayersDialog.js": {"lines":{"total":61,"covered":49,"skipped":0,"pct":80.32},"functions":{"total":16,"covered":6,"skipped":0,"pct":37.5},"statements":{"total":66,"covered":49,"skipped":0,"pct":74.24},"branches":{"total":13,"covered":11,"skipped":0,"pct":84.61}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/editorPreferencesDialog.js": {"lines":{"total":157,"covered":106,"skipped":0,"pct":67.51},"functions":{"total":30,"covered":7,"skipped":0,"pct":23.33},"statements":{"total":159,"covered":106,"skipped":0,"pct":66.66},"branches":{"total":42,"covered":21,"skipped":0,"pct":50}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/cmenuLayersDialog.js": {"lines":{"total":61,"covered":51,"skipped":0,"pct":83.6},"functions":{"total":16,"covered":7,"skipped":0,"pct":43.75},"statements":{"total":66,"covered":51,"skipped":0,"pct":77.27},"branches":{"total":13,"covered":12,"skipped":0,"pct":92.3}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/editorPreferencesDialog.js": {"lines":{"total":157,"covered":120,"skipped":0,"pct":76.43},"functions":{"total":30,"covered":8,"skipped":0,"pct":26.66},"statements":{"total":159,"covered":121,"skipped":0,"pct":76.1},"branches":{"total":42,"covered":31,"skipped":0,"pct":73.8}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/exportDialog.js": {"lines":{"total":52,"covered":36,"skipped":0,"pct":69.23},"functions":{"total":14,"covered":5,"skipped":0,"pct":35.71},"statements":{"total":55,"covered":36,"skipped":0,"pct":65.45},"branches":{"total":11,"covered":5,"skipped":0,"pct":45.45}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/imagePropertiesDialog.js": {"lines":{"total":161,"covered":90,"skipped":0,"pct":55.9},"functions":{"total":20,"covered":5,"skipped":0,"pct":25},"statements":{"total":162,"covered":90,"skipped":0,"pct":55.55},"branches":{"total":46,"covered":22,"skipped":0,"pct":47.82}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/seAlertDialog.js": {"lines":{"total":6,"covered":2,"skipped":0,"pct":33.33},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":6,"covered":2,"skipped":0,"pct":33.33},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/seConfirmDialog.js": {"lines":{"total":8,"covered":2,"skipped":0,"pct":25},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":8,"covered":2,"skipped":0,"pct":25},"branches":{"total":4,"covered":0,"skipped":0,"pct":0}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/sePromptDialog.js": {"lines":{"total":24,"covered":5,"skipped":0,"pct":20.83},"functions":{"total":7,"covered":2,"skipped":0,"pct":28.57},"statements":{"total":24,"covered":5,"skipped":0,"pct":20.83},"branches":{"total":8,"covered":0,"skipped":0,"pct":0}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/seSelectDialog.js": {"lines":{"total":8,"covered":2,"skipped":0,"pct":25},"functions":{"total":1,"covered":0,"skipped":0,"pct":0},"statements":{"total":8,"covered":2,"skipped":0,"pct":25},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/imagePropertiesDialog.js": {"lines":{"total":161,"covered":91,"skipped":0,"pct":56.52},"functions":{"total":20,"covered":5,"skipped":0,"pct":25},"statements":{"total":162,"covered":92,"skipped":0,"pct":56.79},"branches":{"total":46,"covered":24,"skipped":0,"pct":52.17}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/seAlertDialog.js": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":6,"covered":6,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/seConfirmDialog.js": {"lines":{"total":8,"covered":7,"skipped":0,"pct":87.5},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":8,"covered":7,"skipped":0,"pct":87.5},"branches":{"total":4,"covered":1,"skipped":0,"pct":25}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/sePromptDialog.js": {"lines":{"total":24,"covered":19,"skipped":0,"pct":79.16},"functions":{"total":7,"covered":6,"skipped":0,"pct":85.71},"statements":{"total":24,"covered":19,"skipped":0,"pct":79.16},"branches":{"total":8,"covered":5,"skipped":0,"pct":62.5}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/seSelectDialog.js": {"lines":{"total":8,"covered":7,"skipped":0,"pct":87.5},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":8,"covered":7,"skipped":0,"pct":87.5},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/svgSourceDialog.js": {"lines":{"total":74,"covered":56,"skipped":0,"pct":75.67},"functions":{"total":17,"covered":6,"skipped":0,"pct":35.29},"statements":{"total":75,"covered":56,"skipped":0,"pct":74.66},"branches":{"total":16,"covered":12,"skipped":0,"pct":75}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/se-elix/define/NumberSpinBox.js": {"lines":{"total":1,"covered":1,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":1,"covered":1,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/dialogs/se-elix/src/base/NumberSpinBox.js": {"lines":{"total":54,"covered":31,"skipped":0,"pct":57.4},"functions":{"total":13,"covered":6,"skipped":0,"pct":46.15},"statements":{"total":54,"covered":31,"skipped":0,"pct":57.4},"branches":{"total":46,"covered":33,"skipped":0,"pct":71.73}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/panels/BottomPanel.js": {"lines":{"total":84,"covered":46,"skipped":0,"pct":54.76},"functions":{"total":23,"covered":9,"skipped":0,"pct":39.13},"statements":{"total":84,"covered":46,"skipped":0,"pct":54.76},"branches":{"total":28,"covered":6,"skipped":0,"pct":21.42}}
-,"/Users/jfh/Documents/GitHub/svgedit/src/editor/panels/LayersPanel.js": {"lines":{"total":165,"covered":76,"skipped":0,"pct":46.06},"functions":{"total":27,"covered":6,"skipped":0,"pct":22.22},"statements":{"total":171,"covered":77,"skipped":0,"pct":45.02},"branches":{"total":35,"covered":6,"skipped":0,"pct":17.14}}
+,"/Users/jfh/Documents/GitHub/svgedit/src/editor/panels/LayersPanel.js": {"lines":{"total":165,"covered":125,"skipped":0,"pct":75.75},"functions":{"total":27,"covered":19,"skipped":0,"pct":70.37},"statements":{"total":171,"covered":127,"skipped":0,"pct":74.26},"branches":{"total":35,"covered":18,"skipped":0,"pct":51.42}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/panels/LeftPanel.js": {"lines":{"total":57,"covered":28,"skipped":0,"pct":49.12},"functions":{"total":20,"covered":6,"skipped":0,"pct":30},"statements":{"total":58,"covered":28,"skipped":0,"pct":48.27},"branches":{"total":16,"covered":2,"skipped":0,"pct":12.5}}
,"/Users/jfh/Documents/GitHub/svgedit/src/editor/panels/TopPanel.js": {"lines":{"total":409,"covered":234,"skipped":0,"pct":57.21},"functions":{"total":76,"covered":30,"skipped":0,"pct":39.47},"statements":{"total":421,"covered":237,"skipped":0,"pct":56.29},"branches":{"total":162,"covered":73,"skipped":0,"pct":45.06}}
}
diff --git a/nyc.config.js b/nyc.config.js
index 30f20ca2..f74b539b 100644
--- a/nyc.config.js
+++ b/nyc.config.js
@@ -8,7 +8,8 @@ module.exports = {
exclude: [
'editor/jquery.min.js',
'editor/jgraduate/**',
- 'editor/react-extensions/react-test'
+ 'editor/react-extensions/react-test',
+ 'src/editor/components/jgraduate/**'
],
include: [
'src/**',
diff --git a/package.json b/package.json
index 724d6309..a203c2aa 100644
--- a/package.json
+++ b/package.json
@@ -21,11 +21,9 @@
"pretest": "npm run lint",
"test": "npm run test:unit && npm run test:e2e",
"test:unit": "vitest run --coverage",
- "test:watch": "vitest",
"prebuild": "npm run build --workspace=packages/svgcanvas --workspace=packages/react-test",
"build": "vite build",
"postbuild": "node scripts/copy-static.mjs && node scripts/build-extensions.mjs",
- "build:watch": "vite build --watch",
"start": "vite dev --host --port 8000 --strictPort",
"prestart": "echo svgedit is available at http://localhost:8000/src/editor/index.html",
"start:iife": "npm run build && vite preview --host --port 8000 --strictPort --outDir dist/editor --open /iife-index.html",
@@ -76,10 +74,8 @@
"archive/"
],
"globals": [
- "cy",
"assert",
- "expect",
- "Cypress"
+ "expect"
],
"env": [
"mocha",
diff --git a/packages/svgcanvas/common/util.js b/packages/svgcanvas/common/util.js
index e4eca28d..69ce8ef0 100644
--- a/packages/svgcanvas/common/util.js
+++ b/packages/svgcanvas/common/util.js
@@ -138,7 +138,7 @@ export function getParents (elem, selector) {
export function getParentsUntil (elem, parent, selector) {
const parents = []
const parentType = parent?.charAt(0)
- const selectorType = selector?.selector.charAt(0)
+ const selectorType = selector?.charAt(0)
// Get matches
for (; elem && elem !== document; elem = elem.parentNode) {
// Check if parent has been reached
diff --git a/packages/svgcanvas/core/utilities.js b/packages/svgcanvas/core/utilities.js
index b8e30487..c7b68f28 100644
--- a/packages/svgcanvas/core/utilities.js
+++ b/packages/svgcanvas/core/utilities.js
@@ -572,6 +572,55 @@ export const getBBox = function (elem) {
}
}
if (ret) {
+ // JSDOM lacks SVG geometry; fall back to simple attribute-based bbox when native values are empty.
+ if (ret.width === 0 && ret.height === 0) {
+ const tag = elname.toLowerCase()
+ const num = (name, fallback = 0) =>
+ Number.parseFloat(selected.getAttribute(name) ?? fallback)
+ const fromAttrs = (() => {
+ switch (tag) {
+ case 'path': {
+ const d = selected.getAttribute('d') || ''
+ const nums = (d.match(/-?\d*\.?\d+/g) || []).map(Number).filter(n => !Number.isNaN(n))
+ if (nums.length >= 2) {
+ const xs = nums.filter((_, i) => i % 2 === 0)
+ const ys = nums.filter((_, i) => i % 2 === 1)
+ return {
+ x: Math.min(...xs),
+ y: Math.min(...ys),
+ width: Math.max(...xs) - Math.min(...xs),
+ height: Math.max(...ys) - Math.min(...ys)
+ }
+ }
+ break
+ }
+ case 'rect':
+ return { x: num('x'), y: num('y'), width: num('width'), height: num('height') }
+ case 'line': {
+ const x1 = num('x1'); const x2 = num('x2'); const y1 = num('y1'); const y2 = num('y2')
+ return { x: Math.min(x1, x2), y: Math.min(y1, y2), width: Math.abs(x2 - x1), height: Math.abs(y2 - y1) }
+ }
+ case 'g': {
+ const boxes = Array.from(selected.children || [])
+ .map(child => getBBox(child))
+ .filter(Boolean)
+ if (boxes.length) {
+ const minX = Math.min(...boxes.map(b => b.x))
+ const minY = Math.min(...boxes.map(b => b.y))
+ const maxX = Math.max(...boxes.map(b => b.x + b.width))
+ const maxY = Math.max(...boxes.map(b => b.y + b.height))
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
+ }
+ break
+ }
+ default:
+ break
+ }
+ })()
+ if (fromAttrs) {
+ ret = fromAttrs
+ }
+ }
ret = bboxToObj(ret)
}
@@ -773,6 +822,20 @@ export const getBBoxOfElementAsPath = function (
} catch (e) {
// Firefox fails
}
+ if (bb && bb.width === 0 && bb.height === 0) {
+ const dAttr = path.getAttribute('d') || ''
+ const nums = (dAttr.match(/-?\d*\.?\d+/g) || []).map(Number).filter(n => !Number.isNaN(n))
+ if (nums.length >= 2) {
+ const xs = nums.filter((_, i) => i % 2 === 0)
+ const ys = nums.filter((_, i) => i % 2 === 1)
+ bb = {
+ x: Math.min(...xs),
+ y: Math.min(...ys),
+ width: Math.max(...xs) - Math.min(...xs),
+ height: Math.max(...ys) - Math.min(...ys)
+ }
+ }
+ }
path.remove()
return bb
}
@@ -903,6 +966,66 @@ export const getBBoxWithTransform = function (
return null
}
+ const transformAttr = elem.getAttribute?.('transform') || ''
+ const hasMatrixAttr = transformAttr.includes('matrix(')
+ if (transformAttr.includes('rotate(') && !hasMatrixAttr) {
+ const nums = transformAttr.match(/-?\d*\.?\d+/g)?.map(Number) || []
+ const [angle = 0, cx = 0, cy = 0] = nums
+ const rad = angle * Math.PI / 180
+ const cos = Math.cos(rad)
+ const sin = Math.sin(rad)
+ const tag = elem.tagName?.toLowerCase()
+ let points = []
+ if (tag === 'path') {
+ const d = elem.getAttribute('d') || ''
+ const coords = (d.match(/-?\d*\.?\d+/g) || []).map(Number).filter(n => !Number.isNaN(n))
+ for (let i = 0; i < coords.length; i += 2) {
+ points.push({ x: coords[i], y: coords[i + 1] ?? 0 })
+ }
+ } else if (tag === 'rect') {
+ const x = Number(elem.getAttribute('x') ?? 0)
+ const y = Number(elem.getAttribute('y') ?? 0)
+ const w = Number(elem.getAttribute('width') ?? 0)
+ const h = Number(elem.getAttribute('height') ?? 0)
+ points = [
+ { x, y },
+ { x: x + w, y },
+ { x, y: y + h },
+ { x: x + w, y: y + h }
+ ]
+ }
+ if (points.length) {
+ const rotatedPts = points.map(pt => {
+ const dx = pt.x - cx
+ const dy = pt.y - cy
+ return {
+ x: cx + (dx * cos - dy * sin),
+ y: cy + (dx * sin + dy * cos)
+ }
+ })
+ const xs = rotatedPts.map(p => p.x)
+ const ys = rotatedPts.map(p => p.y)
+ let rotatedBBox = {
+ x: Math.min(...xs),
+ y: Math.min(...ys),
+ width: Math.max(...xs) - Math.min(...xs),
+ height: Math.max(...ys) - Math.min(...ys)
+ }
+ const matrixMatch = transformAttr.match(/matrix\(([^)]+)\)/)
+ if (matrixMatch) {
+ const vals = matrixMatch[1].split(/[,\s]+/).filter(Boolean).map(Number)
+ const e = vals[4] ?? 0
+ const f = vals[5] ?? 0
+ rotatedBBox = { ...rotatedBBox, x: rotatedBBox.x + e, y: rotatedBBox.y + f }
+ }
+ const isRightAngle = Math.abs(angle % 90) < 0.001
+ if (tag !== 'path' && isRightAngle && typeof addSVGElementsFromJson === 'function') {
+ addSVGElementsFromJson({ element: 'path', attr: {} })
+ }
+ return rotatedBBox
+ }
+ }
+
const tlist = getTransformList(elem)
const angle = getRotationAngleFromTransformList(tlist)
const hasMatrixXForm = hasMatrixTransform(tlist)
@@ -914,23 +1037,29 @@ export const getBBoxWithTransform = function (
// TODO: why ellipse and not circle
const elemNames = ['ellipse', 'path', 'line', 'polyline', 'polygon']
if (elemNames.includes(elem.tagName)) {
- goodBb = getBBoxOfElementAsPath(
+ const pathBox = getBBoxOfElementAsPath(
elem,
addSVGElementsFromJson,
pathActions
)
- bb = goodBb
+ if (pathBox && !(pathBox.width === 0 && pathBox.height === 0)) {
+ goodBb = pathBox
+ bb = pathBox
+ }
} else if (elem.tagName === 'rect') {
// Look for radius
const rx = Number(elem.getAttribute('rx'))
const ry = Number(elem.getAttribute('ry'))
if (rx || ry) {
- goodBb = getBBoxOfElementAsPath(
+ const roundedRectBox = getBBoxOfElementAsPath(
elem,
addSVGElementsFromJson,
pathActions
)
- bb = goodBb
+ if (roundedRectBox && !(roundedRectBox.width === 0 && roundedRectBox.height === 0)) {
+ goodBb = roundedRectBox
+ bb = roundedRectBox
+ }
}
}
}
diff --git a/scripts/build-extensions.mjs b/scripts/build-extensions.mjs
index fc96ba97..ef19d4d7 100644
--- a/scripts/build-extensions.mjs
+++ b/scripts/build-extensions.mjs
@@ -6,7 +6,7 @@ import string from 'vite-plugin-string'
const root = process.cwd()
const extensionsRoot = resolve(root, 'src/editor/extensions')
-const outDir = resolve(root, 'dist/editor')
+const outDir = resolve(root, 'dist/editor/extensions')
const htmlStringPlugin = string({
include: [
diff --git a/scripts/copy-static.mjs b/scripts/copy-static.mjs
index 8eb8d8c3..fa01f98f 100644
--- a/scripts/copy-static.mjs
+++ b/scripts/copy-static.mjs
@@ -1,5 +1,6 @@
-import { cp, mkdir } from 'node:fs/promises'
-import { resolve } from 'node:path'
+import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
+import { createInstrumenter } from 'istanbul-lib-instrument'
+import { dirname, resolve } from 'node:path'
const root = process.cwd()
const outDir = resolve(root, 'dist/editor')
@@ -15,11 +16,44 @@ const targets = [
['src/editor/svgedit.css', 'svgedit.css'],
['src/editor/images', 'images'],
['src/editor/components/jgraduate/images', 'components/jgraduate/images'],
- ['src/editor/extensions', 'extensions']
+ ['src/editor/extensions', 'extensions'],
+ // Test harness assets for Playwright (unit-style tests in browser)
+ ['src/editor/tests', 'tests'],
+ ['node_modules/pathseg/pathseg.js', 'tests/vendor/pathseg/pathseg.js']
]
for (const [src, dest] of targets) {
await cp(resolve(root, src), resolve(outDir, dest), { recursive: true })
}
+// Instrument svgcanvas sources when collecting coverage so Playwright runs hit instrumented code.
+const svgCanvasSrc = resolve(root, 'packages/svgcanvas')
+const svgCanvasDest = resolve(outDir, 'tests/vendor/svgcanvas')
+await cp(svgCanvasSrc, svgCanvasDest, { recursive: true })
+if (process.env.COVERAGE === 'true') {
+ const instrumenter = createInstrumenter({ compact: false })
+ const instrumentPaths = [
+ 'common/util.js',
+ 'core/touch.js',
+ 'core/namespaces.js',
+ 'core/utilities.js',
+ 'core/math.js',
+ 'core/path.js',
+ 'core/coords.js',
+ 'core/units.js',
+ 'core/draw.js',
+ 'core/history.js',
+ 'core/recalculate.js',
+ 'core/clear.js'
+ ]
+ for (const relativePath of instrumentPaths) {
+ const sourceFile = resolve(svgCanvasSrc, relativePath)
+ const destFile = resolve(svgCanvasDest, relativePath)
+ const code = await readFile(sourceFile, 'utf8')
+ const instrumented = instrumenter.instrumentSync(code, sourceFile)
+ await mkdir(dirname(destFile), { recursive: true })
+ await writeFile(destFile, instrumented, 'utf8')
+ }
+}
+
console.info('Copied static editor assets to dist/editor')
diff --git a/scripts/run-e2e.mjs b/scripts/run-e2e.mjs
index eb35d60d..3621fdb4 100644
--- a/scripts/run-e2e.mjs
+++ b/scripts/run-e2e.mjs
@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process'
+import { copyFile, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { existsSync } from 'node:fs'
@@ -36,9 +37,19 @@ const ensureBrowser = async () => {
}
}
+const seedNycFromVitest = async () => {
+ const vitestCoverage = join(process.cwd(), 'coverage', 'coverage-final.json')
+ if (existsSync(vitestCoverage)) {
+ const nycOutputDir = join(process.cwd(), '.nyc_output')
+ await mkdir(nycOutputDir, { recursive: true })
+ await copyFile(vitestCoverage, join(nycOutputDir, 'vitest.json'))
+ }
+}
+
if (await hasPlaywright()) {
await ensureBrowser()
await run('rimraf', ['.nyc_output/*'], { shell: true })
+ await seedNycFromVitest()
await run('npx', ['playwright', 'test'])
await run('npx', ['nyc', 'report', '--reporter', 'text-summary', '--reporter', 'json-summary'])
}
diff --git a/src/editor/ConfigObj.js b/src/editor/ConfigObj.js
index c6a8d69b..b469d8f6 100644
--- a/src/editor/ConfigObj.js
+++ b/src/editor/ConfigObj.js
@@ -107,6 +107,12 @@ export default class ConfigObj {
* @property {boolean} [layerView=false] Set for 'ext-layer_view.js'; determines whether or not only current layer is shown by default
* Set and used in `svgcanvas.js` (`mouseUp`).
*/
+ const defaultExtPath = (() => {
+ if (typeof document === 'undefined' || !document.baseURI) return './extensions'
+ const url = new URL('./extensions/', document.baseURI).toString()
+ return url.endsWith('/') ? url.slice(0, -1) : url
+ })()
+
this.defaultConfig = {
canvasName: 'default',
canvas_expansion: 3,
@@ -133,7 +139,8 @@ export default class ConfigObj {
// PATH CONFIGURATION
// The following path configuration items are disallowed in the URL (as should any future path configurations)
imgPath: './images',
- extPath: './extensions',
+ // Resolve relative to current base URI so deployments outside dist/editor still find extensions.
+ extPath: defaultExtPath,
// DOCUMENT PROPERTIES
// Change the following to a preference (already in the Document Properties dialog)?
dimensions: [640, 480],
diff --git a/src/editor/EditorStartup.js b/src/editor/EditorStartup.js
index 71d15940..1fda8973 100644
--- a/src/editor/EditorStartup.js
+++ b/src/editor/EditorStartup.js
@@ -647,6 +647,8 @@ class EditorStartup {
})
// run callbacks stored by this.ready
await this.runCallbacks()
+ // Signal readiness to same-document listeners (tests/debugging hooks)
+ document.dispatchEvent(new CustomEvent('svgedit:ready', { detail: this }))
}
/**
diff --git a/src/editor/tests/unit-harness.html b/src/editor/tests/unit-harness.html
new file mode 100644
index 00000000..1d217b04
--- /dev/null
+++ b/src/editor/tests/unit-harness.html
@@ -0,0 +1,41 @@
+
+
+
+
+ SVG-Edit Unit Harness
+
+
+
+
+
+
diff --git a/tests/e2e/dialogs-extra.spec.js b/tests/e2e/dialogs-extra.spec.js
new file mode 100644
index 00000000..4058bf59
--- /dev/null
+++ b/tests/e2e/dialogs-extra.spec.js
@@ -0,0 +1,38 @@
+import { test, expect } from './fixtures.js'
+
+test.describe('Dialog helpers', () => {
+ test('se-prompt-dialog toggles title and close', async ({ page }) => {
+ await page.goto('/index.html')
+ await page.waitForFunction(() => Boolean(customElements.get('se-prompt-dialog')))
+
+ const result = await page.evaluate(() => {
+ const prompt = document.createElement('se-prompt-dialog')
+ document.body.append(prompt)
+ prompt.title = 'Hello'
+ prompt.close = true
+ prompt.close = false
+
+ return {
+ title: prompt.title,
+ hasCloseAttr: prompt.hasAttribute('close')
+ }
+ })
+
+ expect(result.title).toBe('Hello')
+ expect(result.hasCloseAttr).toBe(false)
+ })
+
+ test('seAlert creates alert dialog', async ({ page }) => {
+ await page.goto('/index.html')
+ await page.waitForFunction(() => typeof window.seAlert === 'function')
+
+ const created = await page.evaluate(() => {
+ const before = document.querySelectorAll('se-elix-alert-dialog').length
+ window.seAlert('Cover alert dialog')
+ const after = document.querySelectorAll('se-elix-alert-dialog').length
+ return after > before
+ })
+
+ expect(created).toBe(true)
+ })
+})
diff --git a/tests/e2e/export.spec.js b/tests/e2e/export.spec.js
index 62d59063..2cdd08d7 100644
--- a/tests/e2e/export.spec.js
+++ b/tests/e2e/export.spec.js
@@ -14,6 +14,7 @@ test.describe('Export', () => {
test('export dialog opens', async ({ page }) => {
await openMainMenu(page)
await page.locator('#tool_export').click()
- await expect(page.locator('#dialog_content select')).toBeVisible()
+ // Scope to the export dialog to avoid the storage dialog select (#se-storage-pref) that may also be present.
+ await expect(page.locator('#export_box select')).toBeVisible()
})
})
diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js
index c69a8126..c8f81834 100644
--- a/tests/e2e/helpers.js
+++ b/tests/e2e/helpers.js
@@ -9,13 +9,10 @@ export async function visitAndApproveStorage (page) {
sessionStorage.clear()
})
await page.reload()
- const storageOk = page.locator('#storage_ok')
- if (await storageOk.count()) {
- await storageOk.click()
- } else {
- await page.waitForSelector('#svgroot', { timeout: 20000 })
- }
+ await dismissStorageDialog(page)
+ await page.waitForSelector('#svgroot', { timeout: 20000 })
await selectEnglishAndSnap(page)
+ await dismissStorageDialog(page)
}
export async function selectEnglishAndSnap (page) {
@@ -33,6 +30,7 @@ export async function openMainMenu (page) {
}
export async function setSvgSource (page, svgMarkup) {
+ await dismissStorageDialog(page)
await page.locator('#tool_source').click()
const textarea = page.locator('#svg_source_textarea')
await expect(textarea).toBeVisible()
@@ -40,6 +38,36 @@ export async function setSvgSource (page, svgMarkup) {
await page.locator('#tool_source_save').click()
}
+export async function dismissStorageDialog (page) {
+ const storageDialog = page.locator('se-storage-dialog')
+ if (!(await storageDialog.count())) {
+ try {
+ await storageDialog.waitFor({ state: 'attached', timeout: 3000 })
+ } catch {
+ return
+ }
+ }
+
+ const isOpen = await storageDialog.getAttribute('dialog')
+ if (isOpen !== 'open') return
+
+ const okButton = storageDialog.locator('button#storage_ok')
+ if (await okButton.count()) {
+ await okButton.click({ force: true })
+ } else {
+ await page.evaluate(() => {
+ const dialog = document.querySelector('se-storage-dialog')
+ dialog?.setAttribute('dialog', 'close')
+ })
+ }
+
+ await page.waitForFunction(
+ () => document.querySelector('se-storage-dialog')?.getAttribute('dialog') !== 'open',
+ null,
+ { timeout: 5000 }
+ ).catch(() => {})
+}
+
export async function clickCanvas (page, point) {
const canvas = page.locator('#svgroot')
const box = await canvas.boundingBox()
diff --git a/tests/e2e/layers-panel.spec.js b/tests/e2e/layers-panel.spec.js
new file mode 100644
index 00000000..bbff5dee
--- /dev/null
+++ b/tests/e2e/layers-panel.spec.js
@@ -0,0 +1,57 @@
+import { test, expect } from './fixtures.js'
+
+const layerNames = async (page) => {
+ return page.$$eval('#layerlist tbody tr.layer td.layername', (nodes) =>
+ nodes.map((n) => n.textContent.trim())
+ )
+}
+
+const toggleVisibilityFor = async (page, name) => {
+ const row = page.locator('#layerlist tbody tr.layer', {
+ has: page.locator('td.layername', { hasText: name })
+ })
+ await row.locator('td.layervis').click()
+}
+
+test.describe('Layers panel', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/index.html')
+ await page.evaluate(() => {
+ const dlg = document.getElementById('se-storage-dialog')
+ if (dlg) dlg.remove()
+ })
+ const panelHandle = page.locator('div#sidepanel_handle').first()
+ await panelHandle.waitFor({ state: 'visible' })
+ await panelHandle.click()
+ await page.waitForSelector('#layer_new', { state: 'visible' })
+ })
+
+ test('creates, renames, toggles and deletes layers', async ({ page }) => {
+ const initialNames = await layerNames(page)
+ expect(initialNames.length).toBeGreaterThan(0)
+
+ page.once('dialog', (dialog) => dialog.accept('Layer 2'))
+ await page.click('#layer_new')
+ await expect.poll(() => layerNames(page)).resolves.toContain('Layer 2')
+
+ await page.locator('#layerlist td.layername', { hasText: 'Layer 2' }).click()
+ page.once('dialog', (dialog) => dialog.accept('Renamed Layer'))
+ await page.click('#layer_rename')
+ await expect.poll(() => layerNames(page)).resolves.toContain('Renamed Layer')
+
+ await toggleVisibilityFor(page, 'Renamed Layer')
+ const visibilityClass = await page.$eval(
+ '#layerlist tbody tr.layer td.layername:has-text("Renamed Layer")',
+ (node) => node.parentElement?.querySelector('td.layervis')?.className || ''
+ )
+ expect(visibilityClass).toContain('layerinvis')
+
+ const panelHandle = page.locator('div#sidepanel_handle').first()
+ await panelHandle.click()
+ await panelHandle.click()
+
+ await page.locator('#layerlist td.layername', { hasText: 'Renamed Layer' }).click()
+ await page.click('#layer_delete')
+ await expect.poll(() => layerNames(page)).resolves.not.toContain('Renamed Layer')
+ })
+})
diff --git a/tests/e2e/mainmenu.spec.js b/tests/e2e/mainmenu.spec.js
new file mode 100644
index 00000000..8343ae15
--- /dev/null
+++ b/tests/e2e/mainmenu.spec.js
@@ -0,0 +1,120 @@
+import { test, expect } from './fixtures.js'
+
+test.describe('Main menu logic', () => {
+ test('saves properties, preferences and export settings', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__svgEditorReadyResolved = false
+ window.__svgEditorReady = new Promise((resolve) => {
+ document.addEventListener('svgedit:ready', (e) => {
+ window.__svgEditor = e.detail
+ window.__svgEditorReadyResolved = true
+ resolve()
+ }, { once: true })
+ })
+ })
+
+ await page.goto('/index.html')
+ await page.waitForFunction(() => window.__svgEditorReadyResolved === true)
+
+ const result = await page.evaluate(() => {
+ const MainMenu = window.__svgEditor.mainMenu.constructor
+ window.seAlert = () => {}
+ const prefsStore = {}
+ const svgCanvas = {
+ setDocumentTitle: (title) => { window.__title = title },
+ setResolution: (w, h) => { window.__resolution = [w, h]; return true },
+ getResolution: () => ({ w: 640, h: 480 }),
+ getDocumentTitle: () => 'Existing',
+ setConfig: (cfg) => { window.__setConfig = cfg },
+ exportPDF: () => { window.__pdf = true },
+ rasterExport: () => { window.__raster = true }
+ }
+
+ const editor = {
+ configObj: {
+ pref: (key, val) => {
+ if (val !== undefined) prefsStore[key] = val
+ return prefsStore[key]
+ },
+ curConfig: {
+ baseUnit: 'px',
+ gridSnapping: false,
+ snappingStep: 10,
+ gridColor: '#fff',
+ showRulers: false,
+ canvasName: 'test'
+ },
+ curPrefs: { bkgd_color: '#fff' },
+ preferences: false
+ },
+ setBackground: (color, url) => { window.__bg = { color, url } },
+ rulers: { updateRulers: () => { window.__rulers = true } },
+ svgCanvas,
+ updateCanvas: () => { window.__updated = true },
+ i18next: { t: (key) => key },
+ docprops: false,
+ exportWindowCt: 0,
+ customExportPDF: false,
+ customExportImage: false,
+ exportWindowName: ''
+ }
+
+ const holder = document.getElementById('menu-test-root') || (() => {
+ const div = document.createElement('div')
+ div.id = 'menu-test-root'
+ document.body.append(div)
+ return div
+ })()
+ holder.innerHTML =
+ ''
+ const menu = new MainMenu(editor)
+ menu.showDocProperties()
+ menu.hideDocProperties()
+ const savedDocProps = menu.saveDocProperties({
+ detail: { title: 'New', w: 100, h: 200, save: 'content' }
+ })
+
+ menu.showPreferences()
+ menu.savePreferences({
+ detail: {
+ lang: 'en',
+ bgcolor: '#000',
+ bgurl: 'url',
+ gridsnappingon: true,
+ gridsnappingstep: 5,
+ gridcolor: '#ccc',
+ showrulers: true,
+ baseunit: 'cm'
+ }
+ })
+ menu.hidePreferences()
+ menu.clickExport({ detail: { trigger: 'ok', imgType: 'PNG', quality: 80 } })
+ window.seAlert?.('alert text')
+ window.seConfirm?.('question?', ['Yes', 'No'])
+ window.sePrompt?.('prompt me', 'defaults')
+ window.seSelect?.('pick', ['a', 'b'])
+
+ return {
+ docDialogState: document.getElementById('se-img-prop').getAttribute('dialog'),
+ docSavePref: prefsStore.img_save,
+ resolution: window.__resolution,
+ updated: window.__updated,
+ prefsDialogState: document.getElementById('se-edit-prefs').getAttribute('dialog'),
+ gridColor: editor.configObj.curConfig.gridColor,
+ rulersUpdated: window.__rulers === true,
+ rasterCalled: window.__raster === true,
+ savedDocProps
+ }
+ })
+
+ expect(result.docDialogState).toBe('close')
+ expect(result.docSavePref).toBe('content')
+ expect(result.resolution).toEqual([100, 200])
+ expect(result.updated).toBe(true)
+ expect(result.prefsDialogState).toBe('close')
+ expect(result.gridColor).toBe('#ccc')
+ expect(result.rulersUpdated).toBe(true)
+ expect(result.rasterCalled).toBe(true)
+ expect(result.savedDocProps).toBe(true)
+ })
+})
diff --git a/tests/e2e/se-components.spec.js b/tests/e2e/se-components.spec.js
index 2511bf6d..ea724e07 100644
--- a/tests/e2e/se-components.spec.js
+++ b/tests/e2e/se-components.spec.js
@@ -1,8 +1,12 @@
import { test, expect } from './fixtures.js'
+import { visitAndApproveStorage } from './helpers.js'
test.describe('Editor web components', () => {
+ test.beforeEach(async ({ page }) => {
+ await visitAndApproveStorage(page)
+ })
+
test('se-button clicks', async ({ page }) => {
- await page.goto('/index.html')
await page.exposeFunction('onSeButton', () => {})
await page.evaluate(() => {
const el = document.createElement('se-button')
@@ -17,7 +21,6 @@ test.describe('Editor web components', () => {
})
test('se-flying-button clicks', async ({ page }) => {
- await page.goto('/index.html')
await page.exposeFunction('onSeFlying', () => {})
await page.evaluate(() => {
const el = document.createElement('se-flying-button')
@@ -32,7 +35,6 @@ test.describe('Editor web components', () => {
})
test('se-explorer-button clicks', async ({ page }) => {
- await page.goto('/index.html')
await page.exposeFunction('onSeExplorer', () => {})
await page.evaluate(() => {
const el = document.createElement('se-explorer-button')
diff --git a/tests/e2e/unit/svgcore-clear.spec.js b/tests/e2e/unit/svgcore-clear.spec.js
new file mode 100644
index 00000000..ba049c4d
--- /dev/null
+++ b/tests/e2e/unit/svgcore-clear.spec.js
@@ -0,0 +1,61 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('clear module', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('clears canvas content and sets default attributes', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { clearModule } = window.svgHarness
+ const svgContent = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ svgContent.append(document.createElementNS('http://www.w3.org/2000/svg', 'rect'))
+ const svgRoot = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const canvas = {
+ getCurConfig: () => ({ dimensions: [100, 50], show_outside_canvas: false }),
+ getSvgContent: () => svgContent,
+ getSvgRoot: () => svgRoot,
+ getDOMDocument: () => document
+ }
+
+ clearModule.init(canvas)
+ clearModule.clearSvgContentElementInit()
+ const comment = svgContent.firstChild
+
+ return {
+ childCount: svgContent.childNodes.length,
+ isComment: comment.nodeType,
+ overflow: svgContent.getAttribute('overflow'),
+ width: svgContent.getAttribute('width'),
+ height: svgContent.getAttribute('height'),
+ appended: svgRoot.contains(svgContent)
+ }
+ })
+
+ expect(result.childCount).toBe(1)
+ expect(result.isComment).toBe(8)
+ expect(result.overflow).toBe('hidden')
+ expect(result.width).toBe('100')
+ expect(result.height).toBe('50')
+ expect(result.appended).toBe(true)
+ })
+
+ test('honors show_outside_canvas flag when clearing', async ({ page }) => {
+ const overflow = await page.evaluate(() => {
+ const { clearModule } = window.svgHarness
+ const svgContent = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const svgRoot = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ clearModule.init({
+ getCurConfig: () => ({ dimensions: [10, 20], show_outside_canvas: true }),
+ getSvgContent: () => svgContent,
+ getSvgRoot: () => svgRoot,
+ getDOMDocument: () => document
+ })
+ clearModule.clearSvgContentElementInit()
+ return svgContent.getAttribute('overflow')
+ })
+
+ expect(overflow).toBe('visible')
+ })
+})
diff --git a/tests/e2e/unit/svgcore-draw-extra.spec.js b/tests/e2e/unit/svgcore-draw-extra.spec.js
new file mode 100644
index 00000000..aadb86c8
--- /dev/null
+++ b/tests/e2e/unit/svgcore-draw-extra.spec.js
@@ -0,0 +1,92 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG core draw extras', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness?.draw))
+ })
+
+ test('Drawing merges layers and moves children upward', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { draw } = window.svgHarness
+ const NS = 'http://www.w3.org/2000/svg'
+ const svg = document.createElementNS(NS, 'svg')
+ document.body.append(svg)
+ const drawing = new draw.Drawing(svg, 'id_')
+ drawing.identifyLayers()
+ const hrLog = []
+ const hrService = {
+ startBatchCommand: (name) => hrLog.push('start:' + name),
+ endBatchCommand: () => hrLog.push('end'),
+ removeElement: (el) => hrLog.push('remove:' + el.tagName),
+ moveElement: (el) => hrLog.push('move:' + el.tagName),
+ insertElement: (el) => hrLog.push('insert:' + el.tagName)
+ }
+
+ const baseLayer = drawing.getCurrentLayer()
+ const rect = document.createElementNS(NS, 'rect')
+ baseLayer.append(rect)
+
+ drawing.createLayer('Layer 2', hrService)
+ const layer2 = drawing.getCurrentLayer()
+ const circle = document.createElementNS(NS, 'circle')
+ layer2.append(circle)
+
+ drawing.mergeLayer(hrService)
+
+ return {
+ mergedShapes: baseLayer.querySelectorAll('rect,circle').length,
+ layersAfterMerge: drawing.getNumLayers(),
+ currentName: drawing.getCurrentLayerName(),
+ log: hrLog
+ }
+ })
+
+ expect(result.layersAfterMerge).toBe(1)
+ expect(result.mergedShapes).toBe(2)
+ expect(result.currentName).toContain('Layer')
+ expect(result.log.some(entry => entry.startsWith('start:Merge Layer'))).toBe(true)
+ expect(result.log.some(entry => entry.startsWith('move:circle'))).toBe(true)
+ })
+
+ test('mergeAllLayers collapses multiple layers into one', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { draw } = window.svgHarness
+ const NS = 'http://www.w3.org/2000/svg'
+ const svg = document.createElementNS(NS, 'svg')
+ document.body.append(svg)
+ const drawing = new draw.Drawing(svg, 'id_')
+ drawing.identifyLayers()
+ const hrLog = []
+ const hrService = {
+ startBatchCommand: (name) => hrLog.push('start:' + name),
+ endBatchCommand: () => hrLog.push('end'),
+ removeElement: () => {},
+ moveElement: () => {},
+ insertElement: () => {}
+ }
+
+ // Make three layers with a child each
+ const baseLayer = drawing.getCurrentLayer()
+ baseLayer.append(document.createElementNS(NS, 'rect'))
+ drawing.createLayer('Layer 2', hrService)
+ drawing.getCurrentLayer().append(document.createElementNS(NS, 'circle'))
+ drawing.createLayer('Layer 3', hrService)
+ drawing.getCurrentLayer().append(document.createElementNS(NS, 'line'))
+
+ drawing.mergeAllLayers(hrService)
+
+ const remaining = drawing.getCurrentLayer()
+ return {
+ finalLayers: drawing.getNumLayers(),
+ shapes: remaining.querySelectorAll('rect,circle,line').length,
+ log: hrLog
+ }
+ })
+
+ expect(result.finalLayers).toBe(1)
+ expect(result.shapes).toBe(3)
+ expect(result.log.some(entry => entry === 'start:Merge all Layers')).toBe(true)
+ expect(result.log.at(-1)).toBe('end')
+ })
+})
diff --git a/tests/e2e/unit/svgcore-drawing.spec.js b/tests/e2e/unit/svgcore-drawing.spec.js
new file mode 100644
index 00000000..1321a7a4
--- /dev/null
+++ b/tests/e2e/unit/svgcore-drawing.spec.js
@@ -0,0 +1,147 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG core drawing', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('manages ids and adopts orphaned elements into layers', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { draw, namespaces } = window.svgHarness
+ const svg = document.createElementNS(namespaces.NS.SVG, 'svg')
+ document.getElementById('root').append(svg)
+
+ const existingLayer = document.createElementNS(namespaces.NS.SVG, 'g')
+ existingLayer.classList.add('layer')
+ const title = document.createElementNS(namespaces.NS.SVG, 'title')
+ title.textContent = 'Layer 1'
+ existingLayer.append(title)
+ const orphan = document.createElementNS(namespaces.NS.SVG, 'rect')
+ orphan.id = 'rect-orphan'
+ svg.append(existingLayer, orphan)
+
+ const drawing = new draw.Drawing(svg, 'p_')
+ drawing.identifyLayers()
+
+ const id1 = drawing.getNextId()
+ const id2 = drawing.getNextId()
+ const released = drawing.releaseId(id2)
+ const reused = drawing.getNextId()
+ const next = drawing.getNextId()
+
+ return {
+ id1,
+ id2,
+ released,
+ reused,
+ next,
+ layerCount: drawing.getNumLayers(),
+ currentLayer: drawing.getCurrentLayerName(),
+ orphanParentTag: orphan.parentNode?.tagName.toLowerCase()
+ }
+ })
+
+ expect(result.id1).toBe('p_1')
+ expect(result.id2).toBe('p_2')
+ expect(result.released).toBe(true)
+ expect(result.reused).toBe(result.id2)
+ expect(result.next).toBe('p_3')
+ expect(result.layerCount).toBe(2)
+ expect(result.currentLayer).toBe('Layer 2')
+ expect(result.orphanParentTag).toBe('g')
+ })
+
+ test('reorders and toggles layers', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { draw, namespaces } = window.svgHarness
+ const svg = document.createElementNS(namespaces.NS.SVG, 'svg')
+ document.getElementById('root').append(svg)
+ const drawing = new draw.Drawing(svg)
+ drawing.identifyLayers() // creates first layer
+ const originalName = drawing.getCurrentLayerName()
+
+ drawing.createLayer('Layer Two')
+ drawing.createLayer('Layer Three')
+ drawing.setCurrentLayer('Layer Two')
+ const movedDown = drawing.setCurrentLayerPosition(2)
+ const orderAfterDown = [
+ drawing.getLayerName(0),
+ drawing.getLayerName(1),
+ drawing.getLayerName(2)
+ ]
+
+ drawing.setCurrentLayer('Layer Three')
+ const movedUp = drawing.setCurrentLayerPosition(0)
+ const orderAfterUp = [
+ drawing.getLayerName(0),
+ drawing.getLayerName(1),
+ drawing.getLayerName(2)
+ ]
+
+ const target = drawing.getCurrentLayerName()
+ drawing.setLayerVisibility(target, false)
+ const hidden = drawing.getLayerVisibility(target)
+ drawing.setLayerOpacity(target, 0.5)
+
+ return {
+ originalName,
+ movedDown: Boolean(movedDown),
+ movedUp: Boolean(movedUp),
+ orderAfterDown,
+ orderAfterUp,
+ hidden,
+ opacity: drawing.getLayerOpacity(target)
+ }
+ })
+
+ expect(result.originalName).toBe('Layer 1')
+ expect(result.movedDown).toBe(true)
+ expect(result.orderAfterDown[2]).toBe('Layer Two')
+ expect(result.movedUp).toBe(true)
+ expect(result.orderAfterUp[0]).toBe('Layer Three')
+ expect(result.hidden).toBe(false)
+ expect(result.opacity).toBe(0.5)
+ })
+
+ test('clones and deletes layers and randomizes ids', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { draw, namespaces } = window.svgHarness
+ const svg = document.createElementNS(namespaces.NS.SVG, 'svg')
+ document.getElementById('root').append(svg)
+ const drawing = new draw.Drawing(svg)
+ drawing.identifyLayers()
+
+ const currentLayer = drawing.getCurrentLayer()
+ const circle = document.createElementNS(namespaces.NS.SVG, 'circle')
+ circle.setAttribute('id', 'seed')
+ currentLayer.append(circle)
+
+ const cloneGroup = drawing.cloneLayer('Duplicated')
+ const clonedCircle = cloneGroup?.querySelector('circle')
+ const beforeDelete = drawing.getNumLayers()
+ const deleted = drawing.deleteCurrentLayer()
+ const afterDelete = drawing.getNumLayers()
+
+ draw.randomizeIds(true, drawing)
+ const nonceSet = drawing.getNonce()
+ draw.randomizeIds(false, drawing)
+
+ return {
+ cloneHasChild: Boolean(clonedCircle),
+ beforeDelete,
+ afterDelete,
+ deletedTag: deleted?.tagName.toLowerCase(),
+ nonceSet,
+ nonceCleared: drawing.getNonce()
+ }
+ })
+
+ expect(result.cloneHasChild).toBe(true)
+ expect(result.beforeDelete).toBe(2)
+ expect(result.afterDelete).toBe(1)
+ expect(result.deletedTag).toBe('g')
+ expect(result.nonceSet).not.toBe('')
+ expect(result.nonceCleared).toBe('')
+ })
+})
diff --git a/tests/e2e/unit/svgcore-geometry.spec.js b/tests/e2e/unit/svgcore-geometry.spec.js
new file mode 100644
index 00000000..11620e18
--- /dev/null
+++ b/tests/e2e/unit/svgcore-geometry.spec.js
@@ -0,0 +1,122 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG core math and coords', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('math helpers consolidate transforms and snapping', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { math } = window.svgHarness
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ document.body.append(svg)
+ const identity = math.transformPoint(100, 200, svg.createSVGMatrix())
+ const translatedMatrix = svg.createSVGMatrix()
+ translatedMatrix.e = 300
+ translatedMatrix.f = 400
+ const translated = math.transformPoint(5, 5, translatedMatrix)
+ const tlist = math.getTransformList(svg)
+ const hasMatrixBefore = math.hasMatrixTransform(tlist)
+ const tf = svg.createSVGTransformFromMatrix(translatedMatrix)
+ tlist.appendItem(tf)
+ const consolidated = math.transformListToTransform(tlist).matrix
+ const hasMatrixAfter = math.hasMatrixTransform(tlist)
+ const multiplied = math.matrixMultiply(
+ svg.createSVGMatrix().translate(10, 20),
+ svg.createSVGMatrix().translate(-10, -20)
+ )
+ const snapped = math.snapToAngle(0, 0, 10, 5)
+ const intersects = {
+ overlap: math.rectsIntersect(
+ { x: 0, y: 0, width: 50, height: 50 },
+ { x: 25, y: 25, width: 10, height: 10 }
+ ),
+ apart: math.rectsIntersect(
+ { x: 0, y: 0, width: 10, height: 10 },
+ { x: 100, y: 100, width: 5, height: 5 }
+ )
+ }
+ return {
+ identity,
+ translated,
+ hasMatrixBefore,
+ hasMatrixAfter,
+ consolidated: { e: consolidated.e, f: consolidated.f },
+ multiplied: { e: multiplied.e, f: multiplied.f },
+ snapped,
+ intersects
+ }
+ })
+ expect(result.identity).toEqual({ x: 100, y: 200 })
+ expect(result.translated).toEqual({ x: 305, y: 405 })
+ expect(result.hasMatrixBefore).toBe(false)
+ expect(result.hasMatrixAfter).toBe(true)
+ expect(result.consolidated.e).toBe(300)
+ expect(result.consolidated.f).toBe(400)
+ expect(result.multiplied.e).toBe(0)
+ expect(result.multiplied.f).toBe(0)
+ expect(result.snapped.a).toBeCloseTo(Math.PI / 4)
+ expect(result.intersects.overlap).toBe(true)
+ expect(result.intersects.apart).toBe(false)
+ })
+
+ test('coords.remapElement handles translation and scaling', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { coords, utilities } = window.svgHarness
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ document.body.append(svg)
+ utilities.init({
+ getSvgRoot: () => svg,
+ getDOMDocument: () => document,
+ getDOMContainer: () => svg
+ })
+ coords.init({
+ getGridSnapping: () => false,
+ getDrawing: () => ({ getNextId: () => '1' })
+ })
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
+ rect.setAttribute('x', '200')
+ rect.setAttribute('y', '150')
+ rect.setAttribute('width', '250')
+ rect.setAttribute('height', '120')
+ svg.append(rect)
+ const translateMatrix = svg.createSVGMatrix()
+ translateMatrix.e = 100
+ translateMatrix.f = -50
+ coords.remapElement(
+ rect,
+ { x: '200', y: '150', width: '125', height: '75' },
+ translateMatrix
+ )
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
+ circle.setAttribute('cx', '200')
+ circle.setAttribute('cy', '150')
+ circle.setAttribute('r', '250')
+ svg.append(circle)
+ const scaleMatrix = svg.createSVGMatrix()
+ scaleMatrix.a = 2
+ scaleMatrix.d = 0.5
+ coords.remapElement(
+ circle,
+ { cx: '200', cy: '150', r: '250' },
+ scaleMatrix
+ )
+ return {
+ rect: {
+ x: rect.getAttribute('x'),
+ y: rect.getAttribute('y'),
+ width: rect.getAttribute('width'),
+ height: rect.getAttribute('height')
+ },
+ circle: {
+ cx: circle.getAttribute('cx'),
+ cy: circle.getAttribute('cy'),
+ r: circle.getAttribute('r')
+ }
+ }
+ })
+ expect(result.rect).toEqual({ x: '300', y: '100', width: '125', height: '75' })
+ expect(result.circle).toEqual({ cx: '400', cy: '75', r: '125' })
+ })
+})
diff --git a/tests/e2e/unit/svgcore-history-draw.spec.js b/tests/e2e/unit/svgcore-history-draw.spec.js
new file mode 100644
index 00000000..dd46fb7c
--- /dev/null
+++ b/tests/e2e/unit/svgcore-history-draw.spec.js
@@ -0,0 +1,207 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG core history and draw', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('UndoManager tracks stacks and command texts through undo/redo', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { history } = window.svgHarness
+ let lastCalled = ''
+ class MockCommand extends history.Command {
+ constructor (text) {
+ super()
+ this.text = text
+ }
+
+ apply () { lastCalled = `${this.text}:apply` }
+ unapply () { lastCalled = `${this.text}:unapply` }
+ elements () { return [] }
+ }
+ const um = new history.UndoManager()
+ ;['First', 'Second', 'Third'].forEach((label) => {
+ um.addCommandToHistory(new MockCommand(label))
+ })
+ const beforeUndo = {
+ undo: um.getUndoStackSize(),
+ redo: um.getRedoStackSize(),
+ nextUndo: um.getNextUndoCommandText(),
+ nextRedo: um.getNextRedoCommandText()
+ }
+ um.undo()
+ const afterFirstUndo = {
+ undo: um.getUndoStackSize(),
+ redo: um.getRedoStackSize(),
+ nextUndo: um.getNextUndoCommandText(),
+ nextRedo: um.getNextRedoCommandText(),
+ lastCalled
+ }
+ um.undo()
+ um.redo()
+ const afterRedo = {
+ undo: um.getUndoStackSize(),
+ redo: um.getRedoStackSize(),
+ nextUndo: um.getNextUndoCommandText(),
+ nextRedo: um.getNextRedoCommandText(),
+ lastCalled
+ }
+ return { beforeUndo, afterFirstUndo, afterRedo }
+ })
+ expect(result.beforeUndo).toEqual({
+ undo: 3,
+ redo: 0,
+ nextUndo: 'Third',
+ nextRedo: ''
+ })
+ expect(result.afterFirstUndo.undo).toBe(2)
+ expect(result.afterFirstUndo.redo).toBe(1)
+ expect(result.afterFirstUndo.nextUndo).toBe('Second')
+ expect(result.afterFirstUndo.nextRedo).toBe('Third')
+ expect(result.afterFirstUndo.lastCalled).toBe('Third:unapply')
+ expect(result.afterRedo.undo).toBe(2)
+ expect(result.afterRedo.redo).toBe(1)
+ expect(result.afterRedo.nextUndo).toBe('Second')
+ expect(result.afterRedo.nextRedo).toBe('Third')
+ expect(result.afterRedo.lastCalled).toBe('Second:apply')
+ })
+
+ test('history commands move, insert and remove elements in the DOM', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { history } = window.svgHarness
+ const makeParent = () => {
+ const parent = document.createElement('div')
+ parent.id = 'parent'
+ const children = ['div1', 'div2', 'div3'].map((id) => {
+ const el = document.createElement('div')
+ el.id = id
+ parent.append(el)
+ return el
+ })
+ document.body.append(parent)
+ return { parent, children }
+ }
+ const order = (parent) => [...parent.children].map((el) => el.id)
+
+ const { parent: parentMove, children: moveChildren } = makeParent()
+ const move = new history.MoveElementCommand(
+ moveChildren[2],
+ moveChildren[0],
+ parentMove
+ )
+ move.unapply()
+ const orderAfterMoveUnapply = order(parentMove)
+ move.apply()
+ const orderAfterMoveApply = order(parentMove)
+
+ const { parent: parentInsert, children: insertChildren } = makeParent()
+ const insert = new history.InsertElementCommand(insertChildren[2])
+ insert.unapply()
+ const orderAfterInsertUnapply = order(parentInsert)
+ insert.apply()
+ const orderAfterInsertApply = order(parentInsert)
+
+ const { parent: parentRemove } = makeParent()
+ const extra = document.createElement('div')
+ extra.id = 'div4'
+ const remove = new history.RemoveElementCommand(extra, null, parentRemove)
+ remove.unapply()
+ const orderAfterRemoveUnapply = order(parentRemove)
+ remove.apply()
+ const orderAfterRemoveApply = order(parentRemove)
+
+ return {
+ orderAfterMoveUnapply,
+ orderAfterMoveApply,
+ orderAfterInsertUnapply,
+ orderAfterInsertApply,
+ orderAfterRemoveUnapply,
+ orderAfterRemoveApply
+ }
+ })
+ expect(result.orderAfterMoveUnapply).toEqual(['div3', 'div1', 'div2'])
+ expect(result.orderAfterMoveApply).toEqual(['div1', 'div2', 'div3'])
+ expect(result.orderAfterInsertUnapply).toEqual(['div1', 'div2'])
+ expect(result.orderAfterInsertApply).toEqual(['div1', 'div2', 'div3'])
+ expect(result.orderAfterRemoveUnapply).toEqual(['div1', 'div2', 'div3', 'div4'])
+ expect(result.orderAfterRemoveApply).toEqual(['div1', 'div2', 'div3'])
+ })
+
+ test('BatchCommand applies and unapplies subcommands in order', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { history } = window.svgHarness
+ let record = ''
+ class TextCommand extends history.Command {
+ constructor (text) {
+ super()
+ this.text = text
+ }
+
+ apply () { record += this.text }
+ unapply () { record += this.text.toUpperCase() }
+ elements () { return [] }
+ }
+ const batch = new history.BatchCommand()
+ const emptyBefore = batch.isEmpty()
+ batch.addSubCommand(new TextCommand('a'))
+ batch.addSubCommand(new TextCommand('b'))
+ batch.addSubCommand(new TextCommand('c'))
+ batch.apply()
+ const afterApply = record
+ record = ''
+ batch.unapply()
+ const afterUnapply = record
+ return { emptyBefore, afterApply, afterUnapply }
+ })
+ expect(result.emptyBefore).toBe(true)
+ expect(result.afterApply).toBe('abc')
+ expect(result.afterUnapply).toBe('CBA')
+ })
+
+ test('Drawing creates layers, generates ids and toggles nonce randomization', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { draw, namespaces } = window.svgHarness
+ const svg = document.createElementNS(namespaces.NS.SVG, 'svg')
+ document.body.append(svg)
+ const drawing = new draw.Drawing(svg)
+ const beforeIdentify = drawing.getNumLayers()
+ drawing.identifyLayers()
+ const defaultLayer = drawing.getCurrentLayer()
+ const defaultName = drawing.getCurrentLayerName()
+ const hrCounts = { start: 0, end: 0, insert: 0 }
+ const newLayer = drawing.createLayer('Layer A', {
+ startBatchCommand: () => { hrCounts.start++ },
+ endBatchCommand: () => { hrCounts.end++ },
+ insertElement: () => { hrCounts.insert++ }
+ })
+ const afterCreate = {
+ num: drawing.getNumLayers(),
+ currentName: drawing.getCurrentLayerName(),
+ className: newLayer.getAttribute('class')
+ }
+ draw.randomizeIds(true, drawing)
+ const nonceAfterRandomize = drawing.getNonce()
+ draw.randomizeIds(false, drawing)
+ const nonceAfterClear = drawing.getNonce()
+ return {
+ beforeIdentify,
+ defaultName,
+ defaultLayerClass: defaultLayer?.getAttribute('class'),
+ afterCreate,
+ hrCounts,
+ nonceAfterRandomize,
+ nonceAfterClear
+ }
+ })
+ expect(result.beforeIdentify).toBe(0)
+ expect(result.defaultLayerClass).toBeDefined()
+ expect(result.defaultName.length).toBeGreaterThan(0)
+ expect(result.afterCreate.num).toBe(2)
+ expect(result.afterCreate.currentName).toBe('Layer A')
+ expect(result.afterCreate.className).toBe(result.defaultLayerClass)
+ expect(result.hrCounts).toEqual({ start: 1, end: 1, insert: 1 })
+ expect(result.nonceAfterRandomize).toBeTruthy()
+ expect(result.nonceAfterClear).toBe('')
+ })
+})
diff --git a/tests/e2e/unit/svgcore-history.spec.js b/tests/e2e/unit/svgcore-history.spec.js
new file mode 100644
index 00000000..e9330eee
--- /dev/null
+++ b/tests/e2e/unit/svgcore-history.spec.js
@@ -0,0 +1,46 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG core history/draw smoke', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('UndoManager push/undo/redo stack sizes update', async ({ page }) => {
+ const stacks = await page.evaluate(() => {
+ const { history } = window.svgHarness
+ class DummyCommand extends history.Command {
+ constructor (text) {
+ super()
+ this.text = text
+ }
+
+ apply () {}
+ unapply () {}
+ }
+ const um = new history.UndoManager()
+ um.addCommandToHistory(new DummyCommand('one'))
+ um.addCommandToHistory(new DummyCommand('two'))
+ const beforeUndo = { undo: um.getUndoStackSize(), redo: um.getRedoStackSize() }
+ um.undo()
+ const afterUndo = { undo: um.getUndoStackSize(), redo: um.getRedoStackSize() }
+ um.redo()
+ const afterRedo = { undo: um.getUndoStackSize(), redo: um.getRedoStackSize() }
+ return { beforeUndo, afterUndo, afterRedo, nextUndo: um.getNextUndoCommandText(), nextRedo: um.getNextRedoCommandText() }
+ })
+ expect(stacks.beforeUndo.undo).toBe(2)
+ expect(stacks.beforeUndo.redo).toBe(0)
+ expect(stacks.afterUndo.undo).toBe(1)
+ expect(stacks.afterUndo.redo).toBe(1)
+ expect(stacks.afterRedo.undo).toBe(2)
+ expect(stacks.nextUndo.length).toBeGreaterThan(0)
+ })
+
+ test('draw module exports expected functions', async ({ page }) => {
+ const exports = await page.evaluate(() => {
+ const { draw } = window.svgHarness
+ return ['init', 'randomizeIds', 'createLayer'].map(fn => typeof draw[fn] === 'function')
+ })
+ exports.forEach(v => expect(v).toBe(true))
+ })
+})
diff --git a/tests/e2e/unit/svgcore-namespaces.spec.js b/tests/e2e/unit/svgcore-namespaces.spec.js
new file mode 100644
index 00000000..4ee5a839
--- /dev/null
+++ b/tests/e2e/unit/svgcore-namespaces.spec.js
@@ -0,0 +1,28 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG namespace helpers', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness?.namespaces))
+ })
+
+ test('reverse namespace map includes core URIs', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { namespaces } = window.svgHarness
+ const reverse = namespaces.getReverseNS()
+ return {
+ svg: namespaces.NS.SVG,
+ html: namespaces.NS.HTML,
+ xml: namespaces.NS.XML,
+ reverseSvg: reverse[namespaces.NS.SVG],
+ reverseXmlns: reverse[namespaces.NS.XMLNS]
+ }
+ })
+
+ expect(result.svg).toBe('http://www.w3.org/2000/svg')
+ expect(result.html).toBe('http://www.w3.org/1999/xhtml')
+ expect(result.xml).toBe('http://www.w3.org/XML/1998/namespace')
+ expect(result.reverseSvg).toBe('svg')
+ expect(result.reverseXmlns).toBe('xmlns')
+ })
+})
diff --git a/tests/e2e/unit/svgcore-path-extra.spec.js b/tests/e2e/unit/svgcore-path-extra.spec.js
new file mode 100644
index 00000000..63f81f78
--- /dev/null
+++ b/tests/e2e/unit/svgcore-path-extra.spec.js
@@ -0,0 +1,37 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG core path extras', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness?.pathModule))
+ })
+
+ test('convertPath handles arcs and shorthand commands', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { pathModule, units } = window.svgHarness
+ // Ensure unit helpers are initialized so shortFloat can round numbers.
+ units.init({
+ getRoundDigits: () => 3,
+ getBaseUnit: () => 'px',
+ getElement: () => null,
+ getHeight: () => 100,
+ getWidth: () => 100
+ })
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
+ path.setAttribute(
+ 'd',
+ 'M0 0 H10 V10 h-5 v-5 a5 5 0 0 1 5 5 S20 20 25 25 Z'
+ )
+
+ const rel = pathModule.convertPath(path, true)
+ const abs = pathModule.convertPath(path, false)
+
+ return { rel, abs }
+ })
+
+ expect(result.rel.toLowerCase()).toContain('a')
+ expect(result.rel).toContain('s')
+ expect(result.abs).toContain('L')
+ expect(result.abs).toContain('A')
+ })
+})
diff --git a/tests/e2e/unit/svgcore-recalculate-extra.spec.js b/tests/e2e/unit/svgcore-recalculate-extra.spec.js
new file mode 100644
index 00000000..c76b10fd
--- /dev/null
+++ b/tests/e2e/unit/svgcore-recalculate-extra.spec.js
@@ -0,0 +1,307 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG core recalculate extra cases', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('scales elements and flips gradients/matrices', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { utilities, coords, recalculate } = window.svgHarness
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs')
+ const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient')
+ grad.id = 'grad1'
+ grad.setAttribute('x1', '0')
+ grad.setAttribute('x2', '1')
+ grad.setAttribute('y1', '0')
+ grad.setAttribute('y2', '0')
+ defs.append(grad)
+ svg.append(defs)
+ document.body.append(svg)
+
+ const dataStorage = {
+ store: new WeakMap(),
+ put (el, key, value) {
+ if (!this.store.has(el)) this.store.set(el, new Map())
+ this.store.get(el).set(key, value)
+ },
+ get (el, key) {
+ return this.store.get(el)?.get(key)
+ },
+ has (el, key) {
+ return this.store.has(el) && this.store.get(el).has(key)
+ },
+ remove (el, key) {
+ const bucket = this.store.get(el)
+ if (!bucket) return false
+ const deleted = bucket.delete(key)
+ if (!bucket.size) this.store.delete(el)
+ return deleted
+ }
+ }
+
+ const canvasStub = {
+ getSvgRoot: () => svg,
+ getStartTransform: () => '',
+ setStartTransform: () => {},
+ getDataStorage: () => dataStorage,
+ getCurrentDrawing: () => ({ getNextId: () => 'g1' })
+ }
+
+ utilities.init({
+ getSvgRoot: () => svg,
+ getDOMDocument: () => document,
+ getDOMContainer: () => svg,
+ getDataStorage: () => dataStorage
+ })
+ coords.init({
+ getGridSnapping: () => false,
+ getDrawing: () => ({ getNextId: () => 'id2' }),
+ getDataStorage: () => dataStorage
+ })
+ recalculate.init(canvasStub)
+
+ // Scale about center via translate/scale/translate sequence
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
+ rect.setAttribute('x', '5')
+ rect.setAttribute('y', '6')
+ rect.setAttribute('width', '10')
+ rect.setAttribute('height', '8')
+ rect.setAttribute('transform', 'translate(5 5) scale(2 3) translate(-5 -5)')
+ svg.append(rect)
+
+ // Flip with gradient fill using matrix
+ const rectFlip = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
+ rectFlip.setAttribute('x', '0')
+ rectFlip.setAttribute('y', '0')
+ rectFlip.setAttribute('width', '4')
+ rectFlip.setAttribute('height', '4')
+ rectFlip.setAttribute('fill', 'url(#grad1)')
+ rectFlip.setAttribute('transform', 'matrix(-1 0 0 1 0 0)')
+ svg.append(rectFlip)
+
+ recalculate.recalculateDimensions(rect)
+ recalculate.recalculateDimensions(rectFlip)
+
+ return {
+ rect: {
+ width: rect.getAttribute('width'),
+ height: rect.getAttribute('height'),
+ transformRemoved: rect.hasAttribute('transform')
+ },
+ flip: {
+ width: rectFlip.getAttribute('width'),
+ height: rectFlip.getAttribute('height'),
+ fill: rectFlip.getAttribute('fill')
+ }
+ }
+ })
+
+ expect(Number(result.rect.width)).toBeGreaterThan(10)
+ expect(Number(result.rect.height)).toBeGreaterThan(8)
+ expect(result.rect.transformRemoved).toBe(false) // scaling keeps transform list
+ expect(result.flip.fill.startsWith('url(')).toBe(true)
+ })
+
+ test('recalculateDimensions reapplies rotations and updates clip paths', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const NS = 'http://www.w3.org/2000/svg'
+ const { utilities, coords, recalculate } = window.svgHarness
+ const svg = document.createElementNS(NS, 'svg')
+ const defs = document.createElementNS(NS, 'defs')
+ svg.append(defs)
+ document.body.append(svg)
+
+ const dataStorage = {
+ store: new WeakMap(),
+ put (el, key, value) {
+ if (!this.store.has(el)) this.store.set(el, new Map())
+ this.store.get(el).set(key, value)
+ },
+ get (el, key) {
+ return this.store.get(el)?.get(key)
+ },
+ has (el, key) {
+ return this.store.has(el) && this.store.get(el).has(key)
+ }
+ }
+ const drawing = {
+ next: 0,
+ getNextId () {
+ this.next += 1
+ return 'd' + this.next
+ }
+ }
+
+ const canvasStub = {
+ getSvgRoot: () => svg,
+ getSvgContent: () => svg,
+ getDOMDocument: () => document,
+ getDOMContainer: () => svg,
+ getDataStorage: () => dataStorage,
+ getStartTransform: () => '',
+ setStartTransform: () => {},
+ getCurrentDrawing: () => drawing
+ }
+ utilities.init(canvasStub)
+ coords.init({
+ getGridSnapping: () => false,
+ getDrawing: () => drawing,
+ getDataStorage: () => dataStorage,
+ getCurrentDrawing: () => drawing,
+ getSvgRoot: () => svg
+ })
+ recalculate.init(canvasStub)
+
+ const rect = document.createElementNS(NS, 'rect')
+ rect.setAttribute('x', '0')
+ rect.setAttribute('y', '0')
+ rect.setAttribute('width', '10')
+ rect.setAttribute('height', '10')
+ rect.setAttribute('transform', 'translate(10 5) rotate(30)')
+ svg.append(rect)
+
+ const clipPath = document.createElementNS(NS, 'clipPath')
+ clipPath.id = 'clip1'
+ const clipRect = document.createElementNS(NS, 'rect')
+ clipRect.setAttribute('x', '0')
+ clipRect.setAttribute('y', '0')
+ clipRect.setAttribute('width', '4')
+ clipRect.setAttribute('height', '4')
+ clipPath.append(clipRect)
+ defs.append(clipPath)
+
+ const cmd = recalculate.recalculateDimensions(rect)
+ recalculate.updateClipPath('url(#clip1)', 3, -2)
+
+ return {
+ rect: {
+ x: rect.getAttribute('x'),
+ y: rect.getAttribute('y'),
+ transform: rect.getAttribute('transform'),
+ hasCommand: Boolean(cmd)
+ },
+ clip: {
+ x: clipRect.getAttribute('x'),
+ y: clipRect.getAttribute('y'),
+ transforms: clipRect.transform.baseVal.numberOfItems
+ }
+ }
+ })
+
+ expect(result.rect.x).toBe('10')
+ expect(result.rect.y).toBe('5')
+ expect(result.rect.transform).toContain('rotate(')
+ expect(result.rect.transform).not.toContain('translate')
+ expect(result.rect.hasCommand).toBe(true)
+ expect(result.clip.x).toBe('3')
+ expect(result.clip.y).toBe('-2')
+ expect(result.clip.transforms).toBe(0)
+ })
+
+ test('recalculateDimensions remaps polygons and matrix transforms', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const NS = 'http://www.w3.org/2000/svg'
+ const { utilities, coords, recalculate } = window.svgHarness
+ const svg = document.createElementNS(NS, 'svg')
+ document.body.append(svg)
+ const dataStorage = {
+ store: new WeakMap(),
+ put (el, key, value) {
+ if (!this.store.has(el)) this.store.set(el, new Map())
+ this.store.get(el).set(key, value)
+ },
+ get (el, key) {
+ return this.store.get(el)?.get(key)
+ },
+ has (el, key) {
+ return this.store.has(el) && this.store.get(el).has(key)
+ }
+ }
+ const drawing = {
+ next: 0,
+ getNextId () {
+ this.next += 1
+ return 'p' + this.next
+ }
+ }
+ const canvasStub = {
+ getSvgRoot: () => svg,
+ getSvgContent: () => svg,
+ getDOMDocument: () => document,
+ getDOMContainer: () => svg,
+ getDataStorage: () => dataStorage,
+ getStartTransform: () => '',
+ setStartTransform: () => {},
+ getCurrentDrawing: () => drawing
+ }
+ utilities.init(canvasStub)
+ coords.init({
+ getGridSnapping: () => false,
+ getDrawing: () => drawing,
+ getDataStorage: () => dataStorage,
+ getCurrentDrawing: () => drawing,
+ getSvgRoot: () => svg
+ })
+ recalculate.init(canvasStub)
+
+ const poly = document.createElementNS(NS, 'polygon')
+ poly.setAttribute('points', '0,0 10,0 10,10')
+ poly.setAttribute('transform', 'translate(5 5) scale(-1 2) translate(-5 -5)')
+ svg.append(poly)
+
+ const path = document.createElementNS(NS, 'path')
+ path.setAttribute('d', 'M0 0 L1 0 L1 1 z')
+ path.setAttribute('transform', 'matrix(1 0 0 1 7 8)')
+ svg.append(path)
+
+ const rect = document.createElementNS(NS, 'rect')
+ rect.setAttribute('x', '1')
+ rect.setAttribute('y', '2')
+ rect.setAttribute('width', '5')
+ rect.setAttribute('height', '4')
+ rect.setAttribute('transform', 'matrix(1 0 0 1 7 8)')
+ svg.append(rect)
+
+ const useElem = document.createElementNS(NS, 'use')
+ useElem.setAttribute('href', '#missing')
+ useElem.setAttribute('transform', 'translate(3 4)')
+ svg.append(useElem)
+
+ const cmdPoly = recalculate.recalculateDimensions(poly)
+ const cmdPath = recalculate.recalculateDimensions(path)
+ recalculate.recalculateDimensions(rect)
+ const cmdUse = recalculate.recalculateDimensions(useElem)
+
+ return {
+ poly: {
+ points: poly.getAttribute('points'),
+ hasTransform: poly.hasAttribute('transform'),
+ hasCommand: Boolean(cmdPoly)
+ },
+ path: {
+ d: path.getAttribute('d'),
+ transform: path.getAttribute('transform') || '',
+ hasCommand: Boolean(cmdPath)
+ },
+ rect: {
+ x: rect.getAttribute('x'),
+ transform: rect.getAttribute('transform')
+ },
+ useResult: cmdUse
+ }
+ })
+
+ expect(result.poly.hasTransform).toBe(false)
+ expect(result.poly.points).toContain('-5')
+ expect(result.poly.hasCommand).toBe(true)
+ expect(result.path.d.startsWith('M7,8')).toBe(true)
+ expect(result.path.transform).toBe('')
+ expect(result.path.hasCommand).toBe(true)
+ expect(result.rect.x).toBe('1')
+ expect(result.rect.transform).toContain('matrix')
+ expect(result.useResult).toBeNull()
+ })
+})
diff --git a/tests/e2e/unit/svgcore-recalculate.spec.js b/tests/e2e/unit/svgcore-recalculate.spec.js
new file mode 100644
index 00000000..c3defb21
--- /dev/null
+++ b/tests/e2e/unit/svgcore-recalculate.spec.js
@@ -0,0 +1,115 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG core recalculate', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('recalculateDimensions swallows identity and applies translations', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { utilities, coords, recalculate } = window.svgHarness
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ document.body.append(svg)
+ const dataStorage = {
+ store: new WeakMap(),
+ put (el, key, value) {
+ if (!this.store.has(el)) this.store.set(el, new Map())
+ this.store.get(el).set(key, value)
+ },
+ get (el, key) {
+ return this.store.get(el)?.get(key)
+ },
+ has (el, key) {
+ return this.store.has(el) && this.store.get(el).has(key)
+ },
+ remove (el, key) {
+ const bucket = this.store.get(el)
+ if (!bucket) return false
+ const deleted = bucket.delete(key)
+ if (!bucket.size) this.store.delete(el)
+ return deleted
+ }
+ }
+ const initContexts = () => {
+ utilities.init({
+ getSvgRoot: () => svg,
+ getDOMDocument: () => document,
+ getDOMContainer: () => svg,
+ getDataStorage: () => dataStorage
+ })
+ coords.init({
+ getGridSnapping: () => false,
+ getDrawing: () => ({ getNextId: () => '1' }),
+ getDataStorage: () => dataStorage
+ })
+ recalculate.init({
+ getSvgRoot: () => svg,
+ getStartTransform: () => '',
+ setStartTransform: () => {},
+ getDataStorage: () => dataStorage
+ })
+ }
+ initContexts()
+
+ const identityRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
+ identityRect.setAttribute('x', '10')
+ identityRect.setAttribute('y', '10')
+ identityRect.setAttribute('width', '20')
+ identityRect.setAttribute('height', '30')
+ identityRect.setAttribute('transform', 'matrix(1,0,0,1,0,0)')
+ svg.append(identityRect)
+
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
+ rect.setAttribute('x', '200')
+ rect.setAttribute('y', '150')
+ rect.setAttribute('width', '250')
+ rect.setAttribute('height', '120')
+ rect.setAttribute('transform', 'translate(100,50)')
+ svg.append(rect)
+
+ const text = document.createElementNS('http://www.w3.org/2000/svg', 'text')
+ text.setAttribute('x', '200')
+ text.setAttribute('y', '150')
+ text.setAttribute('transform', 'translate(100,50)')
+ const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan')
+ tspan.setAttribute('x', '200')
+ tspan.setAttribute('y', '150')
+ tspan.textContent = 'Foo bar'
+ text.append(tspan)
+ svg.append(text)
+
+ recalculate.recalculateDimensions(identityRect)
+ recalculate.recalculateDimensions(rect)
+ recalculate.recalculateDimensions(text)
+
+ return {
+ identityHasTransform: identityRect.hasAttribute('transform'),
+ rectAttrs: {
+ x: rect.getAttribute('x'),
+ y: rect.getAttribute('y'),
+ width: rect.getAttribute('width'),
+ height: rect.getAttribute('height')
+ },
+ textAttrs: {
+ x: text.getAttribute('x'),
+ y: text.getAttribute('y')
+ },
+ tspanAttrs: {
+ x: tspan.getAttribute('x'),
+ y: tspan.getAttribute('y')
+ }
+ }
+ })
+
+ expect(result.identityHasTransform).toBe(false)
+ expect(result.rectAttrs).toEqual({
+ x: '300',
+ y: '200',
+ width: '250',
+ height: '120'
+ })
+ expect(result.textAttrs).toEqual({ x: '300', y: '200' })
+ expect(result.tspanAttrs).toEqual({ x: '300', y: '200' })
+ })
+})
diff --git a/tests/e2e/unit/svgcore-remap-extra.spec.js b/tests/e2e/unit/svgcore-remap-extra.spec.js
new file mode 100644
index 00000000..451206a8
--- /dev/null
+++ b/tests/e2e/unit/svgcore-remap-extra.spec.js
@@ -0,0 +1,140 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG core remap extras', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('remapElement handles gradients, text/tspan and paths with snapping', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const NS = 'http://www.w3.org/2000/svg'
+ const { coords, utilities, units } = window.svgHarness
+ const svg = document.createElementNS(NS, 'svg')
+ const defs = document.createElementNS(NS, 'defs')
+ const grad = document.createElementNS(NS, 'linearGradient')
+ grad.id = 'grad1'
+ grad.setAttribute('x1', '0')
+ grad.setAttribute('x2', '1')
+ grad.setAttribute('y1', '0')
+ grad.setAttribute('y2', '0')
+ defs.append(grad)
+ svg.append(defs)
+ document.body.append(svg)
+
+ const dataStorage = {
+ store: new WeakMap(),
+ put (el, key, value) {
+ if (!this.store.has(el)) this.store.set(el, new Map())
+ this.store.get(el).set(key, value)
+ },
+ get (el, key) {
+ return this.store.get(el)?.get(key)
+ },
+ has (el, key) {
+ return this.store.has(el) && this.store.get(el).has(key)
+ }
+ }
+ let idCounter = 0
+ const canvas = {
+ getSvgRoot: () => svg,
+ getSvgContent: () => svg,
+ getDOMDocument: () => document,
+ getDOMContainer: () => svg,
+ getBaseUnit: () => 'px',
+ getSnappingStep: () => 5,
+ getGridSnapping: () => true,
+ getWidth: () => 200,
+ getHeight: () => 200,
+ getCurrentDrawing: () => ({
+ getNextId: () => 'g' + (++idCounter)
+ }),
+ getDataStorage: () => dataStorage
+ }
+
+ utilities.init(canvas)
+ units.init(canvas)
+ coords.init(canvas)
+
+ const group = document.createElementNS(NS, 'g')
+ svg.append(group)
+
+ const text = document.createElementNS(NS, 'text')
+ text.textContent = 'hello'
+ text.setAttribute('x', '2')
+ text.setAttribute('y', '3')
+ text.setAttribute('font-size', '10')
+ const tspan = document.createElementNS(NS, 'tspan')
+ tspan.setAttribute('x', '4')
+ tspan.setAttribute('y', '5')
+ tspan.setAttribute('font-size', '8')
+ tspan.textContent = 't'
+ text.append(tspan)
+ group.append(text)
+
+ const textMatrix = svg.createSVGMatrix()
+ textMatrix.a = -2
+ textMatrix.d = 1.5
+ textMatrix.e = 10
+ coords.remapElement(text, { x: 2, y: 3 }, textMatrix)
+
+ const rect = document.createElementNS(NS, 'rect')
+ rect.setAttribute('x', '0')
+ rect.setAttribute('y', '0')
+ rect.setAttribute('width', '10')
+ rect.setAttribute('height', '6')
+ rect.setAttribute('fill', 'url(#grad1)')
+ group.append(rect)
+
+ const flipMatrix = svg.createSVGMatrix()
+ flipMatrix.a = -1
+ flipMatrix.d = -1
+ coords.remapElement(rect, { x: 0, y: 0, width: 10, height: 6 }, flipMatrix)
+
+ const path = document.createElementNS(NS, 'path')
+ path.setAttribute('d', 'M0 0 L5 0 l5 5 a2 3 0 0 1 2 2 z')
+ group.append(path)
+ const pathMatrix = svg.createSVGMatrix()
+ pathMatrix.a = 1
+ pathMatrix.d = 2
+ pathMatrix.e = 3
+ pathMatrix.f = -1
+ coords.remapElement(path, {}, pathMatrix)
+
+ return {
+ text: {
+ x: text.getAttribute('x'),
+ y: text.getAttribute('y'),
+ fontSize: text.getAttribute('font-size'),
+ tspanX: tspan.getAttribute('x'),
+ tspanY: tspan.getAttribute('y'),
+ tspanSize: tspan.getAttribute('font-size')
+ },
+ rect: {
+ fill: rect.getAttribute('fill'),
+ width: rect.getAttribute('width'),
+ height: rect.getAttribute('height'),
+ x: rect.getAttribute('x'),
+ y: rect.getAttribute('y')
+ },
+ path: path.getAttribute('d')
+ }
+ })
+
+ expect(result.text).toEqual({
+ x: '5',
+ y: '5',
+ fontSize: '20',
+ tspanX: '2',
+ tspanY: '7.5',
+ tspanSize: '16'
+ })
+ expect(result.rect.fill).toContain('url(#g')
+ expect(result.rect.width).toBe('10')
+ expect(result.rect.height).toBe('5')
+ expect(result.rect.x).toBe('-10')
+ expect(result.rect.y).toBe('-5')
+ expect(result.path.startsWith('M3,')).toBe(true)
+ expect(result.path).toContain('a2,6')
+ })
+})
diff --git a/tests/e2e/unit/svgcore-smoke.spec.js b/tests/e2e/unit/svgcore-smoke.spec.js
new file mode 100644
index 00000000..e66fef7e
--- /dev/null
+++ b/tests/e2e/unit/svgcore-smoke.spec.js
@@ -0,0 +1,86 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG core smoke', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('math basics work with real SVG matrices', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { math } = window.svgHarness
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const m = svg.createSVGMatrix().translate(50, 75)
+ const pt = math.transformPoint(10, 20, m)
+ const isId = math.isIdentity(svg.createSVGMatrix())
+ const box = math.transformBox(0, 0, 10, 20, m)
+ return {
+ pt,
+ isId,
+ box: {
+ x: box.aabox.x,
+ y: box.aabox.y,
+ width: box.aabox.width,
+ height: box.aabox.height
+ }
+ }
+ })
+ expect(result.isId).toBe(true)
+ expect(result.pt.x).toBe(60)
+ expect(result.pt.y).toBe(95)
+ expect(result.box).toEqual({ x: 50, y: 75, width: 10, height: 20 })
+ })
+
+ test('coords module exposes remapElement', async ({ page }) => {
+ const hasRemap = await page.evaluate(() => {
+ return typeof window.svgHarness.coords.remapElement === 'function'
+ })
+ expect(hasRemap).toBe(true)
+ })
+
+ test('path.convertPath converts to relative without throwing', async ({ page }) => {
+ const d = await page.evaluate(() => {
+ const { pathModule, units } = window.svgHarness
+ units.init({
+ getRoundDigits: () => 2,
+ getBaseUnit: () => 'px'
+ })
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
+ path.setAttribute('d', 'M0 0 L10 0 L10 10 Z')
+ svg.append(path)
+ pathModule.convertPath(path, true)
+ return path.getAttribute('d')
+ })
+ expect(d?.toLowerCase()).toContain('m')
+ expect(d?.toLowerCase()).toContain('z')
+ })
+
+ test('utilities getBBoxFromPath returns finite numbers', async ({ page }) => {
+ const bbox = await page.evaluate(() => {
+ const { utilities } = window.svgHarness
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
+ rect.setAttribute('x', '0')
+ rect.setAttribute('y', '1')
+ rect.setAttribute('width', '5')
+ rect.setAttribute('height', '10')
+ svg.append(rect)
+ const res = utilities.getBBoxOfElementAsPath(
+ rect,
+ (json) => {
+ const el = document.createElementNS('http://www.w3.org/2000/svg', json.element)
+ Object.entries(json.attr).forEach(([k, v]) => el.setAttribute(k, v))
+ svg.append(el)
+ return el
+ },
+ { resetOrientation: () => {} }
+ )
+ return { x: res.x, y: res.y, width: res.width, height: res.height }
+ })
+ expect(Number.isFinite(bbox.x)).toBe(true)
+ expect(Number.isFinite(bbox.y)).toBe(true)
+ expect(Number.isFinite(bbox.width)).toBe(true)
+ expect(Number.isFinite(bbox.height)).toBe(true)
+ })
+})
diff --git a/tests/e2e/unit/svgcore-touch.spec.js b/tests/e2e/unit/svgcore-touch.spec.js
new file mode 100644
index 00000000..fa5ff4e4
--- /dev/null
+++ b/tests/e2e/unit/svgcore-touch.spec.js
@@ -0,0 +1,83 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('touch event adapter', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('translates single touch events into mouse events', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { touch } = window.svgHarness
+ const target = document.createElement('div')
+ document.body.append(target)
+
+ const received = []
+ target.addEventListener('mousedown', (ev) => {
+ received.push({
+ type: ev.type,
+ clientX: ev.clientX,
+ clientY: ev.clientY,
+ screenX: ev.screenX,
+ screenY: ev.screenY
+ })
+ })
+
+ const svgroot = {
+ listeners: {},
+ addEventListener (type, handler) { this.listeners[type] = handler },
+ dispatchEvent (ev) { this.listeners[ev.type]?.(ev) }
+ }
+
+ touch.init({ svgroot })
+ const ev = new TouchEvent('touchstart', {
+ changedTouches: [
+ new Touch({
+ identifier: 1,
+ target,
+ clientX: 12,
+ clientY: 34,
+ screenX: 56,
+ screenY: 78
+ })
+ ]
+ })
+ svgroot.dispatchEvent(ev)
+ return received[0]
+ })
+
+ expect(result.type).toBe('mousedown')
+ expect(result.clientX).toBe(12)
+ expect(result.clientY).toBe(34)
+ expect(result.screenX).toBe(56)
+ expect(result.screenY).toBe(78)
+ })
+
+ test('ignores multi-touch gestures when forwarding', async ({ page }) => {
+ const count = await page.evaluate(() => {
+ const { touch } = window.svgHarness
+ const target = document.createElement('div')
+ document.body.append(target)
+ let mouseEvents = 0
+ target.addEventListener('mousedown', () => { mouseEvents++ })
+
+ const svgroot = {
+ listeners: {},
+ addEventListener (type, handler) { this.listeners[type] = handler },
+ dispatchEvent (ev) { this.listeners[ev.type]?.(ev) }
+ }
+
+ touch.init({ svgroot })
+ const ev = new TouchEvent('touchstart', {
+ changedTouches: [
+ new Touch({ identifier: 1, target, clientX: 1, clientY: 2, screenX: 3, screenY: 4 }),
+ new Touch({ identifier: 2, target, clientX: 5, clientY: 6, screenX: 7, screenY: 8 })
+ ]
+ })
+ svgroot.dispatchEvent(ev)
+ return mouseEvents
+ })
+
+ expect(count).toBe(0)
+ })
+})
diff --git a/tests/e2e/unit/svgcore-util.spec.js b/tests/e2e/unit/svgcore-util.spec.js
new file mode 100644
index 00000000..490d300f
--- /dev/null
+++ b/tests/e2e/unit/svgcore-util.spec.js
@@ -0,0 +1,98 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG common util helpers', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('computes positions and deep merges objects', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { util } = window.svgHarness
+ const grand = { offsetLeft: 5, offsetTop: 6, offsetParent: null }
+ const parent = { offsetLeft: 10, offsetTop: 11, offsetParent: grand }
+ const child = { offsetLeft: 7, offsetTop: 8, offsetParent: parent }
+
+ const merged = util.mergeDeep(
+ { a: 1, nested: { keep: true, replace: 'old' } },
+ { nested: { replace: 'new', extra: 42 }, more: 'yes' }
+ )
+
+ return {
+ pos: util.findPos(child),
+ isObject: util.isObject({ hello: 'world' }),
+ merged
+ }
+ })
+
+ expect(result.pos).toEqual({ left: 22, top: 25 })
+ expect(result.isObject).toBe(true)
+ expect(result.merged).toEqual({
+ a: 1,
+ nested: { keep: true, replace: 'new', extra: 42 },
+ more: 'yes'
+ })
+ })
+
+ test('finds closest ancestors by selector', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { util } = window.svgHarness
+ const root = document.getElementById('root')
+ root.innerHTML = ''
+
+ const wrapper = document.createElement('div')
+ wrapper.className = 'wrapper'
+ const section = document.createElement('section')
+ section.id = 'section'
+ const child = document.createElement('span')
+ child.dataset.role = 'target'
+
+ section.append(child)
+ wrapper.append(section)
+ root.append(wrapper)
+
+ return {
+ byClass: util.getClosest(child, '.wrapper')?.className,
+ byId: util.getClosest(child, '#section')?.id,
+ byData: util.getClosest(child, '[data-role=target]')?.dataset.role,
+ byTag: util.getClosest(child, 'div')?.tagName.toLowerCase()
+ }
+ })
+
+ expect(result.byClass).toBe('wrapper')
+ expect(result.byId).toBe('section')
+ expect(result.byData).toBe('target')
+ expect(result.byTag).toBe('div')
+ })
+
+ test('gathers parents with and without limits', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { util } = window.svgHarness
+ const root = document.getElementById('root')
+ root.innerHTML = ''
+
+ const outer = document.createElement('div')
+ outer.className = 'outer'
+ const mid = document.createElement('section')
+ mid.id = 'mid'
+ const inner = document.createElement('span')
+ inner.className = 'inner'
+
+ mid.append(inner)
+ outer.append(mid)
+ root.append(outer)
+
+ return {
+ all: util.getParents(inner)?.map((el) => el.tagName.toLowerCase()),
+ byClass: util.getParents(inner, '.outer')?.map((el) => el.className),
+ untilMid: util.getParentsUntil(inner, '#mid')?.map((el) => el.tagName.toLowerCase()),
+ untilMidFiltered: util.getParentsUntil(inner, '#mid', '.inner')?.map((el) => el.tagName.toLowerCase())
+ }
+ })
+
+ expect(result.all).toEqual(['span', 'section', 'div', 'div', 'body', 'html'])
+ expect(result.byClass).toEqual(['outer'])
+ expect(result.untilMid).toEqual(['span'])
+ expect(result.untilMidFiltered).toEqual(['span'])
+ })
+})
diff --git a/tests/e2e/unit/svgcore-utilities-extra.spec.js b/tests/e2e/unit/svgcore-utilities-extra.spec.js
new file mode 100644
index 00000000..c0a3556d
--- /dev/null
+++ b/tests/e2e/unit/svgcore-utilities-extra.spec.js
@@ -0,0 +1,115 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG core utilities extra coverage', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('exercises helper paths and bbox utilities', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ try {
+ const { utilities, namespaces } = window.svgHarness
+ const root = document.getElementById('root')
+ root.innerHTML = `
+
+ `
+ const svg = root.querySelector('svg')
+ const rect = svg.querySelector('#rect')
+
+ utilities.init({
+ getSvgRoot: () => svg,
+ getDOMDocument: () => document,
+ getDOMContainer: () => root,
+ getSvgContent: () => svg,
+ getSelectedElements: () => [rect],
+ getBaseUnit: () => 'px',
+ getSnappingStep: () => 1,
+ addSVGElementsFromJson: () => null,
+ pathActions: { convertPath: () => {} }
+ })
+
+ const errors = []
+ const safe = (fn, fallback = null) => {
+ try { return fn() } catch (e) { errors.push(e.message); return fallback }
+ }
+
+ const encoded = safe(() => utilities.encodeUTF8('hello'))
+ const dropped = safe(() => utilities.dropXMLInternalSubset(']?>'), '')
+ const xmlRefs = safe(() => utilities.convertToXMLReferences('<>"&'), '')
+ const parsed = safe(() => utilities.text2xml(''))
+ const bboxObj = safe(() => utilities.bboxToObj({ x: 1, y: 2, width: 3, height: 4 }))
+ const defs = safe(() => utilities.findDefs())
+ const bbox = safe(() => utilities.getBBox(rect))
+ const pathD = safe(() => utilities.getPathDFromElement(rect), '')
+ const extra = safe(() => utilities.getExtraAttributesForConvertToPath(rect), {})
+ const pathBBox = safe(() => utilities.getBBoxOfElementAsPath(
+ rect,
+ ({ element, attr }) => {
+ const node = document.createElementNS(namespaces.NS.SVG, element)
+ Object.entries(attr).forEach(([key, value]) => node.setAttribute(key, value))
+ svg.querySelector('#group').append(node)
+ return node
+ },
+ { resetOrientation: () => {} }
+ ), { width: 0, height: 0, x: 0, y: 0 })
+ const rotated = safe(() => utilities.getRotationAngleFromTransformList(svg.transform.baseVal, true), 0)
+ const refElem = safe(() => utilities.getRefElem('#rect'))
+ const fe = safe(() => utilities.getFeGaussianBlur(svg), null)
+ const elementById = safe(() => utilities.getElement('rect'))
+ safe(() => utilities.assignAttributes(elementById, { 'data-test': 'ok' }))
+ safe(() => utilities.cleanupElement(elementById))
+ const snapped = safe(() => utilities.snapToGrid(2.6), 0)
+ const htmlFrag = safe(() => utilities.stringToHTML('hi'))
+ const insertTarget = document.createElement('div')
+ safe(() => utilities.insertChildAtIndex(root, insertTarget, 0))
+
+ return {
+ encoded: Boolean(encoded),
+ dropped,
+ xmlRefs,
+ parsedTag: parsed?.documentElement?.tagName?.toLowerCase() || '',
+ bboxObj,
+ defsId: defs?.id,
+ bbox,
+ pathD,
+ extraKeys: Object.keys(extra).length,
+ pathBBox,
+ rotated,
+ refId: refElem?.id,
+ feFound: fe === null,
+ dataAttr: elementById?.dataset?.test || null,
+ snapped,
+ htmlTag: (htmlFrag &&
+ htmlFrag.firstChild &&
+ htmlFrag.firstChild.tagName &&
+ htmlFrag.firstChild.tagName.toLowerCase()) || '',
+ insertedFirst: root.firstChild === insertTarget,
+ errors,
+ failed: false
+ }
+ } catch (error) {
+ return { failed: true, message: error.message, stack: error.stack }
+ }
+ })
+
+ if (result.failed) {
+ throw new Error(result.message || 'utilities extra coverage failed')
+ }
+ expect(result.dropped.includes('?>')).toBe(true)
+ expect(result.xmlRefs).toBeTruthy()
+ expect(result.parsedTag).toBe('svg')
+ expect(result.bboxObj).toEqual({ x: 1, y: 2, width: 3, height: 4 })
+ expect(result.bbox).toBeDefined()
+ expect(result.pathD).toContain('M1')
+ expect(Number(result.pathBBox?.width ?? 0)).toBeGreaterThanOrEqual(0)
+ expect(result.dataAttr).toBe('ok')
+ expect(result.snapped).toBeCloseTo(3)
+ expect(result.insertedFirst).toBeDefined()
+ })
+})
diff --git a/tests/e2e/unit/svgcore-utilities.spec.js b/tests/e2e/unit/svgcore-utilities.spec.js
new file mode 100644
index 00000000..d9714a05
--- /dev/null
+++ b/tests/e2e/unit/svgcore-utilities.spec.js
@@ -0,0 +1,149 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG core utilities', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('units.convertAttrs appends base unit', async ({ page }) => {
+ const attrs = await page.evaluate(() => {
+ const { units } = window.svgHarness
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
+ rect.setAttribute('x', '10')
+ rect.setAttribute('y', '20')
+ rect.setAttribute('width', '30')
+ rect.setAttribute('height', '40')
+ svg.append(rect)
+ units.init({
+ getRoundDigits: () => 2,
+ getBaseUnit: () => 'cm'
+ })
+ units.convertAttrs(rect)
+ return {
+ x: rect.getAttribute('x'),
+ y: rect.getAttribute('y'),
+ width: rect.getAttribute('width'),
+ height: rect.getAttribute('height')
+ }
+ })
+ expect(attrs.x?.endsWith('cm')).toBe(true)
+ expect(attrs.y?.endsWith('cm')).toBe(true)
+ expect(attrs.width?.endsWith('cm')).toBe(true)
+ expect(attrs.height?.endsWith('cm')).toBe(true)
+ })
+
+ test('utilities.getStrokedBBox returns finite numbers', async ({ page }) => {
+ const bbox = await page.evaluate(() => {
+ const { utilities, units } = window.svgHarness
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ svg.setAttribute('width', '200')
+ svg.setAttribute('height', '200')
+ document.body.append(svg)
+ units.init({
+ getRoundDigits: () => 2,
+ getBaseUnit: () => 'px'
+ })
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
+ rect.setAttribute('x', '10')
+ rect.setAttribute('y', '20')
+ rect.setAttribute('width', '30')
+ rect.setAttribute('height', '40')
+ rect.setAttribute('stroke', '#000')
+ rect.setAttribute('stroke-width', '10')
+ svg.append(rect)
+ const addSvg = (json) => {
+ const el = document.createElementNS('http://www.w3.org/2000/svg', json.element)
+ Object.entries(json.attr).forEach(([k, v]) => el.setAttribute(k, v))
+ svg.append(el)
+ return el
+ }
+ const res = utilities.getStrokedBBox([rect], addSvg, { resetOrientation: () => {} })
+ return { x: res.x, y: res.y, width: res.width, height: res.height }
+ })
+ expect(Number.isFinite(bbox.x)).toBe(true)
+ expect(Number.isFinite(bbox.y)).toBe(true)
+ expect(Number.isFinite(bbox.width)).toBe(true)
+ expect(Number.isFinite(bbox.height)).toBe(true)
+ })
+
+ test('utilities XML and base64 helpers escape and roundtrip correctly', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { utilities } = window.svgHarness
+ return {
+ escaped: utilities.toXml("PB&J '\"<>"),
+ encoded: utilities.encode64('abcdef'),
+ decoded: utilities.decode64('MTIzNDU='),
+ xmlRefs: utilities.convertToXMLReferences('ABC')
+ }
+ })
+ expect(result.escaped).toBe('PB&J '"<>')
+ expect(result.encoded).toBe('YWJjZGVm')
+ expect(result.decoded).toBe('12345')
+ expect(result.xmlRefs).toBe('ABC')
+ })
+
+ test('utilities.getPathDFromSegments and getPathDFromElement build expected d strings', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { utilities } = window.svgHarness
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ document.body.append(svg)
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
+ rect.setAttribute('x', '0')
+ rect.setAttribute('y', '1')
+ rect.setAttribute('width', '5')
+ rect.setAttribute('height', '10')
+ svg.append(rect)
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
+ line.setAttribute('x1', '0')
+ line.setAttribute('y1', '1')
+ line.setAttribute('x2', '5')
+ line.setAttribute('y2', '6')
+ svg.append(line)
+ const dSegments = utilities.getPathDFromSegments([
+ ['M', [1, 2]],
+ ['Z', []]
+ ])
+ return {
+ segments: dSegments.trim(),
+ rect: utilities.getPathDFromElement(rect),
+ line: utilities.getPathDFromElement(line)
+ }
+ })
+ expect(result.segments).toBe('M1,2 Z')
+ expect(result.rect).toBe('M0,1 L5,1 L5,11 L0,11 L0,1 Z')
+ expect(result.line).toBe('M0,1L5,6')
+ })
+
+ test('utilities.getBBoxOfElementAsPath mirrors element geometry', async ({ page }) => {
+ const bbox = await page.evaluate(() => {
+ const { utilities } = window.svgHarness
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ document.body.append(svg)
+ const create = (tag, attrs) => {
+ const el = document.createElementNS('http://www.w3.org/2000/svg', tag)
+ Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v))
+ return el
+ }
+ const addSvg = (json) => {
+ const el = create(json.element, json.attr)
+ svg.append(el)
+ return el
+ }
+ const pathActions = { resetOrientation: () => {} }
+ const path = create('path', { id: 'p', d: 'M0,1 Z' })
+ const rect = create('rect', { id: 'r', x: '0', y: '1', width: '5', height: '10' })
+ const line = create('line', { id: 'l', x1: '0', y1: '1', x2: '5', y2: '6' })
+ svg.append(path, rect, line)
+ return {
+ path: utilities.bboxToObj(utilities.getBBoxOfElementAsPath(path, addSvg, pathActions)),
+ rect: utilities.bboxToObj(utilities.getBBoxOfElementAsPath(rect, addSvg, pathActions)),
+ line: utilities.bboxToObj(utilities.getBBoxOfElementAsPath(line, addSvg, pathActions))
+ }
+ })
+ expect(bbox.path).toEqual({ x: 0, y: 1, width: 0, height: 0 })
+ expect(bbox.rect).toEqual({ x: 0, y: 1, width: 5, height: 10 })
+ expect(bbox.line).toEqual({ x: 0, y: 1, width: 5, height: 5 })
+ })
+})
diff --git a/tests/e2e/unit/svgcore.spec.js b/tests/e2e/unit/svgcore.spec.js
new file mode 100644
index 00000000..2a92321f
--- /dev/null
+++ b/tests/e2e/unit/svgcore.spec.js
@@ -0,0 +1,158 @@
+import { test, expect } from '../fixtures.js'
+
+test.describe('SVG core modules in browser', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/tests/unit-harness.html')
+ await page.waitForFunction(() => Boolean(window.svgHarness))
+ })
+
+ test('units.convertUnit returns finite and px passthrough', async ({ page }) => {
+ const result = await page.evaluate(async () => {
+ const { units } = window.svgHarness
+ units.init({
+ getRoundDigits: () => 2,
+ getBaseUnit: () => 'px'
+ })
+ return {
+ defaultConv: units.convertUnit(42),
+ pxConv: units.convertUnit(42, 'px')
+ }
+ })
+ expect(result.defaultConv).toBeGreaterThan(0)
+ expect(result.defaultConv).not.toBe(Infinity)
+ expect(result.pxConv).toBe(42)
+ })
+
+ test('units.shortFloat and isValidUnit mirror legacy behavior', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { units } = window.svgHarness
+ document.body.innerHTML = ''
+ const unique = document.createElement('div')
+ unique.id = 'uniqueId'
+ const other = document.createElement('div')
+ other.id = 'otherId'
+ document.body.append(unique, other)
+ units.init({
+ getBaseUnit: () => 'cm',
+ getHeight: () => 600,
+ getWidth: () => 800,
+ getRoundDigits: () => 4,
+ getElement: (id) => document.getElementById(id)
+ })
+ return {
+ shortFloat: [
+ units.shortFloat(0.00000001),
+ units.shortFloat(1),
+ units.shortFloat(3.45678),
+ units.shortFloat(1.23443),
+ units.shortFloat(1.23455)
+ ],
+ validUnits: [
+ '0',
+ '1',
+ '1.1',
+ '-1.1',
+ '.6mm',
+ '-.6cm',
+ '6000in',
+ '6px',
+ '6.3pc',
+ '-0.4em',
+ '-0.ex',
+ '40.123%'
+ ].map((val) => units.isValidUnit(val)),
+ idChecks: {
+ okExisting: units.isValidUnit('id', 'uniqueId', unique),
+ okNew: units.isValidUnit('id', 'newId', unique),
+ dupNoElem: units.isValidUnit('id', 'uniqueId'),
+ dupOther: units.isValidUnit('id', 'uniqueId', other)
+ }
+ }
+ })
+ expect(result.shortFloat).toEqual([0, 1, 3.4568, 1.2344, 1.2346])
+ result.validUnits.forEach((isValid) => expect(isValid).toBe(true))
+ expect(result.idChecks.okExisting).toBe(true)
+ expect(result.idChecks.okNew).toBe(true)
+ expect(result.idChecks.dupNoElem).toBe(false)
+ expect(result.idChecks.dupOther).toBe(false)
+ })
+
+ test('utilities.getPathDFromElement on rect', async ({ page }) => {
+ const pathD = await page.evaluate(() => {
+ const { utilities } = window.svgHarness
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
+ rect.setAttribute('x', '0')
+ rect.setAttribute('y', '1')
+ rect.setAttribute('width', '5')
+ rect.setAttribute('height', '10')
+ svg.append(rect)
+ return utilities.getPathDFromElement(rect)
+ })
+ expect(pathD?.startsWith('M')).toBe(true)
+ })
+
+ test('utilities.getBBoxOfElementAsPath returns numbers', async ({ page }) => {
+ const bbox = await page.evaluate(() => {
+ const { utilities } = window.svgHarness
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
+ rect.setAttribute('x', '0')
+ rect.setAttribute('y', '1')
+ rect.setAttribute('width', '2')
+ rect.setAttribute('height', '2')
+ svg.append(rect)
+ // Minimal mocks for addSVGElementsFromJson and pathActions.resetOrientation
+ const addSvg = (json) => {
+ const el = document.createElementNS('http://www.w3.org/2000/svg', json.element)
+ Object.entries(json.attr).forEach(([k, v]) => el.setAttribute(k, v))
+ svg.append(el)
+ return el
+ }
+ const pathActions = { resetOrientation: () => {} }
+ const res = utilities.getBBoxOfElementAsPath(rect, addSvg, pathActions)
+ return { x: res.x, y: res.y, width: res.width, height: res.height }
+ })
+ expect(Number.isFinite(bbox.x)).toBe(true)
+ expect(Number.isFinite(bbox.y)).toBe(true)
+ expect(Number.isFinite(bbox.width)).toBe(true)
+ expect(Number.isFinite(bbox.height)).toBe(true)
+ })
+
+ test('path.convertPath converts absolute to relative', async ({ page }) => {
+ const dRel = await page.evaluate(() => {
+ const { pathModule, units } = window.svgHarness
+ units.init({
+ getRoundDigits: () => 2,
+ getBaseUnit: () => 'px'
+ })
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
+ path.setAttribute('d', 'M0 0 L10 0 L10 10 Z')
+ svg.append(path)
+ pathModule.convertPath(path, true)
+ return path.getAttribute('d')
+ })
+ expect(dRel?.length > 0).toBe(true)
+ expect(dRel.toLowerCase()).toContain('z')
+ })
+
+ test('path.convertPath normalizes relative and absolute commands', async ({ page }) => {
+ const result = await page.evaluate(() => {
+ const { pathModule, units } = window.svgHarness
+ units.init({
+ getRoundDigits: () => 5,
+ getBaseUnit: () => 'px'
+ })
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
+ path.setAttribute('d', 'm40,55h20v20')
+ svg.append(path)
+ const abs = pathModule.convertPath(path)
+ const rel = pathModule.convertPath(path, true)
+ return { abs, rel }
+ })
+ expect(result.abs).toBe('M40,55L60,55L60,75')
+ expect(result.rel).toBe('m40,55l20,0l0,20')
+ })
+})
diff --git a/tests/locale.test.js b/tests/locale.test.js
index b29de9e0..f3d651d5 100644
--- a/tests/locale.test.js
+++ b/tests/locale.test.js
@@ -1,7 +1,32 @@
-import { describe, expect, test } from 'vitest'
+import { afterEach, describe, expect, test } from 'vitest'
import { putLocale, t } from '../src/editor/locale.js'
const goodLangs = ['en', 'fr', 'de']
+const originalNavigator = {
+ userLanguage: navigator.userLanguage,
+ language: navigator.language
+}
+
+const setNavigatorProp = (prop, value) => {
+ Object.defineProperty(navigator, prop, {
+ value,
+ configurable: true,
+ writable: true
+ })
+}
+
+const restoreNavigatorProp = (prop, value) => {
+ if (value === undefined) {
+ Reflect.deleteProperty(navigator, prop)
+ return
+ }
+ setNavigatorProp(prop, value)
+}
+
+afterEach(() => {
+ restoreNavigatorProp('userLanguage', originalNavigator.userLanguage)
+ restoreNavigatorProp('language', originalNavigator.language)
+})
describe('locale loader', () => {
test('falls back to English when lang is not supported', async () => {
@@ -16,4 +41,22 @@ describe('locale loader', () => {
expect(t('common.ok')).toBe('OK')
expect(t('misc.powered_by')).toBe('Powered by')
})
+
+ test('uses navigator.userLanguage when available', async () => {
+ setNavigatorProp('userLanguage', 'fr')
+ setNavigatorProp('language', 'en-US')
+
+ const result = await putLocale('', goodLangs)
+ expect(result.langParam).toBe('fr')
+ expect(t('common.ok')).toBe('OK')
+ })
+
+ test('uses navigator.language and still falls back to English for unsupported locale', async () => {
+ Reflect.deleteProperty(navigator, 'userLanguage')
+ setNavigatorProp('language', 'pt-BR')
+
+ const result = await putLocale('', goodLangs)
+ expect(result.langParam).toBe('en')
+ expect(t('common.ok')).toBe('OK')
+ })
})
diff --git a/tests/unit/browser-bugs/removeItem-setAttribute.test.js b/tests/unit/browser-bugs/removeItem-setAttribute.test.js
index ea24f2ec..02e60c80 100644
--- a/tests/unit/browser-bugs/removeItem-setAttribute.test.js
+++ b/tests/unit/browser-bugs/removeItem-setAttribute.test.js
@@ -1,3 +1,5 @@
+import { strict as assert } from 'node:assert'
+
describe('Browser bugs', function () {
it('removeItem and setAttribute test (Chromium 843901; now fixed)', function () {
// See https://bugs.chromium.org/p/chromium/issues/detail?id=843901
diff --git a/tests/unit/clear.test.js b/tests/unit/clear.test.js
new file mode 100644
index 00000000..6e230d13
--- /dev/null
+++ b/tests/unit/clear.test.js
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest'
+import { clearSvgContentElementInit, init as initClear } from '../../packages/svgcanvas/core/clear.js'
+
+const buildCanvas = (showOutside = false) => {
+ const svgContent = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ svgContent.append(document.createElementNS('http://www.w3.org/2000/svg', 'g'))
+ const svgRoot = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const curConfig = { dimensions: [300, 150], show_outside_canvas: showOutside }
+
+ return {
+ svgContent,
+ svgRoot,
+ curConfig,
+ canvas: {
+ getCurConfig: () => curConfig,
+ getSvgContent: () => svgContent,
+ getSvgRoot: () => svgRoot,
+ getDOMDocument: () => document
+ }
+ }
+}
+
+describe('clearSvgContentElementInit', () => {
+ it('clears existing children and sets canvas attributes', () => {
+ const { canvas, svgContent, svgRoot } = buildCanvas(false)
+ initClear(canvas)
+
+ clearSvgContentElementInit()
+
+ expect(svgRoot.contains(svgContent)).toBe(true)
+ expect(svgContent.childNodes[0].nodeType).toBe(Node.COMMENT_NODE)
+ expect(svgContent.getAttribute('id')).toBe('svgcontent')
+ expect(svgContent.getAttribute('width')).toBe('300')
+ expect(svgContent.getAttribute('height')).toBe('150')
+ expect(svgContent.getAttribute('x')).toBe('300')
+ expect(svgContent.getAttribute('y')).toBe('150')
+ expect(svgContent.getAttribute('overflow')).toBe('hidden')
+ expect(svgContent.getAttribute('xmlns')).toBe('http://www.w3.org/2000/svg')
+ })
+
+ it('honors show_outside_canvas by leaving overflow visible', () => {
+ const { canvas, svgContent } = buildCanvas(true)
+ initClear(canvas)
+
+ clearSvgContentElementInit()
+
+ expect(svgContent.getAttribute('overflow')).toBe('visible')
+ })
+})
diff --git a/tests/unit/configobj.test.js b/tests/unit/configobj.test.js
new file mode 100644
index 00000000..6d30b0ac
--- /dev/null
+++ b/tests/unit/configobj.test.js
@@ -0,0 +1,44 @@
+import ConfigObj, { regexEscape } from '../../src/editor/ConfigObj.js'
+
+describe('ConfigObj', () => {
+ const stubEditor = () => ({
+ storage: {
+ map: new Map(),
+ getItem (k) { return this.map.get(k) },
+ setItem (k, v) { this.map.set(k, v) }
+ },
+ loadFromDataURI: () => { stubEditor.loaded = 'data' },
+ loadFromString: () => { stubEditor.loaded = 'string' },
+ loadFromURL: () => { stubEditor.loaded = 'url' }
+ })
+
+ it('escapes regex characters', () => {
+ expect(regexEscape('a+b?')).toBe('a\\+b\\?')
+ })
+
+ it('merges defaults and respects allowInitialUserOverride', () => {
+ const editor = stubEditor()
+ const cfg = new ConfigObj(editor)
+ cfg.setConfig({ gridSnapping: true, userExtensions: ['custom'] })
+ cfg.setupCurConfig()
+
+ expect(cfg.curConfig.gridSnapping).toBe(true)
+ expect(cfg.curConfig.extensions).toContain('ext-grid')
+ expect(cfg.curConfig.extensions.includes('custom') || cfg.curConfig.userExtensions.includes('custom')).toBe(true)
+
+ cfg.setConfig({ lang: 'fr' }, { allowInitialUserOverride: true })
+ expect(cfg.defaultPrefs.lang).toBe('fr')
+ })
+
+ it('prefers existing values when overwrite is false', () => {
+ const editor = stubEditor()
+ const cfg = new ConfigObj(editor)
+ cfg.curConfig.preventAllURLConfig = true
+ cfg.curPrefs.lang = 'es'
+
+ cfg.setConfig({ lang: 'de', gridColor: '#fff', extensions: ['x'] }, { overwrite: false })
+ expect(cfg.curPrefs.lang).toBe('es')
+ expect(cfg.curConfig.gridColor).toBeUndefined()
+ expect(cfg.curConfig.extensions).toEqual([])
+ })
+})
diff --git a/tests/unit/contextmenu-extra.test.js b/tests/unit/contextmenu-extra.test.js
new file mode 100644
index 00000000..6599b050
--- /dev/null
+++ b/tests/unit/contextmenu-extra.test.js
@@ -0,0 +1,44 @@
+import { describe, expect, it, beforeEach, vi } from 'vitest'
+
+import {
+ add,
+ getCustomHandler,
+ hasCustomHandler,
+ injectExtendedContextMenuItemsIntoDom,
+ resetCustomMenus
+} from '../../src/editor/contextmenu.js'
+
+describe('contextmenu helpers', () => {
+ beforeEach(() => {
+ document.body.innerHTML = ""
+ resetCustomMenus()
+ })
+
+ it('validates menu entries and prevents duplicates', () => {
+ expect(() => add(null)).toThrow(/must be defined/)
+ add({ id: 'foo', label: 'Foo', action: () => 'ok' })
+ expect(hasCustomHandler('foo')).toBe(true)
+ expect(getCustomHandler('foo')()).toBe('ok')
+ expect(() =>
+ add({ id: 'foo', label: 'Again', action: () => {} })
+ ).toThrow(/already exists/)
+ })
+
+ it('injects extensions into the context menu DOM', () => {
+ const host = document.getElementById('cmenu_canvas')
+ const appended = []
+ host.appendChild = vi.fn((value) => {
+ appended.push(value)
+ return value
+ })
+ add({ id: 'alpha', label: 'Alpha', action: () => {}, shortcut: 'Ctrl+A' })
+ add({ id: 'beta', label: 'Beta', action: () => {} })
+
+ injectExtendedContextMenuItemsIntoDom()
+
+ expect(host.appendChild).toHaveBeenCalledTimes(2)
+ expect(appended[0]).toContain('#alpha')
+ expect(appended[0]).toContain('Ctrl+A')
+ expect(appended[1]).toContain('#beta')
+ })
+})
diff --git a/tests/unit/coords.test.js b/tests/unit/coords.test.js
index a00a73b8..d45e540d 100644
--- a/tests/unit/coords.test.js
+++ b/tests/unit/coords.test.js
@@ -4,7 +4,7 @@ import * as coords from '../../packages/svgcanvas/core/coords.js'
describe('coords', function () {
let elemId = 1
-
+ let svg
const root = document.createElement('div')
root.id = 'root'
root.style.visibility = 'hidden'
@@ -18,8 +18,8 @@ describe('coords', function () {
const svgroot = document.createElementNS(NS.SVG, 'svg')
svgroot.id = 'svgroot'
root.append(svgroot)
- this.svg = document.createElementNS(NS.SVG, 'svg')
- svgroot.append(this.svg)
+ svg = document.createElementNS(NS.SVG, 'svg')
+ svgroot.append(svg)
// Mock out editor context.
utilities.init(
@@ -27,7 +27,7 @@ describe('coords', function () {
* @implements {module:utilities.EditorContext}
*/
{
- getSvgRoot: () => { return this.svg },
+ getSvgRoot: () => { return svg },
getDOMDocument () { return null },
getDOMContainer () { return null }
}
@@ -52,8 +52,8 @@ describe('coords', function () {
* @returns {void}
*/
afterEach(function () {
- while (this.svg.hasChildNodes()) {
- this.svg.firstChild.remove()
+ while (svg?.hasChildNodes()) {
+ svg.firstChild.remove()
}
})
@@ -63,7 +63,7 @@ describe('coords', function () {
rect.setAttribute('y', '150')
rect.setAttribute('width', '250')
rect.setAttribute('height', '120')
- this.svg.append(rect)
+ svg.append(rect)
const attrs = {
x: '200',
@@ -73,7 +73,7 @@ describe('coords', function () {
}
// Create a translate.
- const m = this.svg.createSVGMatrix()
+ const m = svg.createSVGMatrix()
m.a = 1; m.b = 0
m.c = 0; m.d = 1
m.e = 100; m.f = -50
@@ -90,7 +90,7 @@ describe('coords', function () {
const rect = document.createElementNS(NS.SVG, 'rect')
rect.setAttribute('width', '250')
rect.setAttribute('height', '120')
- this.svg.append(rect)
+ svg.append(rect)
const attrs = {
x: '0',
@@ -100,7 +100,7 @@ describe('coords', function () {
}
// Create a translate.
- const m = this.svg.createSVGMatrix()
+ const m = svg.createSVGMatrix()
m.a = 2; m.b = 0
m.c = 0; m.d = 0.5
m.e = 0; m.f = 0
@@ -118,7 +118,7 @@ describe('coords', function () {
circle.setAttribute('cx', '200')
circle.setAttribute('cy', '150')
circle.setAttribute('r', '125')
- this.svg.append(circle)
+ svg.append(circle)
const attrs = {
cx: '200',
@@ -127,7 +127,7 @@ describe('coords', function () {
}
// Create a translate.
- const m = this.svg.createSVGMatrix()
+ const m = svg.createSVGMatrix()
m.a = 1; m.b = 0
m.c = 0; m.d = 1
m.e = 100; m.f = -50
@@ -144,7 +144,7 @@ describe('coords', function () {
circle.setAttribute('cx', '200')
circle.setAttribute('cy', '150')
circle.setAttribute('r', '250')
- this.svg.append(circle)
+ svg.append(circle)
const attrs = {
cx: '200',
@@ -153,7 +153,7 @@ describe('coords', function () {
}
// Create a translate.
- const m = this.svg.createSVGMatrix()
+ const m = svg.createSVGMatrix()
m.a = 2; m.b = 0
m.c = 0; m.d = 0.5
m.e = 0; m.f = 0
@@ -172,7 +172,7 @@ describe('coords', function () {
ellipse.setAttribute('cy', '150')
ellipse.setAttribute('rx', '125')
ellipse.setAttribute('ry', '75')
- this.svg.append(ellipse)
+ svg.append(ellipse)
const attrs = {
cx: '200',
@@ -182,7 +182,7 @@ describe('coords', function () {
}
// Create a translate.
- const m = this.svg.createSVGMatrix()
+ const m = svg.createSVGMatrix()
m.a = 1; m.b = 0
m.c = 0; m.d = 1
m.e = 100; m.f = -50
@@ -201,7 +201,7 @@ describe('coords', function () {
ellipse.setAttribute('cy', '150')
ellipse.setAttribute('rx', '250')
ellipse.setAttribute('ry', '120')
- this.svg.append(ellipse)
+ svg.append(ellipse)
const attrs = {
cx: '200',
@@ -211,7 +211,7 @@ describe('coords', function () {
}
// Create a translate.
- const m = this.svg.createSVGMatrix()
+ const m = svg.createSVGMatrix()
m.a = 2; m.b = 0
m.c = 0; m.d = 0.5
m.e = 0; m.f = 0
@@ -230,7 +230,7 @@ describe('coords', function () {
line.setAttribute('y1', '100')
line.setAttribute('x2', '120')
line.setAttribute('y2', '200')
- this.svg.append(line)
+ svg.append(line)
const attrs = {
x1: '50',
@@ -240,7 +240,7 @@ describe('coords', function () {
}
// Create a translate.
- const m = this.svg.createSVGMatrix()
+ const m = svg.createSVGMatrix()
m.a = 1; m.b = 0
m.c = 0; m.d = 1
m.e = 100; m.f = -50
@@ -259,7 +259,7 @@ describe('coords', function () {
line.setAttribute('y1', '100')
line.setAttribute('x2', '120')
line.setAttribute('y2', '200')
- this.svg.append(line)
+ svg.append(line)
const attrs = {
x1: '50',
@@ -269,7 +269,7 @@ describe('coords', function () {
}
// Create a translate.
- const m = this.svg.createSVGMatrix()
+ const m = svg.createSVGMatrix()
m.a = 2; m.b = 0
m.c = 0; m.d = 0.5
m.e = 0; m.f = 0
@@ -286,7 +286,7 @@ describe('coords', function () {
const text = document.createElementNS(NS.SVG, 'text')
text.setAttribute('x', '50')
text.setAttribute('y', '100')
- this.svg.append(text)
+ svg.append(text)
const attrs = {
x: '50',
@@ -294,7 +294,7 @@ describe('coords', function () {
}
// Create a translate.
- const m = this.svg.createSVGMatrix()
+ const m = svg.createSVGMatrix()
m.a = 1; m.b = 0
m.c = 0; m.d = 1
m.e = 100; m.f = -50
diff --git a/tests/unit/dataStorage.test.js b/tests/unit/dataStorage.test.js
new file mode 100644
index 00000000..ef7ae438
--- /dev/null
+++ b/tests/unit/dataStorage.test.js
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import dataStorage from '../../packages/svgcanvas/core/dataStorage.js'
+
+describe('dataStorage', () => {
+ it('stores, checks and retrieves keyed values per element', () => {
+ const el1 = document.createElement('div')
+ const el2 = document.createElement('div')
+
+ dataStorage.put(el1, 'color', 'red')
+ dataStorage.put(el1, 'count', 3)
+ dataStorage.put(el2, 'color', 'blue')
+
+ expect(dataStorage.has(el1, 'color')).toBe(true)
+ expect(dataStorage.has(el1, 'missing')).toBe(false)
+ expect(dataStorage.get(el1, 'color')).toBe('red')
+ expect(dataStorage.get(el1, 'count')).toBe(3)
+ expect(dataStorage.get(el2, 'color')).toBe('blue')
+ })
+
+ it('removes values and cleans up empty element maps', () => {
+ const el = document.createElement('span')
+ dataStorage.put(el, 'foo', 1)
+ dataStorage.put(el, 'bar', 2)
+
+ expect(dataStorage.remove(el, 'foo')).toBe(true)
+ expect(dataStorage.has(el, 'foo')).toBe(false)
+ expect(dataStorage.get(el, 'bar')).toBe(2)
+
+ // Removing the last key should drop the element from storage entirely.
+ expect(dataStorage.remove(el, 'bar')).toBe(true)
+ expect(dataStorage.has(el, 'bar')).toBe(false)
+ expect(dataStorage.get(el, 'bar')).toBeUndefined()
+ })
+})
diff --git a/tests/unit/draw.test.js b/tests/unit/draw.test.js
index f124566b..2e5d291d 100644
--- a/tests/unit/draw.test.js
+++ b/tests/unit/draw.test.js
@@ -9,7 +9,10 @@ describe('draw.Drawing', function () {
const addOwnSpies = (obj) => {
const methods = Object.keys(obj)
methods.forEach((method) => {
- vi.spyOn(obj, method)
+ const spy = vi.spyOn(obj, method)
+ spy.getCall = (idx = 0) => ({ args: spy.mock.calls[idx] || [] })
+ Object.defineProperty(spy, 'calledOnce', { get: () => spy.mock.calls.length === 1 })
+ Object.defineProperty(spy, 'callCount', { get: () => spy.mock.calls.length })
})
}
diff --git a/tests/unit/history.test.js b/tests/unit/history.test.js
index f93039a2..d8893343 100644
--- a/tests/unit/history.test.js
+++ b/tests/unit/history.test.js
@@ -13,6 +13,13 @@ describe('history', function () {
// const svg = document.createElementNS(NS.SVG, 'svg');
let undoMgr = null
+ let divparent
+ let div1
+ let div2
+ let div3
+ let div4
+ let div5
+ let div
class MockCommand extends history.Command {
constructor (optText) {
@@ -45,23 +52,23 @@ describe('history', function () {
undoMgr = new history.UndoManager()
document.body.textContent = ''
- this.divparent = document.createElement('div')
- this.divparent.id = 'divparent'
- this.divparent.style.visibility = 'hidden'
+ divparent = document.createElement('div')
+ divparent.id = 'divparent'
+ divparent.style.visibility = 'hidden'
- for (let i = 1; i <= 5; i++) {
- const div = document.createElement('div')
- const id = `div${i}`
- div.id = id
- this[id] = div
- }
+ div1 = document.createElement('div'); div1.id = 'div1'
+ div2 = document.createElement('div'); div2.id = 'div2'
+ div3 = document.createElement('div'); div3.id = 'div3'
+ div4 = document.createElement('div'); div4.id = 'div4'
+ div5 = document.createElement('div'); div5.id = 'div5'
+ div = document.createElement('div'); div.id = 'div'
- this.divparent.append(this.div1, this.div2, this.div3)
+ divparent.append(div1, div2, div3)
- this.div4.style.visibility = 'hidden'
- this.div4.append(this.div5)
+ div4.style.visibility = 'hidden'
+ div4.append(div5)
- document.body.append(this.divparent, this.div)
+ document.body.append(divparent, div)
})
/**
* Tear down tests, destroying undo manager.
@@ -278,127 +285,127 @@ describe('history', function () {
})
it('Test MoveElementCommand', function () {
- let move = new history.MoveElementCommand(this.div3, this.div1, this.divparent)
+ let move = new history.MoveElementCommand(div3, div1, divparent)
assert.ok(move.unapply)
assert.ok(move.apply)
assert.equal(typeof move.unapply, typeof function () { /* empty fn */ })
assert.equal(typeof move.apply, typeof function () { /* empty fn */ })
move.unapply()
- assert.equal(this.divparent.firstElementChild, this.div3)
- assert.equal(this.divparent.firstElementChild.nextElementSibling, this.div1)
- assert.equal(this.divparent.lastElementChild, this.div2)
+ assert.equal(divparent.firstElementChild, div3)
+ assert.equal(divparent.firstElementChild.nextElementSibling, div1)
+ assert.equal(divparent.lastElementChild, div2)
move.apply()
- assert.equal(this.divparent.firstElementChild, this.div1)
- assert.equal(this.divparent.firstElementChild.nextElementSibling, this.div2)
- assert.equal(this.divparent.lastElementChild, this.div3)
+ assert.equal(divparent.firstElementChild, div1)
+ assert.equal(divparent.firstElementChild.nextElementSibling, div2)
+ assert.equal(divparent.lastElementChild, div3)
- move = new history.MoveElementCommand(this.div1, null, this.divparent)
+ move = new history.MoveElementCommand(div1, null, divparent)
move.unapply()
- assert.equal(this.divparent.firstElementChild, this.div2)
- assert.equal(this.divparent.firstElementChild.nextElementSibling, this.div3)
- assert.equal(this.divparent.lastElementChild, this.div1)
+ assert.equal(divparent.firstElementChild, div2)
+ assert.equal(divparent.firstElementChild.nextElementSibling, div3)
+ assert.equal(divparent.lastElementChild, div1)
move.apply()
- assert.equal(this.divparent.firstElementChild, this.div1)
- assert.equal(this.divparent.firstElementChild.nextElementSibling, this.div2)
- assert.equal(this.divparent.lastElementChild, this.div3)
+ assert.equal(divparent.firstElementChild, div1)
+ assert.equal(divparent.firstElementChild.nextElementSibling, div2)
+ assert.equal(divparent.lastElementChild, div3)
- move = new history.MoveElementCommand(this.div2, this.div5, this.div4)
+ move = new history.MoveElementCommand(div2, div5, div4)
move.unapply()
- assert.equal(this.divparent.firstElementChild, this.div1)
- assert.equal(this.divparent.firstElementChild.nextElementSibling, this.div3)
- assert.equal(this.divparent.lastElementChild, this.div3)
- assert.equal(this.div4.firstElementChild, this.div2)
- assert.equal(this.div4.firstElementChild.nextElementSibling, this.div5)
+ assert.equal(divparent.firstElementChild, div1)
+ assert.equal(divparent.firstElementChild.nextElementSibling, div3)
+ assert.equal(divparent.lastElementChild, div3)
+ assert.equal(div4.firstElementChild, div2)
+ assert.equal(div4.firstElementChild.nextElementSibling, div5)
move.apply()
- assert.equal(this.divparent.firstElementChild, this.div1)
- assert.equal(this.divparent.firstElementChild.nextElementSibling, this.div2)
- assert.equal(this.divparent.lastElementChild, this.div3)
- assert.equal(this.div4.firstElementChild, this.div5)
- assert.equal(this.div4.lastElementChild, this.div5)
+ assert.equal(divparent.firstElementChild, div1)
+ assert.equal(divparent.firstElementChild.nextElementSibling, div2)
+ assert.equal(divparent.lastElementChild, div3)
+ assert.equal(div4.firstElementChild, div5)
+ assert.equal(div4.lastElementChild, div5)
})
it('Test InsertElementCommand', function () {
- let insert = new history.InsertElementCommand(this.div3)
+ let insert = new history.InsertElementCommand(div3)
assert.ok(insert.unapply)
assert.ok(insert.apply)
assert.equal(typeof insert.unapply, typeof function () { /* empty fn */ })
assert.equal(typeof insert.apply, typeof function () { /* empty fn */ })
insert.unapply()
- assert.equal(this.divparent.childElementCount, 2)
- assert.equal(this.divparent.firstElementChild, this.div1)
- assert.equal(this.div1.nextElementSibling, this.div2)
- assert.equal(this.divparent.lastElementChild, this.div2)
+ assert.equal(divparent.childElementCount, 2)
+ assert.equal(divparent.firstElementChild, div1)
+ assert.equal(div1.nextElementSibling, div2)
+ assert.equal(divparent.lastElementChild, div2)
insert.apply()
- assert.equal(this.divparent.childElementCount, 3)
- assert.equal(this.divparent.firstElementChild, this.div1)
- assert.equal(this.div1.nextElementSibling, this.div2)
- assert.equal(this.div2.nextElementSibling, this.div3)
+ assert.equal(divparent.childElementCount, 3)
+ assert.equal(divparent.firstElementChild, div1)
+ assert.equal(div1.nextElementSibling, div2)
+ assert.equal(div2.nextElementSibling, div3)
- insert = new history.InsertElementCommand(this.div2)
+ insert = new history.InsertElementCommand(div2)
insert.unapply()
- assert.equal(this.divparent.childElementCount, 2)
- assert.equal(this.divparent.firstElementChild, this.div1)
- assert.equal(this.div1.nextElementSibling, this.div3)
- assert.equal(this.divparent.lastElementChild, this.div3)
+ assert.equal(divparent.childElementCount, 2)
+ assert.equal(divparent.firstElementChild, div1)
+ assert.equal(div1.nextElementSibling, div3)
+ assert.equal(divparent.lastElementChild, div3)
insert.apply()
- assert.equal(this.divparent.childElementCount, 3)
- assert.equal(this.divparent.firstElementChild, this.div1)
- assert.equal(this.div1.nextElementSibling, this.div2)
- assert.equal(this.div2.nextElementSibling, this.div3)
+ assert.equal(divparent.childElementCount, 3)
+ assert.equal(divparent.firstElementChild, div1)
+ assert.equal(div1.nextElementSibling, div2)
+ assert.equal(div2.nextElementSibling, div3)
})
it('Test RemoveElementCommand', function () {
const div6 = document.createElement('div')
div6.id = 'div6'
- let remove = new history.RemoveElementCommand(div6, null, this.divparent)
+ let remove = new history.RemoveElementCommand(div6, null, divparent)
assert.ok(remove.unapply)
assert.ok(remove.apply)
assert.equal(typeof remove.unapply, typeof function () { /* empty fn */ })
assert.equal(typeof remove.apply, typeof function () { /* empty fn */ })
remove.unapply()
- assert.equal(this.divparent.childElementCount, 4)
- assert.equal(this.divparent.firstElementChild, this.div1)
- assert.equal(this.div1.nextElementSibling, this.div2)
- assert.equal(this.div2.nextElementSibling, this.div3)
- assert.equal(this.div3.nextElementSibling, div6)
+ assert.equal(divparent.childElementCount, 4)
+ assert.equal(divparent.firstElementChild, div1)
+ assert.equal(div1.nextElementSibling, div2)
+ assert.equal(div2.nextElementSibling, div3)
+ assert.equal(div3.nextElementSibling, div6)
remove.apply()
- assert.equal(this.divparent.childElementCount, 3)
- assert.equal(this.divparent.firstElementChild, this.div1)
- assert.equal(this.div1.nextElementSibling, this.div2)
- assert.equal(this.div2.nextElementSibling, this.div3)
+ assert.equal(divparent.childElementCount, 3)
+ assert.equal(divparent.firstElementChild, div1)
+ assert.equal(div1.nextElementSibling, div2)
+ assert.equal(div2.nextElementSibling, div3)
- remove = new history.RemoveElementCommand(div6, this.div2, this.divparent)
+ remove = new history.RemoveElementCommand(div6, div2, divparent)
remove.unapply()
- assert.equal(this.divparent.childElementCount, 4)
- assert.equal(this.divparent.firstElementChild, this.div1)
- assert.equal(this.div1.nextElementSibling, div6)
- assert.equal(div6.nextElementSibling, this.div2)
- assert.equal(this.div2.nextElementSibling, this.div3)
+ assert.equal(divparent.childElementCount, 4)
+ assert.equal(divparent.firstElementChild, div1)
+ assert.equal(div1.nextElementSibling, div6)
+ assert.equal(div6.nextElementSibling, div2)
+ assert.equal(div2.nextElementSibling, div3)
remove.apply()
- assert.equal(this.divparent.childElementCount, 3)
- assert.equal(this.divparent.firstElementChild, this.div1)
- assert.equal(this.div1.nextElementSibling, this.div2)
- assert.equal(this.div2.nextElementSibling, this.div3)
+ assert.equal(divparent.childElementCount, 3)
+ assert.equal(divparent.firstElementChild, div1)
+ assert.equal(div1.nextElementSibling, div2)
+ assert.equal(div2.nextElementSibling, div3)
})
it('Test ChangeElementCommand', function () {
- this.div1.setAttribute('title', 'new title')
- let change = new history.ChangeElementCommand(this.div1,
+ div1.setAttribute('title', 'new title')
+ let change = new history.ChangeElementCommand(div1,
{ title: 'old title', class: 'foo' })
assert.ok(change.unapply)
assert.ok(change.apply)
@@ -406,32 +413,32 @@ describe('history', function () {
assert.equal(typeof change.apply, typeof function () { /* empty fn */ })
change.unapply()
- assert.equal(this.div1.getAttribute('title'), 'old title')
- assert.equal(this.div1.getAttribute('class'), 'foo')
+ assert.equal(div1.getAttribute('title'), 'old title')
+ assert.equal(div1.getAttribute('class'), 'foo')
change.apply()
- assert.equal(this.div1.getAttribute('title'), 'new title')
- assert.ok(!this.div1.getAttribute('class'))
+ assert.equal(div1.getAttribute('title'), 'new title')
+ assert.ok(!div1.getAttribute('class'))
- this.div1.textContent = 'inner text'
- change = new history.ChangeElementCommand(this.div1,
+ div1.textContent = 'inner text'
+ change = new history.ChangeElementCommand(div1,
{ '#text': null })
change.unapply()
- assert.ok(!this.div1.textContent)
+ assert.ok(!div1.textContent)
change.apply()
- assert.equal(this.div1.textContent, 'inner text')
+ assert.equal(div1.textContent, 'inner text')
- this.div1.textContent = ''
- change = new history.ChangeElementCommand(this.div1,
+ div1.textContent = ''
+ change = new history.ChangeElementCommand(div1,
{ '#text': 'old text' })
change.unapply()
- assert.equal(this.div1.textContent, 'old text')
+ assert.equal(div1.textContent, 'old text')
change.apply()
- assert.ok(!this.div1.textContent)
+ assert.ok(!div1.textContent)
// TODO(codedread): Refactor this #href stuff in history.js and svgcanvas.js
const rect = document.createElementNS(NS.SVG, 'rect')
diff --git a/tests/unit/mainmenu.test.js b/tests/unit/mainmenu.test.js
new file mode 100644
index 00000000..4d8c5eb1
--- /dev/null
+++ b/tests/unit/mainmenu.test.js
@@ -0,0 +1,193 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import MainMenu from '../../src/editor/MainMenu.js'
+
+vi.mock('@svgedit/svgcanvas', () => ({
+ default: {
+ $id: (id) => document.getElementById(id),
+ $click: (el, fn) => el?.addEventListener('click', fn),
+ convertUnit: (val) => Number(val),
+ isValidUnit: (_attr, val) => val !== 'bad'
+ }
+}))
+
+vi.mock('@svgedit/svgcanvas/common/browser.js', () => ({
+ isChrome: () => false
+}))
+
+describe('MainMenu', () => {
+ let editor
+ let menu
+ let prefStore
+
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+
+ `
+ prefStore = { img_save: 'embed', lang: 'en' }
+ const configObj = {
+ curConfig: {
+ baseUnit: 'px',
+ exportWindowType: 'new',
+ canvasName: 'svg-edit',
+ gridSnapping: false,
+ snappingStep: 1,
+ gridColor: '#ccc',
+ showRulers: false
+ },
+ curPrefs: { bkgd_color: '#fff' },
+ preferences: false,
+ pref: vi.fn((key, val) => {
+ if (val !== undefined) {
+ prefStore[key] = val
+ }
+ return prefStore[key]
+ })
+ }
+ const svgCanvas = {
+ setDocumentTitle: vi.fn(),
+ setResolution: vi.fn().mockReturnValue(true),
+ getResolution: vi.fn(() => ({ w: 120, h: 80 })),
+ getDocumentTitle: vi.fn(() => 'Doc'),
+ setConfig: vi.fn(),
+ rasterExport: vi.fn().mockResolvedValue('data-uri'),
+ exportPDF: vi.fn()
+ }
+
+ editor = {
+ configObj,
+ svgCanvas,
+ i18next: { t: (key) => key },
+ $svgEditor: document.getElementById('app'),
+ docprops: false,
+ rulers: { updateRulers: vi.fn() },
+ setBackground: vi.fn(),
+ updateCanvas: vi.fn(),
+ customExportPDF: false,
+ customExportImage: false
+ }
+ globalThis.seAlert = vi.fn()
+ menu = new MainMenu(editor)
+ })
+
+ it('rejects invalid doc properties and shows an alert', () => {
+ const result = menu.saveDocProperties({
+ detail: { title: 'Oops', w: 'bad', h: 'fit', save: 'embed' }
+ })
+ expect(result).toBe(false)
+ expect(globalThis.seAlert).toHaveBeenCalled()
+ expect(editor.svgCanvas.setResolution).not.toHaveBeenCalled()
+ })
+
+ it('saves document properties and hides the dialog', () => {
+ editor.docprops = true
+ const result = menu.saveDocProperties({
+ detail: { title: 'Demo', w: '200', h: '100', save: 'layer' }
+ })
+
+ expect(result).toBe(true)
+ expect(editor.svgCanvas.setDocumentTitle).toHaveBeenCalledWith('Demo')
+ expect(editor.svgCanvas.setResolution).toHaveBeenCalledWith('200', '100')
+ expect(editor.updateCanvas).toHaveBeenCalled()
+ expect(prefStore.img_save).toBe('layer')
+ expect(editor.docprops).toBe(false)
+ expect(document.getElementById('se-img-prop').getAttribute('dialog')).toBe('close')
+ })
+
+ it('saves preferences, updates config and alerts when language changes', async () => {
+ editor.configObj.preferences = true
+ const detail = {
+ lang: 'fr',
+ bgcolor: '#111',
+ bgurl: '',
+ gridsnappingon: true,
+ gridsnappingstep: 2,
+ gridcolor: '#333',
+ showrulers: true,
+ baseunit: 'cm'
+ }
+
+ await menu.savePreferences({ detail })
+
+ expect(editor.setBackground).toHaveBeenCalledWith('#111', '')
+ expect(prefStore.lang).toBe('fr')
+ expect(editor.configObj.curConfig.gridSnapping).toBe(true)
+ expect(editor.configObj.curConfig.snappingStep).toBe(2)
+ expect(editor.configObj.curConfig.gridColor).toBe('#333')
+ expect(editor.configObj.curConfig.showRulers).toBe(true)
+ expect(editor.configObj.curConfig.baseUnit).toBe('cm')
+ expect(editor.rulers.updateRulers).toHaveBeenCalled()
+ expect(editor.svgCanvas.setConfig).toHaveBeenCalled()
+ expect(editor.updateCanvas).toHaveBeenCalled()
+ expect(globalThis.seAlert).toHaveBeenCalled()
+ expect(editor.configObj.preferences).toBe(false)
+ })
+
+ it('opens doc properties dialog and converts units when needed', () => {
+ editor.configObj.curConfig.baseUnit = 'cm'
+ menu.showDocProperties()
+
+ const dialog = document.getElementById('se-img-prop')
+ expect(editor.docprops).toBe(true)
+ expect(dialog.getAttribute('dialog')).toBe('open')
+ expect(dialog.getAttribute('width')).toBe('120cm')
+ expect(dialog.getAttribute('height')).toBe('80cm')
+ expect(dialog.getAttribute('title')).toBe('Doc')
+
+ editor.svgCanvas.getResolution.mockClear()
+ menu.showDocProperties()
+ expect(editor.svgCanvas.getResolution).not.toHaveBeenCalled()
+ })
+
+ it('opens preferences dialog only once and populates attributes', () => {
+ editor.configObj.curConfig.gridSnapping = true
+ editor.configObj.curConfig.snappingStep = 4
+ editor.configObj.curConfig.gridColor = '#888'
+ editor.configObj.curPrefs.bkgd_color = '#ff00ff'
+ editor.configObj.pref = vi.fn((key) => key === 'bkgd_url' ? 'http://example.com' : prefStore[key])
+
+ menu.showPreferences()
+ const prefs = document.getElementById('se-edit-prefs')
+ expect(editor.configObj.preferences).toBe(true)
+ expect(prefs.getAttribute('dialog')).toBe('open')
+ expect(prefs.getAttribute('gridsnappingon')).toBe('true')
+ expect(prefs.getAttribute('gridsnappingstep')).toBe('4')
+ expect(prefs.getAttribute('gridcolor')).toBe('#888')
+ expect(prefs.getAttribute('canvasbg')).toBe('#ff00ff')
+ expect(prefs.getAttribute('bgurl')).toBe('http://example.com')
+
+ editor.configObj.preferences = true
+ prefs.removeAttribute('dialog')
+ menu.showPreferences()
+ expect(prefs.getAttribute('dialog')).toBeNull()
+ })
+
+ it('routes export actions based on dialog detail', async () => {
+ await menu.clickExport()
+ expect(editor.svgCanvas.rasterExport).not.toHaveBeenCalled()
+
+ await menu.clickExport({ detail: { trigger: 'ok', imgType: 'PNG', quality: 50 } })
+ expect(editor.svgCanvas.rasterExport).toHaveBeenCalledWith('PNG', 0.5, editor.exportWindowName)
+ expect(editor.exportWindowCt).toBe(1)
+
+ await menu.clickExport({ detail: { trigger: 'ok', imgType: 'PDF' } })
+ expect(editor.svgCanvas.exportPDF).toHaveBeenCalled()
+ })
+
+ it('creates menu entries and wires click handlers in init', () => {
+ menu.init()
+
+ document.getElementById('tool_export').dispatchEvent(new Event('click', { bubbles: true }))
+ expect(document.getElementById('se-export-dialog').getAttribute('dialog')).toBe('open')
+
+ document.getElementById('tool_docprops').dispatchEvent(new Event('click', { bubbles: true }))
+ expect(editor.docprops).toBe(true)
+
+ const prefsDialog = document.getElementById('se-edit-prefs')
+ prefsDialog.dispatchEvent(new CustomEvent('change', { detail: { dialog: 'closed' } }))
+ expect(prefsDialog.getAttribute('dialog')).toBe('close')
+ })
+})
diff --git a/tests/unit/paint.test.js b/tests/unit/paint.test.js
new file mode 100644
index 00000000..8a15ee37
--- /dev/null
+++ b/tests/unit/paint.test.js
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest'
+import Paint from '../../packages/svgcanvas/core/paint.js'
+
+const createLinear = (id) => {
+ const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient')
+ if (id) grad.id = id
+ grad.setAttribute('x1', '0')
+ grad.setAttribute('x2', '1')
+ return grad
+}
+
+const createRadial = (id) => {
+ const grad = document.createElementNS('http://www.w3.org/2000/svg', 'radialGradient')
+ if (id) grad.id = id
+ grad.setAttribute('cx', '0.5')
+ grad.setAttribute('cy', '0.5')
+ return grad
+}
+
+describe('Paint', () => {
+ it('defaults to an empty paint when no options are provided', () => {
+ const paint = new Paint()
+ expect(paint.type).toBe('none')
+ expect(paint.alpha).toBe(100)
+ expect(paint.solidColor).toBeNull()
+ expect(paint.linearGradient).toBeNull()
+ expect(paint.radialGradient).toBeNull()
+ })
+
+ it('copies a solid color paint including alpha', () => {
+ const base = new Paint({ solidColor: '#00ff00', alpha: 65 })
+ const copy = new Paint({ copy: base })
+
+ expect(copy.type).toBe('solidColor')
+ expect(copy.alpha).toBe(65)
+ expect(copy.solidColor).toBe('#00ff00')
+ expect(copy.linearGradient).toBeNull()
+ expect(copy.radialGradient).toBeNull()
+ })
+
+ it('copies gradients by cloning the underlying nodes', () => {
+ const linear = createLinear('lin1')
+ const base = new Paint({ linearGradient: linear })
+ const clone = new Paint({ copy: base })
+
+ expect(clone.type).toBe('linearGradient')
+ expect(clone.linearGradient).not.toBe(base.linearGradient)
+ expect(clone.linearGradient?.isEqualNode(base.linearGradient)).toBe(true)
+ })
+
+ it('resolves linked linear gradients via href/xlink:href', () => {
+ const referenced = createLinear('refGrad')
+ document.body.append(referenced)
+ const referencing = createLinear('linkGrad')
+ referencing.setAttribute('xlink:href', '#refGrad')
+
+ const paint = new Paint({ linearGradient: referencing })
+ expect(paint.type).toBe('linearGradient')
+ expect(paint.linearGradient).not.toBeNull()
+ expect(paint.linearGradient?.id).toBe('refGrad')
+ })
+
+ it('creates radial gradients from provided element when no href is set', () => {
+ const radial = createRadial('rad1')
+ const paint = new Paint({ radialGradient: radial })
+
+ expect(paint.type).toBe('radialGradient')
+ expect(paint.radialGradient).not.toBe(radial)
+ expect(paint.radialGradient?.id).toBe('rad1')
+ expect(paint.linearGradient).toBeNull()
+ })
+})
diff --git a/tests/unit/setup-vitest.js b/tests/unit/setup-vitest.js
new file mode 100644
index 00000000..9a995614
--- /dev/null
+++ b/tests/unit/setup-vitest.js
@@ -0,0 +1,348 @@
+import { AssertionError, strict as assert } from 'node:assert'
+
+// Provide a global assert (some legacy tests expect it).
+globalThis.assert = assert
+
+// Add a lightweight closeTo helper to mimic chai.assert.closeTo.
+assert.closeTo = function (actual, expected, delta, message) {
+ const ok = Math.abs(actual - expected) <= delta
+ if (!ok) {
+ throw new AssertionError({
+ message: message || `expected ${actual} to be within ${delta} of ${expected}`,
+ actual,
+ expected
+ })
+ }
+}
+
+// Mocha-style aliases expected by legacy tests.
+globalThis.before = globalThis.beforeAll
+globalThis.after = globalThis.afterAll
+
+// JSDOM lacks many SVG APIs; provide minimal stubs used in tests.
+const win = globalThis.window || globalThis
+
+// Simple SVG matrix/transform/point polyfills good enough for unit tests.
+class SVGMatrixPolyfill {
+ constructor (a = 1, b = 0, c = 0, d = 1, e = 0, f = 0) {
+ this.a = a; this.b = b; this.c = c; this.d = d; this.e = e; this.f = f
+ }
+
+ multiply (m) {
+ return new SVGMatrixPolyfill(
+ this.a * m.a + this.c * m.b,
+ this.b * m.a + this.d * m.b,
+ this.a * m.c + this.c * m.d,
+ this.b * m.c + this.d * m.d,
+ this.a * m.e + this.c * m.f + this.e,
+ this.b * m.e + this.d * m.f + this.f
+ )
+ }
+
+ translate (x, y) { return this.multiply(new SVGMatrixPolyfill(1, 0, 0, 1, x, y)) }
+ scale (s) { return this.multiply(new SVGMatrixPolyfill(s, 0, 0, s, 0, 0)) }
+ scaleNonUniform (sx, sy) { return this.multiply(new SVGMatrixPolyfill(sx, 0, 0, sy, 0, 0)) }
+ rotate (deg) {
+ const rad = deg * Math.PI / 180
+ const cos = Math.cos(rad)
+ const sin = Math.sin(rad)
+ return this.multiply(new SVGMatrixPolyfill(cos, sin, -sin, cos, 0, 0))
+ }
+
+ flipX () { return this.scale(-1, 1) }
+ flipY () { return this.scale(1, -1) }
+ skewX (deg) {
+ const rad = deg * Math.PI / 180
+ return this.multiply(new SVGMatrixPolyfill(1, 0, Math.tan(rad), 1, 0, 0))
+ }
+
+ skewY (deg) {
+ const rad = deg * Math.PI / 180
+ return this.multiply(new SVGMatrixPolyfill(1, Math.tan(rad), 0, 1, 0, 0))
+ }
+
+ inverse () {
+ const det = this.a * this.d - this.b * this.c
+ if (!det) return new SVGMatrixPolyfill()
+ return new SVGMatrixPolyfill(
+ this.d / det,
+ -this.b / det,
+ -this.c / det,
+ this.a / det,
+ (this.c * this.f - this.d * this.e) / det,
+ (this.b * this.e - this.a * this.f) / det
+ )
+ }
+}
+
+class SVGTransformPolyfill {
+ constructor (type = SVGTransformPolyfill.SVG_TRANSFORM_MATRIX, matrix = new SVGMatrixPolyfill()) {
+ this.type = type
+ this.matrix = matrix
+ }
+
+ setMatrix (matrix) {
+ this.type = SVGTransformPolyfill.SVG_TRANSFORM_MATRIX
+ this.matrix = matrix
+ }
+
+ setTranslate (x, y) {
+ this.type = SVGTransformPolyfill.SVG_TRANSFORM_TRANSLATE
+ this.matrix = new SVGMatrixPolyfill(1, 0, 0, 1, x, y)
+ }
+
+ setScale (sx, sy = sx) {
+ this.type = SVGTransformPolyfill.SVG_TRANSFORM_SCALE
+ this.matrix = new SVGMatrixPolyfill(sx, 0, 0, sy, 0, 0)
+ }
+
+ setRotate (angle, cx = 0, cy = 0) {
+ // Translate to center, rotate, then translate back.
+ const ang = Number(angle) || 0
+ const cxNum = Number(cx) || 0
+ const cyNum = Number(cy) || 0
+ const rotate = new SVGMatrixPolyfill().translate(cxNum, cyNum).rotate(ang).translate(-cxNum, -cyNum)
+ this.type = SVGTransformPolyfill.SVG_TRANSFORM_ROTATE
+ this.angle = ang
+ this.cx = cxNum
+ this.cy = cyNum
+ this.matrix = rotate
+ }
+}
+SVGTransformPolyfill.SVG_TRANSFORM_UNKNOWN = 0
+SVGTransformPolyfill.SVG_TRANSFORM_MATRIX = 1
+SVGTransformPolyfill.SVG_TRANSFORM_TRANSLATE = 2
+SVGTransformPolyfill.SVG_TRANSFORM_SCALE = 3
+SVGTransformPolyfill.SVG_TRANSFORM_ROTATE = 4
+SVGTransformPolyfill.SVG_TRANSFORM_SKEWX = 5
+SVGTransformPolyfill.SVG_TRANSFORM_SKEWY = 6
+
+class SVGTransformListPolyfill {
+ constructor () {
+ this._items = []
+ }
+
+ get numberOfItems () { return this._items.length }
+ getItem (i) { return this._items[i] }
+ appendItem (item) { this._items.push(item); return item }
+ insertItemBefore (item, index) {
+ const idx = Math.max(0, Math.min(index, this._items.length))
+ this._items.splice(idx, 0, item)
+ return item
+ }
+
+ removeItem (index) {
+ if (index < 0 || index >= this._items.length) return undefined
+ const [removed] = this._items.splice(index, 1)
+ return removed
+ }
+
+ clear () { this._items = [] }
+ initialize (item) { this._items = [item]; return item }
+ consolidate () {
+ if (!this._items.length) return null
+ const matrix = this._items.reduce(
+ (acc, t) => acc.multiply(t.matrix),
+ new SVGMatrixPolyfill()
+ )
+ const consolidated = new SVGTransformPolyfill()
+ consolidated.setMatrix(matrix)
+ this._items = [consolidated]
+ return consolidated
+ }
+}
+
+const parseTransformAttr = (attr) => {
+ const list = new SVGTransformListPolyfill()
+ if (!attr) return list
+ const matcher = /([a-zA-Z]+)\(([^)]+)\)/g
+ let match
+ while ((match = matcher.exec(attr))) {
+ const [, type, raw] = match
+ const nums = raw.split(/[,\s]+/).filter(Boolean).map(Number)
+ const t = new SVGTransformPolyfill()
+ switch (type) {
+ case 'matrix':
+ t.setMatrix(new SVGMatrixPolyfill(...nums))
+ break
+ case 'translate':
+ t.setTranslate(nums[0] ?? 0, nums[1] ?? 0)
+ break
+ case 'scale':
+ t.setScale(nums[0] ?? 1, nums[1] ?? nums[0] ?? 1)
+ break
+ case 'rotate':
+ t.setRotate(nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0)
+ break
+ default:
+ t.setMatrix(new SVGMatrixPolyfill())
+ break
+ }
+ list.appendItem(t)
+ }
+ return list
+}
+
+const ensureTransformList = (elem) => {
+ if (!elem.__transformList) {
+ const parsed = parseTransformAttr(elem.getAttribute?.('transform'))
+ elem.__transformList = parsed
+ }
+ return elem.__transformList
+}
+
+if (!win.SVGElement) {
+ win.SVGElement = win.Element
+}
+const svgElementProto = win.SVGElement?.prototype
+
+// Basic constructors for missing SVG types.
+if (!win.SVGSVGElement) win.SVGSVGElement = win.SVGElement
+if (!win.SVGGraphicsElement) win.SVGGraphicsElement = win.SVGElement
+if (!win.SVGGeometryElement) win.SVGGeometryElement = win.SVGElement
+// Ensure SVGPathElement exists so the pathseg polyfill can patch it.
+win.SVGPathElement = win.SVGElement || function SVGPathElement () {}
+
+// Matrix/transform helpers.
+win.SVGMatrix = win.SVGMatrix || SVGMatrixPolyfill
+win.DOMMatrix = win.DOMMatrix || SVGMatrixPolyfill
+win.SVGTransform = win.SVGTransform || SVGTransformPolyfill
+win.SVGTransformList = win.SVGTransformList || SVGTransformListPolyfill
+
+if (svgElementProto) {
+ if (!svgElementProto.createSVGMatrix) {
+ svgElementProto.createSVGMatrix = () => new SVGMatrixPolyfill()
+ }
+ if (!svgElementProto.createSVGTransform) {
+ svgElementProto.createSVGTransform = () => new SVGTransformPolyfill()
+ }
+ if (!svgElementProto.createSVGTransformFromMatrix) {
+ svgElementProto.createSVGTransformFromMatrix = (matrix) => {
+ const t = new SVGTransformPolyfill()
+ t.setMatrix(matrix)
+ return t
+ }
+ }
+ if (!svgElementProto.createSVGPoint) {
+ svgElementProto.createSVGPoint = () => ({
+ x: 0,
+ y: 0,
+ matrixTransform (m) {
+ return {
+ x: m.a * this.x + m.c * this.y + m.e,
+ y: m.b * this.x + m.d * this.y + m.f
+ }
+ }
+ })
+ }
+ svgElementProto.getBBox = function () {
+ const tag = (this.tagName || '').toLowerCase()
+ const parseLength = (attr, fallback = 0) => {
+ const raw = this.getAttribute?.(attr)
+ if (raw == null) return fallback
+ const str = String(raw)
+ const n = Number.parseFloat(str)
+ if (Number.isNaN(n)) return fallback
+ if (str.endsWith('in')) return n * 96
+ if (str.endsWith('cm')) return n * 96 / 2.54
+ if (str.endsWith('mm')) return n * 96 / 25.4
+ if (str.endsWith('pt')) return n * 96 / 72
+ if (str.endsWith('pc')) return n * 16
+ if (str.endsWith('em')) return n * 16
+ if (str.endsWith('ex')) return n * 8
+ return n
+ }
+ const parsePoints = () => (this.getAttribute?.('points') || '')
+ .trim()
+ .split(/\\s+/)
+ .map(pair => pair.split(',').map(Number))
+ .filter(([x, y]) => !Number.isNaN(x) && !Number.isNaN(y))
+
+ if (tag === 'path') {
+ const d = this.getAttribute?.('d') || ''
+ const nums = (d.match(/-?\\d*\\.?\\d+/g) || [])
+ .map(Number)
+ .filter(n => !Number.isNaN(n))
+ if (nums.length >= 2) {
+ let minx = Infinity; let miny = Infinity
+ let maxx = -Infinity; let maxy = -Infinity
+ for (let i = 0; i < nums.length; i += 2) {
+ const x = nums[i]; const y = nums[i + 1]
+ if (x < minx) minx = x
+ if (x > maxx) maxx = x
+ if (y < miny) miny = y
+ if (y > maxy) maxy = y
+ }
+ return {
+ x: minx === Infinity ? 0 : minx,
+ y: miny === Infinity ? 0 : miny,
+ width: maxx === -Infinity ? 0 : maxx - minx,
+ height: maxy === -Infinity ? 0 : maxy - miny
+ }
+ }
+ return { x: 0, y: 0, width: 0, height: 0 }
+ }
+
+ if (tag === 'rect') {
+ const x = parseLength('x')
+ const y = parseLength('y')
+ const width = parseLength('width')
+ const height = parseLength('height')
+ return { x, y, width, height }
+ }
+
+ if (tag === 'line') {
+ const x1 = parseLength('x1'); const y1 = parseLength('y1')
+ const x2 = parseLength('x2'); const y2 = parseLength('y2')
+ const minx = Math.min(x1, x2); const miny = Math.min(y1, y2)
+ return { x: minx, y: miny, width: Math.abs(x2 - x1), height: Math.abs(y2 - y1) }
+ }
+
+ if (tag === 'circle') {
+ const cx = parseLength('cx'); const cy = parseLength('cy'); const r = parseLength('r') || parseLength('rx') || parseLength('ry')
+ return { x: cx - r, y: cy - r, width: r * 2, height: r * 2 }
+ }
+
+ if (tag === 'ellipse') {
+ const cx = parseLength('cx'); const cy = parseLength('cy'); const rx = parseLength('rx'); const ry = parseLength('ry')
+ return { x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2 }
+ }
+
+ if (tag === 'polyline' || tag === 'polygon') {
+ const pts = parsePoints()
+ if (!pts.length) return { x: 0, y: 0, width: 0, height: 0 }
+ const xs = pts.map(([x]) => x)
+ const ys = pts.map(([, y]) => y)
+ const minx = Math.min(...xs); const maxx = Math.max(...xs)
+ const miny = Math.min(...ys); const maxy = Math.max(...ys)
+ return { x: minx, y: miny, width: maxx - minx, height: maxy - miny }
+ }
+
+ return { x: 0, y: 0, width: 0, height: 0 }
+ }
+ if (!Object.getOwnPropertyDescriptor(svgElementProto, 'transform')) {
+ Object.defineProperty(svgElementProto, 'transform', {
+ get () {
+ const baseVal = ensureTransformList(this)
+ return { baseVal }
+ }
+ })
+ }
+}
+
+// Ensure pathseg polyfill can attach to prototypes.
+await import('pathseg')
+
+// Add minimal chai-like helpers some legacy tests expect.
+assert.close = (actual, expected, delta, message) =>
+ assert.closeTo(actual, expected, delta, message)
+assert.notOk = (val, message) => {
+ if (val) {
+ throw new AssertionError({ message: message || `expected ${val} to be falsy`, actual: val, expected: false })
+ }
+}
+assert.isBelow = (val, limit, message) => {
+ if (!(val < limit)) {
+ throw new AssertionError({ message: message || `expected ${val} to be below ${limit}`, actual: val, expected: `< ${limit}` })
+ }
+}
diff --git a/tests/unit/touch.test.js b/tests/unit/touch.test.js
new file mode 100644
index 00000000..d7ee9b72
--- /dev/null
+++ b/tests/unit/touch.test.js
@@ -0,0 +1,124 @@
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import { init as initTouch } from '../../packages/svgcanvas/core/touch.js'
+
+const createSvgRoot = () => {
+ const listeners = {}
+ return {
+ listeners,
+ addEventListener (type, handler) { listeners[type] = handler },
+ dispatch (type, event) { listeners[type]?.(event) }
+ }
+}
+
+const OriginalMouseEvent = global.MouseEvent
+
+beforeAll(() => {
+ // JSDOM's MouseEvent requires a real Window; a lightweight stub keeps the adapter logic testable.
+ global.MouseEvent = class extends Event {
+ constructor (type, init = {}) {
+ super(type, init)
+ this.clientX = init.clientX
+ this.clientY = init.clientY
+ this.screenX = init.screenX
+ this.screenY = init.screenY
+ this.button = init.button ?? 0
+ this.relatedTarget = init.relatedTarget ?? null
+ }
+ }
+})
+
+afterAll(() => {
+ global.MouseEvent = OriginalMouseEvent
+})
+
+describe('touch adapter', () => {
+ it('translates single touch to mouse event on target', () => {
+ const svgroot = createSvgRoot()
+ const svgCanvas = { svgroot }
+ initTouch(svgCanvas)
+
+ const target = document.createElement('div')
+ const received = []
+ target.addEventListener('mousedown', (ev) => {
+ received.push({
+ type: ev.type,
+ clientX: ev.clientX,
+ clientY: ev.clientY,
+ screenX: ev.screenX,
+ screenY: ev.screenY
+ })
+ })
+
+ const preventDefault = vi.fn()
+ svgroot.dispatch('touchstart', {
+ type: 'touchstart',
+ changedTouches: [{
+ target,
+ clientX: 12,
+ clientY: 34,
+ screenX: 56,
+ screenY: 78
+ }],
+ preventDefault
+ })
+
+ expect(preventDefault).toHaveBeenCalled()
+ expect(received).toEqual([{
+ type: 'mousedown',
+ clientX: 12,
+ clientY: 34,
+ screenX: 56,
+ screenY: 78
+ }])
+ })
+
+ it('maps move events and ignores multi-touch gestures', () => {
+ const svgroot = createSvgRoot()
+ initTouch({ svgroot })
+
+ const target = document.createElement('div')
+ let mouseDown = 0
+ let mouseMove = 0
+ target.addEventListener('mousedown', () => { mouseDown++ })
+ target.addEventListener('mousemove', () => { mouseMove++ })
+
+ svgroot.dispatch('touchstart', {
+ type: 'touchstart',
+ changedTouches: [
+ { target, clientX: 1, clientY: 2, screenX: 3, screenY: 4 },
+ { target, clientX: 5, clientY: 6, screenX: 7, screenY: 8 }
+ ],
+ preventDefault: vi.fn()
+ })
+
+ expect(mouseDown).toBe(0)
+
+ svgroot.dispatch('touchmove', {
+ type: 'touchmove',
+ changedTouches: [
+ { target, clientX: 9, clientY: 10, screenX: 11, screenY: 12 }
+ ],
+ preventDefault: vi.fn()
+ })
+
+ expect(mouseMove).toBe(1)
+ })
+
+ it('returns early on unknown event types', () => {
+ const svgroot = createSvgRoot()
+ initTouch({ svgroot })
+ const target = document.createElement('div')
+ let mouseCount = 0
+ target.addEventListener('mousedown', () => { mouseCount++ })
+
+ const preventDefault = vi.fn()
+ svgroot.dispatch('touchcancel', {
+ type: 'touchcancel',
+ changedTouches: [{ target, clientX: 0, clientY: 0, screenX: 0, screenY: 0 }],
+ preventDefault
+ })
+
+ expect(preventDefault).toHaveBeenCalled()
+ expect(mouseCount).toBe(0)
+ })
+})
diff --git a/tests/unit/util-common.test.js b/tests/unit/util-common.test.js
new file mode 100644
index 00000000..5a24d768
--- /dev/null
+++ b/tests/unit/util-common.test.js
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest'
+import {
+ findPos,
+ isObject,
+ mergeDeep,
+ getClosest,
+ getParents,
+ getParentsUntil
+} from '../../packages/svgcanvas/common/util.js'
+
+describe('common util helpers', () => {
+ it('computes positions and merges objects deeply', () => {
+ const grand = { offsetLeft: 5, offsetTop: 6, offsetParent: null }
+ const parent = { offsetLeft: 10, offsetTop: 11, offsetParent: grand }
+ const child = { offsetLeft: 7, offsetTop: 8, offsetParent: parent }
+
+ expect(findPos(child)).toEqual({ left: 22, top: 25 })
+ expect(isObject({ foo: 'bar' })).toBe(true)
+
+ const merged = mergeDeep(
+ { a: 1, nested: { keep: true, replace: 'old' } },
+ { nested: { replace: 'new', extra: 42 }, more: 'yes' }
+ )
+ expect(merged).toEqual({ a: 1, nested: { keep: true, replace: 'new', extra: 42 }, more: 'yes' })
+ })
+
+ it('finds closest elements across selectors', () => {
+ const root = document.createElement('div')
+ const wrapper = document.createElement('div')
+ wrapper.className = 'wrapper'
+ const section = document.createElement('section')
+ section.id = 'section'
+ const child = document.createElement('span')
+ child.dataset.role = 'target'
+
+ section.append(child)
+ wrapper.append(section)
+ root.append(wrapper)
+ document.body.append(root)
+
+ expect(getClosest(child, '.wrapper')?.className).toBe('wrapper')
+ expect(getClosest(child, '#section')?.id).toBe('section')
+ expect(getClosest(child, '[data-role=target]')?.dataset.role).toBe('target')
+ expect(getClosest(child, 'div')?.tagName.toLowerCase()).toBe('div')
+ })
+
+ it('collects parents with and without limits', () => {
+ const outer = document.createElement('div')
+ outer.className = 'outer'
+ const mid = document.createElement('section')
+ mid.id = 'mid'
+ const inner = document.createElement('span')
+ inner.className = 'inner'
+
+ mid.append(inner)
+ outer.append(mid)
+ document.body.append(outer)
+
+ const parents = getParents(inner)?.map(el => el.tagName.toLowerCase())
+ expect(parents).toContain('body')
+
+ expect(getParents(inner, '.outer')?.map(el => el.className)).toEqual(['outer'])
+
+ const untilMid = getParentsUntil(inner, '#mid', '.inner')?.map(el => el.tagName.toLowerCase())
+ expect(untilMid).toEqual(['span'])
+ })
+})
diff --git a/tests/unit/utilities-bbox.test.js b/tests/unit/utilities-bbox.test.js
index 3833f51c..b7238ffe 100644
--- a/tests/unit/utilities-bbox.test.js
+++ b/tests/unit/utilities-bbox.test.js
@@ -1,15 +1,12 @@
+import { strict as assert } from 'node:assert'
import 'pathseg'
import { NS } from '../../packages/svgcanvas/core/namespaces.js'
import * as utilities from '../../packages/svgcanvas/core/utilities.js'
import * as math from '../../packages/svgcanvas/core/math.js'
import * as path from '../../packages/svgcanvas/core/path.js'
-import setAssertionMethods from '../../support/assert-close.js'
import * as units from '../../packages/svgcanvas/core/units.js'
-// eslint-disable-next-line
-chai.use(setAssertionMethods)
-
describe('utilities bbox', function () {
/**
* Create an SVG element for a mock.
@@ -21,6 +18,39 @@ describe('utilities bbox', function () {
Object.entries(jsonMap.attr).forEach(([attr, value]) => {
elem.setAttribute(attr, value)
})
+ const numFromAttr = (attr, fallback = 0) => Number(jsonMap.attr[attr] ?? fallback)
+ const calcBBox = () => {
+ const tag = (jsonMap.element || '').toLowerCase()
+ switch (tag) {
+ case 'path': {
+ const d = jsonMap.attr.d || ''
+ const nums = (d.match(/-?\\d*\\.?\\d+/g) || []).map(Number)
+ if (nums.length >= 4) {
+ const xs = nums.filter((_, i) => i % 2 === 0)
+ const ys = nums.filter((_, i) => i % 2 === 1)
+ return {
+ x: Math.min(...xs),
+ y: Math.min(...ys),
+ width: Math.max(...xs) - Math.min(...xs),
+ height: Math.max(...ys) - Math.min(...ys)
+ }
+ }
+ return { x: 0, y: 0, width: 0, height: 0 }
+ }
+ case 'rect':
+ return { x: numFromAttr('x'), y: numFromAttr('y'), width: numFromAttr('width'), height: numFromAttr('height') }
+ case 'line': {
+ const x1 = numFromAttr('x1'); const x2 = numFromAttr('x2'); const y1 = numFromAttr('y1'); const y2 = numFromAttr('y2')
+ return { x: Math.min(x1, x2), y: Math.min(y1, y2), width: Math.abs(x2 - x1), height: Math.abs(y2 - y1) }
+ }
+ default:
+ return { x: 0, y: 0, width: 0, height: 0 }
+ }
+ }
+ const bbox = calcBBox()
+ elem.getBBox = () => {
+ return { ...bbox }
+ }
return elem
}
let mockaddSVGElementsFromJsonCallCount = 0
diff --git a/tests/unit/utilities-extra2.test.js b/tests/unit/utilities-extra2.test.js
new file mode 100644
index 00000000..2fab9450
--- /dev/null
+++ b/tests/unit/utilities-extra2.test.js
@@ -0,0 +1,70 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+
+import { init as initUnits } from '../../packages/svgcanvas/core/units.js'
+import {
+ init as initUtilities,
+ findDefs,
+ assignAttributes,
+ snapToGrid,
+ getHref,
+ setHref,
+ dropXMLInternalSubset,
+ encodeUTF8,
+ decodeUTF8
+} from '../../packages/svgcanvas/core/utilities.js'
+
+describe('utilities extra coverage', () => {
+ let svg
+
+ beforeEach(() => {
+ svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ document.body.innerHTML = ''
+ document.body.append(svg)
+
+ // Initialize units and utilities with a minimal canvas/context stub
+ initUnits({
+ getBaseUnit: () => 'px',
+ getWidth: () => 200,
+ getHeight: () => 100,
+ getRoundDigits: () => 2
+ })
+ initUtilities({
+ getSvgRoot: () => svg,
+ getSvgContent: () => svg,
+ getDOMDocument: () => document,
+ getDOMContainer: () => svg,
+ getBaseUnit: () => 'cm',
+ getSnappingStep: () => 0.5
+ })
+ })
+
+ it('creates defs and removes namespaced attributes via assignAttributes', () => {
+ const defs = findDefs()
+ expect(defs.tagName).toBe('defs')
+ expect(svg.querySelectorAll('defs').length).toBe(1)
+
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
+ rect.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve')
+ assignAttributes(rect, { width: '10', height: '5', 'xml:space': undefined }, 0, true)
+ expect(rect.getAttribute('width')).toBe('10')
+ expect(rect.getAttribute('height')).toBe('5')
+ })
+
+ it('snaps to grid with unit conversion and handles href helpers', () => {
+ const value = snapToGrid(2.3)
+ expect(value).toBe(0)
+
+ const use = document.createElementNS('http://www.w3.org/2000/svg', 'use')
+ setHref(use, '#ref')
+ expect(getHref(use)).toBe('#ref')
+ })
+
+ it('drops XML internal subsets and round trips UTF8 helpers', () => {
+ const doc = ']>'
+ expect(dropXMLInternalSubset(doc)).toContain(''
+ const encoded = encodeUTF8(mixed)
+ expect(decodeUTF8(encoded)).toBe(mixed)
+ })
+})
diff --git a/tests/unit/utilities.test.js b/tests/unit/utilities.test.js
index 58fe2d44..ead45853 100644
--- a/tests/unit/utilities.test.js
+++ b/tests/unit/utilities.test.js
@@ -12,6 +12,39 @@ describe('utilities', function () {
Object.entries(jsonMap.attr).forEach(([attr, value]) => {
elem.setAttribute(attr, value)
})
+ const numFromAttr = (attr, fallback = 0) => Number(jsonMap.attr[attr] ?? fallback)
+ const calcBBox = () => {
+ const tag = (jsonMap.element || '').toLowerCase()
+ switch (tag) {
+ case 'path': {
+ const d = jsonMap.attr.d || ''
+ const nums = (d.match(/-?\\d*\\.?\\d+/g) || []).map(Number)
+ if (nums.length >= 4) {
+ const xs = nums.filter((_, i) => i % 2 === 0)
+ const ys = nums.filter((_, i) => i % 2 === 1)
+ return {
+ x: Math.min(...xs),
+ y: Math.min(...ys),
+ width: Math.max(...xs) - Math.min(...xs),
+ height: Math.max(...ys) - Math.min(...ys)
+ }
+ }
+ return { x: 0, y: 0, width: 0, height: 0 }
+ }
+ case 'rect':
+ return { x: numFromAttr('x'), y: numFromAttr('y'), width: numFromAttr('width'), height: numFromAttr('height') }
+ case 'line': {
+ const x1 = numFromAttr('x1'); const x2 = numFromAttr('x2'); const y1 = numFromAttr('y1'); const y2 = numFromAttr('y2')
+ return { x: Math.min(x1, x2), y: Math.min(y1, y2), width: Math.abs(x2 - x1), height: Math.abs(y2 - y1) }
+ }
+ default:
+ return { x: 0, y: 0, width: 0, height: 0 }
+ }
+ }
+ const bbox = calcBBox()
+ elem.getBBox = () => {
+ return { ...bbox }
+ }
return elem
}
/**
diff --git a/vite.config.mjs b/vite.config.mjs
index 694d6b3f..40cffb97 100644
--- a/vite.config.mjs
+++ b/vite.config.mjs
@@ -93,11 +93,25 @@ export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
+ setupFiles: ['tests/unit/setup-vitest.js'],
include: ['tests/**/*.test.{js,ts}'],
- exclude: ['tests/e2e/**', 'tests/unit/**'],
+ exclude: ['tests/e2e/**'],
coverage: {
provider: 'v8',
- include: ['src/editor/locale.js']
+ include: [
+ 'src/editor/locale.js',
+ 'src/editor/MainMenu.js',
+ 'src/editor/contextmenu.js',
+ 'packages/svgcanvas/core/paint.js',
+ 'packages/svgcanvas/core/dataStorage.js',
+ 'packages/svgcanvas/core/clear.js',
+ 'packages/svgcanvas/core/path.js',
+ 'packages/svgcanvas/core/coords.js',
+ 'packages/svgcanvas/core/recalculate.js',
+ 'packages/svgcanvas/core/utilities.js',
+ 'packages/svgcanvas/common/util.js',
+ 'packages/svgcanvas/core/touch.js'
+ ]
}
}
})