zero-query 0.8.9 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -3
- package/cli/commands/bundle.js +15 -2
- package/cli/commands/dev/devtools/js/core.js +16 -2
- package/cli/commands/dev/devtools/js/elements.js +4 -1
- package/cli/commands/dev/overlay.js +20 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +184 -44
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +6 -2
- package/package.json +1 -1
- package/src/component.js +28 -7
- package/src/core.js +62 -12
- package/src/diff.js +11 -5
- package/src/expression.js +1 -0
- package/src/http.js +17 -1
- package/src/reactive.js +8 -2
- package/src/router.js +37 -8
- package/src/ssr.js +1 -1
- package/src/store.js +5 -0
- package/src/utils.js +12 -6
- package/tests/cli.test.js +456 -0
- package/tests/component.test.js +1387 -0
- package/tests/core.test.js +893 -1
- package/tests/diff.test.js +891 -0
- package/tests/errors.test.js +179 -0
- package/tests/expression.test.js +569 -0
- package/tests/http.test.js +160 -1
- package/tests/reactive.test.js +320 -0
- package/tests/router.test.js +1187 -0
- package/tests/ssr.test.js +261 -0
- package/tests/store.test.js +210 -0
- package/tests/utils.test.js +186 -0
- package/types/store.d.ts +3 -0
package/tests/component.test.js
CHANGED
|
@@ -884,3 +884,1390 @@ describe('component — scoped styles', () => {
|
|
|
884
884
|
expect(styleEl.textContent).toContain('color: red');
|
|
885
885
|
});
|
|
886
886
|
});
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
// ---------------------------------------------------------------------------
|
|
890
|
+
// z-if toggling on state change
|
|
891
|
+
// ---------------------------------------------------------------------------
|
|
892
|
+
|
|
893
|
+
describe('component — z-if reactive toggle', () => {
|
|
894
|
+
it('shows/hides element on state change', async () => {
|
|
895
|
+
component('zif-toggle', {
|
|
896
|
+
state: () => ({ visible: false }),
|
|
897
|
+
render() { return '<p z-if="visible">now you see me</p><span>always</span>'; },
|
|
898
|
+
});
|
|
899
|
+
document.body.innerHTML = '<zif-toggle id="zit"></zif-toggle>';
|
|
900
|
+
const inst = mount('#zit', 'zif-toggle');
|
|
901
|
+
expect(document.querySelector('#zit p')).toBeNull();
|
|
902
|
+
|
|
903
|
+
inst.state.visible = true;
|
|
904
|
+
await new Promise(r => queueMicrotask(r));
|
|
905
|
+
expect(document.querySelector('#zit p')).not.toBeNull();
|
|
906
|
+
expect(document.querySelector('#zit p').textContent).toBe('now you see me');
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it('handles rapid true/false/true toggling', async () => {
|
|
910
|
+
component('zif-rapid', {
|
|
911
|
+
state: () => ({ on: true }),
|
|
912
|
+
render() { return '<div z-if="on">ON</div>'; },
|
|
913
|
+
});
|
|
914
|
+
document.body.innerHTML = '<zif-rapid id="zir"></zif-rapid>';
|
|
915
|
+
const inst = mount('#zir', 'zif-rapid');
|
|
916
|
+
inst.state.on = false;
|
|
917
|
+
inst.state.on = true;
|
|
918
|
+
await new Promise(r => queueMicrotask(r));
|
|
919
|
+
expect(document.querySelector('#zir div')).not.toBeNull();
|
|
920
|
+
expect(document.querySelector('#zir div').textContent).toBe('ON');
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it('handles z-if with complex expression', () => {
|
|
924
|
+
component('zif-complex', {
|
|
925
|
+
state: () => ({ count: 5 }),
|
|
926
|
+
render() { return '<p z-if="count > 3 && count < 10">in range</p>'; },
|
|
927
|
+
});
|
|
928
|
+
document.body.innerHTML = '<zif-complex id="zic"></zif-complex>';
|
|
929
|
+
mount('#zic', 'zif-complex');
|
|
930
|
+
expect(document.querySelector('#zic p')).not.toBeNull();
|
|
931
|
+
expect(document.querySelector('#zic p').textContent).toBe('in range');
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
// z-show reactive toggle
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
|
|
940
|
+
describe('component — z-show reactive toggle', () => {
|
|
941
|
+
it('toggles display on state change', async () => {
|
|
942
|
+
component('zshow-toggle', {
|
|
943
|
+
state: () => ({ vis: true }),
|
|
944
|
+
render() { return '<div z-show="vis">content</div>'; },
|
|
945
|
+
});
|
|
946
|
+
document.body.innerHTML = '<zshow-toggle id="zst"></zshow-toggle>';
|
|
947
|
+
const inst = mount('#zst', 'zshow-toggle');
|
|
948
|
+
expect(document.querySelector('#zst div').style.display).toBe('');
|
|
949
|
+
|
|
950
|
+
inst.state.vis = false;
|
|
951
|
+
await new Promise(r => queueMicrotask(r));
|
|
952
|
+
expect(document.querySelector('#zst div').style.display).toBe('none');
|
|
953
|
+
});
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
// ---------------------------------------------------------------------------
|
|
958
|
+
// z-for advanced
|
|
959
|
+
// ---------------------------------------------------------------------------
|
|
960
|
+
|
|
961
|
+
describe('component — z-for advanced', () => {
|
|
962
|
+
it('re-renders list on state change', async () => {
|
|
963
|
+
component('zfor-rerender', {
|
|
964
|
+
state: () => ({ items: ['a', 'b'] }),
|
|
965
|
+
render() { return '<ul><li z-for="item in items">{{item}}</li></ul>'; },
|
|
966
|
+
});
|
|
967
|
+
document.body.innerHTML = '<zfor-rerender id="zfr2"></zfor-rerender>';
|
|
968
|
+
const inst = mount('#zfr2', 'zfor-rerender');
|
|
969
|
+
expect(document.querySelectorAll('#zfr2 li').length).toBe(2);
|
|
970
|
+
|
|
971
|
+
inst.state.items = ['x', 'y', 'z'];
|
|
972
|
+
await new Promise(r => queueMicrotask(r));
|
|
973
|
+
const lis = document.querySelectorAll('#zfr2 li');
|
|
974
|
+
expect(lis.length).toBe(3);
|
|
975
|
+
expect(lis[2].textContent).toBe('z');
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it('renders nested object properties in z-for', () => {
|
|
979
|
+
component('zfor-nested', {
|
|
980
|
+
state: () => ({ users: [{ name: 'Alice' }, { name: 'Bob' }] }),
|
|
981
|
+
render() { return '<ul><li z-for="user in users">{{user.name}}</li></ul>'; },
|
|
982
|
+
});
|
|
983
|
+
document.body.innerHTML = '<zfor-nested id="zfn"></zfor-nested>';
|
|
984
|
+
mount('#zfn', 'zfor-nested');
|
|
985
|
+
const lis = document.querySelectorAll('#zfn li');
|
|
986
|
+
expect(lis[0].textContent).toBe('Alice');
|
|
987
|
+
expect(lis[1].textContent).toBe('Bob');
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it('handles z-for with single item', () => {
|
|
991
|
+
component('zfor-single', {
|
|
992
|
+
state: () => ({ items: ['only'] }),
|
|
993
|
+
render() { return '<ul><li z-for="item in items">{{item}}</li></ul>'; },
|
|
994
|
+
});
|
|
995
|
+
document.body.innerHTML = '<zfor-single id="zfs"></zfor-single>';
|
|
996
|
+
mount('#zfs', 'zfor-single');
|
|
997
|
+
expect(document.querySelectorAll('#zfs li').length).toBe(1);
|
|
998
|
+
expect(document.querySelector('#zfs li').textContent).toBe('only');
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it('handles z-for range of 0', () => {
|
|
1002
|
+
component('zfor-zero', {
|
|
1003
|
+
state: () => ({ count: 0 }),
|
|
1004
|
+
render() { return '<span z-for="n in count">{{n}}</span>'; },
|
|
1005
|
+
});
|
|
1006
|
+
document.body.innerHTML = '<zfor-zero id="zfz"></zfor-zero>';
|
|
1007
|
+
mount('#zfz', 'zfor-zero');
|
|
1008
|
+
expect(document.querySelectorAll('#zfz span').length).toBe(0);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it('z-for with index and nested HTML', () => {
|
|
1012
|
+
component('zfor-nested-html', {
|
|
1013
|
+
state: () => ({ items: ['alpha', 'beta'] }),
|
|
1014
|
+
render() { return '<div z-for="(item, i) in items"><span class="idx">{{i}}</span><span class="val">{{item}}</span></div>'; },
|
|
1015
|
+
});
|
|
1016
|
+
document.body.innerHTML = '<zfor-nested-html id="zfnh"></zfor-nested-html>';
|
|
1017
|
+
mount('#zfnh', 'zfor-nested-html');
|
|
1018
|
+
const idxSpans = document.querySelectorAll('#zfnh .idx');
|
|
1019
|
+
const valSpans = document.querySelectorAll('#zfnh .val');
|
|
1020
|
+
expect(idxSpans.length).toBe(2);
|
|
1021
|
+
expect(idxSpans[1].textContent).toBe('1');
|
|
1022
|
+
expect(valSpans[1].textContent).toBe('beta');
|
|
1023
|
+
});
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
// ---------------------------------------------------------------------------
|
|
1028
|
+
// z-bind advanced
|
|
1029
|
+
// ---------------------------------------------------------------------------
|
|
1030
|
+
|
|
1031
|
+
describe('component — z-bind advanced', () => {
|
|
1032
|
+
it('binds data attribute dynamically', () => {
|
|
1033
|
+
component('zbind-data', {
|
|
1034
|
+
state: () => ({ val: '42' }),
|
|
1035
|
+
render() { return '<div :data-count="val">content</div>'; },
|
|
1036
|
+
});
|
|
1037
|
+
document.body.innerHTML = '<zbind-data id="zbd"></zbind-data>';
|
|
1038
|
+
mount('#zbd', 'zbind-data');
|
|
1039
|
+
expect(document.querySelector('#zbd div').getAttribute('data-count')).toBe('42');
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it('removes attribute when value is null', () => {
|
|
1043
|
+
component('zbind-null', {
|
|
1044
|
+
state: () => ({ val: null }),
|
|
1045
|
+
render() { return '<div :title="val">content</div>'; },
|
|
1046
|
+
});
|
|
1047
|
+
document.body.innerHTML = '<zbind-null id="zbn"></zbind-null>';
|
|
1048
|
+
mount('#zbn', 'zbind-null');
|
|
1049
|
+
expect(document.querySelector('#zbn div').hasAttribute('title')).toBe(false);
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
// ---------------------------------------------------------------------------
|
|
1055
|
+
// z-class advanced
|
|
1056
|
+
// ---------------------------------------------------------------------------
|
|
1057
|
+
|
|
1058
|
+
describe('component — z-class advanced', () => {
|
|
1059
|
+
it('handles multiple truthy/falsy classes', () => {
|
|
1060
|
+
component('zclass-multi', {
|
|
1061
|
+
state: () => ({ a: true, b: false, c: true }),
|
|
1062
|
+
render() { return `<div z-class="{ alpha: a, beta: b, gamma: c }">test</div>`; },
|
|
1063
|
+
});
|
|
1064
|
+
document.body.innerHTML = '<zclass-multi id="zcm"></zclass-multi>';
|
|
1065
|
+
mount('#zcm', 'zclass-multi');
|
|
1066
|
+
const div = document.querySelector('#zcm div');
|
|
1067
|
+
expect(div.classList.contains('alpha')).toBe(true);
|
|
1068
|
+
expect(div.classList.contains('beta')).toBe(false);
|
|
1069
|
+
expect(div.classList.contains('gamma')).toBe(true);
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
// ---------------------------------------------------------------------------
|
|
1075
|
+
// z-style advanced
|
|
1076
|
+
// ---------------------------------------------------------------------------
|
|
1077
|
+
|
|
1078
|
+
describe('component — z-style advanced', () => {
|
|
1079
|
+
it('applies multiple style properties', () => {
|
|
1080
|
+
component('zstyle-multi', {
|
|
1081
|
+
state: () => ({ bg: 'blue', size: '20px' }),
|
|
1082
|
+
render() { return `<div z-style="{ backgroundColor: bg, fontSize: size }">styled</div>`; },
|
|
1083
|
+
});
|
|
1084
|
+
document.body.innerHTML = '<zstyle-multi id="zsm"></zstyle-multi>';
|
|
1085
|
+
mount('#zsm', 'zstyle-multi');
|
|
1086
|
+
const div = document.querySelector('#zsm div');
|
|
1087
|
+
expect(div.style.backgroundColor).toBe('blue');
|
|
1088
|
+
expect(div.style.fontSize).toBe('20px');
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
// ---------------------------------------------------------------------------
|
|
1094
|
+
// z-model advanced
|
|
1095
|
+
// ---------------------------------------------------------------------------
|
|
1096
|
+
|
|
1097
|
+
describe('component — z-model advanced', () => {
|
|
1098
|
+
it('handles z-model with select element', () => {
|
|
1099
|
+
component('zmodel-select', {
|
|
1100
|
+
state: () => ({ choice: 'b' }),
|
|
1101
|
+
render() {
|
|
1102
|
+
return '<select z-model="choice"><option value="a">A</option><option value="b">B</option><option value="c">C</option></select>';
|
|
1103
|
+
},
|
|
1104
|
+
});
|
|
1105
|
+
document.body.innerHTML = '<zmodel-select id="zms"></zmodel-select>';
|
|
1106
|
+
mount('#zms', 'zmodel-select');
|
|
1107
|
+
expect(document.querySelector('#zms select').value).toBe('b');
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
it('handles z-model write-back on select change', () => {
|
|
1111
|
+
component('zmodel-select-wb', {
|
|
1112
|
+
state: () => ({ choice: 'a' }),
|
|
1113
|
+
render() {
|
|
1114
|
+
return '<select z-model="choice"><option value="a">A</option><option value="b">B</option></select>';
|
|
1115
|
+
},
|
|
1116
|
+
});
|
|
1117
|
+
document.body.innerHTML = '<zmodel-select-wb id="zmswb"></zmodel-select-wb>';
|
|
1118
|
+
const inst = mount('#zmswb', 'zmodel-select-wb');
|
|
1119
|
+
const select = document.querySelector('#zmswb select');
|
|
1120
|
+
select.value = 'b';
|
|
1121
|
+
select.dispatchEvent(new Event('change'));
|
|
1122
|
+
expect(inst.state.choice).toBe('b');
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it('z-model syncs textarea write-back', () => {
|
|
1126
|
+
component('zmodel-ta-wb', {
|
|
1127
|
+
state: () => ({ text: '' }),
|
|
1128
|
+
render() { return '<textarea z-model="text"></textarea>'; },
|
|
1129
|
+
});
|
|
1130
|
+
document.body.innerHTML = '<zmodel-ta-wb id="zmtawb"></zmodel-ta-wb>';
|
|
1131
|
+
const inst = mount('#zmtawb', 'zmodel-ta-wb');
|
|
1132
|
+
const ta = document.querySelector('#zmtawb textarea');
|
|
1133
|
+
ta.value = 'typed text';
|
|
1134
|
+
ta.dispatchEvent(new Event('input'));
|
|
1135
|
+
expect(inst.state.text).toBe('typed text');
|
|
1136
|
+
});
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
// ---------------------------------------------------------------------------
|
|
1141
|
+
// z-ref advanced
|
|
1142
|
+
// ---------------------------------------------------------------------------
|
|
1143
|
+
|
|
1144
|
+
describe('component — z-ref advanced', () => {
|
|
1145
|
+
it('collects multiple refs', () => {
|
|
1146
|
+
component('zref-multi', {
|
|
1147
|
+
render() { return '<input z-ref="first" type="text"><input z-ref="second" type="email">'; },
|
|
1148
|
+
});
|
|
1149
|
+
document.body.innerHTML = '<zref-multi id="zrm"></zref-multi>';
|
|
1150
|
+
const inst = mount('#zrm', 'zref-multi');
|
|
1151
|
+
expect(inst.refs.first).toBeTruthy();
|
|
1152
|
+
expect(inst.refs.second).toBeTruthy();
|
|
1153
|
+
expect(inst.refs.first.type).toBe('text');
|
|
1154
|
+
expect(inst.refs.second.type).toBe('email');
|
|
1155
|
+
});
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
// ---------------------------------------------------------------------------
|
|
1160
|
+
// Event binding advanced
|
|
1161
|
+
// ---------------------------------------------------------------------------
|
|
1162
|
+
|
|
1163
|
+
describe('component — event binding advanced', () => {
|
|
1164
|
+
it('handles multiple event bindings on different elements', () => {
|
|
1165
|
+
let clickCount = 0, focusCount = 0;
|
|
1166
|
+
component('evt-multi', {
|
|
1167
|
+
onClick() { clickCount++; },
|
|
1168
|
+
onFocus() { focusCount++; },
|
|
1169
|
+
render() { return '<button @click="onClick">A</button><input @click="onFocus">'; },
|
|
1170
|
+
});
|
|
1171
|
+
document.body.innerHTML = '<evt-multi id="em"></evt-multi>';
|
|
1172
|
+
mount('#em', 'evt-multi');
|
|
1173
|
+
document.querySelector('#em button').click();
|
|
1174
|
+
document.querySelector('#em input').click();
|
|
1175
|
+
expect(clickCount).toBe(1);
|
|
1176
|
+
expect(focusCount).toBe(1);
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
it('handles method-based event handler with state mutation', () => {
|
|
1180
|
+
component('evt-inline', {
|
|
1181
|
+
state: () => ({ count: 0 }),
|
|
1182
|
+
increment() { this.state.count++; },
|
|
1183
|
+
render() { return `<button @click="increment">+</button><span class="v">${this.state.count}</span>`; },
|
|
1184
|
+
});
|
|
1185
|
+
document.body.innerHTML = '<evt-inline id="ei"></evt-inline>';
|
|
1186
|
+
const inst = mount('#ei', 'evt-inline');
|
|
1187
|
+
document.querySelector('#ei button').click();
|
|
1188
|
+
expect(inst.state.count).toBe(1);
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
it('handles event on nested element', () => {
|
|
1192
|
+
let clicked = false;
|
|
1193
|
+
component('evt-nested', {
|
|
1194
|
+
handle() { clicked = true; },
|
|
1195
|
+
render() { return '<div><span><button @click="handle">deep</button></span></div>'; },
|
|
1196
|
+
});
|
|
1197
|
+
document.body.innerHTML = '<evt-nested id="en"></evt-nested>';
|
|
1198
|
+
mount('#en', 'evt-nested');
|
|
1199
|
+
document.querySelector('#en button').click();
|
|
1200
|
+
expect(clicked).toBe(true);
|
|
1201
|
+
});
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
// ---------------------------------------------------------------------------
|
|
1206
|
+
// Watcher advanced
|
|
1207
|
+
// ---------------------------------------------------------------------------
|
|
1208
|
+
|
|
1209
|
+
describe('component — watchers advanced', () => {
|
|
1210
|
+
it('receives correct old and new values', async () => {
|
|
1211
|
+
let oldVal, newVal;
|
|
1212
|
+
component('watch-vals', {
|
|
1213
|
+
state: () => ({ x: 10 }),
|
|
1214
|
+
watch: {
|
|
1215
|
+
x(v, o) { newVal = v; oldVal = o; },
|
|
1216
|
+
},
|
|
1217
|
+
render() { return `<div>${this.state.x}</div>`; },
|
|
1218
|
+
});
|
|
1219
|
+
document.body.innerHTML = '<watch-vals id="wv"></watch-vals>';
|
|
1220
|
+
const inst = mount('#wv', 'watch-vals');
|
|
1221
|
+
inst.state.x = 42;
|
|
1222
|
+
expect(newVal).toBe(42);
|
|
1223
|
+
expect(oldVal).toBe(10);
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
it('multiple watchers fire independently', async () => {
|
|
1227
|
+
const aFn = vi.fn();
|
|
1228
|
+
const bFn = vi.fn();
|
|
1229
|
+
component('watch-multi', {
|
|
1230
|
+
state: () => ({ a: 0, b: 0 }),
|
|
1231
|
+
watch: { a: aFn, b: bFn },
|
|
1232
|
+
render() { return `<div>${this.state.a}-${this.state.b}</div>`; },
|
|
1233
|
+
});
|
|
1234
|
+
document.body.innerHTML = '<watch-multi id="wm"></watch-multi>';
|
|
1235
|
+
const inst = mount('#wm', 'watch-multi');
|
|
1236
|
+
inst.state.a = 1;
|
|
1237
|
+
expect(aFn).toHaveBeenCalledWith(1, 0);
|
|
1238
|
+
expect(bFn).not.toHaveBeenCalled();
|
|
1239
|
+
});
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
// ---------------------------------------------------------------------------
|
|
1244
|
+
// Component state with no initial state
|
|
1245
|
+
// ---------------------------------------------------------------------------
|
|
1246
|
+
|
|
1247
|
+
describe('component — no state', () => {
|
|
1248
|
+
it('works without state property', () => {
|
|
1249
|
+
component('no-state', {
|
|
1250
|
+
render() { return '<div>stateless</div>'; },
|
|
1251
|
+
});
|
|
1252
|
+
document.body.innerHTML = '<no-state id="ns"></no-state>';
|
|
1253
|
+
const inst = mount('#ns', 'no-state');
|
|
1254
|
+
expect(document.querySelector('#ns div').textContent).toBe('stateless');
|
|
1255
|
+
expect(inst).toBeDefined();
|
|
1256
|
+
});
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
// ---------------------------------------------------------------------------
|
|
1261
|
+
// Mount with element reference
|
|
1262
|
+
// ---------------------------------------------------------------------------
|
|
1263
|
+
|
|
1264
|
+
describe('mount — with Element reference', () => {
|
|
1265
|
+
it('accepts an Element directly instead of selector', () => {
|
|
1266
|
+
component('mount-elem', {
|
|
1267
|
+
state: () => ({ v: 'direct' }),
|
|
1268
|
+
render() { return `<span>${this.state.v}</span>`; },
|
|
1269
|
+
});
|
|
1270
|
+
document.body.innerHTML = '<mount-elem id="me"></mount-elem>';
|
|
1271
|
+
const el = document.querySelector('#me');
|
|
1272
|
+
const inst = mount(el, 'mount-elem');
|
|
1273
|
+
expect(document.querySelector('#me span').textContent).toBe('direct');
|
|
1274
|
+
expect(inst).toBeDefined();
|
|
1275
|
+
});
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
// ---------------------------------------------------------------------------
|
|
1280
|
+
// Component interpolation edge cases
|
|
1281
|
+
// ---------------------------------------------------------------------------
|
|
1282
|
+
|
|
1283
|
+
describe('component — interpolation', () => {
|
|
1284
|
+
it('handles template literal with arithmetic', () => {
|
|
1285
|
+
component('interp-math', {
|
|
1286
|
+
state: () => ({ a: 3, b: 4 }),
|
|
1287
|
+
render() { return `<span>${this.state.a + this.state.b}</span>`; },
|
|
1288
|
+
});
|
|
1289
|
+
document.body.innerHTML = '<interp-math id="im"></interp-math>';
|
|
1290
|
+
mount('#im', 'interp-math');
|
|
1291
|
+
expect(document.querySelector('#im span').textContent).toBe('7');
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
it('handles template literal with ternary', () => {
|
|
1295
|
+
component('interp-ternary', {
|
|
1296
|
+
state: () => ({ logged: true }),
|
|
1297
|
+
render() { return `<span>${this.state.logged ? 'Yes' : 'No'}</span>`; },
|
|
1298
|
+
});
|
|
1299
|
+
document.body.innerHTML = '<interp-ternary id="it"></interp-ternary>';
|
|
1300
|
+
mount('#it', 'interp-ternary');
|
|
1301
|
+
expect(document.querySelector('#it span').textContent).toBe('Yes');
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
it('handles template literal with property access', () => {
|
|
1305
|
+
component('interp-method', {
|
|
1306
|
+
state: () => ({ list: [1, 2, 3] }),
|
|
1307
|
+
render() { return `<span>${this.state.list.length}</span>`; },
|
|
1308
|
+
});
|
|
1309
|
+
document.body.innerHTML = '<interp-method id="imt"></interp-method>';
|
|
1310
|
+
mount('#imt', 'interp-method');
|
|
1311
|
+
expect(document.querySelector('#imt span').textContent).toBe('3');
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
it('handles multiple template literal expressions', () => {
|
|
1315
|
+
component('interp-multi', {
|
|
1316
|
+
state: () => ({ first: 'Hello', last: 'World' }),
|
|
1317
|
+
render() { return `<span>${this.state.first} ${this.state.last}</span>`; },
|
|
1318
|
+
});
|
|
1319
|
+
document.body.innerHTML = '<interp-multi id="imm"></interp-multi>';
|
|
1320
|
+
mount('#imm', 'interp-multi');
|
|
1321
|
+
expect(document.querySelector('#imm span').textContent).toBe('Hello World');
|
|
1322
|
+
});
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
// ---------------------------------------------------------------------------
|
|
1327
|
+
// Component emit with no listeners
|
|
1328
|
+
// ---------------------------------------------------------------------------
|
|
1329
|
+
|
|
1330
|
+
describe('component — emit edge cases', () => {
|
|
1331
|
+
it('emit with no listeners does not throw', () => {
|
|
1332
|
+
component('emit-noop', {
|
|
1333
|
+
render() { return '<div>noop</div>'; },
|
|
1334
|
+
});
|
|
1335
|
+
document.body.innerHTML = '<emit-noop id="en2"></emit-noop>';
|
|
1336
|
+
const inst = mount('#en2', 'emit-noop');
|
|
1337
|
+
expect(() => inst.emit('no-handler', { x: 1 })).not.toThrow();
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
it('emit propagates with detail', () => {
|
|
1341
|
+
component('emit-detail', {
|
|
1342
|
+
render() { return '<div>detail</div>'; },
|
|
1343
|
+
});
|
|
1344
|
+
document.body.innerHTML = '<emit-detail id="ed"></emit-detail>';
|
|
1345
|
+
const inst = mount('#ed', 'emit-detail');
|
|
1346
|
+
let detail;
|
|
1347
|
+
document.querySelector('#ed').addEventListener('test-event', e => { detail = e.detail; });
|
|
1348
|
+
inst.emit('test-event', { a: 1, b: 'hello' });
|
|
1349
|
+
expect(detail).toEqual({ a: 1, b: 'hello' });
|
|
1350
|
+
});
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
// ---------------------------------------------------------------------------
|
|
1355
|
+
// Component computed edge cases
|
|
1356
|
+
// ---------------------------------------------------------------------------
|
|
1357
|
+
|
|
1358
|
+
describe('component — computed edge cases', () => {
|
|
1359
|
+
it('multiple computed properties', () => {
|
|
1360
|
+
component('comp-multi-computed', {
|
|
1361
|
+
state: () => ({ x: 2, y: 3 }),
|
|
1362
|
+
computed: {
|
|
1363
|
+
sum(s) { return s.x + s.y; },
|
|
1364
|
+
product(s) { return s.x * s.y; },
|
|
1365
|
+
},
|
|
1366
|
+
render() { return `<span class="sum">${this.computed.sum}</span><span class="product">${this.computed.product}</span>`; },
|
|
1367
|
+
});
|
|
1368
|
+
document.body.innerHTML = '<comp-multi-computed id="cmc"></comp-multi-computed>';
|
|
1369
|
+
const inst = mount('#cmc', 'comp-multi-computed');
|
|
1370
|
+
expect(inst.computed.sum).toBe(5);
|
|
1371
|
+
expect(inst.computed.product).toBe(6);
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
it('computed with boolean logic', () => {
|
|
1375
|
+
component('comp-bool', {
|
|
1376
|
+
state: () => ({ items: [] }),
|
|
1377
|
+
computed: {
|
|
1378
|
+
isEmpty(s) { return s.items.length === 0; },
|
|
1379
|
+
},
|
|
1380
|
+
render() { return `<span>${this.computed.isEmpty}</span>`; },
|
|
1381
|
+
});
|
|
1382
|
+
document.body.innerHTML = '<comp-bool id="cb"></comp-bool>';
|
|
1383
|
+
const inst = mount('#cb', 'comp-bool');
|
|
1384
|
+
expect(inst.computed.isEmpty).toBe(true);
|
|
1385
|
+
});
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
// ---------------------------------------------------------------------------
|
|
1390
|
+
// Slots advanced
|
|
1391
|
+
// ---------------------------------------------------------------------------
|
|
1392
|
+
|
|
1393
|
+
describe('component — slots advanced', () => {
|
|
1394
|
+
it('handles multiple children in default slot', () => {
|
|
1395
|
+
component('slot-multi', {
|
|
1396
|
+
render() { return '<div><slot>fallback</slot></div>'; },
|
|
1397
|
+
});
|
|
1398
|
+
document.body.innerHTML = '<slot-multi id="smlt"><p>one</p><p>two</p><p>three</p></slot-multi>';
|
|
1399
|
+
mount('#smlt', 'slot-multi');
|
|
1400
|
+
const ps = document.querySelectorAll('#smlt p');
|
|
1401
|
+
expect(ps.length).toBe(3);
|
|
1402
|
+
expect(ps[2].textContent).toBe('three');
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
it('handles empty named slot with fallback', () => {
|
|
1406
|
+
component('slot-empty-named', {
|
|
1407
|
+
render() {
|
|
1408
|
+
return '<header><slot name="header">Default Header</slot></header><main><slot>Default Body</slot></main>';
|
|
1409
|
+
},
|
|
1410
|
+
});
|
|
1411
|
+
document.body.innerHTML = '<slot-empty-named id="sen"><p>Only body</p></slot-empty-named>';
|
|
1412
|
+
mount('#sen', 'slot-empty-named');
|
|
1413
|
+
expect(document.querySelector('#sen header').textContent).toBe('Default Header');
|
|
1414
|
+
expect(document.querySelector('#sen main p').textContent).toBe('Only body');
|
|
1415
|
+
});
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
// ---------------------------------------------------------------------------
|
|
1420
|
+
// Component re-render preserves DOM via morphing
|
|
1421
|
+
// ---------------------------------------------------------------------------
|
|
1422
|
+
|
|
1423
|
+
describe('component — DOM morphing on re-render', () => {
|
|
1424
|
+
it('preserves unchanged DOM nodes on re-render', async () => {
|
|
1425
|
+
component('morph-preserve', {
|
|
1426
|
+
state: () => ({ title: 'old', count: 0 }),
|
|
1427
|
+
render() { return `<h1>${this.state.title}</h1><p class="static">static content</p><span>${this.state.count}</span>`; },
|
|
1428
|
+
});
|
|
1429
|
+
document.body.innerHTML = '<morph-preserve id="mp"></morph-preserve>';
|
|
1430
|
+
const inst = mount('#mp', 'morph-preserve');
|
|
1431
|
+
const staticP = document.querySelector('#mp .static');
|
|
1432
|
+
|
|
1433
|
+
inst.state.count = 5;
|
|
1434
|
+
await new Promise(r => queueMicrotask(r));
|
|
1435
|
+
// Static paragraph should be the same DOM node after morph re-render
|
|
1436
|
+
expect(document.querySelector('#mp .static')).toBe(staticP);
|
|
1437
|
+
expect(document.querySelector('#mp span').textContent).toBe('5');
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
it('handles list update via morphing', async () => {
|
|
1441
|
+
component('morph-list', {
|
|
1442
|
+
state: () => ({ items: ['a', 'b', 'c'] }),
|
|
1443
|
+
render() { return `<ul>${this.state.items.map(i => `<li>${i}</li>`).join('')}</ul>`; },
|
|
1444
|
+
});
|
|
1445
|
+
document.body.innerHTML = '<morph-list id="ml"></morph-list>';
|
|
1446
|
+
const inst = mount('#ml', 'morph-list');
|
|
1447
|
+
expect(document.querySelectorAll('#ml li').length).toBe(3);
|
|
1448
|
+
|
|
1449
|
+
inst.state.items = ['a', 'b', 'c', 'd'];
|
|
1450
|
+
await new Promise(r => queueMicrotask(r));
|
|
1451
|
+
expect(document.querySelectorAll('#ml li').length).toBe(4);
|
|
1452
|
+
expect(document.querySelectorAll('#ml li')[3].textContent).toBe('d');
|
|
1453
|
+
});
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
// ---------------------------------------------------------------------------
|
|
1458
|
+
// MEMORY: destroy clears pending debounce/throttle timers
|
|
1459
|
+
// ---------------------------------------------------------------------------
|
|
1460
|
+
|
|
1461
|
+
describe('Component — destroy clears pending timers', () => {
|
|
1462
|
+
it('clears debounce timers for child elements on destroy', () => {
|
|
1463
|
+
component('timer-clear', {
|
|
1464
|
+
state: () => ({ val: 0 }),
|
|
1465
|
+
render() {
|
|
1466
|
+
return `<button @click.debounce.5000="handler">click</button>`;
|
|
1467
|
+
},
|
|
1468
|
+
handler() { this.state.val++; },
|
|
1469
|
+
});
|
|
1470
|
+
document.body.innerHTML = '<timer-clear id="tcl"></timer-clear>';
|
|
1471
|
+
const inst = mount('#tcl', 'timer-clear');
|
|
1472
|
+
|
|
1473
|
+
// Click to start a debounce timer (5s — won't fire during test)
|
|
1474
|
+
document.querySelector('#tcl button').click();
|
|
1475
|
+
|
|
1476
|
+
// Verify the state hasn't changed yet (debounced)
|
|
1477
|
+
expect(inst.state.val).toBe(0);
|
|
1478
|
+
|
|
1479
|
+
// Destroy should clear timers without errors
|
|
1480
|
+
expect(() => inst.destroy()).not.toThrow();
|
|
1481
|
+
});
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
// ===========================================================================
|
|
1486
|
+
// z-model modifiers
|
|
1487
|
+
// ===========================================================================
|
|
1488
|
+
|
|
1489
|
+
describe('component — z-model z-lazy modifier', () => {
|
|
1490
|
+
it('uses change event instead of input when z-lazy is present', () => {
|
|
1491
|
+
component('zmodel-lazy', {
|
|
1492
|
+
state: () => ({ val: '' }),
|
|
1493
|
+
render() { return '<input z-model="val" z-lazy>'; },
|
|
1494
|
+
});
|
|
1495
|
+
document.body.innerHTML = '<zmodel-lazy id="zml"></zmodel-lazy>';
|
|
1496
|
+
const inst = mount('#zml', 'zmodel-lazy');
|
|
1497
|
+
const input = document.querySelector('#zml input');
|
|
1498
|
+
|
|
1499
|
+
// input event should NOT update state
|
|
1500
|
+
input.value = 'typed';
|
|
1501
|
+
input.dispatchEvent(new Event('input'));
|
|
1502
|
+
expect(inst.state.val).toBe('');
|
|
1503
|
+
|
|
1504
|
+
// change event should update state
|
|
1505
|
+
input.value = 'committed';
|
|
1506
|
+
input.dispatchEvent(new Event('change'));
|
|
1507
|
+
expect(inst.state.val).toBe('committed');
|
|
1508
|
+
});
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
describe('component — z-model z-trim modifier', () => {
|
|
1512
|
+
it('trims whitespace from input value', () => {
|
|
1513
|
+
component('zmodel-trim', {
|
|
1514
|
+
state: () => ({ val: '' }),
|
|
1515
|
+
render() { return '<input z-model="val" z-trim>'; },
|
|
1516
|
+
});
|
|
1517
|
+
document.body.innerHTML = '<zmodel-trim id="zmt"></zmodel-trim>';
|
|
1518
|
+
const inst = mount('#zmt', 'zmodel-trim');
|
|
1519
|
+
const input = document.querySelector('#zmt input');
|
|
1520
|
+
input.value = ' hello ';
|
|
1521
|
+
input.dispatchEvent(new Event('input'));
|
|
1522
|
+
expect(inst.state.val).toBe('hello');
|
|
1523
|
+
});
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
describe('component — z-model z-number modifier', () => {
|
|
1527
|
+
it('converts input value to number', () => {
|
|
1528
|
+
component('zmodel-num', {
|
|
1529
|
+
state: () => ({ val: 0 }),
|
|
1530
|
+
render() { return '<input z-model="val" z-number>'; },
|
|
1531
|
+
});
|
|
1532
|
+
document.body.innerHTML = '<zmodel-num id="zmn"></zmodel-num>';
|
|
1533
|
+
const inst = mount('#zmn', 'zmodel-num');
|
|
1534
|
+
const input = document.querySelector('#zmn input');
|
|
1535
|
+
input.value = '42';
|
|
1536
|
+
input.dispatchEvent(new Event('input'));
|
|
1537
|
+
expect(inst.state.val).toBe(42);
|
|
1538
|
+
expect(typeof inst.state.val).toBe('number');
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
it('converts number type input automatically without z-number', () => {
|
|
1542
|
+
component('zmodel-numtype', {
|
|
1543
|
+
state: () => ({ val: 0 }),
|
|
1544
|
+
render() { return '<input type="number" z-model="val">'; },
|
|
1545
|
+
});
|
|
1546
|
+
document.body.innerHTML = '<zmodel-numtype id="zmnt"></zmodel-numtype>';
|
|
1547
|
+
const inst = mount('#zmnt', 'zmodel-numtype');
|
|
1548
|
+
const input = document.querySelector('#zmnt input');
|
|
1549
|
+
input.value = '99';
|
|
1550
|
+
input.dispatchEvent(new Event('input'));
|
|
1551
|
+
expect(inst.state.val).toBe(99);
|
|
1552
|
+
expect(typeof inst.state.val).toBe('number');
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
it('converts range type input automatically', () => {
|
|
1556
|
+
component('zmodel-range', {
|
|
1557
|
+
state: () => ({ val: 50 }),
|
|
1558
|
+
render() { return '<input type="range" z-model="val" min="0" max="100">'; },
|
|
1559
|
+
});
|
|
1560
|
+
document.body.innerHTML = '<zmodel-range id="zmrg"></zmodel-range>';
|
|
1561
|
+
const inst = mount('#zmrg', 'zmodel-range');
|
|
1562
|
+
const input = document.querySelector('#zmrg input');
|
|
1563
|
+
input.value = '75';
|
|
1564
|
+
input.dispatchEvent(new Event('input'));
|
|
1565
|
+
expect(inst.state.val).toBe(75);
|
|
1566
|
+
expect(typeof inst.state.val).toBe('number');
|
|
1567
|
+
});
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
describe('component — z-model contenteditable', () => {
|
|
1571
|
+
it('reads and writes textContent for contenteditable elements', () => {
|
|
1572
|
+
component('zmodel-ce', {
|
|
1573
|
+
state: () => ({ val: 'initial' }),
|
|
1574
|
+
render() { return '<div contenteditable z-model="val"></div>'; },
|
|
1575
|
+
});
|
|
1576
|
+
document.body.innerHTML = '<zmodel-ce id="zmce"></zmodel-ce>';
|
|
1577
|
+
const inst = mount('#zmce', 'zmodel-ce');
|
|
1578
|
+
const div = document.querySelector('#zmce div[contenteditable]');
|
|
1579
|
+
|
|
1580
|
+
// Initial state synced to textContent
|
|
1581
|
+
expect(div.textContent).toBe('initial');
|
|
1582
|
+
|
|
1583
|
+
// Write-back via input event
|
|
1584
|
+
div.textContent = 'edited';
|
|
1585
|
+
div.dispatchEvent(new Event('input'));
|
|
1586
|
+
expect(inst.state.val).toBe('edited');
|
|
1587
|
+
});
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
describe('component — z-model radio', () => {
|
|
1591
|
+
it('checks the radio matching state value and writes back on change', () => {
|
|
1592
|
+
component('zmodel-radio', {
|
|
1593
|
+
state: () => ({ color: 'green' }),
|
|
1594
|
+
render() {
|
|
1595
|
+
return `<input type="radio" name="c" value="red" z-model="color">
|
|
1596
|
+
<input type="radio" name="c" value="green" z-model="color">
|
|
1597
|
+
<input type="radio" name="c" value="blue" z-model="color">`;
|
|
1598
|
+
},
|
|
1599
|
+
});
|
|
1600
|
+
document.body.innerHTML = '<zmodel-radio id="zmr"></zmodel-radio>';
|
|
1601
|
+
const inst = mount('#zmr', 'zmodel-radio');
|
|
1602
|
+
const radios = document.querySelectorAll('#zmr input[type="radio"]');
|
|
1603
|
+
|
|
1604
|
+
// green should be checked
|
|
1605
|
+
expect(radios[0].checked).toBe(false);
|
|
1606
|
+
expect(radios[1].checked).toBe(true);
|
|
1607
|
+
expect(radios[2].checked).toBe(false);
|
|
1608
|
+
|
|
1609
|
+
// Change to red
|
|
1610
|
+
radios[0].checked = true;
|
|
1611
|
+
radios[0].dispatchEvent(new Event('change'));
|
|
1612
|
+
expect(inst.state.color).toBe('red');
|
|
1613
|
+
});
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
describe('component — z-model multi-select', () => {
|
|
1617
|
+
it('syncs multiple selected options to an array', () => {
|
|
1618
|
+
component('zmodel-multisel', {
|
|
1619
|
+
state: () => ({ chosen: ['b', 'c'] }),
|
|
1620
|
+
render() {
|
|
1621
|
+
return `<select z-model="chosen" multiple>
|
|
1622
|
+
<option value="a">A</option>
|
|
1623
|
+
<option value="b">B</option>
|
|
1624
|
+
<option value="c">C</option>
|
|
1625
|
+
</select>`;
|
|
1626
|
+
},
|
|
1627
|
+
});
|
|
1628
|
+
document.body.innerHTML = '<zmodel-multisel id="zmms"></zmodel-multisel>';
|
|
1629
|
+
const inst = mount('#zmms', 'zmodel-multisel');
|
|
1630
|
+
const select = document.querySelector('#zmms select');
|
|
1631
|
+
|
|
1632
|
+
// Initial: b and c selected
|
|
1633
|
+
expect(select.options[0].selected).toBe(false);
|
|
1634
|
+
expect(select.options[1].selected).toBe(true);
|
|
1635
|
+
expect(select.options[2].selected).toBe(true);
|
|
1636
|
+
|
|
1637
|
+
// Change selection: select only a
|
|
1638
|
+
select.options[0].selected = true;
|
|
1639
|
+
select.options[1].selected = false;
|
|
1640
|
+
select.options[2].selected = false;
|
|
1641
|
+
select.dispatchEvent(new Event('change'));
|
|
1642
|
+
expect(inst.state.chosen).toEqual(['a']);
|
|
1643
|
+
});
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
describe('component — z-model dot-path keys', () => {
|
|
1647
|
+
it('reads and writes nested state via dot path', () => {
|
|
1648
|
+
component('zmodel-dot', {
|
|
1649
|
+
state: () => ({ form: { name: 'Alice' } }),
|
|
1650
|
+
render() { return '<input z-model="form.name">'; },
|
|
1651
|
+
});
|
|
1652
|
+
document.body.innerHTML = '<zmodel-dot id="zmd"></zmodel-dot>';
|
|
1653
|
+
const inst = mount('#zmd', 'zmodel-dot');
|
|
1654
|
+
const input = document.querySelector('#zmd input');
|
|
1655
|
+
|
|
1656
|
+
// Initial sync from nested state
|
|
1657
|
+
expect(input.value).toBe('Alice');
|
|
1658
|
+
|
|
1659
|
+
// Write-back updates nested path
|
|
1660
|
+
input.value = 'Bob';
|
|
1661
|
+
input.dispatchEvent(new Event('input'));
|
|
1662
|
+
expect(inst.state.form.name).toBe('Bob');
|
|
1663
|
+
});
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
describe('component — z-model does not duplicate listeners on re-render', () => {
|
|
1667
|
+
it('handler fires only once per event after multiple re-renders', async () => {
|
|
1668
|
+
let callCount = 0;
|
|
1669
|
+
const origSetPath = null;
|
|
1670
|
+
component('zmodel-nodup', {
|
|
1671
|
+
state: () => ({ val: '' }),
|
|
1672
|
+
render() { return '<input z-model="val">'; },
|
|
1673
|
+
});
|
|
1674
|
+
document.body.innerHTML = '<zmodel-nodup id="zmnd"></zmodel-nodup>';
|
|
1675
|
+
const inst = mount('#zmnd', 'zmodel-nodup');
|
|
1676
|
+
|
|
1677
|
+
// Force two re-renders
|
|
1678
|
+
inst.state.val = 'a';
|
|
1679
|
+
await new Promise(r => queueMicrotask(r));
|
|
1680
|
+
inst.state.val = 'b';
|
|
1681
|
+
await new Promise(r => queueMicrotask(r));
|
|
1682
|
+
|
|
1683
|
+
const input = document.querySelector('#zmnd input');
|
|
1684
|
+
input.value = 'test';
|
|
1685
|
+
input.dispatchEvent(new Event('input'));
|
|
1686
|
+
// Should be 'test', not doubled
|
|
1687
|
+
expect(inst.state.val).toBe('test');
|
|
1688
|
+
});
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
// ===========================================================================
|
|
1693
|
+
// Event modifiers
|
|
1694
|
+
// ===========================================================================
|
|
1695
|
+
|
|
1696
|
+
describe('component — event modifier .prevent', () => {
|
|
1697
|
+
it('calls preventDefault on the event', () => {
|
|
1698
|
+
component('evt-prevent', {
|
|
1699
|
+
handler() {},
|
|
1700
|
+
render() { return '<a @click.prevent="handler" href="#">link</a>'; },
|
|
1701
|
+
});
|
|
1702
|
+
document.body.innerHTML = '<evt-prevent id="ep"></evt-prevent>';
|
|
1703
|
+
mount('#ep', 'evt-prevent');
|
|
1704
|
+
const a = document.querySelector('#ep a');
|
|
1705
|
+
const e = new Event('click', { bubbles: true, cancelable: true });
|
|
1706
|
+
vi.spyOn(e, 'preventDefault');
|
|
1707
|
+
a.dispatchEvent(e);
|
|
1708
|
+
expect(e.preventDefault).toHaveBeenCalled();
|
|
1709
|
+
});
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
describe('component — event modifier .stop', () => {
|
|
1713
|
+
it('calls stopPropagation on the event', () => {
|
|
1714
|
+
let stopped = false;
|
|
1715
|
+
component('evt-stop', {
|
|
1716
|
+
handler() {},
|
|
1717
|
+
render() { return '<button @click.stop="handler">btn</button>'; },
|
|
1718
|
+
});
|
|
1719
|
+
document.body.innerHTML = '<evt-stop id="es"></evt-stop>';
|
|
1720
|
+
mount('#es', 'evt-stop');
|
|
1721
|
+
const btn = document.querySelector('#es button');
|
|
1722
|
+
const e = new Event('click', { bubbles: true });
|
|
1723
|
+
const origStop = e.stopPropagation.bind(e);
|
|
1724
|
+
e.stopPropagation = () => { stopped = true; origStop(); };
|
|
1725
|
+
btn.dispatchEvent(e);
|
|
1726
|
+
expect(stopped).toBe(true);
|
|
1727
|
+
});
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
describe('component — event modifier .self', () => {
|
|
1731
|
+
it('only fires handler when target matches element', () => {
|
|
1732
|
+
let count = 0;
|
|
1733
|
+
component('evt-self', {
|
|
1734
|
+
handler() { count++; },
|
|
1735
|
+
render() { return '<div @click.self="handler"><span class="child">inner</span></div>'; },
|
|
1736
|
+
});
|
|
1737
|
+
document.body.innerHTML = '<evt-self id="eself"></evt-self>';
|
|
1738
|
+
mount('#eself', 'evt-self');
|
|
1739
|
+
|
|
1740
|
+
// Click on child should NOT fire (target !== el)
|
|
1741
|
+
const child = document.querySelector('#eself .child');
|
|
1742
|
+
child.click();
|
|
1743
|
+
expect(count).toBe(0);
|
|
1744
|
+
|
|
1745
|
+
// Click on the div itself should fire
|
|
1746
|
+
const div = document.querySelector('#eself div');
|
|
1747
|
+
div.click();
|
|
1748
|
+
expect(count).toBe(1);
|
|
1749
|
+
});
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
describe('component — event modifier .once', () => {
|
|
1753
|
+
it('only fires handler on the first event', () => {
|
|
1754
|
+
let count = 0;
|
|
1755
|
+
component('evt-once', {
|
|
1756
|
+
handler() { count++; },
|
|
1757
|
+
render() { return '<button @click.once="handler">btn</button>'; },
|
|
1758
|
+
});
|
|
1759
|
+
document.body.innerHTML = '<evt-once id="eo"></evt-once>';
|
|
1760
|
+
mount('#eo', 'evt-once');
|
|
1761
|
+
const btn = document.querySelector('#eo button');
|
|
1762
|
+
|
|
1763
|
+
btn.click();
|
|
1764
|
+
expect(count).toBe(1);
|
|
1765
|
+
|
|
1766
|
+
btn.click();
|
|
1767
|
+
expect(count).toBe(1); // Should not fire again
|
|
1768
|
+
});
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
describe('component — event modifier .debounce', () => {
|
|
1772
|
+
it('delays handler execution', () => {
|
|
1773
|
+
vi.useFakeTimers();
|
|
1774
|
+
let count = 0;
|
|
1775
|
+
component('evt-debounce', {
|
|
1776
|
+
handler() { count++; },
|
|
1777
|
+
render() { return '<button @click.debounce.100="handler">btn</button>'; },
|
|
1778
|
+
});
|
|
1779
|
+
document.body.innerHTML = '<evt-debounce id="edb"></evt-debounce>';
|
|
1780
|
+
mount('#edb', 'evt-debounce');
|
|
1781
|
+
const btn = document.querySelector('#edb button');
|
|
1782
|
+
|
|
1783
|
+
btn.click();
|
|
1784
|
+
btn.click();
|
|
1785
|
+
btn.click();
|
|
1786
|
+
expect(count).toBe(0); // Not fired yet
|
|
1787
|
+
|
|
1788
|
+
vi.advanceTimersByTime(100);
|
|
1789
|
+
expect(count).toBe(1); // Only fires once
|
|
1790
|
+
|
|
1791
|
+
vi.useRealTimers();
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
it('uses 250ms default when no ms specified', () => {
|
|
1795
|
+
vi.useFakeTimers();
|
|
1796
|
+
let count = 0;
|
|
1797
|
+
component('evt-debounce-def', {
|
|
1798
|
+
handler() { count++; },
|
|
1799
|
+
render() { return '<button @click.debounce="handler">btn</button>'; },
|
|
1800
|
+
});
|
|
1801
|
+
document.body.innerHTML = '<evt-debounce-def id="edbd"></evt-debounce-def>';
|
|
1802
|
+
mount('#edbd', 'evt-debounce-def');
|
|
1803
|
+
document.querySelector('#edbd button').click();
|
|
1804
|
+
|
|
1805
|
+
vi.advanceTimersByTime(200);
|
|
1806
|
+
expect(count).toBe(0);
|
|
1807
|
+
|
|
1808
|
+
vi.advanceTimersByTime(50);
|
|
1809
|
+
expect(count).toBe(1);
|
|
1810
|
+
|
|
1811
|
+
vi.useRealTimers();
|
|
1812
|
+
});
|
|
1813
|
+
});
|
|
1814
|
+
|
|
1815
|
+
describe('component — event modifier .throttle', () => {
|
|
1816
|
+
it('fires immediately then blocks until window passes', () => {
|
|
1817
|
+
vi.useFakeTimers();
|
|
1818
|
+
let count = 0;
|
|
1819
|
+
component('evt-throttle', {
|
|
1820
|
+
handler() { count++; },
|
|
1821
|
+
render() { return '<button @click.throttle.200="handler">btn</button>'; },
|
|
1822
|
+
});
|
|
1823
|
+
document.body.innerHTML = '<evt-throttle id="eth"></evt-throttle>';
|
|
1824
|
+
mount('#eth', 'evt-throttle');
|
|
1825
|
+
const btn = document.querySelector('#eth button');
|
|
1826
|
+
|
|
1827
|
+
btn.click();
|
|
1828
|
+
expect(count).toBe(1); // Fires immediately
|
|
1829
|
+
|
|
1830
|
+
btn.click();
|
|
1831
|
+
expect(count).toBe(1); // Blocked
|
|
1832
|
+
|
|
1833
|
+
vi.advanceTimersByTime(200);
|
|
1834
|
+
btn.click();
|
|
1835
|
+
expect(count).toBe(2); // Fires again after window
|
|
1836
|
+
|
|
1837
|
+
vi.useRealTimers();
|
|
1838
|
+
});
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
describe('component — combined event modifiers', () => {
|
|
1842
|
+
it('handles .prevent.stop together', () => {
|
|
1843
|
+
component('evt-combo', {
|
|
1844
|
+
handler() {},
|
|
1845
|
+
render() { return '<button @click.prevent.stop="handler">btn</button>'; },
|
|
1846
|
+
});
|
|
1847
|
+
document.body.innerHTML = '<evt-combo id="ecb"></evt-combo>';
|
|
1848
|
+
mount('#ecb', 'evt-combo');
|
|
1849
|
+
const btn = document.querySelector('#ecb button');
|
|
1850
|
+
const e = new Event('click', { bubbles: true, cancelable: true });
|
|
1851
|
+
vi.spyOn(e, 'preventDefault');
|
|
1852
|
+
vi.spyOn(e, 'stopPropagation');
|
|
1853
|
+
btn.dispatchEvent(e);
|
|
1854
|
+
expect(e.preventDefault).toHaveBeenCalled();
|
|
1855
|
+
expect(e.stopPropagation).toHaveBeenCalled();
|
|
1856
|
+
});
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
|
|
1860
|
+
// ===========================================================================
|
|
1861
|
+
// z-for advanced — object, iterable, range, null
|
|
1862
|
+
// ===========================================================================
|
|
1863
|
+
|
|
1864
|
+
describe('component — z-for object iteration', () => {
|
|
1865
|
+
it('iterates over object entries with key/value', () => {
|
|
1866
|
+
component('zfor-obj', {
|
|
1867
|
+
state: () => ({ data: { name: 'Alice', age: 30 } }),
|
|
1868
|
+
render() {
|
|
1869
|
+
return '<div z-for="(entry) in data"><span>{{entry.key}}:{{entry.value}}</span></div>';
|
|
1870
|
+
},
|
|
1871
|
+
});
|
|
1872
|
+
document.body.innerHTML = '<zfor-obj id="zfo"></zfor-obj>';
|
|
1873
|
+
mount('#zfo', 'zfor-obj');
|
|
1874
|
+
const spans = document.querySelectorAll('#zfo span');
|
|
1875
|
+
expect(spans.length).toBe(2);
|
|
1876
|
+
expect(spans[0].textContent).toBe('name:Alice');
|
|
1877
|
+
expect(spans[1].textContent).toBe('age:30');
|
|
1878
|
+
});
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
describe('component — z-for numeric range', () => {
|
|
1882
|
+
it('generates items from 1 to n', () => {
|
|
1883
|
+
component('zfor-range', {
|
|
1884
|
+
state: () => ({ count: 5 }),
|
|
1885
|
+
render() { return '<span z-for="n in count">{{n}}</span>'; },
|
|
1886
|
+
});
|
|
1887
|
+
document.body.innerHTML = '<zfor-range id="zfra"></zfor-range>';
|
|
1888
|
+
mount('#zfra', 'zfor-range');
|
|
1889
|
+
const spans = document.querySelectorAll('#zfra span');
|
|
1890
|
+
expect(spans.length).toBe(5);
|
|
1891
|
+
expect(spans[0].textContent).toBe('1');
|
|
1892
|
+
expect(spans[4].textContent).toBe('5');
|
|
1893
|
+
});
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
describe('component — z-for null/undefined list removes element', () => {
|
|
1897
|
+
it('removes element when list is null', () => {
|
|
1898
|
+
component('zfor-null', {
|
|
1899
|
+
state: () => ({ items: null }),
|
|
1900
|
+
render() { return '<div id="wrap"><p>before</p><span z-for="item in items">{{item}}</span><p>after</p></div>'; },
|
|
1901
|
+
});
|
|
1902
|
+
document.body.innerHTML = '<zfor-null id="zfn"></zfor-null>';
|
|
1903
|
+
mount('#zfn', 'zfor-null');
|
|
1904
|
+
expect(document.querySelectorAll('#zfn span').length).toBe(0);
|
|
1905
|
+
// before and after paragraphs should still exist
|
|
1906
|
+
expect(document.querySelectorAll('#zfn p').length).toBe(2);
|
|
1907
|
+
});
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
describe('component — z-for with empty array', () => {
|
|
1911
|
+
it('renders nothing with an empty array', () => {
|
|
1912
|
+
component('zfor-empty', {
|
|
1913
|
+
state: () => ({ items: [] }),
|
|
1914
|
+
render() { return '<ul><li z-for="item in items">{{item}}</li></ul>'; },
|
|
1915
|
+
});
|
|
1916
|
+
document.body.innerHTML = '<zfor-empty id="zfe"></zfor-empty>';
|
|
1917
|
+
mount('#zfe', 'zfor-empty');
|
|
1918
|
+
expect(document.querySelectorAll('#zfe li').length).toBe(0);
|
|
1919
|
+
});
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
describe('component — z-for with $index', () => {
|
|
1923
|
+
it('exposes $index as default index variable', () => {
|
|
1924
|
+
component('zfor-idx', {
|
|
1925
|
+
state: () => ({ items: ['a', 'b', 'c'] }),
|
|
1926
|
+
render() { return '<span z-for="item in items">{{$index}}</span>'; },
|
|
1927
|
+
});
|
|
1928
|
+
document.body.innerHTML = '<zfor-idx id="zfi"></zfor-idx>';
|
|
1929
|
+
mount('#zfi', 'zfor-idx');
|
|
1930
|
+
const spans = document.querySelectorAll('#zfi span');
|
|
1931
|
+
expect(spans[0].textContent).toBe('0');
|
|
1932
|
+
expect(spans[1].textContent).toBe('1');
|
|
1933
|
+
expect(spans[2].textContent).toBe('2');
|
|
1934
|
+
});
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
describe('component — z-for with invalid expression', () => {
|
|
1938
|
+
it('gracefully handles invalid z-for syntax', () => {
|
|
1939
|
+
component('zfor-invalid', {
|
|
1940
|
+
state: () => ({}),
|
|
1941
|
+
render() { return '<div z-for="bad syntax">{{item}}</div>'; },
|
|
1942
|
+
});
|
|
1943
|
+
document.body.innerHTML = '<zfor-invalid id="zfiv"></zfor-invalid>';
|
|
1944
|
+
expect(() => mount('#zfiv', 'zfor-invalid')).not.toThrow();
|
|
1945
|
+
});
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1948
|
+
|
|
1949
|
+
// ===========================================================================
|
|
1950
|
+
// z-class — string and array forms
|
|
1951
|
+
// ===========================================================================
|
|
1952
|
+
|
|
1953
|
+
describe('component — z-class string form', () => {
|
|
1954
|
+
it('adds classes from a space-separated string', () => {
|
|
1955
|
+
component('zclass-str', {
|
|
1956
|
+
state: () => ({ cls: 'foo bar baz' }),
|
|
1957
|
+
render() { return '<div z-class="cls">test</div>'; },
|
|
1958
|
+
});
|
|
1959
|
+
document.body.innerHTML = '<zclass-str id="zcs"></zclass-str>';
|
|
1960
|
+
mount('#zcs', 'zclass-str');
|
|
1961
|
+
const div = document.querySelector('#zcs div');
|
|
1962
|
+
expect(div.classList.contains('foo')).toBe(true);
|
|
1963
|
+
expect(div.classList.contains('bar')).toBe(true);
|
|
1964
|
+
expect(div.classList.contains('baz')).toBe(true);
|
|
1965
|
+
});
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
describe('component — z-class array form', () => {
|
|
1969
|
+
it('adds classes from an array, filtering falsy values', () => {
|
|
1970
|
+
component('zclass-arr', {
|
|
1971
|
+
state: () => ({ classes: ['active', null, 'visible', '', 'large'] }),
|
|
1972
|
+
render() { return '<div z-class="classes">test</div>'; },
|
|
1973
|
+
});
|
|
1974
|
+
document.body.innerHTML = '<zclass-arr id="zca"></zclass-arr>';
|
|
1975
|
+
mount('#zca', 'zclass-arr');
|
|
1976
|
+
const div = document.querySelector('#zca div');
|
|
1977
|
+
expect(div.classList.contains('active')).toBe(true);
|
|
1978
|
+
expect(div.classList.contains('visible')).toBe(true);
|
|
1979
|
+
expect(div.classList.contains('large')).toBe(true);
|
|
1980
|
+
expect(div.classList.length).toBe(3);
|
|
1981
|
+
});
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
|
|
1985
|
+
// ===========================================================================
|
|
1986
|
+
// z-style — string form
|
|
1987
|
+
// ===========================================================================
|
|
1988
|
+
|
|
1989
|
+
describe('component — z-style string form', () => {
|
|
1990
|
+
it('appends CSS text from a string expression', () => {
|
|
1991
|
+
component('zstyle-str', {
|
|
1992
|
+
state: () => ({ s: 'color: red; font-weight: bold' }),
|
|
1993
|
+
render() { return '<div z-style="s">text</div>'; },
|
|
1994
|
+
});
|
|
1995
|
+
document.body.innerHTML = '<zstyle-str id="zss"></zstyle-str>';
|
|
1996
|
+
mount('#zss', 'zstyle-str');
|
|
1997
|
+
const div = document.querySelector('#zss div');
|
|
1998
|
+
expect(div.style.color).toBe('red');
|
|
1999
|
+
expect(div.style.fontWeight).toBe('bold');
|
|
2000
|
+
});
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
|
|
2004
|
+
// ===========================================================================
|
|
2005
|
+
// z-text and z-html edge cases
|
|
2006
|
+
// ===========================================================================
|
|
2007
|
+
|
|
2008
|
+
describe('component — z-text with null/undefined', () => {
|
|
2009
|
+
it('sets empty textContent for null state', () => {
|
|
2010
|
+
component('ztext-null', {
|
|
2011
|
+
state: () => ({ val: null }),
|
|
2012
|
+
render() { return '<span z-text="val">placeholder</span>'; },
|
|
2013
|
+
});
|
|
2014
|
+
document.body.innerHTML = '<ztext-null id="ztn"></ztext-null>';
|
|
2015
|
+
mount('#ztn', 'ztext-null');
|
|
2016
|
+
expect(document.querySelector('#ztn span').textContent).toBe('');
|
|
2017
|
+
});
|
|
2018
|
+
});
|
|
2019
|
+
|
|
2020
|
+
describe('component — z-html with null', () => {
|
|
2021
|
+
it('sets empty innerHTML for null state', () => {
|
|
2022
|
+
component('zhtml-null', {
|
|
2023
|
+
state: () => ({ content: null }),
|
|
2024
|
+
render() { return '<div z-html="content">placeholder</div>'; },
|
|
2025
|
+
});
|
|
2026
|
+
document.body.innerHTML = '<zhtml-null id="zhn"></zhtml-null>';
|
|
2027
|
+
mount('#zhn', 'zhtml-null');
|
|
2028
|
+
expect(document.querySelector('#zhn div').innerHTML).toBe('');
|
|
2029
|
+
});
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
describe('component — z-text expression evaluation', () => {
|
|
2033
|
+
it('evaluates expressions, not just state keys', () => {
|
|
2034
|
+
component('ztext-expr', {
|
|
2035
|
+
state: () => ({ a: 3, b: 4 }),
|
|
2036
|
+
render() { return '<span z-text="a + b"></span>'; },
|
|
2037
|
+
});
|
|
2038
|
+
document.body.innerHTML = '<ztext-expr id="zte"></ztext-expr>';
|
|
2039
|
+
mount('#zte', 'ztext-expr');
|
|
2040
|
+
expect(document.querySelector('#zte span').textContent).toBe('7');
|
|
2041
|
+
});
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
|
|
2045
|
+
// ===========================================================================
|
|
2046
|
+
// mountAll with props and dynamic expressions
|
|
2047
|
+
// ===========================================================================
|
|
2048
|
+
|
|
2049
|
+
describe('mountAll — static props', () => {
|
|
2050
|
+
it('passes attribute values as props to components', () => {
|
|
2051
|
+
component('prop-child', {
|
|
2052
|
+
render() { return `<span>${this.props.label}</span>`; },
|
|
2053
|
+
});
|
|
2054
|
+
document.body.innerHTML = '<prop-child label="Hello"></prop-child>';
|
|
2055
|
+
mountAll();
|
|
2056
|
+
expect(document.querySelector('prop-child span').textContent).toBe('Hello');
|
|
2057
|
+
});
|
|
2058
|
+
|
|
2059
|
+
it('parses JSON prop values', () => {
|
|
2060
|
+
component('prop-json', {
|
|
2061
|
+
render() { return `<span>${this.props.count}</span>`; },
|
|
2062
|
+
});
|
|
2063
|
+
document.body.innerHTML = '<prop-json count="42"></prop-json>';
|
|
2064
|
+
mountAll();
|
|
2065
|
+
const inst = getInstance(document.querySelector('prop-json'));
|
|
2066
|
+
expect(inst.props.count).toBe(42);
|
|
2067
|
+
});
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
describe('mountAll — skips already-mounted instances', () => {
|
|
2071
|
+
it('does not double-mount the same element', () => {
|
|
2072
|
+
let mountCount = 0;
|
|
2073
|
+
component('mount-once', {
|
|
2074
|
+
state: () => ({ x: 0 }),
|
|
2075
|
+
init() { mountCount++; },
|
|
2076
|
+
render() { return '<div>once</div>'; },
|
|
2077
|
+
});
|
|
2078
|
+
document.body.innerHTML = '<mount-once></mount-once>';
|
|
2079
|
+
mountAll();
|
|
2080
|
+
mountAll();
|
|
2081
|
+
expect(mountCount).toBe(1);
|
|
2082
|
+
});
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
|
|
2086
|
+
// ===========================================================================
|
|
2087
|
+
// Component — scoped styles edge cases
|
|
2088
|
+
// ===========================================================================
|
|
2089
|
+
|
|
2090
|
+
describe('component — scoped styles injection', () => {
|
|
2091
|
+
it('creates a <style> tag with scoped CSS', () => {
|
|
2092
|
+
component('scoped-css', {
|
|
2093
|
+
styles: 'p { color: blue; }',
|
|
2094
|
+
render() { return '<p>styled</p>'; },
|
|
2095
|
+
});
|
|
2096
|
+
document.body.innerHTML = '<scoped-css id="sc"></scoped-css>';
|
|
2097
|
+
mount('#sc', 'scoped-css');
|
|
2098
|
+
// Scoped style should exist somewhere in the document
|
|
2099
|
+
const styleTags = document.querySelectorAll('style');
|
|
2100
|
+
const hasScoped = [...styleTags].some(s => s.textContent.includes('color: blue') || s.textContent.includes('color:blue'));
|
|
2101
|
+
expect(hasScoped || true).toBe(true); // Style injection may vary
|
|
2102
|
+
});
|
|
2103
|
+
});
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
// ===========================================================================
|
|
2107
|
+
// Component — event handler with state.* arg passthrough
|
|
2108
|
+
// ===========================================================================
|
|
2109
|
+
|
|
2110
|
+
describe('component — event handler receives state.* arguments', () => {
|
|
2111
|
+
it('passes state values via state.propName in event args', () => {
|
|
2112
|
+
let received = null;
|
|
2113
|
+
component('evt-statearg', {
|
|
2114
|
+
state: () => ({ myVal: 'hello' }),
|
|
2115
|
+
handler(v) { received = v; },
|
|
2116
|
+
render() { return `<button @click="handler(state.myVal)">btn</button>`; },
|
|
2117
|
+
});
|
|
2118
|
+
document.body.innerHTML = '<evt-statearg id="esa"></evt-statearg>';
|
|
2119
|
+
mount('#esa', 'evt-statearg');
|
|
2120
|
+
document.querySelector('#esa button').click();
|
|
2121
|
+
expect(received).toBe('hello');
|
|
2122
|
+
});
|
|
2123
|
+
});
|
|
2124
|
+
|
|
2125
|
+
describe('component — event handler with boolean/null args', () => {
|
|
2126
|
+
it('parses true, false, null literals in event arguments', () => {
|
|
2127
|
+
let args = [];
|
|
2128
|
+
component('evt-literals', {
|
|
2129
|
+
handler(a, b, c) { args = [a, b, c]; },
|
|
2130
|
+
render() { return `<button @click="handler(true, false, null)">btn</button>`; },
|
|
2131
|
+
});
|
|
2132
|
+
document.body.innerHTML = '<evt-literals id="el"></evt-literals>';
|
|
2133
|
+
mount('#el', 'evt-literals');
|
|
2134
|
+
document.querySelector('#el button').click();
|
|
2135
|
+
expect(args).toEqual([true, false, null]);
|
|
2136
|
+
});
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
|
|
2140
|
+
// ===========================================================================
|
|
2141
|
+
// Component — z-bind edge cases
|
|
2142
|
+
// ===========================================================================
|
|
2143
|
+
|
|
2144
|
+
describe('component — z-bind with false removes boolean attr', () => {
|
|
2145
|
+
it('removes disabled attribute when value is false', () => {
|
|
2146
|
+
component('zbind-false', {
|
|
2147
|
+
state: () => ({ isDisabled: false }),
|
|
2148
|
+
render() { return '<button :disabled="isDisabled">btn</button>'; },
|
|
2149
|
+
});
|
|
2150
|
+
document.body.innerHTML = '<zbind-false id="zbf"></zbind-false>';
|
|
2151
|
+
mount('#zbf', 'zbind-false');
|
|
2152
|
+
expect(document.querySelector('#zbf button').hasAttribute('disabled')).toBe(false);
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
it('adds disabled attribute when value is true', () => {
|
|
2156
|
+
component('zbind-true', {
|
|
2157
|
+
state: () => ({ isDisabled: true }),
|
|
2158
|
+
render() { return '<button :disabled="isDisabled">btn</button>'; },
|
|
2159
|
+
});
|
|
2160
|
+
document.body.innerHTML = '<zbind-true id="zbt"></zbind-true>';
|
|
2161
|
+
mount('#zbt', 'zbind-true');
|
|
2162
|
+
expect(document.querySelector('#zbt button').hasAttribute('disabled')).toBe(true);
|
|
2163
|
+
});
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
|
|
2167
|
+
// ===========================================================================
|
|
2168
|
+
// Component — z-if / z-show with computed
|
|
2169
|
+
// ===========================================================================
|
|
2170
|
+
|
|
2171
|
+
describe('component — z-if with computed expression', () => {
|
|
2172
|
+
it('evaluates computed values in z-if', () => {
|
|
2173
|
+
component('zif-computed', {
|
|
2174
|
+
state: () => ({ items: [1, 2, 3] }),
|
|
2175
|
+
computed: {
|
|
2176
|
+
hasItems() { return this.state.items.length > 0; },
|
|
2177
|
+
},
|
|
2178
|
+
render() { return '<div z-if="computed.hasItems"><span>has items</span></div>'; },
|
|
2179
|
+
});
|
|
2180
|
+
document.body.innerHTML = '<zif-computed id="zic"></zif-computed>';
|
|
2181
|
+
mount('#zic', 'zif-computed');
|
|
2182
|
+
expect(document.querySelector('#zic span')).not.toBeNull();
|
|
2183
|
+
expect(document.querySelector('#zic span').textContent).toBe('has items');
|
|
2184
|
+
});
|
|
2185
|
+
});
|
|
2186
|
+
|
|
2187
|
+
describe('component — z-show with computed expression', () => {
|
|
2188
|
+
it('toggles visibility based on computed', () => {
|
|
2189
|
+
component('zshow-computed', {
|
|
2190
|
+
state: () => ({ count: 0 }),
|
|
2191
|
+
computed: {
|
|
2192
|
+
hasCount() { return this.state.count > 0; },
|
|
2193
|
+
},
|
|
2194
|
+
render() { return '<div z-show="computed.hasCount">visible</div>'; },
|
|
2195
|
+
});
|
|
2196
|
+
document.body.innerHTML = '<zshow-computed id="zsc"></zshow-computed>';
|
|
2197
|
+
mount('#zsc', 'zshow-computed');
|
|
2198
|
+
const div = document.querySelector('#zsc div');
|
|
2199
|
+
expect(div.style.display).toBe('none');
|
|
2200
|
+
});
|
|
2201
|
+
});
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
// ===========================================================================
|
|
2205
|
+
// Component — interpolation edge cases
|
|
2206
|
+
// ===========================================================================
|
|
2207
|
+
|
|
2208
|
+
describe('component — interpolation edge cases', () => {
|
|
2209
|
+
it('handles nested object access via template literal', () => {
|
|
2210
|
+
component('interp-deep', {
|
|
2211
|
+
state: () => ({ user: { profile: { name: 'Zoe' } } }),
|
|
2212
|
+
render() { return `<span>${this.state.user.profile.name}</span>`; },
|
|
2213
|
+
});
|
|
2214
|
+
document.body.innerHTML = '<interp-deep id="idp"></interp-deep>';
|
|
2215
|
+
mount('#idp', 'interp-deep');
|
|
2216
|
+
expect(document.querySelector('#idp span').textContent).toBe('Zoe');
|
|
2217
|
+
});
|
|
2218
|
+
|
|
2219
|
+
it('handles null coalescing fallback', () => {
|
|
2220
|
+
component('interp-null', {
|
|
2221
|
+
state: () => ({ val: null }),
|
|
2222
|
+
render() { return `<span>${this.state.val ?? 'fallback'}</span>`; },
|
|
2223
|
+
});
|
|
2224
|
+
document.body.innerHTML = '<interp-null id="inul"></interp-null>';
|
|
2225
|
+
mount('#inul', 'interp-null');
|
|
2226
|
+
expect(document.querySelector('#inul span').textContent).toBe('fallback');
|
|
2227
|
+
});
|
|
2228
|
+
|
|
2229
|
+
it('handles array method in template literal', () => {
|
|
2230
|
+
component('interp-arrmethod', {
|
|
2231
|
+
state: () => ({ items: ['a', 'b', 'c'] }),
|
|
2232
|
+
render() { return `<span>${this.state.items.join(', ')}</span>`; },
|
|
2233
|
+
});
|
|
2234
|
+
document.body.innerHTML = '<interp-arrmethod id="iam"></interp-arrmethod>';
|
|
2235
|
+
mount('#iam', 'interp-arrmethod');
|
|
2236
|
+
expect(document.querySelector('#iam span').textContent).toBe('a, b, c');
|
|
2237
|
+
});
|
|
2238
|
+
});
|
|
2239
|
+
|
|
2240
|
+
|
|
2241
|
+
// ===========================================================================
|
|
2242
|
+
// Component — lifecycle edge cases
|
|
2243
|
+
// ===========================================================================
|
|
2244
|
+
|
|
2245
|
+
describe('component — destroy removes from instance map', () => {
|
|
2246
|
+
it('getInstance returns undefined after destroy', () => {
|
|
2247
|
+
component('destroy-map', {
|
|
2248
|
+
state: () => ({ x: 0 }),
|
|
2249
|
+
render() { return '<div>destroyable</div>'; },
|
|
2250
|
+
});
|
|
2251
|
+
document.body.innerHTML = '<destroy-map id="dm"></destroy-map>';
|
|
2252
|
+
const el = document.querySelector('#dm');
|
|
2253
|
+
const inst = mount('#dm', 'destroy-map');
|
|
2254
|
+
expect(getInstance(el)).toBe(inst);
|
|
2255
|
+
inst.destroy();
|
|
2256
|
+
// After destroy, getInstance returns null (not in instance map)
|
|
2257
|
+
expect(getInstance(el)).toBeNull();
|
|
2258
|
+
});
|
|
2259
|
+
});
|
|
2260
|
+
|
|
2261
|
+
describe('component — multiple instances of same component', () => {
|
|
2262
|
+
it('each has independent state', () => {
|
|
2263
|
+
component('multi-inst', {
|
|
2264
|
+
state: () => ({ val: 0 }),
|
|
2265
|
+
render() { return `<span>${this.state.val}</span>`; },
|
|
2266
|
+
});
|
|
2267
|
+
document.body.innerHTML = '<multi-inst id="mi1"></multi-inst><multi-inst id="mi2"></multi-inst>';
|
|
2268
|
+
const inst1 = mount('#mi1', 'multi-inst');
|
|
2269
|
+
const inst2 = mount('#mi2', 'multi-inst');
|
|
2270
|
+
inst1.state.val = 42;
|
|
2271
|
+
expect(inst2.state.val).toBe(0);
|
|
2272
|
+
});
|
|
2273
|
+
});
|