increase test coverage

extend test coverage
This commit is contained in:
JFH
2025-12-01 01:22:26 +01:00
parent a37fbac749
commit fa380402e1
52 changed files with 3813 additions and 169 deletions

View File

@@ -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}}
}

View File

@@ -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/**',

View File

@@ -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",

View File

@@ -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

View File

@@ -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
}
}
}
}

View File

@@ -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: [

View File

@@ -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')

View File

@@ -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'])
}

View File

@@ -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],

View File

@@ -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 }))
}
/**

View File

@@ -0,0 +1,41 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SVG-Edit Unit Harness</title>
<script type="module">
import './vendor/pathseg/pathseg.js'
import * as namespaces from './vendor/svgcanvas/core/namespaces.js'
import * as utilities from './vendor/svgcanvas/core/utilities.js'
import * as math from './vendor/svgcanvas/core/math.js'
import * as pathModule from './vendor/svgcanvas/core/path.js'
import * as coords from './vendor/svgcanvas/core/coords.js'
import * as units from './vendor/svgcanvas/core/units.js'
import * as draw from './vendor/svgcanvas/core/draw.js'
import * as history from './vendor/svgcanvas/core/history.js'
import * as recalculate from './vendor/svgcanvas/core/recalculate.js'
import * as util from './vendor/svgcanvas/common/util.js'
import * as touch from './vendor/svgcanvas/core/touch.js'
import * as clearModule from './vendor/svgcanvas/core/clear.js'
// Expose modules for Playwright specs.
window.svgHarness = {
namespaces,
utilities,
math,
pathModule,
coords,
units,
draw,
history,
recalculate,
util,
touch,
clearModule
}
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -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)
})
})

View File

@@ -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()
})
})

View File

@@ -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 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()

View File

@@ -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')
})
})

120
tests/e2e/mainmenu.spec.js Normal file
View File

@@ -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 =
'<div id="se-img-prop"></div><div id="se-edit-prefs"></div>'
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)
})
})

View File

@@ -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')

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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('')
})
})

View File

@@ -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' })
})
})

View File

@@ -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('')
})
})

View File

@@ -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))
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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()
})
})

View File

@@ -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' })
})
})

View File

@@ -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')
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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'])
})
})

View File

@@ -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 = `
<svg id="svgroot" xmlns="${namespaces.NS.SVG}" width="10" height="10">
<defs id="defs"></defs>
<g id="group">
<rect id="rect" x="1" y="2" width="3" height="4"></rect>
</g>
</svg>
`
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('<!DOCTYPE svg [<!ENTITY a "b">]?><svg/>'), '')
const xmlRefs = safe(() => utilities.convertToXMLReferences('<>"&'), '')
const parsed = safe(() => utilities.text2xml('<svg><g></g></svg>'))
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('<span class="x">hi</span>'))
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()
})
})

View File

@@ -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&amp;J &#x27;&quot;&lt;&gt;')
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 })
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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

49
tests/unit/clear.test.js Normal file
View File

@@ -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')
})
})

View File

@@ -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([])
})
})

View File

@@ -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 = "<ul id='cmenu_canvas'></ul>"
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')
})
})

View File

@@ -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

View File

@@ -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()
})
})

View File

@@ -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 })
})
}

View File

@@ -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')

193
tests/unit/mainmenu.test.js Normal file
View File

@@ -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 = `
<div id="app"></div>
<div id="se-export-dialog"></div>
<div id="se-img-prop"></div>
<div id="se-edit-prefs"></div>
`
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')
})
})

72
tests/unit/paint.test.js Normal file
View File

@@ -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()
})
})

348
tests/unit/setup-vitest.js Normal file
View File

@@ -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}` })
}
}

124
tests/unit/touch.test.js Normal file
View File

@@ -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)
})
})

View File

@@ -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'])
})
})

View File

@@ -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

View File

@@ -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 = '<!DOCTYPE svg [<!ENTITY test "x">]><svg/>'
expect(dropXMLInternalSubset(doc)).toContain('<!DOCTYPE svg')
const mixed = 'äöü & < >'
const encoded = encodeUTF8(mixed)
expect(decodeUTF8(encoded)).toBe(mixed)
})
})

View File

@@ -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
}
/**

View File

@@ -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'
]
}
}
})