webpage_quality_analyzer 1.0.2

High-performance webpage quality analyzer with 115 comprehensive metrics - Rust library with WASM, C++, and Python bindings
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
// Phase 6: Output Customization Tests
// Tests for run_with_selector(), run_compact(), run_with_fields(), and WASM link check

use webpage_quality_analyzer::{
    async_runtime::DefaultRuntime, Analyzer, FieldSelector, FieldSelectorBuilder,
};

const TEST_HTML: &str = r#"
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="description" content="This is a test page for output customization testing with comprehensive metadata.">
    <title>Test Page for Output Customization - Phase 6</title>
</head>
<body>
    <header>
        <h1>Main Title for Testing</h1>
        <nav>
            <a href="/page1">Link 1</a>
            <a href="/page2">Link 2</a>
        </nav>
    </header>
    <main>
        <article>
            <h2>Section 1: Introduction</h2>
            <p>This is the first paragraph with some meaningful content for testing purposes. It contains enough words to pass basic content length checks.</p>
            <p>This is the second paragraph that adds more substance to the content. We want to ensure there's sufficient content for analysis.</p>
            
            <h2>Section 2: Details</h2>
            <p>More detailed content goes here. This section provides additional information and ensures we have good structural depth with proper heading hierarchy.</p>
            <ul>
                <li>List item 1</li>
                <li>List item 2</li>
                <li>List item 3</li>
            </ul>
            
            <h3>Subsection 2.1</h3>
            <p>Even more detailed content in a subsection. This demonstrates good document structure with multiple heading levels for better organization.</p>
        </article>
    </main>
    <footer>
        <p>Footer content with copyright information.</p>
    </footer>
</body>
</html>
"#;

// ============================================================
// Test 1-3: Basic run_with_fields functionality
// ============================================================

#[tokio::test]
async fn test_run_with_fields_basic() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    let result = analyzer
        .run_with_fields("https://test.com", Some(TEST_HTML), vec!["url", "score"])
        .await
        .unwrap();

    // Verify requested fields are present
    assert!(result.get("url").is_some());
    assert!(result.get("score").is_some());

    // Verify non-requested fields are absent
    assert!(result.get("metrics").is_none());
    assert!(result.get("metadata").is_none());
    assert!(result.get("processed_document").is_none());
}

#[tokio::test]
async fn test_run_with_fields_score_and_verdict() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    let result = analyzer
        .run_with_fields(
            "https://test.com",
            Some(TEST_HTML),
            vec!["score", "verdict"],
        )
        .await
        .unwrap();

    // Verify only requested fields
    assert!(result.get("score").is_some());
    assert!(result.get("verdict").is_some());
    assert!(result.get("url").is_none());
    assert!(result.get("metrics").is_none());
}

#[tokio::test]
async fn test_run_with_fields_multiple() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    let result = analyzer
        .run_with_fields(
            "https://test.com",
            Some(TEST_HTML),
            vec!["url", "score", "verdict", "version"],
        )
        .await
        .unwrap();

    // All requested fields should be present
    assert!(result.get("url").is_some());
    assert!(result.get("score").is_some());
    assert!(result.get("verdict").is_some());
    assert!(result.get("version").is_some());

    // Non-requested should be absent
    assert!(result.get("metrics").is_none());
}

// ============================================================
// Test 4-6: run_with_selector with FieldSelector
// ============================================================

#[tokio::test]
async fn test_run_with_selector_include_fields() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    let selector = FieldSelectorBuilder::new()
        .include_fields(vec![
            "url".to_string(),
            "score".to_string(),
            "verdict".to_string(),
        ])
        .build();

    let result = analyzer
        .run_with_selector("https://test.com", Some(TEST_HTML), &selector)
        .await
        .unwrap();

    // Verify included fields
    assert!(result.get("url").is_some());
    assert!(result.get("score").is_some());
    assert!(result.get("verdict").is_some());

    // Verify excluded fields
    assert!(result.get("metrics").is_none());
    assert!(result.get("metadata").is_none());
}

