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