Skip to content

Commit b8d04ef

Browse files
committed
test: add tests
1 parent 0d76d00 commit b8d04ef

File tree

3 files changed

+301
-0
lines changed

3 files changed

+301
-0
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { beforeEach, describe, expect, it, spyOn } from 'bun:test'
2+
import { load } from 'cheerio'
3+
import type { ExtractorPlugin } from '../abstract-extractor-plugin'
4+
import { ExtractorNotFoundException } from '../exceptions'
5+
import { RecipeExtractor } from '../recipe-extractor'
6+
import type { ScraperDiagnostics } from '../scraper-diagnostics'
7+
import type { RecipeFields } from '../types/recipe.interface'
8+
9+
describe('RecipeExtractor', () => {
10+
const scraperName = 'TestScraper'
11+
let diagnostics: ScraperDiagnostics
12+
13+
beforeEach(() => {
14+
diagnostics = {
15+
recordFailure: () => {},
16+
recordSuccess: () => {},
17+
} as unknown as ScraperDiagnostics
18+
})
19+
20+
it('uses a single plugin to extract a field', async () => {
21+
const pluginA = {
22+
name: 'PluginA',
23+
priority: 10,
24+
$: load('<html><body></body></html>'),
25+
supports: (field: keyof RecipeFields) => field === 'title',
26+
extract: async (_: keyof RecipeFields) => 'PluginA-Name',
27+
} as ExtractorPlugin
28+
29+
const extractor = new RecipeExtractor([pluginA], scraperName, diagnostics)
30+
const result = await extractor.extract('title')
31+
expect(result).toBe('PluginA-Name')
32+
})
33+
34+
it('respects plugin priority (higher first)', async () => {
35+
// lower priority returns L1, higher priority returns H1
36+
const low = {
37+
name: 'Low',
38+
priority: 1,
39+
$: load('<html><body></body></html>'),
40+
supports: () => true,
41+
extract: () => 'L1',
42+
} as ExtractorPlugin
43+
const high = {
44+
name: 'High',
45+
priority: 100,
46+
$: load('<html><body></body></html>'),
47+
supports: () => true,
48+
extract: () => 'H1',
49+
} as ExtractorPlugin
50+
51+
const spyLow = spyOn(low, 'extract')
52+
const spyHigh = spyOn(high, 'extract')
53+
54+
const extractor = new RecipeExtractor([low, high], scraperName, diagnostics)
55+
const result = await extractor.extract('title')
56+
expect(result).toBe('H1')
57+
expect(spyHigh).toHaveBeenCalled()
58+
expect(spyLow).not.toHaveBeenCalled()
59+
})
60+
61+
it('records plugin failures and continues to next plugin', async () => {
62+
const err = new Error('fail1')
63+
const bad = {
64+
name: 'Bad',
65+
priority: 50,
66+
$: load('<html><body></body></html>'),
67+
supports: () => true,
68+
extract: () => {
69+
throw err
70+
},
71+
} as ExtractorPlugin
72+
const good = {
73+
name: 'Good',
74+
priority: 10,
75+
$: load('<html><body></body></html>'),
76+
supports: () => true,
77+
extract: () => 'GoodValue',
78+
} as ExtractorPlugin
79+
80+
const spyFailure = spyOn(diagnostics, 'recordFailure')
81+
const extractor = new RecipeExtractor([bad, good], scraperName, diagnostics)
82+
const result = await extractor.extract('title')
83+
expect(result).toBe('GoodValue')
84+
expect(spyFailure).toHaveBeenCalledWith('Bad', 'title', err)
85+
})
86+
87+
it('uses site-specific extractor when provided', async () => {
88+
const plugin = {
89+
name: 'X',
90+
priority: 0,
91+
$: load('<html><body></body></html>'),
92+
supports: () => false,
93+
extract: () => 'X',
94+
} as ExtractorPlugin
95+
96+
const extractor = new RecipeExtractor([plugin], scraperName, diagnostics)
97+
const siteValue = await extractor.extract('title', (prev) => {
98+
expect(prev).toBeUndefined()
99+
return 'SiteName'
100+
})
101+
expect(siteValue).toBe('SiteName')
102+
})
103+
104+
it('chains plugin result into site-specific extractor', async () => {
105+
const plugin = {
106+
name: 'P',
107+
priority: 0,
108+
$: load('<html><body></body></html>'),
109+
supports: () => true,
110+
extract: () => 'FromPlugin',
111+
} as ExtractorPlugin
112+
113+
const extractor = new RecipeExtractor([plugin], scraperName, diagnostics)
114+
const final = await extractor.extract('title', (prev) => {
115+
expect(prev).toBe('FromPlugin')
116+
return `${prev}-Site`
117+
})
118+
expect(final).toBe('FromPlugin-Site')
119+
})
120+
121+
it('throws ExtractorNotFoundException when no plugin or extractor applies', async () => {
122+
const plugin = {
123+
name: 'None',
124+
priority: 0,
125+
$: load('<html><body></body></html>'),
126+
supports: () => false,
127+
extract: () => 'X',
128+
} as ExtractorPlugin
129+
130+
const extractor = new RecipeExtractor([plugin], scraperName, diagnostics)
131+
await expect(extractor.extract('title')).rejects.toThrow(
132+
ExtractorNotFoundException,
133+
)
134+
await expect(extractor.extract('title')).rejects.toThrow(
135+
'No extractor found for field: title',
136+
)
137+
})
138+
})