#[tokio::test]
async fn test_run_with_selector_include_sections() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    let selector = FieldSelectorBuilder::new()
        .include_sections(vec!["metadata".to_string()])
        .build();

    let result = analyzer
        .run_with_selector("https://test.com", Some(TEST_HTML), &selector)
        .await
        .unwrap();

    // Should only have metadata section
    assert!(result.get("metadata").is_some());

    // Other sections should be absent
    assert!(result.get("url").is_none());
    assert!(result.get("score").is_none());
    assert!(result.get("metrics").is_none());
}

#[tokio::test]
async fn test_run_with_selector_exclude_fields() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    let selector = FieldSelectorBuilder::new()
        .exclude_fields(vec![
            "metadata".to_string(),
            "processed_document".to_string(),
        ])
        .build();

    let result = analyzer
        .run_with_selector("https://test.com", Some(TEST_HTML), &selector)
        .await
        .unwrap();

    // Core fields should be present (not excluded)
    assert!(result.get("url").is_some());
    assert!(result.get("score").is_some());
    assert!(result.get("verdict").is_some());

    // Excluded fields should be absent
    assert!(result.get("metadata").is_none());
    assert!(result.get("processed_document").is_none());
}

// ============================================================
// Test 7-9: run_compact functionality
// ============================================================

#[tokio::test]
async fn test_run_compact_basic() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    let compact_json = analyzer
        .run_compact("https://test.com", Some(TEST_HTML))
        .await
        .unwrap();

    // Should be valid JSON
    let parsed: serde_json::Value = serde_json::from_str(&compact_json).unwrap();

    // Should have core fields
    assert!(parsed.get("url").is_some());
    assert!(parsed.get("score").is_some());
    assert!(parsed.get("verdict").is_some());
}

#[tokio::test]
async fn test_run_compact_vs_regular() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    // Get compact version
    let compact_json = analyzer
        .run_compact("https://test.com", Some(TEST_HTML))
        .await
        .unwrap();

    // Get regular version
    let regular_report = analyzer
        .run("https://test.com", Some(TEST_HTML))
        .await
        .unwrap();
    let regular_json = regular_report.to_readable_json().unwrap();

    // Parse both
    let compact_parsed: serde_json::Value = serde_json::from_str(&compact_json).unwrap();
    let regular_parsed: serde_json::Value = serde_json::from_str(&regular_json).unwrap();

    // Both should have same score (data should be identical)
    assert_eq!(
        compact_parsed["score"].as_f64().unwrap(),
        regular_parsed["score"].as_f64().unwrap()
    );

    // Compact should be smaller (no pretty printing)
    assert!(compact_json.len() < regular_json.len());
}

#[tokio::test]
async fn test_run_compact_size_reduction() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    let compact_json = analyzer
        .run_compact("https://test.com", Some(TEST_HTML))
        .await
        .unwrap();

    // Compact JSON should not have excessive whitespace
    // Count newlines - should be minimal in compact format
    let newline_count = compact_json.matches('\n').count();

    // Compact format should have few newlines (exact number varies, but should be < 10)
    assert!(
        newline_count < 10,
        "Compact JSON has {} newlines, expected < 10",
        newline_count
    );
}

// ============================================================
// Test 10-12: Field selection with metrics
// ============================================================

#[tokio::test]
async fn test_selector_with_metrics_section() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    let selector = FieldSelectorBuilder::new()
        .include_sections(vec!["metrics".to_string()])
        .build();

    let result = analyzer
        .run_with_selector("https://test.com", Some(TEST_HTML), &selector)
        .await
        .unwrap();

    // Should have metrics
    assert!(result.get("metrics").is_some());

    // Check that metrics has expected structure
    let metrics = result.get("metrics").unwrap();
    assert!(metrics.get("html_analysis").is_some() || metrics.get("network_metrics").is_some());
}

#[tokio::test]
async fn test_selector_url_score_metrics() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    let selector = FieldSelectorBuilder::new()
        .include_fields(vec!["url".to_string(), "score".to_string()])
        .include_sections(vec!["metrics".to_string()])
        .build();

    let result = analyzer
        .run_with_selector("https://test.com", Some(TEST_HTML), &selector)
        .await
        .unwrap();

    // Should have both fields and section
    assert!(result.get("url").is_some());
    assert!(result.get("score").is_some());
    assert!(result.get("metrics").is_some());

    // Should not have other fields
    assert!(result.get("verdict").is_none());
    assert!(result.get("metadata").is_none());
}

