iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0
Mobile Development

Flutter :30天打造念佛App,跨平台應用從Mobile到VR,讓極樂世界在眼前實現!系列 第 20

[ Day 20 ] Flutter 單元測試 — 專案必備的綠色乖乖,程式守門員登場!

  • 分享至 

  • xImage
  •  

2025 iThome鐵人賽
「 Flutter :30天打造念佛App,跨平台從Mobile到VR,讓極樂世界在眼前實現 ! 」
Day 20
「 Flutter 單元測試 — 專案必備的綠色乖乖,程式守門員登場! 」


前言

前幾天我們已經完成「離線即時語音轉文字」的功能開發,
今天我們要接著來進行「單元測試」,
測試「音訊格式轉換邏輯」以及「佛號計數邏輯」,
不僅確保音訊及佛號計算沒有問題,也確保功能對於整個專案開發不會造成不良影響。

Day20 文章目錄:
一、單元測試
二、測試-佛號計數邏輯
三、測試-音訊格式轉換邏輯


一、單元測試

1. 簡介

單元測試(Unit Test)
是針對「最小可測單元(通常是一個函式或類別)進行自動化測試
具備可重現、執行快速、能精準定位錯誤等特性。

2. 功能/優點

  • 防回歸(Regression)
    未來重構或升級套件時,能快速驗證既有行為不被破壞。

  • 設計更乾淨
    為了方便測試,常會將邏輯抽成純函式並降低對平台與I/O 的耦合,
    進而提升可測性與可維護性

  • 即時回饋:本機與 CI 應在毫秒到數秒內得到結果。
    若測試明顯偏慢,通常表示碰到 I/O 或過多依賴。

3. 原則

  • 著重邏輯測試: 聚焦在函式/類別的業務邏輯,
    避免直接依賴 UI、平台細節或外部資源。

  • 測試三步驟 AAA: Arrange 測試前置 – Act 執行 – Assert 驗證。

  • 多情境拆分: 複雜的邏輯拆分成數個 AAA,避免多個行為塞進同一測試裡。
    備註: 不是只能有一個 assert,而是同一測試要聚焦在單一「行為/情境」。

  • 獨立性: 測試彼此不可互相影響,不依賴執行順序與外部狀態。

  • 命名清楚: 建議格式<情境>_<期望結果>,明確表示測試的情境與期望行為。

  • 涵蓋邊界情況: 空輸入、極值、奇數長度、混合變體等。

  • 測試金字塔: 底層多寫單元測試、中間少量整合測試、頂層更少端對端。

  • 覆蓋率: 覆蓋率是測試執行時實際跑到的程式碼比例,
    這是一種指標但不是唯一,最重要的仍然是關鍵邏輯與邊界被完整驗證。


二、測試-佛號計數邏輯

  • 正規化(normalize)是否將字形/空白/標點處理好。
  • 命中計數(hit counting)是否用寬鬆規則正確統計「阿彌陀佛」出現次數。

1. 正規化與命中計數邏輯 amitabha_normalizer.dart

//amitabha_normalizer.dart

String normalizeForAmitabha(String s) {
  final noSpace = s.replaceAll(RegExp(r'\s+'), '');
  final noPunct = noSpace.replaceAll(
    RegExp(r'[,。、「」‘’“”!.??,~\-—…·\[\]\(\)【】<>《》::;;、]'),
    '',
  );

  final sb = StringBuffer();
  for (final ch in noPunct.runes) {
    if (ch == 0x3000) continue;                 // 全形空白
    if (ch >= 0xFF01 && ch <= 0xFF5E) {
      sb.writeCharCode(ch - 0xFEE0);            // 全形→半形
    } else {
      sb.writeCharCode(ch);
    }
  }
  var t = sb.toString();

  // 常見異體/簡繁對齊
  t = t
      .replaceAll('彌', '弥')
      .replaceAll('仏', '佛')
      .replaceAll('驮', '陀');

  return t;
}
// 寬鬆判定:阿(弥/米/咪)陀佛
final RegExp _amituofoTolerant = RegExp(r'阿[弥米咪]陀佛');

int countAmitabhaOccurrences(String text) {
  final norm = normalizeForAmitabha(text);
  return _amituofoTolerant.allMatches(norm).length;
}

bool containsAmitabha(String text) => countAmitabhaOccurrences(text) > 0;

2. 新增單元測試 amitabha_normalizer_test

// test/amitabha_normalizer_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:amitabha/amitabha_normalizer.dart';

