diff --git a/app/lib/__tests__/slug-utils.test.ts b/app/lib/__tests__/slug-utils.test.ts new file mode 100644 index 0000000..7f29618 --- /dev/null +++ b/app/lib/__tests__/slug-utils.test.ts @@ -0,0 +1,520 @@ +import { describe, it, expect } from 'vitest'; +import { matchSetNameToSlugParts } from '../slug-matching'; + +describe('matchSetNameToSlugParts', () => { + describe('homewall sets - full names (Auxiliary/Mainline)', () => { + describe('Auxiliary Kickboard', () => { + it('should match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', ['aux-kicker'])).toBe(true); + }); + + it('should NOT match aux slug (kickboard sets only match -kicker)', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', ['aux'])).toBe(false); + }); + + it('should NOT match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', ['main-kicker'])).toBe(false); + }); + + it('should NOT match main slug', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', ['main'])).toBe(false); + }); + }); + + describe('Mainline Kickboard', () => { + it('should match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', ['main-kicker'])).toBe(true); + }); + + it('should NOT match main slug (kickboard sets only match -kicker)', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', ['main'])).toBe(false); + }); + + it('should NOT match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', ['aux-kicker'])).toBe(false); + }); + + it('should NOT match aux slug', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', ['aux'])).toBe(false); + }); + }); + + describe('Auxiliary (standalone)', () => { + it('should match aux slug', () => { + expect(matchSetNameToSlugParts('Auxiliary', ['aux'])).toBe(true); + }); + + it('should NOT match aux-kicker slug (non-kickboard sets only match plain slug)', () => { + expect(matchSetNameToSlugParts('Auxiliary', ['aux-kicker'])).toBe(false); + }); + + it('should NOT match main slug', () => { + expect(matchSetNameToSlugParts('Auxiliary', ['main'])).toBe(false); + }); + + it('should NOT match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Auxiliary', ['main-kicker'])).toBe(false); + }); + }); + + describe('Mainline (standalone)', () => { + it('should match main slug', () => { + expect(matchSetNameToSlugParts('Mainline', ['main'])).toBe(true); + }); + + it('should NOT match main-kicker slug (non-kickboard sets only match plain slug)', () => { + expect(matchSetNameToSlugParts('Mainline', ['main-kicker'])).toBe(false); + }); + + it('should NOT match aux slug', () => { + expect(matchSetNameToSlugParts('Mainline', ['aux'])).toBe(false); + }); + + it('should NOT match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Mainline', ['aux-kicker'])).toBe(false); + }); + }); + }); + + describe('homewall sets - abbreviated names (Aux/Main)', () => { + describe('Aux Kickboard', () => { + it('should match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', ['aux-kicker'])).toBe(true); + }); + + it('should NOT match aux slug', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', ['aux'])).toBe(false); + }); + }); + + describe('Main Kickboard', () => { + it('should match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Main Kickboard', ['main-kicker'])).toBe(true); + }); + + it('should NOT match main slug', () => { + expect(matchSetNameToSlugParts('Main Kickboard', ['main'])).toBe(false); + }); + }); + + describe('Aux (standalone)', () => { + it('should match aux slug', () => { + expect(matchSetNameToSlugParts('Aux', ['aux'])).toBe(true); + }); + + it('should NOT match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Aux', ['aux-kicker'])).toBe(false); + }); + }); + + describe('Main (standalone)', () => { + it('should match main slug', () => { + expect(matchSetNameToSlugParts('Main', ['main'])).toBe(true); + }); + + it('should NOT match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Main', ['main-kicker'])).toBe(false); + }); + }); + }); + + describe('homewall sets - "kicker" naming variant (used in some sizes like 10x12)', () => { + describe('Aux Kicker (without "board")', () => { + it('should match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Aux Kicker', ['aux-kicker'])).toBe(true); + }); + + it('should NOT match aux slug', () => { + expect(matchSetNameToSlugParts('Aux Kicker', ['aux'])).toBe(false); + }); + }); + + describe('Main Kicker (without "board")', () => { + it('should match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Main Kicker', ['main-kicker'])).toBe(true); + }); + + it('should NOT match main slug', () => { + expect(matchSetNameToSlugParts('Main Kicker', ['main'])).toBe(false); + }); + }); + + describe('Auxiliary Kicker (full name without "board")', () => { + it('should match aux-kicker slug', () => { + expect(matchSetNameToSlugParts('Auxiliary Kicker', ['aux-kicker'])).toBe(true); + }); + + it('should NOT match aux slug', () => { + expect(matchSetNameToSlugParts('Auxiliary Kicker', ['aux'])).toBe(false); + }); + }); + + describe('Mainline Kicker (full name without "board")', () => { + it('should match main-kicker slug', () => { + expect(matchSetNameToSlugParts('Mainline Kicker', ['main-kicker'])).toBe(true); + }); + + it('should NOT match main slug', () => { + expect(matchSetNameToSlugParts('Mainline Kicker', ['main'])).toBe(false); + }); + }); + + describe('10x12 full ride with kicker naming', () => { + const fullRideSlugParts = ['main-kicker', 'main', 'aux-kicker', 'aux']; + + it('should match Aux Kicker to aux-kicker', () => { + expect(matchSetNameToSlugParts('Aux Kicker', fullRideSlugParts)).toBe(true); + }); + + it('should match Main Kicker to main-kicker', () => { + expect(matchSetNameToSlugParts('Main Kicker', fullRideSlugParts)).toBe(true); + }); + + it('should match Aux to aux', () => { + expect(matchSetNameToSlugParts('Aux', fullRideSlugParts)).toBe(true); + }); + + it('should match Main to main', () => { + expect(matchSetNameToSlugParts('Main', fullRideSlugParts)).toBe(true); + }); + }); + }); + + describe('homewall full ride - all four sets with full slug', () => { + const fullRideSlugParts = ['main-kicker', 'main', 'aux-kicker', 'aux']; + + it('should match Auxiliary Kickboard to aux-kicker', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', fullRideSlugParts)).toBe(true); + }); + + it('should match Mainline Kickboard to main-kicker', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', fullRideSlugParts)).toBe(true); + }); + + it('should match Auxiliary to aux', () => { + expect(matchSetNameToSlugParts('Auxiliary', fullRideSlugParts)).toBe(true); + }); + + it('should match Mainline to main', () => { + expect(matchSetNameToSlugParts('Mainline', fullRideSlugParts)).toBe(true); + }); + + it('should match Aux Kickboard to aux-kicker', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', fullRideSlugParts)).toBe(true); + }); + + it('should match Main Kickboard to main-kicker', () => { + expect(matchSetNameToSlugParts('Main Kickboard', fullRideSlugParts)).toBe(true); + }); + + it('should match Aux to aux', () => { + expect(matchSetNameToSlugParts('Aux', fullRideSlugParts)).toBe(true); + }); + + it('should match Main to main', () => { + expect(matchSetNameToSlugParts('Main', fullRideSlugParts)).toBe(true); + }); + }); + + describe('homewall partial selections - critical bug fix scenarios', () => { + describe('selecting only aux (not aux-kicker)', () => { + const slugParts = ['aux']; + + it('should match Auxiliary', () => { + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(true); + }); + + it('should match Aux', () => { + expect(matchSetNameToSlugParts('Aux', slugParts)).toBe(true); + }); + + it('should NOT match Auxiliary Kickboard', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(false); + }); + + it('should NOT match Aux Kickboard', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', slugParts)).toBe(false); + }); + }); + + describe('selecting only main (not main-kicker)', () => { + const slugParts = ['main']; + + it('should match Mainline', () => { + expect(matchSetNameToSlugParts('Mainline', slugParts)).toBe(true); + }); + + it('should match Main', () => { + expect(matchSetNameToSlugParts('Main', slugParts)).toBe(true); + }); + + it('should NOT match Mainline Kickboard', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', slugParts)).toBe(false); + }); + + it('should NOT match Main Kickboard', () => { + expect(matchSetNameToSlugParts('Main Kickboard', slugParts)).toBe(false); + }); + }); + + describe('selecting only aux-kicker (not aux)', () => { + const slugParts = ['aux-kicker']; + + it('should match Auxiliary Kickboard', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(true); + }); + + it('should match Aux Kickboard', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', slugParts)).toBe(true); + }); + + it('should NOT match Auxiliary', () => { + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(false); + }); + + it('should NOT match Aux', () => { + expect(matchSetNameToSlugParts('Aux', slugParts)).toBe(false); + }); + }); + + describe('selecting only main-kicker (not main)', () => { + const slugParts = ['main-kicker']; + + it('should match Mainline Kickboard', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', slugParts)).toBe(true); + }); + + it('should match Main Kickboard', () => { + expect(matchSetNameToSlugParts('Main Kickboard', slugParts)).toBe(true); + }); + + it('should NOT match Mainline', () => { + expect(matchSetNameToSlugParts('Mainline', slugParts)).toBe(false); + }); + + it('should NOT match Main', () => { + expect(matchSetNameToSlugParts('Main', slugParts)).toBe(false); + }); + }); + + describe('selecting aux + main-kicker + main (no aux-kicker)', () => { + const slugParts = ['main-kicker', 'main', 'aux']; + + it('should match Auxiliary', () => { + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(true); + }); + + it('should match Mainline Kickboard', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', slugParts)).toBe(true); + }); + + it('should match Mainline', () => { + expect(matchSetNameToSlugParts('Mainline', slugParts)).toBe(true); + }); + + it('should NOT match Auxiliary Kickboard', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(false); + }); + }); + }); + + describe('case insensitivity', () => { + it('should match lowercase auxiliary kickboard', () => { + expect(matchSetNameToSlugParts('auxiliary kickboard', ['aux-kicker'])).toBe(true); + }); + + it('should match uppercase AUXILIARY KICKBOARD', () => { + expect(matchSetNameToSlugParts('AUXILIARY KICKBOARD', ['aux-kicker'])).toBe(true); + }); + + it('should match mixed case AuXiLiArY KiCkBoArD', () => { + expect(matchSetNameToSlugParts('AuXiLiArY KiCkBoArD', ['aux-kicker'])).toBe(true); + }); + + it('should match lowercase aux', () => { + expect(matchSetNameToSlugParts('aux', ['aux'])).toBe(true); + }); + + it('should match uppercase AUX', () => { + expect(matchSetNameToSlugParts('AUX', ['aux'])).toBe(true); + }); + }); + + describe('whitespace handling', () => { + it('should handle leading whitespace', () => { + expect(matchSetNameToSlugParts(' Auxiliary Kickboard', ['aux-kicker'])).toBe(true); + }); + + it('should handle trailing whitespace', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard ', ['aux-kicker'])).toBe(true); + }); + + it('should handle both leading and trailing whitespace', () => { + expect(matchSetNameToSlugParts(' Auxiliary ', ['aux'])).toBe(true); + }); + }); + + describe('original kilter/tension sets', () => { + describe('Bolt Ons', () => { + it('should match bolt slug', () => { + expect(matchSetNameToSlugParts('Bolt Ons', ['bolt'])).toBe(true); + }); + + it('should NOT match screw slug', () => { + expect(matchSetNameToSlugParts('Bolt Ons', ['screw'])).toBe(false); + }); + }); + + describe('Screw Ons', () => { + it('should match screw slug', () => { + expect(matchSetNameToSlugParts('Screw Ons', ['screw'])).toBe(true); + }); + + it('should NOT match bolt slug', () => { + expect(matchSetNameToSlugParts('Screw Ons', ['bolt'])).toBe(false); + }); + }); + + describe('bolt and screw together', () => { + const slugParts = ['screw', 'bolt']; + + it('should match Bolt Ons', () => { + expect(matchSetNameToSlugParts('Bolt Ons', slugParts)).toBe(true); + }); + + it('should match Screw Ons', () => { + expect(matchSetNameToSlugParts('Screw Ons', slugParts)).toBe(true); + }); + + it('should match bolt on (singular)', () => { + expect(matchSetNameToSlugParts('Bolt On', slugParts)).toBe(true); + }); + + it('should match screw on (singular)', () => { + expect(matchSetNameToSlugParts('Screw On', slugParts)).toBe(true); + }); + }); + }); + + describe('generic set names (fallback matching)', () => { + it('should match exact slug', () => { + expect(matchSetNameToSlugParts('Custom Set', ['custom-set'])).toBe(true); + }); + + it('should handle spaces converted to hyphens', () => { + expect(matchSetNameToSlugParts('My Custom Set', ['my-custom-set'])).toBe(true); + }); + + it('should NOT match partial slugs', () => { + expect(matchSetNameToSlugParts('Custom Set', ['custom'])).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should return false for empty slug parts', () => { + expect(matchSetNameToSlugParts('Auxiliary', [])).toBe(false); + }); + + it('should return false for unmatched set name', () => { + expect(matchSetNameToSlugParts('Unknown Set', ['aux', 'main'])).toBe(false); + }); + + it('should handle set names with numbers', () => { + expect(matchSetNameToSlugParts('Set 1', ['set-1'])).toBe(true); + }); + }); + + describe('mutual exclusivity - ensuring kickboard vs non-kickboard matching', () => { + describe('aux-kicker slug should ONLY match kickboard sets', () => { + const slugParts = ['aux-kicker']; + + it('should match Auxiliary Kickboard', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(true); + }); + + it('should NOT match Auxiliary (no kickboard)', () => { + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(false); + }); + + it('should NOT match Aux (no kickboard)', () => { + expect(matchSetNameToSlugParts('Aux', slugParts)).toBe(false); + }); + }); + + describe('aux slug should ONLY match non-kickboard sets', () => { + const slugParts = ['aux']; + + it('should match Auxiliary', () => { + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(true); + }); + + it('should match Aux', () => { + expect(matchSetNameToSlugParts('Aux', slugParts)).toBe(true); + }); + + it('should NOT match Auxiliary Kickboard', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(false); + }); + + it('should NOT match Aux Kickboard', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', slugParts)).toBe(false); + }); + }); + }); + + describe('real-world URL scenarios', () => { + describe('URL: main-kicker_main_aux-kicker_aux (full ride)', () => { + const slugParts = 'main-kicker_main_aux-kicker_aux'.split('_'); + + it('should have correct slug parts', () => { + expect(slugParts).toEqual(['main-kicker', 'main', 'aux-kicker', 'aux']); + }); + + it('should match all four homewall sets', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(true); + expect(matchSetNameToSlugParts('Mainline Kickboard', slugParts)).toBe(true); + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(true); + expect(matchSetNameToSlugParts('Mainline', slugParts)).toBe(true); + }); + + it('should match abbreviated variants too', () => { + expect(matchSetNameToSlugParts('Aux Kickboard', slugParts)).toBe(true); + expect(matchSetNameToSlugParts('Main Kickboard', slugParts)).toBe(true); + expect(matchSetNameToSlugParts('Aux', slugParts)).toBe(true); + expect(matchSetNameToSlugParts('Main', slugParts)).toBe(true); + }); + }); + + describe('URL: main-kicker_main_aux (no aux-kicker)', () => { + const slugParts = 'main-kicker_main_aux'.split('_'); + + it('should match Auxiliary', () => { + expect(matchSetNameToSlugParts('Auxiliary', slugParts)).toBe(true); + }); + + it('should NOT match Auxiliary Kickboard', () => { + expect(matchSetNameToSlugParts('Auxiliary Kickboard', slugParts)).toBe(false); + }); + + it('should match Mainline Kickboard', () => { + expect(matchSetNameToSlugParts('Mainline Kickboard', slugParts)).toBe(true); + }); + + it('should match Mainline', () => { + expect(matchSetNameToSlugParts('Mainline', slugParts)).toBe(true); + }); + }); + + describe('URL: screw_bolt (original kilter sets)', () => { + const slugParts = 'screw_bolt'.split('_'); + + it('should match Bolt Ons', () => { + expect(matchSetNameToSlugParts('Bolt Ons', slugParts)).toBe(true); + }); + + it('should match Screw Ons', () => { + expect(matchSetNameToSlugParts('Screw Ons', slugParts)).toBe(true); + }); + }); + }); +}); diff --git a/app/lib/__tests__/url-utils.test.ts b/app/lib/__tests__/url-utils.test.ts index 8aeefed..8f3c29f 100644 --- a/app/lib/__tests__/url-utils.test.ts +++ b/app/lib/__tests__/url-utils.test.ts @@ -440,21 +440,222 @@ describe('Slug generation functions', () => { }); describe('generateSetSlug', () => { - it('should handle homewall specific sets', () => { - expect(generateSetSlug(['Auxiliary Kickboard'])).toBe('aux-kicker'); - expect(generateSetSlug(['Mainline Kickboard'])).toBe('main-kicker'); - expect(generateSetSlug(['Auxiliary'])).toBe('aux'); - expect(generateSetSlug(['Mainline'])).toBe('main'); - }); - - it('should handle original kilter/tension sets', () => { - expect(generateSetSlug(['Bolt Ons'])).toBe('bolt'); - expect(generateSetSlug(['Screw Ons'])).toBe('screw'); - }); - - it('should sort multiple sets', () => { - const result = generateSetSlug(['bolt ons', 'screw ons']); - expect(result).toBe('screw_bolt'); + describe('homewall specific sets - full names', () => { + it('should handle Auxiliary Kickboard', () => { + expect(generateSetSlug(['Auxiliary Kickboard'])).toBe('aux-kicker'); + }); + + it('should handle Mainline Kickboard', () => { + expect(generateSetSlug(['Mainline Kickboard'])).toBe('main-kicker'); + }); + + it('should handle Auxiliary (standalone)', () => { + expect(generateSetSlug(['Auxiliary'])).toBe('aux'); + }); + + it('should handle Mainline (standalone)', () => { + expect(generateSetSlug(['Mainline'])).toBe('main'); + }); + }); + + describe('homewall specific sets - abbreviated names (Aux/Main)', () => { + it('should handle Aux Kickboard', () => { + expect(generateSetSlug(['Aux Kickboard'])).toBe('aux-kicker'); + }); + + it('should handle Main Kickboard', () => { + expect(generateSetSlug(['Main Kickboard'])).toBe('main-kicker'); + }); + + it('should handle Aux (standalone)', () => { + expect(generateSetSlug(['Aux'])).toBe('aux'); + }); + + it('should handle Main (standalone)', () => { + expect(generateSetSlug(['Main'])).toBe('main'); + }); + }); + + describe('homewall specific sets - case insensitivity', () => { + it('should handle lowercase auxiliary kickboard', () => { + expect(generateSetSlug(['auxiliary kickboard'])).toBe('aux-kicker'); + }); + + it('should handle uppercase AUXILIARY KICKBOARD', () => { + expect(generateSetSlug(['AUXILIARY KICKBOARD'])).toBe('aux-kicker'); + }); + + it('should handle mixed case AuXiLiArY', () => { + expect(generateSetSlug(['AuXiLiArY'])).toBe('aux'); + }); + + it('should handle lowercase aux', () => { + expect(generateSetSlug(['aux'])).toBe('aux'); + }); + + it('should handle uppercase AUX', () => { + expect(generateSetSlug(['AUX'])).toBe('aux'); + }); + }); + + describe('homewall specific sets - with extra whitespace', () => { + it('should handle leading/trailing whitespace', () => { + expect(generateSetSlug([' Auxiliary Kickboard '])).toBe('aux-kicker'); + expect(generateSetSlug([' Auxiliary '])).toBe('aux'); + }); + }); + + describe('homewall specific sets - "kicker" naming variant (used in some sizes like 10x12)', () => { + it('should handle Aux Kicker (without "board")', () => { + expect(generateSetSlug(['Aux Kicker'])).toBe('aux-kicker'); + }); + + it('should handle Main Kicker (without "board")', () => { + expect(generateSetSlug(['Main Kicker'])).toBe('main-kicker'); + }); + + it('should handle Auxiliary Kicker', () => { + expect(generateSetSlug(['Auxiliary Kicker'])).toBe('aux-kicker'); + }); + + it('should handle Mainline Kicker', () => { + expect(generateSetSlug(['Mainline Kicker'])).toBe('main-kicker'); + }); + + it('should generate correct slug for 10x12 with kicker naming', () => { + const result = generateSetSlug([ + 'Aux Kicker', + 'Main Kicker', + 'Aux', + 'Main' + ]); + expect(result).toBe('main-kicker_main_aux-kicker_aux'); + }); + }); + + describe('homewall full ride - all four sets combined', () => { + it('should generate correct slug for all four homewall sets (full names)', () => { + const result = generateSetSlug([ + 'Auxiliary Kickboard', + 'Mainline Kickboard', + 'Auxiliary', + 'Mainline' + ]); + // Should be sorted alphabetically descending and joined with underscores + expect(result).toBe('main-kicker_main_aux-kicker_aux'); + }); + + it('should generate correct slug for all four homewall sets (abbreviated names)', () => { + const result = generateSetSlug([ + 'Aux Kickboard', + 'Main Kickboard', + 'Aux', + 'Main' + ]); + expect(result).toBe('main-kicker_main_aux-kicker_aux'); + }); + + it('should generate correct slug for mixed full and abbreviated names', () => { + const result = generateSetSlug([ + 'Auxiliary Kickboard', + 'Main Kickboard', + 'Aux', + 'Mainline' + ]); + expect(result).toBe('main-kicker_main_aux-kicker_aux'); + }); + }); + + describe('homewall partial selections', () => { + it('should handle aux + main (no kickers)', () => { + const result = generateSetSlug(['Auxiliary', 'Mainline']); + expect(result).toBe('main_aux'); + }); + + it('should handle aux-kicker + main-kicker (kickers only)', () => { + const result = generateSetSlug(['Auxiliary Kickboard', 'Mainline Kickboard']); + expect(result).toBe('main-kicker_aux-kicker'); + }); + + it('should handle aux + aux-kicker (aux variants only)', () => { + const result = generateSetSlug(['Auxiliary', 'Auxiliary Kickboard']); + expect(result).toBe('aux-kicker_aux'); + }); + + it('should handle main + main-kicker (main variants only)', () => { + const result = generateSetSlug(['Mainline', 'Mainline Kickboard']); + expect(result).toBe('main-kicker_main'); + }); + + it('should handle single aux selection', () => { + expect(generateSetSlug(['Auxiliary'])).toBe('aux'); + expect(generateSetSlug(['Aux'])).toBe('aux'); + }); + + it('should handle aux + main-kicker + main (no aux-kicker)', () => { + const result = generateSetSlug(['Auxiliary', 'Mainline Kickboard', 'Mainline']); + expect(result).toBe('main-kicker_main_aux'); + }); + }); + + describe('original kilter/tension sets', () => { + it('should handle Bolt Ons', () => { + expect(generateSetSlug(['Bolt Ons'])).toBe('bolt'); + }); + + it('should handle Screw Ons', () => { + expect(generateSetSlug(['Screw Ons'])).toBe('screw'); + }); + + it('should handle bolt on (singular)', () => { + expect(generateSetSlug(['Bolt On'])).toBe('bolt'); + }); + + it('should handle screw on (singular)', () => { + expect(generateSetSlug(['Screw On'])).toBe('screw'); + }); + + it('should sort bolt and screw correctly', () => { + const result = generateSetSlug(['Bolt Ons', 'Screw Ons']); + expect(result).toBe('screw_bolt'); + }); + }); + + describe('sorting behavior', () => { + it('should sort slugs alphabetically descending', () => { + // z > a, so 'screw' > 'main' > 'bolt' > 'aux' + const result = generateSetSlug(['Auxiliary', 'Bolt Ons', 'Mainline', 'Screw Ons']); + expect(result).toBe('screw_main_bolt_aux'); + }); + + it('should maintain consistent ordering regardless of input order', () => { + const order1 = generateSetSlug(['Auxiliary', 'Mainline', 'Auxiliary Kickboard', 'Mainline Kickboard']); + const order2 = generateSetSlug(['Mainline Kickboard', 'Auxiliary Kickboard', 'Mainline', 'Auxiliary']); + const order3 = generateSetSlug(['Auxiliary Kickboard', 'Auxiliary', 'Mainline Kickboard', 'Mainline']); + + expect(order1).toBe(order2); + expect(order2).toBe(order3); + expect(order1).toBe('main-kicker_main_aux-kicker_aux'); + }); + }); + + describe('edge cases', () => { + it('should handle empty array', () => { + expect(generateSetSlug([])).toBe(''); + }); + + it('should handle single set', () => { + expect(generateSetSlug(['Auxiliary'])).toBe('aux'); + }); + + it('should handle sets with numbers', () => { + // Generic set names should fall through to general slug generation + expect(generateSetSlug(['Set 1'])).toBe('set-1'); + }); + + it('should handle sets with special characters', () => { + expect(generateSetSlug(['Test Set!'])).toBe('test-set!'); + }); }); }); }); diff --git a/app/lib/slug-matching.ts b/app/lib/slug-matching.ts new file mode 100644 index 0000000..f3be6eb --- /dev/null +++ b/app/lib/slug-matching.ts @@ -0,0 +1,41 @@ +/** + * Pure function to check if a set name matches a given slug. + * This is extracted for testability - the matching logic is complex and needs thorough testing. + * + * @param setName - The name of the set from the database + * @param slugParts - Array of slug parts (e.g., ['main-kicker', 'main', 'aux-kicker', 'aux']) + * @returns true if the set name matches any of the slug parts + */ +export const matchSetNameToSlugParts = (setName: string, slugParts: string[]): boolean => { + const lowercaseName = setName.toLowerCase().trim(); + + // Handle homewall-specific set names (supports both "Auxiliary/Mainline" and "Aux/Main" variants) + const hasAux = lowercaseName.includes('auxiliary') || lowercaseName.includes('aux'); + const hasMain = lowercaseName.includes('mainline') || lowercaseName.includes('main'); + // Support both "kickboard" and "kicker" in set names (different sizes use different naming) + const hasKickerVariant = lowercaseName.includes('kickboard') || lowercaseName.includes('kicker'); + + // Match aux-kicker: sets with aux/auxiliary AND kickboard/kicker + if (hasAux && hasKickerVariant && slugParts.includes('aux-kicker')) { + return true; + } + // Match main-kicker: sets with main/mainline AND kickboard/kicker + if (hasMain && hasKickerVariant && slugParts.includes('main-kicker')) { + return true; + } + // Match aux: sets with aux/auxiliary but NOT kickboard/kicker + if (hasAux && !hasKickerVariant && slugParts.includes('aux')) { + return true; + } + // Match main: sets with main/mainline but NOT kickboard/kicker + if (hasMain && !hasKickerVariant && slugParts.includes('main')) { + return true; + } + + // Handle original kilter/tension set names + const setSlug = lowercaseName + .replace(/\s+ons?$/i, '') // Remove "on" or "ons" suffix + .replace(/^(bolt|screw).*/, '$1') // Extract just "bolt" or "screw" + .replace(/\s+/g, '-'); // Replace spaces with hyphens + return slugParts.includes(setSlug); +}; diff --git a/app/lib/slug-utils.ts b/app/lib/slug-utils.ts index 6c22eda..b1da107 100644 --- a/app/lib/slug-utils.ts +++ b/app/lib/slug-utils.ts @@ -1,5 +1,9 @@ import { sql } from '@/app/lib/db/db'; import { BoardName, LayoutId, Size } from '@/app/lib/types'; +import { matchSetNameToSlugParts } from './slug-matching'; + +// Re-export for backwards compatibility +export { matchSetNameToSlugParts } from './slug-matching'; export type LayoutRow = { id: number; @@ -98,6 +102,15 @@ export const getSizeBySlug = async ( return size || null; }; +/** + * Parses a combined set slug and returns matching sets from the database. + * + * @param board_name - The board type (kilter, tension, etc.) + * @param layout_id - The layout ID + * @param size_id - The size ID + * @param slug - The combined slug (e.g., 'main-kicker_main_aux-kicker_aux') + * @returns Array of matching sets + */ export const getSetsBySlug = async ( board_name: BoardName, layout_id: LayoutId, @@ -107,46 +120,15 @@ export const getSetsBySlug = async ( const rows = (await sql` SELECT sets.id, sets.name FROM ${sql.unsafe(getTableName(board_name, 'sets'))} sets - INNER JOIN ${sql.unsafe(getTableName(board_name, 'product_sizes_layouts_sets'))} psls + INNER JOIN ${sql.unsafe(getTableName(board_name, 'product_sizes_layouts_sets'))} psls ON sets.id = psls.set_id WHERE psls.product_size_id = ${size_id} AND psls.layout_id = ${layout_id} `) as SetRow[]; // Parse the slug to get individual set names - const slugParts = slug.split('_'); // Split by underscore now - const matchingSets = rows.filter((s) => { - const lowercaseName = s.name.toLowerCase().trim(); - - // Handle homewall-specific set names - if ( - lowercaseName.includes('auxiliary') && - lowercaseName.includes('kickboard') && - slugParts.includes('aux-kicker') - ) { - return true; - } - if ( - lowercaseName.includes('mainline') && - lowercaseName.includes('kickboard') && - slugParts.includes('main-kicker') - ) { - return true; - } - if (lowercaseName.includes('auxiliary') && slugParts.includes('aux')) { - return true; - } - if (lowercaseName.includes('mainline') && slugParts.includes('main')) { - return true; - } - - // Handle original kilter/tension set names - const setSlug = lowercaseName - .replace(/\s+ons?$/i, '') // Remove "on" or "ons" suffix - .replace(/^(bolt|screw).*/, '$1') // Extract just "bolt" or "screw" - .replace(/\s+/g, '-'); // Replace spaces with hyphens - return slugParts.includes(setSlug); - }); + const slugParts = slug.split('_'); + const matchingSets = rows.filter((s) => matchSetNameToSlugParts(s.name, slugParts)); return matchingSets; }; diff --git a/app/lib/url-utils.ts b/app/lib/url-utils.ts index 532790c..a383483 100644 --- a/app/lib/url-utils.ts +++ b/app/lib/url-utils.ts @@ -334,17 +334,22 @@ export const generateSetSlug = (setNames: string[]): string => { .map((name) => { const lowercaseName = name.toLowerCase().trim(); - // Handle homewall-specific set names - if (lowercaseName.includes('auxiliary') && lowercaseName.includes('kickboard')) { + // Handle homewall-specific set names (supports both "Auxiliary/Mainline" and "Aux/Main" variants) + const hasAux = lowercaseName.includes('auxiliary') || lowercaseName.includes('aux'); + const hasMain = lowercaseName.includes('mainline') || lowercaseName.includes('main'); + // Support both "kickboard" and "kicker" in set names (different sizes use different naming) + const hasKickerVariant = lowercaseName.includes('kickboard') || lowercaseName.includes('kicker'); + + if (hasAux && hasKickerVariant) { return 'aux-kicker'; } - if (lowercaseName.includes('mainline') && lowercaseName.includes('kickboard')) { + if (hasMain && hasKickerVariant) { return 'main-kicker'; } - if (lowercaseName.includes('auxiliary')) { + if (hasAux) { return 'aux'; } - if (lowercaseName.includes('mainline')) { + if (hasMain) { return 'main'; } diff --git a/package-lock.json b/package-lock.json index 87f6d10..f7048ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,7 +82,6 @@ }, "node_modules/@ampproject/remapping": { "version": "2.3.0", - "dev": true, "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -459,6 +458,71 @@ } }, "node_modules/@auth/core": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.2.tgz", + "integrity": "sha512-KywHKRgLiF3l7PLyL73fjLSIBe1YNcA6sMeew4yMP6cfCWGXZrkkXd32AjRi1hlJ9nvovUBGZHvbn+LijO6ZeQ==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.10.4", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@auth/core/node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@auth/drizzle-adapter": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@auth/drizzle-adapter/-/drizzle-adapter-1.10.0.tgz", + "integrity": "sha512-3MKsdAINTfvV4QKev8PFMNG93HJEUHh9sggDXnmUmriFogRf8qLvgqnPsTlfUyWcLwTzzrrYjeu8CGM+4IxHwQ==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.40.0" + } + }, + "node_modules/@auth/drizzle-adapter/node_modules/@auth/core": { "version": "0.40.0", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.40.0.tgz", "integrity": "sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==", @@ -487,13 +551,31 @@ } } }, - "node_modules/@auth/drizzle-adapter": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@auth/drizzle-adapter/-/drizzle-adapter-1.10.0.tgz", - "integrity": "sha512-3MKsdAINTfvV4QKev8PFMNG93HJEUHh9sggDXnmUmriFogRf8qLvgqnPsTlfUyWcLwTzzrrYjeu8CGM+4IxHwQ==", - "license": "ISC", - "dependencies": { - "@auth/core": "0.40.0" + "node_modules/@auth/drizzle-adapter/node_modules/jose": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.2.tgz", + "integrity": "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/drizzle-adapter/node_modules/oauth4webapi": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz", + "integrity": "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/drizzle-adapter/node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" } }, "node_modules/@babel/code-frame": { @@ -510,7 +592,6 @@ }, "node_modules/@babel/compat-data": { "version": "7.28.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -518,7 +599,6 @@ }, "node_modules/@babel/core": { "version": "7.28.0", - "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", @@ -547,12 +627,10 @@ }, "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", - "dev": true, "license": "MIT" }, "node_modules/@babel/core/node_modules/json5": { "version": "2.2.3", - "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -563,7 +641,6 @@ }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -585,7 +662,6 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.27.2", - "dev": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.27.2", @@ -600,7 +676,6 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -626,7 +701,6 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.27.3", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", @@ -664,7 +738,6 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -672,7 +745,6 @@ }, "node_modules/@babel/helpers": { "version": "7.27.6", - "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", @@ -2190,7 +2262,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/d3-scale": { @@ -2247,12 +2319,12 @@ }, "node_modules/@types/prop-types": { "version": "15.7.15", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.23", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3201,7 +3273,6 @@ }, "node_modules/browserslist": { "version": "4.25.1", - "dev": true, "funding": [ { "type": "opencollective", @@ -3236,7 +3307,7 @@ }, "node_modules/bufferutil": { "version": "4.0.9", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -3941,7 +4012,6 @@ }, "node_modules/electron-to-chromium": { "version": "1.5.183", - "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { @@ -4191,7 +4261,6 @@ }, "node_modules/escalade": { "version": "3.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4913,7 +4982,6 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -5786,10 +5854,12 @@ } }, "node_modules/jose": { - "version": "6.0.12", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz", - "integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", "license": "MIT", + "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -6004,7 +6074,6 @@ }, "node_modules/lru-cache": { "version": "5.1.1", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -6272,12 +6341,6 @@ "preact": ">=10" } }, - "node_modules/next-auth/node_modules/pretty-format": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", - "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", - "license": "MIT" - }, "node_modules/next-auth/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -6289,7 +6352,7 @@ }, "node_modules/node-gyp-build": { "version": "4.8.4", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "node-gyp-build": "bin.js", @@ -6299,7 +6362,6 @@ }, "node_modules/node-releases": { "version": "2.0.19", - "dev": true, "license": "MIT" }, "node_modules/nwsapi": { @@ -6314,10 +6376,12 @@ "license": "MIT" }, "node_modules/oauth4webapi": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.6.2.tgz", - "integrity": "sha512-hwWLiyBYuqhVdcIUJMJVKdEvz+DCweOcbSfqDyIv9PuUwrNfqrzfHP2bypZgZdbYOS67QYqnAnvZa2BJwBBrHw==", + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", + "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", "license": "MIT", + "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -6939,10 +7003,15 @@ } }, "node_modules/preact-render-to-string": { - "version": "6.5.11", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", - "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "pretty-format": "^3.8.0" + }, "peerDependencies": { "preact": ">=10" } @@ -6980,6 +7049,12 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "license": "MIT", @@ -7564,7 +7639,6 @@ }, "node_modules/react": { "version": "18.3.1", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -7583,7 +7657,6 @@ }, "node_modules/react-dom": { "version": "18.3.1", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -7902,7 +7975,6 @@ }, "node_modules/scheduler": { "version": "0.23.2", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -8864,7 +8936,6 @@ }, "node_modules/update-browserslist-db": { "version": "1.1.3", - "dev": true, "funding": [ { "type": "opencollective", @@ -9425,7 +9496,6 @@ }, "node_modules/yallist": { "version": "3.1.1", - "dev": true, "license": "ISC" }, "node_modules/yocto-queue": {