301 lines
9.9 KiB
JavaScript
301 lines
9.9 KiB
JavaScript
/**
|
|
* Property-based tests for format utilities
|
|
* **Feature: miniapp-frontend, Property 16: Timestamp Formatting**
|
|
* **Feature: miniapp-frontend, Property 8: Nickname Auto-Generation**
|
|
* **Feature: miniapp-frontend, Property 21: ID Masking Format**
|
|
* **Validates: Requirements 8.5, 4.2, 12.4**
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest'
|
|
import * as fc from 'fast-check'
|
|
import { formatTimestamp, generateNickname, maskName, maskIdNumber } from '../../utils/format.js'
|
|
|
|
describe('Format Property Tests', () => {
|
|
/**
|
|
* Property 16: Timestamp Formatting
|
|
* *For any* timestamp, the formatted string SHALL be:
|
|
* - "今天 HH:mm" if same day
|
|
* - "昨天" if 1 day ago
|
|
* - "前天" if 2 days ago
|
|
* - "M月D号" if more than 2 days ago
|
|
* **Validates: Requirements 8.5**
|
|
*/
|
|
describe('Property 16: Timestamp Formatting', () => {
|
|
it('should format today timestamps as "今天 HH:mm"', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
// Generate hours (0-23) and minutes (0-59)
|
|
fc.integer({ min: 0, max: 23 }),
|
|
fc.integer({ min: 0, max: 59 }),
|
|
(hours, minutes) => {
|
|
const now = new Date(2026, 0, 1, 12, 0, 0) // Jan 1, 2026, noon
|
|
const timestamp = new Date(2026, 0, 1, hours, minutes, 0)
|
|
|
|
const result = formatTimestamp(timestamp, now)
|
|
|
|
// Should start with "今天 "
|
|
if (!result.startsWith('今天 ')) return false
|
|
|
|
// Should contain properly formatted time
|
|
const expectedHours = String(hours).padStart(2, '0')
|
|
const expectedMinutes = String(minutes).padStart(2, '0')
|
|
const expectedTime = `今天 ${expectedHours}:${expectedMinutes}`
|
|
|
|
return result === expectedTime
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('should format yesterday timestamps as "昨天"', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 0, max: 23 }),
|
|
fc.integer({ min: 0, max: 59 }),
|
|
(hours, minutes) => {
|
|
const now = new Date(2026, 0, 2, 12, 0, 0) // Jan 2, 2026
|
|
const timestamp = new Date(2026, 0, 1, hours, minutes, 0) // Jan 1, 2026
|
|
|
|
const result = formatTimestamp(timestamp, now)
|
|
return result === '昨天'
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('should format day before yesterday timestamps as "前天"', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 0, max: 23 }),
|
|
fc.integer({ min: 0, max: 59 }),
|
|
(hours, minutes) => {
|
|
const now = new Date(2026, 0, 3, 12, 0, 0) // Jan 3, 2026
|
|
const timestamp = new Date(2026, 0, 1, hours, minutes, 0) // Jan 1, 2026
|
|
|
|
const result = formatTimestamp(timestamp, now)
|
|
return result === '前天'
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('should format older timestamps as "M月D号"', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
// Generate days offset (3 or more days ago)
|
|
fc.integer({ min: 3, max: 365 }),
|
|
fc.integer({ min: 1, max: 12 }),
|
|
fc.integer({ min: 1, max: 28 }),
|
|
(daysAgo, month, day) => {
|
|
const now = new Date(2026, month - 1, day + daysAgo, 12, 0, 0)
|
|
const timestamp = new Date(2026, month - 1, day, 10, 30, 0)
|
|
|
|
// Skip if the date calculation results in invalid date
|
|
if (isNaN(now.getTime()) || isNaN(timestamp.getTime())) return true
|
|
|
|
const result = formatTimestamp(timestamp, now)
|
|
|
|
// Should match pattern "M月D号"
|
|
const expectedMonth = timestamp.getMonth() + 1
|
|
const expectedDay = timestamp.getDate()
|
|
const expected = `${expectedMonth}月${expectedDay}号`
|
|
|
|
return result === expected
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Property 8: Nickname Auto-Generation
|
|
* *For any* combination of relationship and surname, the generated nickname
|
|
* SHALL follow the pattern:
|
|
* - "{surname}家长(父亲)" for relationship=1
|
|
* - "{surname}家长(母亲)" for relationship=2
|
|
* - "{surname}先生(本人)" for relationship=3 and gender=1
|
|
* - "{surname}女士(本人)" for relationship=3 and gender=2
|
|
* **Validates: Requirements 4.2**
|
|
*/
|
|
describe('Property 8: Nickname Auto-Generation', () => {
|
|
// Common Chinese surnames for testing
|
|
const surnameArb = fc.stringOf(
|
|
fc.constantFrom('李', '王', '张', '刘', '陈', '杨', '赵', '黄', '周', '吴'),
|
|
{ minLength: 1, maxLength: 1 }
|
|
)
|
|
|
|
it('should generate "{surname}家长(父亲)" for father relationship', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
surnameArb,
|
|
(surname) => {
|
|
const result = generateNickname(1, surname)
|
|
return result === `${surname}家长(父亲)`
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('should generate "{surname}家长(母亲)" for mother relationship', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
surnameArb,
|
|
(surname) => {
|
|
const result = generateNickname(2, surname)
|
|
return result === `${surname}家长(母亲)`
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('should generate "{surname}先生(本人)" for self (male)', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
surnameArb,
|
|
(surname) => {
|
|
const result = generateNickname(3, surname, 1)
|
|
return result === `${surname}先生(本人)`
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('should generate "{surname}女士(本人)" for self (female)', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
surnameArb,
|
|
(surname) => {
|
|
const result = generateNickname(3, surname, 2)
|
|
return result === `${surname}女士(本人)`
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('should return empty string for empty surname', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.constantFrom(1, 2, 3),
|
|
(relationship) => {
|
|
const result = generateNickname(relationship, '')
|
|
return result === ''
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Property 21: ID Masking Format
|
|
* *For any* name and ID number:
|
|
* - Name: show first and last character with asterisks in between
|
|
* - ID: show first 3 and last 4 digits with asterisks in between
|
|
* **Validates: Requirements 12.4**
|
|
*/
|
|
describe('Property 21: ID Masking Format', () => {
|
|
describe('Name Masking', () => {
|
|
it('should mask names with length >= 3 as first + asterisks + last', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.string({ minLength: 3, maxLength: 10 }).filter(s => s.trim().length >= 3),
|
|
(name) => {
|
|
const result = maskName(name)
|
|
|
|
// First character should match
|
|
if (result[0] !== name[0]) return false
|
|
|
|
// Last character should match
|
|
if (result[result.length - 1] !== name[name.length - 1]) return false
|
|
|
|
// Middle should be asterisks
|
|
const middle = result.slice(1, -1)
|
|
if (middle !== '*'.repeat(name.length - 2)) return false
|
|
|
|
// Length should be preserved
|
|
return result.length === name.length
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('should mask 2-character names as first + asterisk', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.string({ minLength: 2, maxLength: 2 }).filter(s => s.trim().length === 2),
|
|
(name) => {
|
|
const result = maskName(name)
|
|
return result === name[0] + '*'
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('should return single character names unchanged', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.string({ minLength: 1, maxLength: 1 }),
|
|
(name) => {
|
|
const result = maskName(name)
|
|
return result === name
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('ID Number Masking', () => {
|
|
it('should mask ID numbers as first 3 + asterisks + last 4', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
// Generate ID-like strings (digits only, length 8-18)
|
|
fc.stringOf(fc.constantFrom('0', '1', '2', '3', '4', '5', '6', '7', '8', '9'), { minLength: 8, maxLength: 18 }),
|
|
(idNumber) => {
|
|
const result = maskIdNumber(idNumber)
|
|
|
|
// First 3 characters should match
|
|
if (result.substring(0, 3) !== idNumber.substring(0, 3)) return false
|
|
|
|
// Last 4 characters should match
|
|
if (result.substring(result.length - 4) !== idNumber.substring(idNumber.length - 4)) return false
|
|
|
|
// Middle should be asterisks
|
|
const middleLength = idNumber.length - 7
|
|
const middle = result.substring(3, result.length - 4)
|
|
if (middle !== '*'.repeat(middleLength)) return false
|
|
|
|
// Length should be preserved
|
|
return result.length === idNumber.length
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('should return short ID numbers unchanged', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.stringOf(fc.constantFrom('0', '1', '2', '3', '4', '5', '6', '7', '8', '9'), { minLength: 1, maxLength: 7 }),
|
|
(idNumber) => {
|
|
const result = maskIdNumber(idNumber)
|
|
return result === idNumber
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
})
|
|
})
|
|
})
|