xiangyixiangqin/miniapp/__tests__/properties/format.property.test.js
2026-01-02 18:00:49 +08:00

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 }
)
})
})
})
})