void main() {
  group('Amitabha detection', () {
    // 基準用例(baseline):
    // 正常寫法(繁體「彌」會被 normalize 成「弥」)與已是「弥」的簡體寫法都要命中 1 次。
    test('baseline hits: 阿彌陀佛 / 阿弥陀佛', () {
      expect(countAmitabhaOccurrences('阿彌陀佛'), 1);
      expect(countAmitabhaOccurrences('阿弥陀佛'), 1);
    });

    // 口音/發音變體(寬鬆規則):
    // 中間字允許「米/咪」等近似音,也應該各計 1 次。
    test('tolerant hits: 阿米陀佛 / 阿咪陀佛', () {
      expect(countAmitabhaOccurrences('阿米陀佛'), 1);
      expect(countAmitabhaOccurrences('阿咪陀佛'), 1);
    });

    // 空白與標點忽略:
    // 任何半形/全形空白、常見標點都會被 normalize 去除,應命中 1 次。
    test('ignore spaces/punctuations (含全形空白)', () {
      expect(countAmitabhaOccurrences('阿 彌 陀 佛'), 1);
      expect(countAmitabhaOccurrences('阿彌,陀佛。'), 1);
      expect(countAmitabhaOccurrences('阿彌陀佛!!'), 1);
    });

    // 多次命中:
    // 同一字串中出現多次時,應正確累計次數。
    test('multiple occurrences in a single string', () {
      expect(countAmitabhaOccurrences('阿彌陀佛阿彌陀佛'), 2);
      expect(countAmitabhaOccurrences('阿弥陀佛…阿米陀佛…阿咪陀佛'), 3);
    });

    // 無命中:
    // 完全不相關的內容應回傳 0;containsAmitabha 為 false。
    test('no match', () {
      expect(countAmitabhaOccurrences('南無觀世音菩薩'), 0);
      expect(containsAmitabha('南無觀世音菩薩'), false);
    });

    // 正規化字形映射:
    // 驮→陀、彌→弥、仏→佛 這些對齊應生效。
    test('normalization mapping sanity', () {
      final n = normalizeForAmitabha('阿驮佛/阿彌陀仏');
      expect(n.contains('陀'), true); // 驮→陀
      expect(n.contains('弥'), true); // 彌→弥
      expect(n.contains('佛'), true); // 仏→佛
    });
  });
}

  1. 測試結果
+1  baseline hits
驗證 阿彌陀佛 / 阿弥陀佛 各被計數 1(含「彌→弥」的正規化)。

+2  tolerant hits
驗證口音變體 阿米陀佛 被計數 1(寬鬆字元集 [弥米咪] 生效)。

+3  tolerant hits 
驗證 阿咪陀佛 被計數 1 (變體)。

+4  ignore spaces/punctuations
驗證含全形空白、中英文標點[阿 彌 陀 佛、阿彌,陀佛。、!!]仍計數 1(正規化去空白/標點)。

+5  multiple occurrences
驗證同字串多次出現能正確累計
[阿彌陀佛阿彌陀佛] → 2;[阿弥…阿米…阿咪…] → 3(寬鬆判定全計入)。

+6  no match & normalization mapping sanity
南無觀世音菩薩 → 0 且 containsAmitabha 為 false;
 同時確認字形映射正常:驮→陀、彌→弥、仏→佛 。


三、測試-音訊格式轉換邏輯

  • 將 PCM Int16 → Float32,驗證邊界值奇數長度大端序子片段位移
  • ByteData.sublistView 避免切片位移錯讀;
  • 奇數長度以樣本配對數 pairCount = floor(n/2) 處理

驗證:

  • 測試邊界值:因為極值最容易暴露正規化是否正確。

  • 測試奇數長度(最後多1 byte)
    串流/切片時很常不是剛好偶數長度;
    讀 Int16 需要兩個位元組,最後 1 byte 沒成對會出界。

  • 大端序(可選)
    endian 是端序(大端/小端),錄音裝置常見是小端Little Endian。
    資料來源不一定都是小端;一些檔案或跨平台串流可能是大端。

  • 子片段位移
    實務上常對大buffer做sublistView;
    如果用ByteData.view,可能因為忽略切片起點而讀到錯位資料。

1. Int16 → Float32轉換邏輯 convertBytesToFloat32

//原始錯誤版
Float32List convertBytesToFloat32(Uint8List bytes, [endian = Endian.little]) {
  final values = Float32List(bytes.length ~/ 2); 

  final data = ByteData.sublistView(bytes);

  for (var i = 0; i < bytes.length; i += 2) {
    int short = data.getInt16(i, endian);
    values[i ~/ 2] = short / 32768.0;
  }

  return values;
}

2. 新增單元測試 utils_convert_test.dart



import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:amitabha/utils.dart';

/// 小工具:小端
Uint8List _i16ToBytesLE(List<int> xs) {
  final b = ByteData(xs.length * 2);
  for (var i = 0; i < xs.length; i++) {
    b.setInt16(i * 2, xs[i], Endian.little);
  }
  return b.buffer.asUint8List();
}