src/utils/__tests__/index.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import {
3+
isDefined,
4+
isFunction,
5+
isNumber,
6+
isPlainObject,
7+
isString,
8+
} from '../index'
9+
10+
describe('isDefined', () => {
11+
it('returns false for undefined', () => {
12+
let value: number | undefined
13+
expect(isDefined(value)).toBe(false)
14+
})
15+
16+
it('returns true for null or other values', () => {
17+
expect(isDefined(null)).toBe(true)
18+
expect(isDefined(0)).toBe(true)
19+
expect(isDefined('')).toBe(true)
20+
expect(isDefined(false)).toBe(true)
21+
})
22+
})
23+
24+
describe('isFunction', () => {
25+
it('returns true for functions', () => {
26+
expect(isFunction(() => {})).toBe(true)
27+
expect(isFunction(function named() {})).toBe(true)
28+
expect(isFunction(async () => {})).toBe(true)
29+
})
30+
31+
it('returns false for non-functions', () => {
32+
expect(isFunction(null)).toBe(false)
33+
expect(isFunction({})).toBe(false)
34+
expect(isFunction(123)).toBe(false)
35+
expect(isFunction('fn')).toBe(false)
36+
expect(isFunction([])).toBe(false)
37+
})
38+
})
39+
40+
describe('isNumber', () => {
41+
it('returns true for numbers', () => {
42+
expect(isNumber(0)).toBe(true)
43+
expect(isNumber(42)).toBe(true)
44+
expect(isNumber(-3.14)).toBe(true)
45+
expect(isNumber(Number.NaN)).toBe(true)
46+
expect(isNumber(Number.POSITIVE_INFINITY)).toBe(true)
47+
})
48+
49+
it('returns false for non-numbers', () => {
50+
expect(isNumber('123')).toBe(false)
51+
expect(isNumber(null)).toBe(false)
52+
expect(isNumber(undefined)).toBe(false)
53+
expect(isNumber({})).toBe(false)
54+
expect(isNumber(() => 1)).toBe(false)
55+
})
56+
})
57+
58+
describe('isPlainObject', () => {
59+
it('returns true for plain object literals', () => {
60+
expect(isPlainObject({})).toBe(true)
61+
expect(isPlainObject({ a: 1, b: '2' })).toBe(true)
62+
const obj = Object.create(Object.prototype)
63+
expect(isPlainObject(obj)).toBe(true)
64+
})
65+
66+
it('returns false for null, arrays, functions, and class instances', () => {
67+
expect(isPlainObject(null)).toBe(false)
68+
expect(isPlainObject([])).toBe(false)
69+
expect(isPlainObject(() => {})).toBe(false)
70+
class C {}
71+
expect(isPlainObject(new C())).toBe(false)
72+
})
73+
74+
it('returns false for objects with non-default prototype', () => {
75+
const noProto = Object.create(null)
76+
expect(isPlainObject(noProto)).toBe(false)
77+
})
78+
})
79+
80+
describe('isString', () => {
81+
it('returns true for string primitives', () => {
82+
expect(isString('')).toBe(true)
83+
expect(isString('hello')).toBe(true)
84+
expect(isString(String(123))).toBe(true)
85+
})
86+
87+
it('returns false for non-strings', () => {
88+
expect(isString(new String('str'))).toBe(false)
89+
expect(isString(123)).toBe(false)
90+
expect(isString(null)).toBe(false)
91+
expect(isString(undefined)).toBe(false)
92+
expect(isString({})).toBe(false)
93+
})
94+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { normalizeString, parseMinutes, splitToList } from '../parsing'
3+
4+
describe('normalizeString', () => {
5+
it('trims leading and trailing whitespace', () => {
6+
expect(normalizeString(' hello world ')).toBe('hello world')
7+
})
8+
9+
it('collapses multiple whitespace characters into single spaces', () => {
10+
expect(normalizeString('foo bar\tbaz\nqux')).toBe('foo bar baz qux')
11+
})
12+
13+
it('returns empty string for null or undefined', () => {
14+
expect(normalizeString(null)).toBe('')
15+
expect(normalizeString(undefined)).toBe('')
16+
})
17+
18+
it('returns empty string for input that becomes empty after trim', () => {
19+
expect(normalizeString(' ')).toBe('')
20+
})
21+
})
22+
23+
describe('splitToList', () => {
24+
it('returns empty array for empty input', () => {
25+
expect(splitToList('', ',')).toEqual([])
26+
})
27+
28+
it('splits by comma and trims items', () => {
29+
const input = ' apple, banana , cherry ,, '
30+
expect(splitToList(input, ',')).toEqual(['apple', 'banana', 'cherry'])
31+
})
32+
33+
it('splits by custom separator', () => {
34+
const input = 'one|two| three | |four'
35+
expect(splitToList(input, '|')).toEqual(['one', 'two', 'three', 'four'])
36+
})
37+
38+
it('ignores items that normalize to empty strings', () => {
39+
const input = 'a,, ,b'
40+
expect(splitToList(input, ',')).toEqual(['a', 'b'])
41+
})
42+
})
43+
44+
describe('parseMinutes', () => {
45+
it('parses hours and minutes', () => {
46+
expect(parseMinutes('PT1H30M')).toBe(90)
47+
})
48+
49+
it('parses only minutes', () => {
50+
expect(parseMinutes('PT45M')).toBe(45)
51+
})
52+
53+
it('parses only seconds and rounds to minutes', () => {
54+
expect(parseMinutes('PT90S')).toBe(2)
55+
})
56+
57+
it('parses hours only', () => {
58+
expect(parseMinutes('PT2H')).toBe(120)
59+
})
60+
61+
it('parses days and hours', () => {
62+
// 1 day = 24h, +2h = 26h -> 1560 minutes
63+
expect(parseMinutes('P1DT2H')).toBe(1560)
64+
})
65+
66+
it('throws on invalid input', () => {
67+
expect(() => parseMinutes('')).toThrow('invalid duration')
68+
})
69+
})

0 commit comments

Comments
 (0)