#[tokio::test]
async fn test_empty_selector() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    // Empty selector should return everything
    let selector = FieldSelectorBuilder::new().build();

    let result = analyzer
        .run_with_selector("https://test.com", Some(TEST_HTML), &selector)
        .await
        .unwrap();

    // Should have all main fields
    assert!(result.get("url").is_some());
    assert!(result.get("score").is_some());
    assert!(result.get("verdict").is_some());
    assert!(result.get("metrics").is_some());
}

// ============================================================
// Test 13-15: Integration with custom profiles
// ============================================================

#[tokio::test]
async fn test_output_customization_with_profile() {
    let analyzer = Analyzer::<DefaultRuntime>::builder()
        .with_profile_name("news")
        .unwrap()
        .build()
        .unwrap();

    let result = analyzer
        .run_with_fields(
            "https://test.com",
            Some(TEST_HTML),
            vec!["score", "verdict", "version"],
        )
        .await
        .unwrap();

    // Should have requested fields
    assert!(result.get("score").is_some());
    assert!(result.get("verdict").is_some());
    assert!(result.get("version").is_some());

    // Version should mention profile
    let version = result["version"].as_str().unwrap();
    assert!(version.contains("news") || version.contains("v0."));
}

#[tokio::test]
async fn test_compact_with_disabled_metrics() {
    let analyzer = Analyzer::<DefaultRuntime>::builder()
        .disable_metric("paragraph_count")
        .unwrap()
        .build()
        .unwrap();

    let compact_json = analyzer
        .run_compact("https://test.com", Some(TEST_HTML))
        .await
        .unwrap();

    // Should still be valid JSON and have core fields
    let parsed: serde_json::Value = serde_json::from_str(&compact_json).unwrap();
    assert!(parsed.get("score").is_some());
    assert!(parsed.get("verdict").is_some());
}

#[tokio::test]
async fn test_selector_with_custom_thresholds() {
    let analyzer = Analyzer::<DefaultRuntime>::builder()
        .set_metric_threshold("word_count", 100.0, 500.0, 2000.0, 5000.0)
        .unwrap()
        .build()
        .unwrap();

    let result = analyzer
        .run_with_fields(
            "https://test.com",
            Some(TEST_HTML),
            vec!["score", "metrics"],
        )
        .await
        .unwrap();

    // Should have score and metrics
    assert!(result.get("score").is_some());
    assert!(result.get("metrics").is_some());
}

// ============================================================
// Test 16-18: Error handling and edge cases
// ============================================================

#[tokio::test]
async fn test_run_with_fields_empty_list() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    // Empty fields list should return empty or minimal result
    let result = analyzer
        .run_with_fields("https://test.com", Some(TEST_HTML), vec![])
        .await
        .unwrap();

    // Result should be valid JSON (possibly empty object or with minimal fields)
    assert!(result.is_object());
}

#[tokio::test]
async fn test_run_with_fields_nonexistent_field() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    // Request a field that doesn't exist
    let result = analyzer
        .run_with_fields(
            "https://test.com",
            Some(TEST_HTML),
            vec!["nonexistent_field"],
        )
        .await
        .unwrap();

    // Should not error, just return without that field
    assert!(result.is_object());
    assert!(result.get("nonexistent_field").is_none());
}

#[tokio::test]
async fn test_selector_exclude_all_then_include_one() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    // Exclude most fields, then explicitly include one
    let selector = FieldSelectorBuilder::new()
        .exclude_fields(vec![
            "metadata".to_string(),
            "processed_document".to_string(),
            "notes".to_string(),
        ])
        .include_fields(vec!["score".to_string()])
        .build();

    let result = analyzer
        .run_with_selector("https://test.com", Some(TEST_HTML), &selector)
        .await
        .unwrap();

    // Should definitely have score (explicitly included)
    assert!(result.get("score").is_some());

    // Excluded fields should be absent
    assert!(result.get("metadata").is_none());
    assert!(result.get("processed_document").is_none());
}

