Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

env:
HUSKY: 0

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
88 changes: 88 additions & 0 deletions lib/detectLineInApp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest'
import { detectLineInApp } from '@/lib/detectLineInApp'

/**
* Characterization tests for detectLineInApp.
* These pin the *current* behaviour of the UA-parsing logic, including its
* quirks. The implementation is not changed by this suite.
*
* Rules pinned (see source):
* - Must match `Line/<digit>` (case-insensitive) preceded by start-of-string
* or whitespace, else returns null.
* - If it is LINE and UA contains "Android" -> 'android'.
* - Else if it contains iPhone|iPad|iPod -> 'ios'.
* - Otherwise (e.g. LINE desktop) -> null.
*/
describe('detectLineInApp', () => {
describe('LINE Android', () => {
it('detects a typical LINE Android UA', () => {
const ua =
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 Line/14.5.0/IAB'
expect(detectLineInApp(ua)).toBe('android')
})

it('detects LINE Android when Line/ is at the very start', () => {
expect(detectLineInApp('Line/14.5.0 Android')).toBe('android')
})

it('is case-insensitive on the Line token and the Android token', () => {
expect(detectLineInApp('foo line/9 ANDROID bar')).toBe('android')
})
})

describe('LINE iOS', () => {
it('detects a typical LINE iPhone UA', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Line/14.5.0'
expect(detectLineInApp(ua)).toBe('ios')
})

it('detects LINE on iPad', () => {
expect(detectLineInApp('Line/14.0 (iPad; CPU OS 17_0)')).toBe('ios')
})

it('detects LINE on iPod', () => {
expect(detectLineInApp('Line/14.0 (iPod touch)')).toBe('ios')
})
})

describe('Android takes precedence over iOS tokens', () => {
it('returns android when both Android and iPhone appear (Android checked first)', () => {
// Quirk pinned: Android is tested before the iPhone/iPad/iPod branch,
// so a UA mentioning both resolves to 'android'.
expect(detectLineInApp('Line/14.0 Android iPhone')).toBe('android')
})
})

describe('LINE on unsupported platform', () => {
it('returns null for LINE desktop (no Android / iOS token)', () => {
expect(detectLineInApp('Line/7.0.0 (Macintosh; Desktop)')).toBe(null)
})
})

describe('not LINE in-app', () => {
it('returns null for a plain Chrome UA', () => {
const ua =
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
expect(detectLineInApp(ua)).toBe(null)
})

it('returns null for lookalike "LineFor..." strings (no digit after Line/)', () => {
// The regex requires Line/ followed immediately by a digit.
expect(detectLineInApp('LineForBusiness/abc Android')).toBe(null)
})

it('returns null when "Line" is not followed by a slash+digit', () => {
expect(detectLineInApp('SomeLineApp Android')).toBe(null)
})

it('returns null when "Line/" is mid-token without a preceding boundary', () => {
// "xLine/14" has no start-or-whitespace boundary before Line.
expect(detectLineInApp('xLine/14 Android')).toBe(null)
})

it('returns null for an empty UA', () => {
expect(detectLineInApp('')).toBe(null)
})
})
})
136 changes: 136 additions & 0 deletions lib/opencv/paper-detection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest'
import { orderPoints, cornersToMat } from '@/lib/opencv/paper-detection'
import type { Mat, OpenCV, Point } from '@/types'

/**
* Characterization tests for the pure (OpenCV-independent) helpers in
* paper-detection.ts. Production code is unchanged.
*
* `orderPoints` only reads `data32S` (an Int32Array of 8 values = 4 (x,y)
* pairs), so it can be exercised with a minimal stub Mat. `cornersToMat`
* only calls `cv.matFromArray`, so a stub `cv` captures the exact array
* layout it constructs.
*/

/** Build a stub Mat whose data32S holds the given (x,y) point pairs. */
function matFromPoints(points: Array<[number, number]>): Mat {
const flat: number[] = []
for (const [x, y] of points) {
flat.push(x, y)
}
return { data32S: Int32Array.from(flat) } as unknown as Mat
}

