/ src / browser / shape-filter.test.ts
shape-filter.test.ts
  1  import { describe, expect, it } from 'vitest';
  2  import type { Shape } from './shape.js';
  3  import {
  4      collectShapeSegments,
  5      extractSegments,
  6      parseFilter,
  7      shapeMatchesFilter,
  8  } from './shape-filter.js';
  9  
 10  describe('parseFilter', () => {
 11      it('splits comma-separated fields, trims, and drops empty tokens', () => {
 12          const r = parseFilter('author, text , likes');
 13          expect(r).toEqual({ fields: ['author', 'text', 'likes'] });
 14      });
 15  
 16      it('dedupes while preserving first-seen order', () => {
 17          const r = parseFilter('a,b,a,c,b');
 18          expect(r).toEqual({ fields: ['a', 'b', 'c'] });
 19      });
 20  
 21      it('rejects empty string as invalid_filter', () => {
 22          const r = parseFilter('');
 23          expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') });
 24      });
 25  
 26      it('rejects whitespace-only as invalid_filter', () => {
 27          const r = parseFilter('   ');
 28          expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') });
 29      });
 30  
 31      it('rejects commas-only as invalid_filter', () => {
 32          const r = parseFilter(',,,');
 33          expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') });
 34      });
 35  
 36      it('accepts a single field', () => {
 37          expect(parseFilter('author')).toEqual({ fields: ['author'] });
 38      });
 39  });
 40  
 41  describe('extractSegments', () => {
 42      it('returns empty for root', () => {
 43          expect(extractSegments('$')).toEqual([]);
 44      });
 45  
 46      it('splits dotted path and drops $', () => {
 47          expect(extractSegments('$.data.user.name')).toEqual(['data', 'user', 'name']);
 48      });
 49  
 50      it('drops numeric array indices', () => {
 51          expect(extractSegments('$.items[0].author')).toEqual(['items', 'author']);
 52          expect(extractSegments('$.rows[0][12]')).toEqual(['rows']);
 53      });
 54  
 55      it('unwraps bracket-quoted keys', () => {
 56          expect(extractSegments('$.data["weird key"]')).toEqual(['data', 'weird key']);
 57      });
 58  
 59      it('handles bracket-quoted keys at root', () => {
 60          expect(extractSegments('$["123bad"]')).toEqual(['123bad']);
 61      });
 62  
 63      it('mixes bracket keys and dot segments', () => {
 64          expect(extractSegments('$.data.user["nick name"].age'))
 65              .toEqual(['data', 'user', 'nick name', 'age']);
 66      });
 67  });
 68  
 69  describe('collectShapeSegments', () => {
 70      it('collects every segment name from every path in a shape', () => {
 71          const shape: Shape = {
 72              '$': 'object',
 73              '$.data': 'object',
 74              '$.data.items': 'array(3)',
 75              '$.data.items[0]': 'object',
 76              '$.data.items[0].author': 'string',
 77              '$.data.items[0].text': 'string',
 78          };
 79          const segs = collectShapeSegments(shape);
 80          expect(segs.has('data')).toBe(true);
 81          expect(segs.has('items')).toBe(true);
 82          expect(segs.has('author')).toBe(true);
 83          expect(segs.has('text')).toBe(true);
 84          expect(segs.has('$')).toBe(false);
 85          expect(segs.has('[0]')).toBe(false);
 86      });
 87  
 88      it('returns an empty set for an empty shape', () => {
 89          expect(collectShapeSegments({}).size).toBe(0);
 90      });
 91  });
 92  
 93  describe('shapeMatchesFilter', () => {
 94      const shape: Shape = {
 95          '$': 'object',
 96          '$.data': 'object',
 97          '$.data.items': 'array(1)',
 98          '$.data.items[0].author': 'string',
 99          '$.data.items[0].text': 'string',
100          '$.data.items[0].likes': 'number',
101      };
102  
103      it('returns true when every field matches some path segment (AND)', () => {
104          expect(shapeMatchesFilter(shape, ['author', 'text', 'likes'])).toBe(true);
105      });
106  
107      it('matches nested container names, not just leaves (any-segment rule)', () => {
108          // `data` and `items` are container segments, not leaves; still must match.
109          expect(shapeMatchesFilter(shape, ['data', 'items'])).toBe(true);
110      });
111  
112      it('returns false when any field is missing', () => {
113          expect(shapeMatchesFilter(shape, ['author', 'missing'])).toBe(false);
114      });
115  
116      it('is case-sensitive', () => {
117          expect(shapeMatchesFilter(shape, ['Author'])).toBe(false);
118          expect(shapeMatchesFilter(shape, ['author'])).toBe(true);
119      });
120  
121      it('empty filter list vacuously matches', () => {
122          expect(shapeMatchesFilter(shape, [])).toBe(true);
123      });
124  
125      it('rejects requests whose shape has no body (empty shape)', () => {
126          expect(shapeMatchesFilter({}, ['author'])).toBe(false);
127      });
128  });