zero-query 0.9.0 → 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.
@@ -647,3 +647,1190 @@ describe('Router — edge cases', () => {
647
647
  expect(r).toBeDefined();
648
648
  });
649
649
  });
650
+
651
+
652
+ // ---------------------------------------------------------------------------
653
+ // Route matching priority (first match wins)
654
+ // ---------------------------------------------------------------------------
655
+
656
+ describe('Router — route matching priority', () => {
657
+ it('first matching route wins', () => {
658
+ const router = createRouter({
659
+ mode: 'hash',
660
+ routes: [
661
+ { path: '/test', component: 'home-page' },
662
+ { path: '/test', component: 'about-page' },
663
+ ],
664
+ });
665
+ // The first route with path '/test' should be matched
666
+ const matched = router._routes[0];
667
+ expect(matched._regex.test('/test')).toBe(true);
668
+ expect(matched.component).toBe('home-page');
669
+ });
670
+
671
+ it('specific route takes priority over wildcard', () => {
672
+ const router = createRouter({
673
+ mode: 'hash',
674
+ routes: [
675
+ { path: '/about', component: 'about-page' },
676
+ { path: '*', component: 'home-page' },
677
+ ],
678
+ });
679
+ // /about should match the first route, not wildcard
680
+ expect(router._routes[0]._regex.test('/about')).toBe(true);
681
+ expect(router._routes[0].component).toBe('about-page');
682
+ });
683
+
684
+ it('parameterized routes match correctly', () => {
685
+ const router = createRouter({
686
+ mode: 'hash',
687
+ routes: [
688
+ { path: '/user/:id', component: 'user-page' },
689
+ { path: '/user/settings', component: 'about-page' },
690
+ ],
691
+ });
692
+ // /user/42 should match parameterized route
693
+ expect(router._routes[0]._regex.test('/user/42')).toBe(true);
694
+ // /user/settings matches first (since :id catches "settings")
695
+ expect(router._routes[0]._regex.test('/user/settings')).toBe(true);
696
+ });
697
+ });
698
+
699
+
700
+ // ---------------------------------------------------------------------------
701
+ // Route removal
702
+ // ---------------------------------------------------------------------------
703
+
704
+ describe('Router — route removal', () => {
705
+ it('remove() deletes the matching route', () => {
706
+ const router = createRouter({
707
+ mode: 'hash',
708
+ routes: [
709
+ { path: '/', component: 'home-page' },
710
+ { path: '/about', component: 'about-page' },
711
+ ],
712
+ });
713
+ expect(router._routes.length).toBe(2);
714
+ router.remove('/about');
715
+ expect(router._routes.length).toBe(1);
716
+ expect(router._routes.find(r => r.path === '/about')).toBeUndefined();
717
+ });
718
+
719
+ it('remove() on non-existent path is a no-op', () => {
720
+ const router = createRouter({
721
+ mode: 'hash',
722
+ routes: [{ path: '/', component: 'home-page' }],
723
+ });
724
+ router.remove('/nonexistent');
725
+ expect(router._routes.length).toBe(1);
726
+ });
727
+
728
+ it('remove() returns the router for chaining', () => {
729
+ const router = createRouter({
730
+ mode: 'hash',
731
+ routes: [{ path: '/', component: 'home-page' }],
732
+ });
733
+ const result = router.remove('/');
734
+ expect(result).toBe(router);
735
+ });
736
+ });
737
+
738
+
739
+ // ---------------------------------------------------------------------------
740
+ // Dynamic route addition
741
+ // ---------------------------------------------------------------------------
742
+
743
+ describe('Router — dynamic route addition', () => {
744
+ it('add() returns the router for chaining', () => {
745
+ const router = createRouter({ mode: 'hash', routes: [] });
746
+ const result = router.add({ path: '/new', component: 'home-page' });
747
+ expect(result).toBe(router);
748
+ expect(router._routes.length).toBe(1);
749
+ });
750
+
751
+ it('add() compiles regex for parameterized routes', () => {
752
+ const router = createRouter({ mode: 'hash', routes: [] });
753
+ router.add({ path: '/item/:id/detail/:section', component: 'home-page' });
754
+ const route = router._routes[0];
755
+ expect(route._regex.test('/item/42/detail/overview')).toBe(true);
756
+ expect(route._keys).toEqual(['id', 'section']);
757
+ });
758
+
759
+ it('add() with fallback creates two routes', () => {
760
+ const router = createRouter({ mode: 'hash', routes: [] });
761
+ router.add({ path: '/docs/:page', fallback: '/docs', component: 'docs-page' });
762
+ expect(router._routes.length).toBe(2);
763
+ expect(router._routes[0]._regex.test('/docs/intro')).toBe(true);
764
+ expect(router._routes[1]._regex.test('/docs')).toBe(true);
765
+ });
766
+ });
767
+
768
+
769
+ // ---------------------------------------------------------------------------
770
+ // Navigation chaining
771
+ // ---------------------------------------------------------------------------
772
+
773
+ describe('Router — navigation chaining', () => {
774
+ let router;
775
+ beforeEach(() => {
776
+ document.body.innerHTML = '<div id="app"></div>';
777
+ window.location.hash = '#/';
778
+ router = createRouter({
779
+ el: '#app',
780
+ mode: 'hash',
781
+ routes: [
782
+ { path: '/', component: 'home-page' },
783
+ { path: '/about', component: 'about-page' },
784
+ ],
785
+ });
786
+ });
787
+
788
+ it('navigate returns the router for chaining', () => {
789
+ const result = router.navigate('/about');
790
+ expect(result).toBe(router);
791
+ });
792
+
793
+ it('replace returns the router for chaining', () => {
794
+ const result = router.replace('/about');
795
+ expect(result).toBe(router);
796
+ });
797
+
798
+ it('back() returns the router', () => {
799
+ expect(router.back()).toBe(router);
800
+ });
801
+
802
+ it('forward() returns the router', () => {
803
+ expect(router.forward()).toBe(router);
804
+ });
805
+
806
+ it('go() returns the router', () => {
807
+ expect(router.go(0)).toBe(router);
808
+ });
809
+ });
810
+
811
+
812
+ // ---------------------------------------------------------------------------
813
+ // Hash mode path parsing
814
+ // ---------------------------------------------------------------------------
815
+
816
+ describe('Router — hash mode path parsing', () => {
817
+ let router;
818
+ beforeEach(() => {
819
+ router = createRouter({
820
+ mode: 'hash',
821
+ routes: [{ path: '/', component: 'home-page' }],
822
+ });
823
+ });
824
+
825
+ it('path returns / when hash is empty', () => {
826
+ window.location.hash = '';
827
+ expect(router.path).toBe('/');
828
+ });
829
+
830
+ it('path returns correct value from hash', () => {
831
+ window.location.hash = '#/about';
832
+ expect(router.path).toBe('/about');
833
+ });
834
+
835
+ it('path returns / when hash is just #/', () => {
836
+ window.location.hash = '#/';
837
+ expect(router.path).toBe('/');
838
+ });
839
+ });
840
+
841
+
842
+ // ---------------------------------------------------------------------------
843
+ // History mode path handling with base
844
+ // ---------------------------------------------------------------------------
845
+
846
+ describe('Router — history mode with base path', () => {
847
+ it('resolve includes base prefix', () => {
848
+ const router = createRouter({
849
+ mode: 'history',
850
+ base: '/myapp',
851
+ routes: [],
852
+ });
853
+ expect(router.resolve('/page')).toBe('/myapp/page');
854
+ expect(router.resolve('/')).toBe('/myapp/');
855
+ });
856
+
857
+ it('_normalizePath strips double base prefix', () => {
858
+ const router = createRouter({
859
+ mode: 'history',
860
+ base: '/myapp',
861
+ routes: [],
862
+ });
863
+ // If someone accidentally includes the base
864
+ expect(router._normalizePath('/myapp/page')).toBe('/page');
865
+ });
866
+
867
+ it('base without leading slash gets normalized', () => {
868
+ const router = createRouter({
869
+ mode: 'history',
870
+ base: 'app',
871
+ routes: [],
872
+ });
873
+ expect(router.base).toBe('/app');
874
+ });
875
+ });
876
+
877
+
878
+ // ---------------------------------------------------------------------------
879
+ // Navigate with query strings in hash mode
880
+ // ---------------------------------------------------------------------------
881
+
882
+ describe('Router — query string in hash mode', () => {
883
+ let router;
884
+ beforeEach(() => {
885
+ document.body.innerHTML = '<div id="app"></div>';
886
+ window.location.hash = '#/';
887
+ router = createRouter({
888
+ el: '#app',
889
+ mode: 'hash',
890
+ routes: [
891
+ { path: '/', component: 'home-page' },
892
+ { path: '/search', component: 'about-page' },
893
+ ],
894
+ });
895
+ });
896
+
897
+ it('navigate preserves path for query routing', () => {
898
+ router.navigate('/search');
899
+ expect(window.location.hash).toBe('#/search');
900
+ });
901
+ });
902
+
903
+
904
+ // ---------------------------------------------------------------------------
905
+ // Guard edge cases
906
+ // ---------------------------------------------------------------------------
907
+
908
+ describe('Router — guard edge cases', () => {
909
+ it('beforeEach returns the router for chaining', () => {
910
+ const router = createRouter({
911
+ mode: 'hash',
912
+ routes: [{ path: '/', component: 'home-page' }],
913
+ });
914
+ const result = router.beforeEach(() => {});
915
+ expect(result).toBe(router);
916
+ });
917
+
918
+ it('afterEach returns the router for chaining', () => {
919
+ const router = createRouter({
920
+ mode: 'hash',
921
+ routes: [{ path: '/', component: 'home-page' }],
922
+ });
923
+ const result = router.afterEach(() => {});
924
+ expect(result).toBe(router);
925
+ });
926
+
927
+ it('guard cancels navigation when returning false', async () => {
928
+ const router = createRouter({
929
+ el: '#app',
930
+ mode: 'hash',
931
+ routes: [
932
+ { path: '/', component: 'home-page' },
933
+ { path: '/blocked', component: 'about-page' },
934
+ ],
935
+ });
936
+ document.body.innerHTML = '<div id="app"></div>';
937
+ router.beforeEach(() => false);
938
+ // Manually trigger resolve for /blocked
939
+ window.location.hash = '#/blocked';
940
+ await router._resolve();
941
+ // current should not be updated to /blocked
942
+ expect(router._current === null || router._current.path !== '/blocked').toBe(true);
943
+ });
944
+
945
+ it('guard redirects to a different route', async () => {
946
+ document.body.innerHTML = '<div id="app"></div>';
947
+ const router = createRouter({
948
+ el: '#app',
949
+ mode: 'hash',
950
+ routes: [
951
+ { path: '/', component: 'home-page' },
952
+ { path: '/login', component: 'about-page' },
953
+ { path: '/dashboard', component: 'docs-page' },
954
+ ],
955
+ });
956
+ router.beforeEach((to) => {
957
+ if (to.path === '/dashboard') return '/login';
958
+ });
959
+ window.location.hash = '#/dashboard';
960
+ await router._resolve();
961
+ expect(window.location.hash).toBe('#/login');
962
+ });
963
+ });
964
+
965
+
966
+ // ---------------------------------------------------------------------------
967
+ // onChange with navigation
968
+ // ---------------------------------------------------------------------------
969
+
970
+ describe('Router — onChange fires on resolve', () => {
971
+ it('fires onChange listener after route resolution', async () => {
972
+ document.body.innerHTML = '<div id="app"></div>';
973
+ const listener = vi.fn();
974
+ const router = createRouter({
975
+ el: '#app',
976
+ mode: 'hash',
977
+ routes: [
978
+ { path: '/', component: 'home-page' },
979
+ { path: '/about', component: 'about-page' },
980
+ ],
981
+ });
982
+ router.onChange(listener);
983
+ // Wait for initial resolve
984
+ await new Promise(r => setTimeout(r, 10));
985
+ listener.mockClear();
986
+
987
+ window.location.hash = '#/about';
988
+ await router._resolve();
989
+ expect(listener).toHaveBeenCalled();
990
+ const [to, from] = listener.mock.calls[0];
991
+ expect(to.path).toBe('/about');
992
+ });
993
+ });
994
+
995
+
996
+ // ---------------------------------------------------------------------------
997
+ // Multi-param extraction
998
+ // ---------------------------------------------------------------------------
999
+
1000
+ describe('Router — multi-param extraction', () => {
1001
+ it('extracts multiple params from URL', () => {
1002
+ const router = createRouter({
1003
+ mode: 'hash',
1004
+ routes: [
1005
+ { path: '/org/:orgId/team/:teamId/member/:memberId', component: 'user-page' },
1006
+ ],
1007
+ });
1008
+ const route = router._routes[0];
1009
+ const match = '/org/acme/team/dev/member/42'.match(route._regex);
1010
+ expect(match).not.toBeNull();
1011
+ const params = {};
1012
+ route._keys.forEach((key, i) => { params[key] = match[i + 1]; });
1013
+ expect(params).toEqual({ orgId: 'acme', teamId: 'dev', memberId: '42' });
1014
+ });
1015
+ });
1016
+
1017
+
1018
+ // ---------------------------------------------------------------------------
1019
+ // Substate in hash mode
1020
+ // ---------------------------------------------------------------------------
1021
+
1022
+ describe('Router — substates hash mode', () => {
1023
+ let router, pushSpy;
1024
+
1025
+ beforeEach(() => {
1026
+ document.body.innerHTML = '<div id="app"></div>';
1027
+ window.location.hash = '#/';
1028
+ pushSpy = vi.spyOn(window.history, 'pushState');
1029
+ router = createRouter({
1030
+ el: '#app',
1031
+ mode: 'hash',
1032
+ routes: [{ path: '/', component: 'home-page' }],
1033
+ });
1034
+ pushSpy.mockClear();
1035
+ });
1036
+
1037
+ afterEach(() => {
1038
+ pushSpy.mockRestore();
1039
+ });
1040
+
1041
+ it('pushSubstate works in hash mode', () => {
1042
+ router.pushSubstate('drawer', { side: 'left' });
1043
+ expect(pushSpy).toHaveBeenCalledTimes(1);
1044
+ const state = pushSpy.mock.calls[0][0];
1045
+ expect(state.__zq).toBe('substate');
1046
+ expect(state.key).toBe('drawer');
1047
+ });
1048
+
1049
+ it('multiple substates can be pushed', () => {
1050
+ router.pushSubstate('modal', { id: 'a' });
1051
+ router.pushSubstate('modal', { id: 'b' });
1052
+ expect(pushSpy).toHaveBeenCalledTimes(2);
1053
+ });
1054
+ });
1055
+
1056
+
1057
+ // ---------------------------------------------------------------------------
1058
+ // _interpolateParams edge cases
1059
+ // ---------------------------------------------------------------------------
1060
+
1061
+ describe('Router — _interpolateParams edge cases', () => {
1062
+ let router;
1063
+ beforeEach(() => {
1064
+ router = createRouter({ mode: 'hash', routes: [] });
1065
+ });
1066
+
1067
+ it('handles special characters in param values', () => {
1068
+ expect(router._interpolateParams('/tag/:name', { name: 'c++' })).toBe('/tag/c%2B%2B');
1069
+ });
1070
+
1071
+ it('handles empty string param value', () => {
1072
+ expect(router._interpolateParams('/user/:id', { id: '' })).toBe('/user/');
1073
+ });
1074
+
1075
+ it('handles zero as param value', () => {
1076
+ expect(router._interpolateParams('/page/:num', { num: 0 })).toBe('/page/0');
1077
+ });
1078
+
1079
+ it('handles boolean param values', () => {
1080
+ expect(router._interpolateParams('/flag/:val', { val: true })).toBe('/flag/true');
1081
+ });
1082
+
1083
+ it('returns path unchanged when params is non-object', () => {
1084
+ expect(router._interpolateParams('/about', 'string')).toBe('/about');
1085
+ });
1086
+
1087
+ it('handles path with no placeholders', () => {
1088
+ expect(router._interpolateParams('/about', { id: 42 })).toBe('/about');
1089
+ });
1090
+
1091
+ it('handles adjacent params', () => {
1092
+ // This is a weird URL but should still work
1093
+ expect(router._interpolateParams('/:a/:b', { a: 'x', b: 'y' })).toBe('/x/y');
1094
+ });
1095
+ });
1096
+
1097
+
1098
+ // ---------------------------------------------------------------------------
1099
+ // Router.destroy cleans up everything
1100
+ // ---------------------------------------------------------------------------
1101
+
1102
+ describe('Router — destroy completeness', () => {
1103
+ it('clears instance, routes, guards, listeners, and substates', () => {
1104
+ document.body.innerHTML = '<div id="app"></div>';
1105
+ const router = createRouter({
1106
+ el: '#app',
1107
+ mode: 'hash',
1108
+ routes: [{ path: '/', component: 'home-page' }],
1109
+ });
1110
+ router.beforeEach(() => {});
1111
+ router.afterEach(() => {});
1112
+ router.onChange(() => {});
1113
+ router.onSubstate(() => {});
1114
+
1115
+ router.destroy();
1116
+
1117
+ expect(router._routes.length).toBe(0);
1118
+ expect(router._guards.before.length).toBe(0);
1119
+ expect(router._guards.after.length).toBe(0);
1120
+ expect(router._listeners.size).toBe(0);
1121
+ expect(router._substateListeners.length).toBe(0);
1122
+ expect(router._inSubstate).toBe(false);
1123
+ });
1124
+
1125
+ it('removes window event listeners on destroy (no leak)', () => {
1126
+ document.body.innerHTML = '<div id="app"></div>';
1127
+ const removeSpy = vi.spyOn(window, 'removeEventListener');
1128
+ const router = createRouter({
1129
+ el: '#app',
1130
+ mode: 'hash',
1131
+ routes: [{ path: '/', component: 'home-page' }],
1132
+ });
1133
+ // Store the handler reference before destroy
1134
+ const navHandler = router._onNavEvent;
1135
+ const clickHandler = router._onLinkClick;
1136
+ expect(navHandler).toBeDefined();
1137
+ expect(clickHandler).toBeDefined();
1138
+
1139
+ router.destroy();
1140
+
1141
+ expect(removeSpy).toHaveBeenCalledWith('hashchange', navHandler);
1142
+ expect(router._onNavEvent).toBeNull();
1143
+ expect(router._onLinkClick).toBeNull();
1144
+ removeSpy.mockRestore();
1145
+ });
1146
+
1147
+ it('removes popstate listener in history mode on destroy', () => {
1148
+ document.body.innerHTML = '<div id="app"></div>';
1149
+ const removeSpy = vi.spyOn(window, 'removeEventListener');
1150
+ const router = createRouter({
1151
+ el: '#app',
1152
+ mode: 'history',
1153
+ routes: [{ path: '/', component: 'home-page' }],
1154
+ });
1155
+ const navHandler = router._onNavEvent;
1156
+ router.destroy();
1157
+ expect(removeSpy).toHaveBeenCalledWith('popstate', navHandler);
1158
+ removeSpy.mockRestore();
1159
+ });
1160
+
1161
+ it('removes document click listener on destroy', () => {
1162
+ document.body.innerHTML = '<div id="app"></div>';
1163
+ const removeSpy = vi.spyOn(document, 'removeEventListener');
1164
+ const router = createRouter({
1165
+ el: '#app',
1166
+ mode: 'hash',
1167
+ routes: [],
1168
+ });
1169
+ const clickHandler = router._onLinkClick;
1170
+ router.destroy();
1171
+ expect(removeSpy).toHaveBeenCalledWith('click', clickHandler);
1172
+ removeSpy.mockRestore();
1173
+ });
1174
+ });
1175
+
1176
+
1177
+ // ---------------------------------------------------------------------------
1178
+ // PERF: same-route comparison uses shallow equality (no JSON.stringify)
1179
+ // ---------------------------------------------------------------------------
1180
+
1181
+ describe('Router — same-route shallow equality', () => {
1182
+ it('skips re-render when navigating to same route with same params', async () => {
1183
+ document.body.innerHTML = '<div id="app"></div>';
1184
+ let renderCount = 0;
1185
+ const router = createRouter({
1186
+ el: '#app',
1187
+ mode: 'hash',
1188
+ routes: [
1189
+ { path: '/user/:id', render: () => '<div>user</div>' },
1190
+ ],
1191
+ });
1192
+ // Mock component mount counting
1193
+ router.afterEach(() => { renderCount++; });
1194
+
1195
+ router.navigate('/user/42');
1196
+ await new Promise(r => setTimeout(r, 50));
1197
+ const firstCount = renderCount;
1198
+
1199
+ // Navigate to the same route — should skip
1200
+ router.navigate('/user/42');
1201
+ await new Promise(r => setTimeout(r, 50));
1202
+ // Hash mode prevents same-hash navigation at URL level,
1203
+ // so renderCount should not increase
1204
+ expect(renderCount).toBe(firstCount);
1205
+ router.destroy();
1206
+ });
1207
+ });
1208
+
1209
+
1210
+ // ===========================================================================
1211
+ // Guard — cancel navigation
1212
+ // ===========================================================================
1213
+
1214
+ describe('Router — guard returning false cancels navigation', () => {
1215
+ it('does not resolve route when guard returns false', async () => {
1216
+ document.body.innerHTML = '<div id="app"></div>';
1217
+ const router = createRouter({
1218
+ el: '#app',
1219
+ mode: 'hash',
1220
+ routes: [
1221
+ { path: '/', component: 'home-page' },
1222
+ { path: '/blocked', component: 'about-page' },
1223
+ ],
1224
+ });
1225
+ router.beforeEach(() => false);
1226
+ await new Promise(r => setTimeout(r, 10));
1227
+ router.navigate('/blocked');
1228
+ await router._resolve();
1229
+ // Should NOT have navigated to /blocked because guard cancelled
1230
+ expect(router.current?.path).not.toBe('/blocked');
1231
+ router.destroy();
1232
+ });
1233
+ });
1234
+
1235
+
1236
+ // ===========================================================================
1237
+ // Guard — redirect loop detection
1238
+ // ===========================================================================
1239
+
1240
+ describe('Router — guard redirect loop protection', () => {
1241
+ it('stops after more than 10 redirects', async () => {
1242
+ document.body.innerHTML = '<div id="app"></div>';
1243
+ const router = createRouter({
1244
+ el: '#app',
1245
+ mode: 'hash',
1246
+ routes: [
1247
+ { path: '/', component: 'home-page' },
1248
+ { path: '/a', component: 'about-page' },
1249
+ { path: '/b', component: 'docs-page' },
1250
+ ],
1251
+ });
1252
+ // Guard that keeps bouncing between /a and /b
1253
+ router.beforeEach((to) => {
1254
+ if (to.path === '/a') return '/b';
1255
+ if (to.path === '/b') return '/a';
1256
+ });
1257
+ await new Promise(r => setTimeout(r, 10));
1258
+ // Navigate to /a — should not infinite loop
1259
+ window.location.hash = '#/a';
1260
+ await router._resolve();
1261
+ // Just verify it doesn't hang — the guard count > 10 stops it
1262
+ router.destroy();
1263
+ });
1264
+ });
1265
+
1266
+
1267
+ // ===========================================================================
1268
+ // Guard — afterEach fires after resolve
1269
+ // ===========================================================================
1270
+
1271
+ describe('Router — afterEach hook', () => {
1272
+ it('fires afterEach with to and from after route resolves', async () => {
1273
+ document.body.innerHTML = '<div id="app"></div>';
1274
+ const afterFn = vi.fn();
1275
+ const router = createRouter({
1276
+ el: '#app',
1277
+ mode: 'hash',
1278
+ routes: [
1279
+ { path: '/', component: 'home-page' },
1280
+ { path: '/about', component: 'about-page' },
1281
+ ],
1282
+ });
1283
+ router.afterEach(afterFn);
1284
+ await new Promise(r => setTimeout(r, 10));
1285
+ afterFn.mockClear();
1286
+
1287
+ window.location.hash = '#/about';
1288
+ await router._resolve();
1289
+ expect(afterFn).toHaveBeenCalledTimes(1);
1290
+ expect(afterFn.mock.calls[0][0].path).toBe('/about');
1291
+ router.destroy();
1292
+ });
1293
+ });
1294
+
1295
+
1296
+ // ===========================================================================
1297
+ // Guard — before guard that throws
1298
+ // ===========================================================================
1299
+
1300
+ describe('Router — before guard that throws', () => {
1301
+ it('catches the error and does not crash', async () => {
1302
+ document.body.innerHTML = '<div id="app"></div>';
1303
+ const router = createRouter({
1304
+ el: '#app',
1305
+ mode: 'hash',
1306
+ routes: [
1307
+ { path: '/', component: 'home-page' },
1308
+ { path: '/err', component: 'about-page' },
1309
+ ],
1310
+ });
1311
+ router.beforeEach(() => { throw new Error('guard boom'); });
1312
+ await new Promise(r => setTimeout(r, 10));
1313
+ window.location.hash = '#/err';
1314
+ await expect(router._resolve()).resolves.not.toThrow();
1315
+ router.destroy();
1316
+ });
1317
+ });
1318
+
1319
+
1320
+ // ===========================================================================
1321
+ // Lazy loading via route.load
1322
+ // ===========================================================================
1323
+
1324
+ describe('Router — lazy loading with route.load', () => {
1325
+ it('calls load() before mounting component', async () => {
1326
+ const loadFn = vi.fn().mockResolvedValue(undefined);
1327
+ document.body.innerHTML = '<div id="app"></div>';
1328
+ const router = createRouter({
1329
+ el: '#app',
1330
+ mode: 'hash',
1331
+ routes: [
1332
+ { path: '/', component: 'home-page' },
1333
+ { path: '/lazy', load: loadFn, component: 'about-page' },
1334
+ ],
1335
+ });
1336
+ await new Promise(r => setTimeout(r, 10));
1337
+ window.location.hash = '#/lazy';
1338
+ await router._resolve();
1339
+ expect(loadFn).toHaveBeenCalledTimes(1);
1340
+ router.destroy();
1341
+ });
1342
+
1343
+ it('does not mount if load() rejects', async () => {
1344
+ const loadFn = vi.fn().mockRejectedValue(new Error('load fail'));
1345
+ document.body.innerHTML = '<div id="app"></div>';
1346
+ const router = createRouter({
1347
+ el: '#app',
1348
+ mode: 'hash',
1349
+ routes: [
1350
+ { path: '/', component: 'home-page' },
1351
+ { path: '/fail', load: loadFn, component: 'about-page' },
1352
+ ],
1353
+ });
1354
+ await new Promise(r => setTimeout(r, 10));
1355
+ window.location.hash = '#/fail';
1356
+ await router._resolve();
1357
+ // Route should not have resolved to /fail since load() threw
1358
+ expect(router.current?.path).not.toBe('/fail');
1359
+ router.destroy();
1360
+ });
1361
+ });
1362
+
1363
+
1364
+ // ===========================================================================
1365
+ // Fallback / 404 route
1366
+ // ===========================================================================
1367
+
1368
+ describe('Router — fallback 404 route', () => {
1369
+ it('resolves to fallback component for unknown paths', async () => {
1370
+ component('notfound-page', { render: () => '<p>404</p>' });
1371
+ document.body.innerHTML = '<div id="app"></div>';
1372
+ window.location.hash = '#/';
1373
+ const router = createRouter({
1374
+ el: '#app',
1375
+ mode: 'hash',
1376
+ routes: [{ path: '/', component: 'home-page' }],
1377
+ fallback: 'notfound-page',
1378
+ });
1379
+ await new Promise(r => setTimeout(r, 10));
1380
+ window.location.hash = '#/nonexistent';
1381
+ await router._resolve();
1382
+ expect(router.current.path).toBe('/nonexistent');
1383
+ expect(router.current.route.component).toBe('notfound-page');
1384
+ router.destroy();
1385
+ });
1386
+ });
1387
+
1388
+
1389
+ // ===========================================================================
1390
+ // replace()
1391
+ // ===========================================================================
1392
+
1393
+ describe('Router — replace()', () => {
1394
+ it('returns router for chaining in hash mode', async () => {
1395
+ document.body.innerHTML = '<div id="app"></div>';
1396
+ window.location.hash = '#/';
1397
+ const router = createRouter({
1398
+ el: '#app',
1399
+ mode: 'hash',
1400
+ routes: [
1401
+ { path: '/', component: 'home-page' },
1402
+ { path: '/replaced', component: 'about-page' },
1403
+ ],
1404
+ });
1405
+ await new Promise(r => setTimeout(r, 10));
1406
+ const result = router.replace('/replaced');
1407
+ expect(result).toBe(router);
1408
+ router.destroy();
1409
+ });
1410
+ });
1411
+
1412
+
1413
+ // ===========================================================================
1414
+ // query getter
1415
+ // ===========================================================================
1416
+
1417
+ describe('Router — query getter', () => {
1418
+ it('returns parsed query params from hash', () => {
1419
+ document.body.innerHTML = '<div id="app"></div>';
1420
+ const router = createRouter({
1421
+ el: '#app',
1422
+ mode: 'hash',
1423
+ routes: [{ path: '/search', component: 'home-page' }],
1424
+ });
1425
+ window.location.hash = '#/search?q=hello&page=2';
1426
+ expect(router.query).toEqual({ q: 'hello', page: '2' });
1427
+ router.destroy();
1428
+ });
1429
+
1430
+ it('returns empty object for no query params', () => {
1431
+ document.body.innerHTML = '<div id="app"></div>';
1432
+ const router = createRouter({
1433
+ el: '#app',
1434
+ mode: 'hash',
1435
+ routes: [{ path: '/', component: 'home-page' }],
1436
+ });
1437
+ window.location.hash = '#/';
1438
+ expect(router.query).toEqual({});
1439
+ router.destroy();
1440
+ });
1441
+ });
1442
+
1443
+
1444
+ // ===========================================================================
1445
+ // resolve() — programmatic link generation
1446
+ // ===========================================================================
1447
+
1448
+ describe('Router — resolve()', () => {
1449
+ it('returns full URL path with base prefix', () => {
1450
+ const router = createRouter({
1451
+ mode: 'hash',
1452
+ base: '/app',
1453
+ routes: [{ path: '/', component: 'home-page' }],
1454
+ });
1455
+ expect(router.resolve('/about')).toBe('/app/about');
1456
+ router.destroy();
1457
+ });
1458
+
1459
+ it('returns path as-is when no base', () => {
1460
+ const router = createRouter({
1461
+ mode: 'hash',
1462
+ routes: [{ path: '/', component: 'home-page' }],
1463
+ });
1464
+ expect(router.resolve('/about')).toBe('/about');
1465
+ router.destroy();
1466
+ });
1467
+ });
1468
+
1469
+
1470
+ // ===========================================================================
1471
+ // back/forward/go wrappers
1472
+ // ===========================================================================
1473
+
1474
+ describe('Router — back/forward/go wrappers', () => {
1475
+ it('calls window.history.back', () => {
1476
+ const spy = vi.spyOn(window.history, 'back').mockImplementation(() => {});
1477
+ const router = createRouter({
1478
+ mode: 'hash',
1479
+ routes: [{ path: '/', component: 'home-page' }],
1480
+ });
1481
+ router.back();
1482
+ expect(spy).toHaveBeenCalled();
1483
+ spy.mockRestore();
1484
+ router.destroy();
1485
+ });
1486
+
1487
+ it('calls window.history.forward', () => {
1488
+ const spy = vi.spyOn(window.history, 'forward').mockImplementation(() => {});
1489
+ const router = createRouter({
1490
+ mode: 'hash',
1491
+ routes: [{ path: '/', component: 'home-page' }],
1492
+ });
1493
+ router.forward();
1494
+ expect(spy).toHaveBeenCalled();
1495
+ spy.mockRestore();
1496
+ router.destroy();
1497
+ });
1498
+
1499
+ it('calls window.history.go with argument', () => {
1500
+ const spy = vi.spyOn(window.history, 'go').mockImplementation(() => {});
1501
+ const router = createRouter({
1502
+ mode: 'hash',
1503
+ routes: [{ path: '/', component: 'home-page' }],
1504
+ });
1505
+ router.go(-2);
1506
+ expect(spy).toHaveBeenCalledWith(-2);
1507
+ spy.mockRestore();
1508
+ router.destroy();
1509
+ });
1510
+ });
1511
+
1512
+
1513
+ // ===========================================================================
1514
+ // Link click interception — modified clicks bypass
1515
+ // ===========================================================================
1516
+
1517
+ describe('Router — link click interception', () => {
1518
+ it('intercepts normal clicks on z-link elements', async () => {
1519
+ document.body.innerHTML = '<div id="app"></div><a z-link="/about">About</a>';
1520
+ const router = createRouter({
1521
+ el: '#app',
1522
+ mode: 'hash',
1523
+ routes: [
1524
+ { path: '/', component: 'home-page' },
1525
+ { path: '/about', component: 'about-page' },
1526
+ ],
1527
+ });
1528
+ await new Promise(r => setTimeout(r, 10));
1529
+
1530
+ const link = document.querySelector('[z-link]');
1531
+ const e = new Event('click', { bubbles: true, cancelable: true });
1532
+ link.dispatchEvent(e);
1533
+ // Should have navigated
1534
+ await new Promise(r => setTimeout(r, 10));
1535
+ expect(window.location.hash).toBe('#/about');
1536
+ router.destroy();
1537
+ });
1538
+
1539
+ it('ignores clicks with meta key (does not navigate)', async () => {
1540
+ document.body.innerHTML = '<div id="app"></div><a z-link="/about">About</a>';
1541
+ window.location.hash = '#/';
1542
+ const router = createRouter({
1543
+ el: '#app',
1544
+ mode: 'hash',
1545
+ routes: [
1546
+ { path: '/', component: 'home-page' },
1547
+ { path: '/about', component: 'about-page' },
1548
+ ],
1549
+ });
1550
+ await new Promise(r => setTimeout(r, 50));
1551
+ // Record current route before meta click
1552
+ const currentBefore = router.current?.path;
1553
+
1554
+ const link = document.querySelector('[z-link]');
1555
+ const e = new MouseEvent('click', { bubbles: true, metaKey: true });
1556
+ link.dispatchEvent(e);
1557
+ await new Promise(r => setTimeout(r, 10));
1558
+ // Route should remain unchanged — meta key bypasses SPA navigation
1559
+ expect(router.current?.path).toBe(currentBefore);
1560
+ router.destroy();
1561
+ });
1562
+
1563
+ it('ignores clicks with ctrl key', async () => {
1564
+ document.body.innerHTML = '<div id="app"></div><a z-link="/about2">About</a>';
1565
+ window.location.hash = '#/';
1566
+ const router = createRouter({
1567
+ el: '#app',
1568
+ mode: 'hash',
1569
+ routes: [
1570
+ { path: '/', component: 'home-page' },
1571
+ { path: '/about2', component: 'about-page' },
1572
+ ],
1573
+ });
1574
+ await new Promise(r => setTimeout(r, 50));
1575
+ const link = document.querySelector('[z-link]');
1576
+ const e = new MouseEvent('click', { bubbles: true, ctrlKey: true });
1577
+ link.dispatchEvent(e);
1578
+ await new Promise(r => setTimeout(r, 10));
1579
+ expect(router.current?.path).not.toBe('/about2');
1580
+ router.destroy();
1581
+ });
1582
+
1583
+ it('ignores links with target=_blank', async () => {
1584
+ document.body.innerHTML = '<div id="app"></div><a z-link="/about" target="_blank">About</a>';
1585
+ window.location.hash = '#/';
1586
+ const router = createRouter({
1587
+ el: '#app',
1588
+ mode: 'hash',
1589
+ routes: [
1590
+ { path: '/', component: 'home-page' },
1591
+ { path: '/about', component: 'about-page' },
1592
+ ],
1593
+ });
1594
+ await new Promise(r => setTimeout(r, 10));
1595
+ const link = document.querySelector('[z-link]');
1596
+ link.click();
1597
+ await new Promise(r => setTimeout(r, 10));
1598
+ expect(window.location.hash).toBe('#/');
1599
+ router.destroy();
1600
+ });
1601
+ });
1602
+
1603
+
1604
+ // ===========================================================================
1605
+ // Router — remove() route
1606
+ // ===========================================================================
1607
+
1608
+ describe('Router — remove()', () => {
1609
+ it('removes route by path', () => {
1610
+ const router = createRouter({
1611
+ mode: 'hash',
1612
+ routes: [
1613
+ { path: '/', component: 'home-page' },
1614
+ { path: '/temp', component: 'about-page' },
1615
+ ],
1616
+ });
1617
+ expect(router._routes.length).toBe(2);
1618
+ router.remove('/temp');
1619
+ expect(router._routes.length).toBe(1);
1620
+ expect(router._routes[0].path).toBe('/');
1621
+ router.destroy();
1622
+ });
1623
+ });
1624
+
1625
+
1626
+ // ===========================================================================
1627
+ // Router — add() chaining
1628
+ // ===========================================================================
1629
+
1630
+ describe('Router — add() chaining', () => {
1631
+ it('supports fluent chaining of add calls', () => {
1632
+ const router = createRouter({
1633
+ mode: 'hash',
1634
+ routes: [],
1635
+ });
1636
+ const result = router.add({ path: '/', component: 'home-page' })
1637
+ .add({ path: '/about', component: 'about-page' });
1638
+ expect(result).toBe(router);
1639
+ expect(router._routes.length).toBe(2);
1640
+ router.destroy();
1641
+ });
1642
+ });
1643
+
1644
+
1645
+ // ===========================================================================
1646
+ // Router — onChange unsubscribe
1647
+ // ===========================================================================
1648
+
1649
+ describe('Router — onChange unsubscribe', () => {
1650
+ it('stops calling listener after unsubscribe', async () => {
1651
+ document.body.innerHTML = '<div id="app"></div>';
1652
+ const listener = vi.fn();
1653
+ const router = createRouter({
1654
+ el: '#app',
1655
+ mode: 'hash',
1656
+ routes: [
1657
+ { path: '/', component: 'home-page' },
1658
+ { path: '/about', component: 'about-page' },
1659
+ ],
1660
+ });
1661
+ const unsub = router.onChange(listener);
1662
+ await new Promise(r => setTimeout(r, 10));
1663
+ listener.mockClear();
1664
+
1665
+ unsub();
1666
+ window.location.hash = '#/about';
1667
+ await router._resolve();
1668
+ expect(listener).not.toHaveBeenCalled();
1669
+ router.destroy();
1670
+ });
1671
+ });
1672
+
1673
+
1674
+ // ===========================================================================
1675
+ // Router — destroy cleans up
1676
+ // ===========================================================================
1677
+
1678
+ describe('Router — destroy cleans up', () => {
1679
+ it('clears listeners, guards, and routes on destroy', () => {
1680
+ document.body.innerHTML = '<div id="app"></div>';
1681
+ const router = createRouter({
1682
+ el: '#app',
1683
+ mode: 'hash',
1684
+ routes: [{ path: '/', component: 'home-page' }],
1685
+ });
1686
+ router.beforeEach(() => {});
1687
+ router.afterEach(() => {});
1688
+ router.onChange(() => {});
1689
+ router.onSubstate(() => {});
1690
+ router.destroy();
1691
+ expect(router._routes.length).toBe(0);
1692
+ expect(router._guards.before.length).toBe(0);
1693
+ expect(router._guards.after.length).toBe(0);
1694
+ expect(router._listeners.size).toBe(0);
1695
+ expect(router._substateListeners.length).toBe(0);
1696
+ });
1697
+ });
1698
+
1699
+
1700
+ // ===========================================================================
1701
+ // Router — _interpolateParams
1702
+ // ===========================================================================
1703
+
1704
+ describe('Router — _interpolateParams', () => {
1705
+ it('replaces :param with provided values', () => {
1706
+ const router = createRouter({
1707
+ mode: 'hash',
1708
+ routes: [{ path: '/user/:id', component: 'user-page' }],
1709
+ });
1710
+ const result = router._interpolateParams('/user/:id/post/:pid', { id: 42, pid: 7 });
1711
+ expect(result).toBe('/user/42/post/7');
1712
+ router.destroy();
1713
+ });
1714
+
1715
+ it('keeps :param when value not provided', () => {
1716
+ const router = createRouter({
1717
+ mode: 'hash',
1718
+ routes: [],
1719
+ });
1720
+ const result = router._interpolateParams('/user/:id', {});
1721
+ expect(result).toBe('/user/:id');
1722
+ router.destroy();
1723
+ });
1724
+
1725
+ it('returns path unchanged when params is null', () => {
1726
+ const router = createRouter({ mode: 'hash', routes: [] });
1727
+ expect(router._interpolateParams('/test', null)).toBe('/test');
1728
+ router.destroy();
1729
+ });
1730
+ });
1731
+
1732
+
1733
+ // ===========================================================================
1734
+ // Router — _normalizePath with base stripping
1735
+ // ===========================================================================
1736
+
1737
+ describe('Router — _normalizePath', () => {
1738
+ it('strips base prefix if accidentally included', () => {
1739
+ const router = createRouter({
1740
+ mode: 'hash',
1741
+ base: '/app',
1742
+ routes: [],
1743
+ });
1744
+ expect(router._normalizePath('/app/about')).toBe('/about');
1745
+ router.destroy();
1746
+ });
1747
+
1748
+ it('returns / when path matches base exactly', () => {
1749
+ const router = createRouter({
1750
+ mode: 'hash',
1751
+ base: '/app',
1752
+ routes: [],
1753
+ });
1754
+ expect(router._normalizePath('/app')).toBe('/');
1755
+ router.destroy();
1756
+ });
1757
+
1758
+ it('adds leading slash to bare paths', () => {
1759
+ const router = createRouter({ mode: 'hash', routes: [] });
1760
+ expect(router._normalizePath('about')).toBe('/about');
1761
+ router.destroy();
1762
+ });
1763
+
1764
+ it('returns / for empty/null path', () => {
1765
+ const router = createRouter({ mode: 'hash', routes: [] });
1766
+ expect(router._normalizePath('')).toBe('/');
1767
+ expect(router._normalizePath(null)).toBe('/');
1768
+ router.destroy();
1769
+ });
1770
+ });
1771
+
1772
+
1773
+ // ===========================================================================
1774
+ // Router — navigate with options.params
1775
+ // ===========================================================================
1776
+
1777
+ describe('Router — navigate with options.params', () => {
1778
+ it('interpolates params in path', async () => {
1779
+ document.body.innerHTML = '<div id="app"></div>';
1780
+ const router = createRouter({
1781
+ el: '#app',
1782
+ mode: 'hash',
1783
+ routes: [
1784
+ { path: '/', component: 'home-page' },
1785
+ { path: '/user/:id', component: 'user-page' },
1786
+ ],
1787
+ });
1788
+ await new Promise(r => setTimeout(r, 10));
1789
+ router.navigate('/user/:id', { params: { id: '99' } });
1790
+ await router._resolve();
1791
+ expect(window.location.hash).toBe('#/user/99');
1792
+ router.destroy();
1793
+ });
1794
+ });
1795
+
1796
+
1797
+ // ===========================================================================
1798
+ // Router — render function components
1799
+ // ===========================================================================
1800
+
1801
+ describe('Router — render function component', () => {
1802
+ it('renders HTML from a function component', async () => {
1803
+ document.body.innerHTML = '<div id="app"></div>';
1804
+ window.location.hash = '#/';
1805
+ const router = createRouter({
1806
+ el: '#app',
1807
+ mode: 'hash',
1808
+ routes: [
1809
+ { path: '/', component: (route) => `<p>fn: ${route.path}</p>` },
1810
+ ],
1811
+ });
1812
+ // Wait for initial resolve (queueMicrotask + rendering)
1813
+ await new Promise(r => setTimeout(r, 100));
1814
+ const p = document.querySelector('#app p');
1815
+ expect(p).not.toBeNull();
1816
+ expect(p.textContent).toBe('fn: /');
1817
+ router.destroy();
1818
+ });
1819
+ });
1820
+
1821
+
1822
+ // ===========================================================================
1823
+ // Router — substate onSubstate unsubscribe
1824
+ // ===========================================================================
1825
+
1826
+ describe('Router — onSubstate unsubscribe', () => {
1827
+ it('removes listener after unsubscribe', () => {
1828
+ const router = createRouter({ mode: 'hash', routes: [] });
1829
+ const fn = vi.fn();
1830
+ const unsub = router.onSubstate(fn);
1831
+ expect(router._substateListeners.length).toBe(1);
1832
+ unsub();
1833
+ expect(router._substateListeners.length).toBe(0);
1834
+ router.destroy();
1835
+ });
1836
+ });