/// 小工具:大端
Uint8List _i16ToBytesBE(List<int> xs) {
  final b = ByteData(xs.length * 2);
  for (var i = 0; i < xs.length; i++) {
    b.setInt16(i * 2, xs[i], Endian.big);
  }
  return b.buffer.asUint8List();
}

void main() {
  group('convertBytesToFloat32', () {
    test('邊界值:-32768、0、32767(小端)', () {
      final bytes = _i16ToBytesLE([-32768, 0, 32767]);
      final f = convertBytesToFloat32(bytes);
      expect(f.length, 3);
      expect(f[0], closeTo(-1.0, 1e-7));                // -32768 / 32768
      expect(f[1], 0.0);
      expect(f[2], closeTo(32767 / 32768.0, 1e-7));     // ≈ 0.9999695
    });

    test('奇數長度:多出 1 byte 應被忽略且不拋錯', () {
      final even = _i16ToBytesLE([1, 2, 3]);            // 6 bytes
      final odd = Uint8List.fromList([...even, 0xFF]);  // 7 bytes
      final f = convertBytesToFloat32(odd);
      expect(f.length, 3);                               // floor(7/2)
    });

    test('大端序解析', () {
      final bytes = _i16ToBytesBE([-32768, 32767]);
      final f = convertBytesToFloat32(bytes, Endian.big);
      expect(f.length, 2);
      expect(f[0], closeTo(-1.0, 1e-7));
      expect(f[1], closeTo(32767 / 32768.0, 1e-7));
    });

    test('子片段(sublist)位移正確', () {
      // 建一個較大的 buffer,取其中一段作為子片段
      final all = _i16ToBytesLE([111, -222, 333, -444, 555]); // 10 bytes
      final slice = Uint8List.sublistView(all, 2, 8); // 對應 [-222, 333, -444]
      final f = convertBytesToFloat32(slice);
      expect(f.length, 3);
      expect(f[0], closeTo(-222 / 32768.0, 1e-7));
      expect(f[1], closeTo( 333 / 32768.0, 1e-7));
      expect(f[2], closeTo(-444 / 32768.0, 1e-7));
    });
  });
}

3. 第一次測試:失敗

+1 邊界值
[-32768, 0, 32767] 轉換正確:對應 [-1.0, 0.0, 32767/32768]。

-1 Index out of range
讀取需要兩個位元組(i 與 i+1)。輸入長度是 7 bytes,超出界線。

4. 修正問題:奇數長度位元組拋錯

//修正版convertBytesToFloat32
Float32List convertBytesToFloat32(Uint8List bytes, [Endian endian = Endian.little]) {
  //每 2 bytes 一個樣本      
  final pairCount = bytes.length ~/ 2;       
  
  //空輸入或奇數長度僅剩 1 byte:直接回傳空陣列,避免越界
  if (pairCount == 0) return Float32List(0); 
  
  // 預先配置剛好的輸出容量
  final out = Float32List(pairCount);       
  
  // 零拷貝視圖;切片(sublist)位移也能正確對齊
  final data = ByteData.sublistView(bytes); 
  
  // j:輸出樣本索引;i:位元組偏移,每次前進 2 bytes(1 個 Int16)
  for (int j = 0, i = 0; j < pairCount; j++, i += 2) {
  
    //組成有號 Int16(範圍 -32768..32767)
    final s = data.getInt16(i, endian);    
    
    //正規化到[-1.0,1.0) 不含1.0
    out[j] = s / 32768.0;                  
    
  }
  return out;
}

5.第二次測試:通過

+1 邊界值
[-32768, 0, 32767] 轉換正確:對應 [-1.0, 0.0, 32767/32768]。

+2 奇數長度
輸入 7 bytes(最後多 1 byte)不崩潰;輸出長度為 floor(n/2),資料正確。

+3 大端序解析
Endian.big 路徑能正確還原數值(和小端對照一致)。

+4 子片段(sublist)位移
對較大緩衝區的切片能正確轉換,無位移錯讀。


Day20 重點回顧

重點 內容
單元測試 最小可測單元進行自動化測試
測試佛號計數 正規化及命中計數皆正確
測試音訊格式轉換 修正奇數長度位元組拋錯

上一篇
[ Day 19 ] Flutter 語音辨識 深入應用篇— 生活在地球的勇者啊,阿彌陀佛怎麼念呀(4) #KWS vs. ASR 準確度實測
下一篇
[ Day 21 ] Flutter 資料儲存 實戰應用篇—穿越到Coding世界的勇者啊,你知道裝備可以放哪嗎(1) #本機儲存
系列文
Flutter :30天打造念佛App,跨平台應用從Mobile到VR,讓極樂世界在眼前實現!21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言