@sideid/id-profanity-filter 1.11.11 → 1.11.13

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.
@@ -29,6 +29,14 @@ interface FindProfanityFunction {
29
29
  lastActualMatches?: Map<string, string[]>;
30
30
  }
31
31
 
32
+ function getWordMetadata(word: string): ProfanityWord | undefined {
33
+ return wordObjects.find(
34
+ (obj) =>
35
+ obj.word.toLowerCase() === word.toLowerCase() ||
36
+ (obj.aliases && obj.aliases.some((alias) => alias.toLowerCase() === word.toLowerCase()))
37
+ );
38
+ }
39
+
32
40
  export function findProfanity(text: string, options: FilterOptions = {}): string[] {
33
41
  const {
34
42
  wordList = [],
@@ -46,6 +54,8 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
46
54
  maxLevenshteinDistance = 2,
47
55
  } = { ...DEFAULT_OPTIONS, ...options };
48
56
 
57
+ const normalizedWhitelist = whitelist.map((w) => w.toLowerCase());
58
+
49
59
  const normalizedText = normalizeText(text);
50
60
 
51
61
  let baseWordsToCheck: string[] = wordList.length > 0 ? wordList : [];
@@ -63,21 +73,25 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
63
73
 
64
74
  const aliasMap = new Map<string, string>();
65
75
  wordObjects.forEach((wordObj) => {
66
- if (wordObj.aliases && wordObj.aliases.length > 0) {
67
- const matchCategory = categories ? categories.includes(wordObj.category) : true;
68
- const matchRegion = regions ? regions.includes(wordObj.region) : true;
69
- const matchSeverity = wordObj.severity >= severityThreshold;
70
-
71
- if (matchCategory && matchRegion && matchSeverity) {
72
- wordObj.aliases.forEach((alias) => {
73
- aliasMap.set(alias.toLowerCase(), wordObj.word.toLowerCase());
74
- });
75
- }
76
+ const matchCategory = categories ? categories.includes(wordObj.category) : true;
77
+ const matchRegion = regions ? regions.includes(wordObj.region) : true;
78
+ const matchSeverity = wordObj.severity >= severityThreshold;
79
+
80
+ if (
81
+ matchCategory &&
82
+ matchRegion &&
83
+ matchSeverity &&
84
+ wordObj.aliases &&
85
+ wordObj.aliases.length > 0
86
+ ) {
87
+ wordObj.aliases.forEach((alias) => {
88
+ aliasMap.set(alias.toLowerCase(), wordObj.word.toLowerCase());
89
+ });
76
90
  }
77
91
  });
78
92
 
79
93
  const wordsToCheck = [...baseWordsToCheck, ...Array.from(aliasMap.keys())].filter(
80
- (word) => !whitelist.includes(word.toLowerCase())
94
+ (word) => !normalizedWhitelist.includes(word.toLowerCase())
81
95
  );
82
96
 
83
97
  if (wordsToCheck.length === 0) {
@@ -87,11 +101,29 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
87
101
  const matches = new Set<string>();
88
102
  const actualMatches = new Map<string, string[]>();
89
103
 
104
+ const passesFilters = (word: string): boolean => {
105
+ if (normalizedWhitelist.includes(word.toLowerCase())) return false;
106
+
107
+ const metadata = getWordMetadata(word);
108
+ if (!metadata) return false;
109
+
110
+ const matchCategory = categories ? categories.includes(metadata.category) : true;
111
+ const matchRegion = regions ? regions.includes(metadata.region) : true;
112
+ const matchSeverity = metadata.severity >= severityThreshold;
113
+
114
+ return matchCategory && matchRegion && matchSeverity;
115
+ };
116
+
90
117
  initializeAhoCorasick(wordsToCheck);
91
118
 
92
119
  const basicMatches = globalAhoCorasick.searchUnique(normalizedText);
93
120
  for (const match of basicMatches) {
121
+ if (normalizedWhitelist.includes(match.toLowerCase())) continue;
122
+
94
123
  const originalWord = aliasMap.get(match.toLowerCase()) || match.toLowerCase();
124
+
125
+ if (!passesFilters(originalWord)) continue;
126
+
95
127
  matches.add(originalWord);
96
128
 
97
129
  if (!actualMatches.has(originalWord)) {
@@ -112,7 +144,13 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
112
144
 
113
145
  let match;
114
146
  while ((match = leetRegex.exec(text)) !== null) {
147
+ const matchedText = match[0];
148
+ if (normalizedWhitelist.includes(matchedText.toLowerCase())) continue;
149
+
115
150
  const originalWord = aliasMap.get(word.toLowerCase()) || word.toLowerCase();
151
+
152
+ if (!passesFilters(originalWord)) continue;
153
+
116
154
  matches.add(originalWord);
117
155
 
118
156
  if (!actualMatches.has(originalWord)) {
@@ -135,7 +173,13 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
135
173
 
136
174
  let match;
137
175
  while ((match = variantRegex.exec(text)) !== null) {
176
+ const matchedText = match[0];
177
+ if (normalizedWhitelist.includes(matchedText.toLowerCase())) continue;
178
+
138
179
  const originalWord = aliasMap.get(word.toLowerCase()) || word.toLowerCase();
180
+
181
+ if (!passesFilters(originalWord)) continue;
182
+
139
183
  matches.add(originalWord);
140
184
 
141
185
  if (!actualMatches.has(originalWord)) {
@@ -158,7 +202,13 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
158
202
 
159
203
  let match;
160
204
  while ((match = splitRegex.exec(text)) !== null) {
205
+ const matchedText = match[0];
206
+ if (normalizedWhitelist.includes(matchedText.toLowerCase())) continue;
207
+
161
208
  const originalWord = aliasMap.get(word.toLowerCase()) || word.toLowerCase();
209
+
210
+ if (!passesFilters(originalWord)) continue;
211
+
162
212
  matches.add(originalWord);
163
213
 
164
214
  if (!actualMatches.has(originalWord)) {
@@ -179,8 +229,12 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
179
229
  );
180
230
 
181
231
  possibleProfanity.forEach((item) => {
232
+ if (normalizedWhitelist.includes(item.word.toLowerCase())) return;
233
+
182
234
  const originalWord =
183
235
  aliasMap.get(item.original.toLowerCase()) || item.original.toLowerCase();
236
+ if (!passesFilters(originalWord)) return;
237
+
184
238
  matches.add(originalWord);
185
239
 
186
240
  if (!actualMatches.has(originalWord)) {
@@ -196,10 +250,14 @@ export function findProfanity(text: string, options: FilterOptions = {}): string
196
250
  );
197
251
 
198
252
  possibleProfanity.forEach((item) => {
199
- matches.add(item.original.toLowerCase());
253
+ if (normalizedWhitelist.includes(item.word.toLowerCase())) return;
200
254
 
201
255
  const originalWord =
202
256
  aliasMap.get(item.original.toLowerCase()) || item.original.toLowerCase();
257
+ if (!passesFilters(originalWord)) return;
258
+
259
+ matches.add(originalWord);
260
+
203
261
  if (!actualMatches.has(originalWord)) {
204
262
  actualMatches.set(originalWord, []);
205
263
  }
@@ -306,7 +364,5 @@ export function calculateSeverity(matchDetails: ProfanityWord[]): number {
306
364
 
307
365
  const severityAvg = severitySum / matchDetails.length;
308
366
 
309
- // Gabungkan jumlah kata dan keparahan rata-rata
310
- // 70% keparahan kata + 30% faktor jumlah
311
367
  return 0.7 * severityAvg + 0.3 * countFactor;
312
368
  }
@@ -58,14 +58,14 @@ export function addLeetSpeakVariations(pattern: string): string {
58
58
  const leetMap: Record<string, string[]> = {
59
59
  a: ['a', '4', '@'],
60
60
  b: ['b', '8', '6'],
61
- c: ['c', '(', '{', '<'],
61
+ c: ['c', '\\(', '\\{', '<'],
62
62
  e: ['e', '3'],
63
63
  g: ['g', '6', '9'],
64
- i: ['i', '1', '!', '|'],
65
- l: ['l', '1', '|'],
64
+ i: ['i', '1', '!', '\\|'],
65
+ l: ['l', '1', '\\|'],
66
66
  o: ['o', '0'],
67
- s: ['s', '5', '$'],
68
- t: ['t', '7', '+'],
67
+ s: ['s', '5', '\\$'],
68
+ t: ['t', '7', '\\+'],
69
69
  z: ['z', '2'],
70
70
  };
71
71
 
@@ -91,8 +91,23 @@ export function addLeetSpeakVariations(pattern: string): string {
91
91
  * @returns Pola regex dengan kemungkinan split
92
92
  */
93
93
  export function addSplitVariations(pattern: string): string {
94
- // Tambahkan kemungkinan spasi atau karakter penghubung di antara setiap huruf
95
- return pattern.split('').join('[\\s\\-._*+]?');
94
+ // Instead of joining character by character with a separator pattern,
95
+ // we'll create a simpler version that matches the pattern with optional separators
96
+
97
+ // Convert each character to a pattern that allows optional separators before it
98
+ // except for the first character
99
+ let result = '';
100
+ const chars = pattern.split('');
101
+
102
+ for (let i = 0; i < chars.length; i++) {
103
+ if (i > 0) {
104
+ // Add optional separator before each character except the first
105
+ result += '[\\s\\-._*+]?';
106
+ }
107
+ result += chars[i];
108
+ }
109
+
110
+ return result;
96
111
  }
97
112
 
98
113
  /**
@@ -103,10 +118,24 @@ export function addSplitVariations(pattern: string): string {
103
118
  * @returns Objek RegExp
104
119
  */
105
120
  export function createEvasionRegex(word: string): RegExp {
106
- // Tambahkan kemungkinan spasi atau karakter penghubung di antara setiap huruf
107
- const pattern = addSplitVariations(escapeRegExp(word));
121
+ // Escape karakter khusus regex
122
+ const escaped = escapeRegExp(word);
108
123
 
109
- return new RegExp(pattern, 'gi');
124
+ // Create a regex pattern that allows any separator between characters
125
+ let result = '';
126
+ const chars = escaped.split('');
127
+
128
+ for (let i = 0; i < chars.length; i++) {
129
+ // Add the character
130
+ result += chars[i];
131
+
132
+ // Add optional separator after each character except the last
133
+ if (i < chars.length - 1) {
134
+ result += '[\\s\\-._*+]?';
135
+ }
136
+ }
137
+
138
+ return new RegExp(result, 'gi');
110
139
  }
111
140
 
112
141
  /**
@@ -120,7 +149,7 @@ export function addIndonesianVariations(pattern: string): string {
120
149
  const variationMap: Record<string, string[]> = {
121
150
  c: ['c', 'k'], // contoh: becok/bekok
122
151
  k: ['k', 'c', 'q'], // contoh: kacau/qacau
123
- j: ['j', 'dj'], // contoh: jualan/djualan (ejaan lama)
152
+ j: ['j', 'd'], // contoh: jualan/dualan (ejaan lama)
124
153
  y: ['y', 'j'], // contoh: ya/ja
125
154
  u: ['u', 'oe'], // contoh: untuk/oentoek (ejaan lama)
126
155
  f: ['f', 'p', 'v'], // contoh: kafir/kapir
@@ -128,20 +157,21 @@ export function addIndonesianVariations(pattern: string): string {
128
157
  x: ['x', 'ks'], // contoh: taxi/taksi
129
158
  };
130
159
 
131
- // Ganti tiap karakter dengan variasinya
132
- return pattern
133
- .split('')
134
- .map((char) => {
135
- const lowerChar = char.toLowerCase();
136
- const variations = variationMap[lowerChar];
137
-
138
- if (variations && variations.length > 1) {
139
- return `[${variations.join('')}]`;
140
- }
160
+ // Go through each character in the pattern and replace with variations
161
+ let result = '';
162
+ for (const char of pattern) {
163
+ const lowerChar = char.toLowerCase();
164
+ const variations = variationMap[lowerChar];
165
+
166
+ if (variations && variations.length > 1) {
167
+ // Create a character class with all variations
168
+ result += `[${variations.join('')}]`;
169
+ } else {
170
+ result += char;
171
+ }
172
+ }
141
173
 
142
- return char;
143
- })
144
- .join('');
174
+ return result;
145
175
  }
146
176
 
147
177
  /**
@@ -151,7 +181,8 @@ export function addIndonesianVariations(pattern: string): string {
151
181
  * @returns Objek RegExp
152
182
  */
153
183
  export function createIndonesianVariationRegex(word: string): RegExp {
154
- const pattern = addIndonesianVariations(escapeRegExp(word));
184
+ const escapedWord = escapeRegExp(word);
185
+ const pattern = addIndonesianVariations(escapedWord);
155
186
  return new RegExp(`\\b${pattern}\\b`, 'gi');
156
187
  }
157
188
 
@@ -179,18 +210,37 @@ export function createContextRegex(word: string, contextSize: number = 3): RegEx
179
210
  */
180
211
  export function createWordFormRegex(word: string): RegExp {
181
212
  // Implementasi sederhana untuk mencocokkan berbagai imbuhan
182
- // Ini bisa dikembangkan lebih lanjut untuk mencocokkan bentukan kata yang lebih kompleks
183
- const prefixes = ['', 'me', 'pe', 'ber', 'di', 'ter', 'se'];
184
- const suffixes = ['', 'kan', 'an', 'i', 'nya'];
213
+ const escapedWord = escapeRegExp(word);
214
+
215
+ // Common Indonesian prefixes and suffixes
216
+ const prefixes = [
217
+ '',
218
+ 'me',
219
+ 'pe',
220
+ 'ber',
221
+ 'di',
222
+ 'ter',
223
+ 'se',
224
+ 'ke',
225
+ 'mem',
226
+ 'pem',
227
+ 'bel',
228
+ 'peng',
229
+ 'meng',
230
+ ];
231
+ const suffixes = ['', 'kan', 'an', 'i', 'nya', 'lah', 'kah'];
185
232
 
186
233
  const patterns = [];
187
234
 
188
- // Kombinasikan prefix dan suffix
189
235
  for (const prefix of prefixes) {
190
236
  for (const suffix of suffixes) {
191
- patterns.push(`\\b${prefix}${escapeRegExp(word)}${suffix}\\b`);
237
+ patterns.push(`\\b${prefix}${escapedWord}${suffix}\\b`);
192
238
  }
193
239
  }
194
240
 
241
+ if (/^[aiueo]/.test(word)) {
242
+ patterns.push(`\\bng${escapedWord}\\b`);
243
+ }
244
+
195
245
  return new RegExp(patterns.join('|'), 'gi');
196
246
  }
@@ -0,0 +1,89 @@
1
+ import { analyze, batchAnalyze, analyzeBySentence, analyzeWithContext } from '../src/core/analyzer';
2
+
3
+ describe('Analyzer Core', () => {
4
+ describe('analyze function', () => {
5
+ test('should correctly analyze clean text', () => {
6
+ const result = analyze('Ini adalah kalimat yang sopan');
7
+ expect(result.hasProfanity).toBe(false);
8
+ expect(result.matches).toEqual([]);
9
+ expect(result.severityScore).toBe(0);
10
+ });
11
+
12
+ test('should correctly analyze text with profanity', () => {
13
+ const result = analyze('Dasar anjing!');
14
+ expect(result.hasProfanity).toBe(true);
15
+ expect(result.matches).toContain('anjing');
16
+ expect(result.severityScore).toBeGreaterThan(0);
17
+ });
18
+
19
+ test('should correctly analyze text with multiple profanities', () => {
20
+ const result = analyze('Dasar anjing dan babi!');
21
+ expect(result.hasProfanity).toBe(true);
22
+ expect(result.matches).toContain('anjing');
23
+ expect(result.matches).toContain('babi');
24
+ expect(result.matches.length).toBe(2);
25
+ });
26
+
27
+ test('should provide region information', () => {
28
+ // Assuming "anjing" is categorized as 'general' region
29
+ const result = analyze('anjing');
30
+ expect(result.regions).toContain('general');
31
+ });
32
+
33
+ test('should provide category information', () => {
34
+ // Assuming "anjing" is categorized as 'profanity'
35
+ const result = analyze('anjing');
36
+ expect(result.categories).toContain('profanity');
37
+ });
38
+
39
+ test('should detect similar words when option enabled', () => {
40
+ const result = analyze('anjiing', { detectSimilarity: true });
41
+ expect(result.hasProfanity).toBe(true);
42
+ expect(result.similarWords).toBeDefined();
43
+ expect(result.similarWords!.length).toBeGreaterThan(0);
44
+ });
45
+
46
+ test('should respect whitelist', () => {
47
+ const result = analyze('anjing', { whitelist: ['anjing'] });
48
+ expect(result.hasProfanity).toBe(false);
49
+ expect(result.matches).toEqual([]);
50
+ });
51
+ });
52
+
53
+ describe('batchAnalyze function', () => {
54
+ test('should analyze multiple texts', () => {
55
+ const texts = ['Ini kalimat bersih', 'Dasar anjing kamu!', 'Babi itu jorok'];
56
+
57
+ const result = batchAnalyze(texts);
58
+ expect(result.totalTexts).toBe(3);
59
+ expect(result.profaneTexts).toBe(2);
60
+ expect(result.cleanTexts).toBe(1);
61
+ expect(result.mostFrequentWords.length).toBeGreaterThan(0);
62
+ });
63
+ });
64
+
65
+ describe('analyzeBySentence function', () => {
66
+ test('should analyze text by sentence', () => {
67
+ const text =
68
+ 'Kalimat pertama bersih. Kalimat kedua anjing kotor. Kalimat ketiga juga bersih.';
69
+ const results = analyzeBySentence(text);
70
+
71
+ expect(results.length).toBe(3);
72
+ expect(results[0].hasProfanity).toBe(false);
73
+ expect(results[1].hasProfanity).toBe(true);
74
+ expect(results[1].matches).toContain('anjing');
75
+ expect(results[2].hasProfanity).toBe(false);
76
+ });
77
+ });
78
+
79
+ describe('analyzeWithContext function', () => {
80
+ test('should provide context around profanity', () => {
81
+ const text = 'Saya sangat kesal dengan perilaku anjing tersebut karena sangat mengganggu';
82
+ const results = analyzeWithContext(text, 3);
83
+
84
+ expect(results.length).toBe(1);
85
+ expect(results[0].word).toBe('anjing');
86
+ expect(results[0].context).toContain('perilaku anjing tersebut');
87
+ });
88
+ });
89
+ });
@@ -0,0 +1,80 @@
1
+ import { filter, isProfane } from '../src/core/filter';
2
+
3
+ describe('Filter Core', () => {
4
+ describe('filter function', () => {
5
+ test('should filter profanity with default settings', () => {
6
+ const result = filter('Dasar anjing kamu!');
7
+ expect(result.filtered).toBe('Dasar ****** kamu!');
8
+ expect(result.censored).toBe(1);
9
+ });
10
+
11
+ test('should not filter clean text', () => {
12
+ const cleanText = 'Ini adalah kalimat yang sopan';
13
+ const result = filter(cleanText);
14
+ expect(result.filtered).toBe(cleanText);
15
+ expect(result.censored).toBe(0);
16
+ expect(result.replacements).toEqual([]);
17
+ });
18
+
19
+ test('should filter multiple profanities', () => {
20
+ const text = 'Dasar anjing dan babi!';
21
+ const result = filter(text);
22
+ expect(result.filtered).not.toBe(text);
23
+ expect(result.censored).toBe(2);
24
+ expect(result.replacements.length).toBe(2);
25
+ });
26
+
27
+ test('should respect custom replacement character', () => {
28
+ const result = filter('Dasar anjing!', { replaceWith: '#' });
29
+ expect(result.filtered).toBe('Dasar ######!');
30
+ });
31
+
32
+ test('should handle keepFirstAndLast option', () => {
33
+ const result = filter('anjing', {
34
+ keepFirstAndLast: true,
35
+ fullWordCensor: false,
36
+ });
37
+ expect(result.filtered).toBe('a****g');
38
+ });
39
+
40
+ // test('should handle random grawlix option', () => {
41
+ // const result = filter('anjing', { useRandomGrawlix: true });
42
+ // expect(result.filtered).not.toBe('anjing');
43
+ // expect(result.filtered.length).toBe(6);
44
+ // });
45
+
46
+ test('should use whitelist', () => {
47
+ const text = 'Anjing adalah binatang peliharaan yang setia';
48
+
49
+ // Without whitelist
50
+ const withoutWhitelist = filter(text);
51
+ expect(withoutWhitelist.censored).toBe(1);
52
+
53
+ // With whitelist
54
+ const withWhitelist = filter(text, { whitelist: ['anjing'] });
55
+ expect(withWhitelist.censored).toBe(0);
56
+ expect(withWhitelist.filtered).toBe(text);
57
+ });
58
+ });
59
+
60
+ describe('isProfane function', () => {
61
+ test('should detect profanity', () => {
62
+ expect(isProfane('anjing')).toBe(true);
63
+ expect(isProfane('babi')).toBe(true);
64
+ expect(isProfane('kucing')).toBe(false);
65
+ });
66
+
67
+ test('should respect whitelist', () => {
68
+ expect(isProfane('anjing', { whitelist: ['anjing'] })).toBe(false);
69
+ });
70
+
71
+ test('should detect based on threshold', () => {
72
+ // Assuming "kontol" has a higher severity than "babi"
73
+ expect(isProfane('kontol', { severityThreshold: 0.8 })).toBe(true);
74
+
75
+ // This might fail if the severity isn't configured as expected
76
+ // Just an example of how the test would work
77
+ expect(isProfane('babi', { severityThreshold: 0.7 })).toBe(false);
78
+ });
79
+ });
80
+ });
@@ -0,0 +1,9 @@
1
+ // Any global setup code can go here
2
+
3
+ // For example, if you need to mock some global objects:
4
+ // global.fetch = jest.fn();
5
+
6
+ // Or silence console logs during tests
7
+ // global.console.log = jest.fn();
8
+ // global.console.warn = jest.fn();
9
+ // global.console.error = jest.fn();
@@ -0,0 +1,120 @@
1
+ import {
2
+ findProfanity,
3
+ findProfanityWithMetadata,
4
+ findCategories,
5
+ findRegions,
6
+ calculateSeverity,
7
+ } from '../src/core/matcher';
8
+
9
+ describe('Matcher Core', () => {
10
+ describe('findProfanity function', () => {
11
+ test('should find profanity in text', () => {
12
+ const results = findProfanity('Dasar anjing!');
13
+ expect(results).toContain('anjing');
14
+ expect(results.length).toBe(1);
15
+ });
16
+
17
+ test('should find multiple profanities', () => {
18
+ const results = findProfanity('Dasar anjing dan babi!');
19
+ expect(results).toContain('anjing');
20
+ expect(results).toContain('babi');
21
+ expect(results.length).toBe(2);
22
+ });
23
+
24
+ test('should detect leetspeak variations', () => {
25
+ const results = findProfanity('Dasar 4nj1ng!', { detectLeetSpeak: true });
26
+ expect(results).toContain('anjing');
27
+ });
28
+
29
+ test('should detect Indonesian variations', () => {
30
+ const results = findProfanity('Kamu itu anjic!', { indonesianVariation: true });
31
+ expect(results).toContain('anjing');
32
+ });
33
+
34
+ test('should detect split words', () => {
35
+ const results = findProfanity('a-n-j-i-n-g', { detectSplit: true });
36
+ expect(results).toContain('anjing');
37
+ });
38
+
39
+ test('should respect whitelist', () => {
40
+ const results = findProfanity('anjing', { whitelist: ['anjing'] });
41
+ expect(results).toEqual([]);
42
+ });
43
+
44
+ test('should filter by category', () => {
45
+ // Assuming "kontol" is sexual and "anjing" is profanity
46
+ const resultsWithSexual = findProfanity('memek dan anjing', {
47
+ categories: ['sexual'],
48
+ });
49
+ expect(resultsWithSexual).toContain('memek');
50
+ expect(resultsWithSexual).not.toContain('anjing');
51
+
52
+ const resultsWithProfanity = findProfanity('kontol dan anjing', {
53
+ categories: ['profanity'],
54
+ });
55
+ expect(resultsWithProfanity).toContain('anjing');
56
+ expect(resultsWithProfanity).not.toContain('kontol');
57
+ });
58
+
59
+ test('should filter by region', () => {
60
+ // Assuming "jancok" is from jawa region and "anjing" is general
61
+ const resultsWithJawa = findProfanity('jancok dan anjing', { regions: ['jawa'] });
62
+ expect(resultsWithJawa).toContain('jancok');
63
+ expect(resultsWithJawa).not.toContain('anjing');
64
+
65
+ const resultsWithGeneral = findProfanity('jancok dan anjing', { regions: ['general'] });
66
+ expect(resultsWithGeneral).toContain('anjing');
67
+ expect(resultsWithGeneral).not.toContain('jancok');
68
+ });
69
+ });
70
+
71
+ describe('findProfanityWithMetadata function', () => {
72
+ test('should return metadata for profanity', () => {
73
+ const results = findProfanityWithMetadata('anjing');
74
+ expect(results.length).toBe(1);
75
+ expect(results[0].word).toBe('anjing');
76
+ expect(results[0].category).toBeDefined();
77
+ expect(results[0].region).toBeDefined();
78
+ expect(results[0].severity).toBeDefined();
79
+ });
80
+ });
81
+
82
+ describe('findCategories function', () => {
83
+ test('should extract unique categories', () => {
84
+ const metadata = findProfanityWithMetadata('anjing dan kontol');
85
+ const categories = findCategories(metadata);
86
+
87
+ // Assuming "anjing" is profanity and "kontol" is sexual
88
+ expect(categories.includes('profanity')).toBe(true);
89
+ expect(categories.includes('sexual')).toBe(true);
90
+ expect(categories.length).toBe(2); // Should be unique
91
+ });
92
+ });
93
+
94
+ describe('findRegions function', () => {
95
+ test('should extract unique regions', () => {
96
+ const metadata = findProfanityWithMetadata('anjing dan jancok');
97
+ const regions = findRegions(metadata);
98
+
99
+ // Assuming "anjing" is general and "jancok" is jawa
100
+ expect(regions.includes('general')).toBe(true);
101
+ expect(regions.includes('jawa')).toBe(true);
102
+ expect(regions.length).toBe(2); // Should be unique
103
+ });
104
+ });
105
+
106
+ describe('calculateSeverity function', () => {
107
+ test('should calculate severity score', () => {
108
+ const metadata = findProfanityWithMetadata('anjing');
109
+ const severity = calculateSeverity(metadata);
110
+
111
+ expect(severity).toBeGreaterThanOrEqual(0);
112
+ expect(severity).toBeLessThanOrEqual(1);
113
+ });
114
+
115
+ test('should return 0 for empty metadata', () => {
116
+ const severity = calculateSeverity([]);
117
+ expect(severity).toBe(0);
118
+ });
119
+ });
120
+ });