// ============================================================
// Test 19-21: Performance and size comparisons
// ============================================================

#[tokio::test]
async fn test_field_selection_reduces_size() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    // Get full report
    let full_report = analyzer
        .run("https://test.com", Some(TEST_HTML))
        .await
        .unwrap();
    let full_json = serde_json::to_string(&full_report).unwrap();

    // Get filtered report
    let filtered = analyzer
        .run_with_fields(
            "https://test.com",
            Some(TEST_HTML),
            vec!["url", "score", "verdict"],
        )
        .await
        .unwrap();
    let filtered_json = serde_json::to_string(&filtered).unwrap();

    // Filtered should be significantly smaller
    assert!(
        filtered_json.len() < full_json.len() / 2,
        "Filtered JSON ({} bytes) should be less than half of full JSON ({} bytes)",
        filtered_json.len(),
        full_json.len()
    );
}

#[tokio::test]
async fn test_compact_vs_pretty_size() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    // Get compact
    let compact = analyzer
        .run_compact("https://test.com", Some(TEST_HTML))
        .await
        .unwrap();

    // Get pretty
    let report = analyzer
        .run("https://test.com", Some(TEST_HTML))
        .await
        .unwrap();
    let pretty = report.to_readable_json().unwrap();

    // Compact should be smaller than pretty-printed
    assert!(
        compact.len() < pretty.len(),
        "Compact ({} bytes) should be smaller than pretty ({} bytes)",
        compact.len(),
        pretty.len()
    );
}

#[tokio::test]
async fn test_minimal_output_combination() {
    let analyzer = Analyzer::<DefaultRuntime>::builder().build().unwrap();

    // Combine field selection with compact output
    let selector = FieldSelectorBuilder::new()
        .include_fields(vec!["url".to_string(), "score".to_string()])
        .build();

    let result = analyzer
        .run_with_selector("https://test.com", Some(TEST_HTML), &selector)
        .await
        .unwrap();

    // Convert to compact JSON
    let compact_json = serde_json::to_string(&result).unwrap();

    // Should be very small (only 2 fields, compact format)
    assert!(
        compact_json.len() < 200,
        "Minimal output should be very small, got {} bytes",
        compact_json.len()
    );
}

// ============================================================
// Test 22: Comprehensive integration test
// ============================================================

#[tokio::test]
async fn test_output_customization_comprehensive() {
    // Build analyzer with all phases integrated
    let analyzer = Analyzer::<DefaultRuntime>::builder()
        .with_profile_name("content_article")
        .unwrap()
        .disable_metric("paragraph_count")
        .unwrap()
        .set_metric_threshold("word_count", 200.0, 800.0, 2500.0, 10000.0)
        .unwrap()
        .set_metric_weight("word_count", 1.5)
        .unwrap()
        .build()
        .unwrap();

    // Test all three output methods

    // 1. run_with_fields
    let fields_result = analyzer
        .run_with_fields(
            "https://test.com",
            Some(TEST_HTML),
            vec!["score", "verdict"],
        )
        .await
        .unwrap();
    assert!(fields_result.get("score").is_some());
    assert!(fields_result.get("verdict").is_some());

    // 2. run_with_selector
    let selector = FieldSelectorBuilder::new()
        .include_fields(vec!["url".to_string(), "score".to_string()])
        .build();
    let selector_result = analyzer
        .run_with_selector("https://test.com", Some(TEST_HTML), &selector)
        .await
        .unwrap();
    assert!(selector_result.get("url").is_some());
    assert!(selector_result.get("score").is_some());

    // 3. run_compact
    let compact = analyzer
        .run_compact("https://test.com", Some(TEST_HTML))
        .await
        .unwrap();
    let compact_parsed: serde_json::Value = serde_json::from_str(&compact).unwrap();
    assert!(compact_parsed.get("score").is_some());

    // All three should have similar scores (with floating point tolerance)
    let fields_score = fields_result["score"].as_f64().unwrap();
    let selector_score = selector_result["score"].as_f64().unwrap();
    let compact_score = compact_parsed["score"].as_f64().unwrap();

    assert!((fields_score - selector_score).abs() < 0.01);
    assert!((fields_score - compact_score).abs() < 0.01);
}