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