describe('orderPoints', () => {
it('returns points as [top-left, top-right, bottom-right, bottom-left]', () => {
// Input order deliberately scrambled.
const pts = matFromPoints([
[100, 100], // bottom-right
[0, 0], // top-left
[0, 100], // bottom-left
[100, 0], // top-right
])
expect(orderPoints(pts)).toEqual<Point[]>([
{ x: 0, y: 0 }, // TL
{ x: 100, y: 0 }, // TR
{ x: 100, y: 100 }, // BR
{ x: 0, y: 100 }, // BL
])
})

it('orders a rotated/skewed quad by y then x', () => {
// A slightly skewed paper: two clearly-upper points, two clearly-lower.
const pts = matFromPoints([
[30, 90], // lower-left
[110, 20], // upper-right
[10, 10], // upper-left
[120, 100], // lower-right
])
expect(orderPoints(pts)).toEqual<Point[]>([
{ x: 10, y: 10 }, // TL
{ x: 110, y: 20 }, // TR
{ x: 120, y: 100 }, // BR
{ x: 30, y: 90 }, // BL
])
})

it('only consumes the first 4 points even if more data is present', () => {
const pts = matFromPoints([
[0, 0],
[10, 0],
[10, 10],
[0, 10],
[999, 999], // extra, must be ignored
])
expect(orderPoints(pts)).toEqual<Point[]>([
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 },
])
})

it('quirk: with all four points on the same y, the split is the first-two vs last-two by x', () => {
// y-sort is stable (Array.prototype.sort is stable), so order is preserved
// on the y tie; then "top" = first two as given, "bottom" = last two.
// Input x order: 50, 10, 40, 20.
// top = [50,10] sorted by x -> [10, 50] => TL=10, TR=50
// bot = [40,20] sorted by x -> [20, 40] => BR=40, BL=20
const pts = matFromPoints([
[50, 5],
[10, 5],
[40, 5],
[20, 5],
])
expect(orderPoints(pts)).toEqual<Point[]>([
{ x: 10, y: 5 }, // TL
{ x: 50, y: 5 }, // TR
{ x: 40, y: 5 }, // BR
{ x: 20, y: 5 }, // BL
])
})

it('handles negative coordinates', () => {
const pts = matFromPoints([
[-100, -100], // TL
[100, -100], // TR
[100, 100], // BR
[-100, 100], // BL
])
expect(orderPoints(pts)).toEqual<Point[]>([
{ x: -100, y: -100 },
{ x: 100, y: -100 },
{ x: 100, y: 100 },
{ x: -100, y: 100 },
])
})
})

describe('cornersToMat', () => {
it('flattens corners into matFromArray(4, 1, CV_32FC2, [x0,y0,...,x3,y3])', () => {
const calls: Array<{ rows: number; cols: number; type: number; data: number[] }> = []
const SENTINEL_MAT = {} as Mat
const cv = {
CV_32FC2: 13, // arbitrary sentinel; only identity matters
matFromArray(rows: number, cols: number, type: number, data: number[]): Mat {
calls.push({ rows, cols, type, data })
return SENTINEL_MAT
},
} as unknown as OpenCV

const corners: Point[] = [
{ x: 1, y: 2 },
{ x: 3, y: 4 },
{ x: 5, y: 6 },
{ x: 7, y: 8 },
]
const result = cornersToMat(corners, cv)

expect(result).toBe(SENTINEL_MAT)
expect(calls).toHaveLength(1)
expect(calls[0].rows).toBe(4)
expect(calls[0].cols).toBe(1)
expect(calls[0].type).toBe(13) // cv.CV_32FC2 passed through
expect(calls[0].data).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
})
})
34 changes: 34 additions & 0 deletions lib/storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { formatSaveDate } from '@/lib/storage'

/**
* Characterization tests for formatSaveDate — a pure date formatter.
* It builds a `new Date(iso)` and formats the *local-time* components as
* `YYYY/MM/DD HH:mm` with zero-padded month/day/hour/minute (year is NOT
* padded). The vitest config pins TZ=UTC so local-time getters here equal
* the UTC wall-clock of the input, keeping these assertions deterministic.
*
* Production code is unchanged by this suite.
*/
describe('formatSaveDate (TZ=UTC)', () => {
it('formats a UTC ISO timestamp as YYYY/MM/DD HH:mm', () => {
expect(formatSaveDate('2026-06-15T09:05:00.000Z')).toBe('2026/06/15 09:05')
})

it('zero-pads single-digit month, day, hour and minute', () => {
expect(formatSaveDate('2026-01-02T03:04:00.000Z')).toBe('2026/01/02 03:04')
})

it('uses two-digit hours for afternoon times (24-hour clock)', () => {
expect(formatSaveDate('2026-12-31T23:59:00.000Z')).toBe('2026/12/31 23:59')
})

it('does not pad the year', () => {
// Year 999 stays three digits — pin the quirk that only M/D/H/m are padded.
expect(formatSaveDate('0999-06-15T00:00:00.000Z')).toBe('999/06/15 00:00')
})

it('drops seconds and milliseconds', () => {
expect(formatSaveDate('2026-06-15T09:05:59.789Z')).toBe('2026/06/15 09:05')
})
})
Loading
Loading