diff --git a/F53OSC.xcodeproj/project.pbxproj b/F53OSC.xcodeproj/project.pbxproj index 7f0c48a..4f3ea73 100644 --- a/F53OSC.xcodeproj/project.pbxproj +++ b/F53OSC.xcodeproj/project.pbxproj @@ -129,6 +129,7 @@ 3DA5A9AB242FC4AB0068AF88 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 3DA5A9A1242FC4AB0068AF88 /* GCDAsyncSocket.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3DA5A9AC242FC4AB0068AF88 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 3DA5A9A1242FC4AB0068AF88 /* GCDAsyncSocket.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3DA5A9AD242FC4AB0068AF88 /* GCDAsyncSocket.h in Headers */ = {isa = PBXBuildFile; fileRef = 3DA5A9A1242FC4AB0068AF88 /* GCDAsyncSocket.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 3DD52F4427B2B80400F1DD6B /* F53OSC_NSStringTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3DD52F4327B2B80400F1DD6B /* F53OSC_NSStringTests.m */; }; 66AFE43C1B79485100985C54 /* ActivityChartView.m in Sources */ = {isa = PBXBuildFile; fileRef = 66AFE43B1B79485100985C54 /* ActivityChartView.m */; }; 66EE17591B729EA0008B6743 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 66EE17581B729EA0008B6743 /* AppDelegate.m */; }; 66EE175B1B729EA0008B6743 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 66EE175A1B729EA0008B6743 /* main.m */; }; @@ -218,6 +219,7 @@ 3DA5A9A0242FC4AB0068AF88 /* GCDAsyncUdpSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCDAsyncUdpSocket.h; sourceTree = ""; }; 3DA5A9A1242FC4AB0068AF88 /* GCDAsyncSocket.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCDAsyncSocket.h; sourceTree = ""; }; 3DA5A9B02432674F0068AF88 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; + 3DD52F4327B2B80400F1DD6B /* F53OSC_NSStringTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = F53OSC_NSStringTests.m; sourceTree = ""; }; 66AFE43A1B79485100985C54 /* ActivityChartView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ActivityChartView.h; sourceTree = ""; }; 66AFE43B1B79485100985C54 /* ActivityChartView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ActivityChartView.m; sourceTree = ""; }; 66EE17521B729EA0008B6743 /* F53OSC Monitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "F53OSC Monitor.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -284,6 +286,7 @@ isa = PBXGroup; children = ( 3D1E07FF242A7E1000655E76 /* F53OSC_NSNumberTests.m */, + 3DD52F4327B2B80400F1DD6B /* F53OSC_NSStringTests.m */, 3D1E07FE242A7E1000655E76 /* F53OSCMessageTests.m */, 3D1E07FD242A7E1000655E76 /* F53OSCServerTests.m */, ); @@ -768,6 +771,7 @@ files = ( 3D1E08AF242A847E00655E76 /* F53OSC_NSNumberTests.m in Sources */, 3D1E08AE242A847C00655E76 /* F53OSCMessageTests.m in Sources */, + 3DD52F4427B2B80400F1DD6B /* F53OSC_NSStringTests.m in Sources */, 3D1E08AD242A847A00655E76 /* F53OSCServerTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Sources/F53OSC/NSString+F53OSCString.m b/Sources/F53OSC/NSString+F53OSCString.m index 1e7b3e5..d842dd3 100644 --- a/Sources/F53OSC/NSString+F53OSCString.m +++ b/Sources/F53OSC/NSString+F53OSCString.m @@ -70,26 +70,28 @@ + (nullable NSString *) stringWithOSCStringBytes:(const char *)buf maxLength:(NS return nil; } + NSUInteger bytesRead = 0; for ( NSUInteger index = 0; index < maxLength; index++ ) { if ( buf[index] == 0 ) - goto valid; // found a NULL character within the buffer + { + // found a NULL character within the buffer + bytesRead = index + 1; // include length of null terminator character + break; + } + } + if (bytesRead == 0) + { + // Buffer wasn't null terminated, so it's not a valid OSC string. + if ( outBytesRead != NULL ) + *outBytesRead = 0; + return nil; } - - // Buffer wasn't null terminated, so it's not a valid OSC string. - if ( outBytesRead != NULL ) - *outBytesRead = 0; - return nil; - -valid:; NSString *result = [NSString stringWithUTF8String:buf]; if ( outBytesRead != NULL ) - { - NSUInteger bytesRead = result.length + 1; // include length of null terminator character *outBytesRead = 4 * ceil( bytesRead / 4.0 ); // round up to a multiple of 32 bits - } return result; } diff --git a/Tests/F53OSCTests/F53OSC_NSStringTests.m b/Tests/F53OSCTests/F53OSC_NSStringTests.m new file mode 100644 index 0000000..a259272 --- /dev/null +++ b/Tests/F53OSCTests/F53OSC_NSStringTests.m @@ -0,0 +1,182 @@ +// +// F53OSC_NSStringTests.m +// F53OSC +// +// Created by Brent Lord on 2/8/22. +// Copyright (c) 2022 Figure 53. All rights reserved. +// + +#if !__has_feature(objc_arc) +#error This file must be compiled with ARC. Use -fobjc-arc flag (or convert project to ARC). +#endif + +#import +#import +#import "F53OSCMessage.h" +#import "F53OSCParser.h" +#import "NSString+F53OSCString.h" + + +NS_ASSUME_NONNULL_BEGIN + +@interface F53OSC_NSStringTests : XCTestCase +@end + + +@implementation F53OSC_NSStringTests + +- (void) setUp +{ + [super setUp]; + // Put setup code here. This method is called before the invocation of each test method in the class. +} + +- (void) tearDown +{ + // Put teardown code here. This method is called after the invocation of each test method in the class. + [super tearDown]; +} + +NS_INLINE NSUInteger fourBytePaddedInteger(NSUInteger value) +{ + return 4 * (ceil((value + 1) / 4.0)); +} + +- (void)testThat_oscStringDataLengthsAreCorrect +{ + // given + // OSC strings are null-terminated and encoded as data in multiples of 4 bytes. + // If the data is already a multiple of 4 bytes, it gets an additional four null bytes appended. + NSDictionary *testStrings = @{ + @"" : @4, // 0 bytes + 4 null + @"a" : @4, // 1 bytes + 3 null + @"ab" : @4, // 2 bytes + 3 null + @"abc" : @4, // 3 bytes + 1 null + + @"abcd" : @8, // 4 bytes + 4 null, etc. + @"abcde" : @8, + @"abcdef" : @8, + @"abcdefg" : @8, + + @"abcdefgh" : @12, + @"abcdefghi" : @12, + @"abcdefghij" : @12, + @"abcdefghijk" : @12, + + // two-byte composed characters encoded as NSUTF8StringEncoding + @"å" : @4, // 2 bytes + 2 null + @"åb" : @4, // 3 bytes + 1 null + @"åbc" : @8, // 4 bytes + 4 null + @"åbcd" : @8, // 5 bytes + 3 null + @"åbcdé" : @8, // 7 bytes + 1 null + @"åéîøü" : @12, // 10 bytes + 2 null + + // three-byte composed characters encoded as NSUTF8StringEncoding + @"郵" : @4, // 3 bytes + 1 null (U+90F5) + @"郵政" : @8, // 6 bytes + 2 null + @"MAIL ROOM 郵政" : @20, // 16 bytes + 4 null + }; + + NSData *oscStringData; + NSString *decodedString; + for (NSString *testString in testStrings) + { + // ENCODE + // when + oscStringData = [testString oscStringData]; + NSUInteger expectedDataLength = [testStrings[testString] unsignedIntegerValue]; + + // then + XCTAssertNotNil(oscStringData, "%@", testString); + XCTAssertEqual(oscStringData.length, expectedDataLength, "%@", testString); + + // DECODE + // when + NSUInteger bytesRead = 0; + decodedString = [NSString stringWithOSCStringBytes:oscStringData.bytes maxLength:oscStringData.length bytesRead:&bytesRead]; + + // then + XCTAssertNotNil(decodedString, "%@", testString); + XCTAssertGreaterThanOrEqual(bytesRead, 4, "%@", testString); // empty string encodes as a minimum of 4 null bytes + XCTAssertEqual(bytesRead % 4, 0, "%@", testString); // multiple of 4 bytes + + XCTAssertEqualObjects(testString, decodedString, "%@", testString); + XCTAssertEqual([testString compare:decodedString options:NSDiacriticInsensitiveSearch], NSOrderedSame, "%@", testString); + XCTAssertEqualObjects(testString.decomposedStringWithCanonicalMapping, decodedString.decomposedStringWithCanonicalMapping); + + XCTAssertEqual(testString.length, decodedString.length, "%@", testString); + XCTAssertEqual([testString lengthOfBytesUsingEncoding:NSUTF8StringEncoding], [decodedString lengthOfBytesUsingEncoding:NSUTF8StringEncoding], "%@", testString); + + XCTAssertEqual(bytesRead, expectedDataLength, "%@", testString); + + XCTAssertEqual(fourBytePaddedInteger([testString lengthOfBytesUsingEncoding:NSUTF8StringEncoding]), bytesRead, "%@", testString); + XCTAssertEqual(fourBytePaddedInteger([decodedString lengthOfBytesUsingEncoding:NSUTF8StringEncoding]), bytesRead, "%@", testString); + + NSString *escapedTestString = [testString stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLPathAllowedCharacterSet]]; + if ([testString isEqual:escapedTestString] == NO) + { + // NSString `-length` counts composed characters as 1, while `-lengthOfBytesUsingEncoding` & `bytesRead` counts the decomposed character length. + XCTAssertLessThan(testString.length, [testString lengthOfBytesUsingEncoding:NSUTF8StringEncoding], "%@", testString); + XCTAssertLessThan(decodedString.length, [decodedString lengthOfBytesUsingEncoding:NSUTF8StringEncoding], "%@", testString); + XCTAssertLessThanOrEqual(fourBytePaddedInteger(testString.length), bytesRead, "%@", testString); + XCTAssertLessThanOrEqual(fourBytePaddedInteger(decodedString.length), bytesRead, "%@", testString); + } + else + { + XCTAssertEqual(testString.length, [testString lengthOfBytesUsingEncoding:NSUTF8StringEncoding], "%@", testString); + XCTAssertEqual(decodedString.length, [decodedString lengthOfBytesUsingEncoding:NSUTF8StringEncoding], "%@", testString); + XCTAssertEqual(fourBytePaddedInteger(testString.length), bytesRead, "%@", testString); + XCTAssertEqual(fourBytePaddedInteger(decodedString.length), bytesRead, "%@", testString); + } + } +} + +- (void)testThat_oscStringArgumentsCanBeParsed +{ + // given + NSArray *> *allTestArgs = @[ + @[@"a"], + @[@"a", @"ab"], + @[@"a", @"ab", @"abc"], + @[@"a", @"ab", @"abc", @"abcd"], + + // two-byte composed characters + @[@"å", @"åb"], + @[@"a", @"åb", @"abc"], + @[@"å", @"åb", @"abc", @"abcd"], + @[@"å", @"ab", @"åbc", @"åbcd", @"åbcdé"], + @[@"å", @"ab", @"åbc", @"åbcd", @"åbcdé", @"åbcdéf"], + + // three-byte composed characters encoded as NSUTF8StringEncoding + @[@"郵"], + @[@"郵", @"政"], + @[@"123", @"郵", @"456"], + @[@"MAIL ROOM 郵政", @"123"], + @[@"123", @"MAIL ROOM 郵政", @"456"], + ]; + + F53OSCMessage *testMessage; + for (NSArray *testArgs in allTestArgs) + { + testMessage = [F53OSCMessage messageWithAddressPattern:@"/some/method" arguments:testArgs]; + XCTAssertNotNil(testMessage, "%@", testArgs); + XCTAssertEqualObjects(testMessage.arguments, testArgs, "%@", testArgs); + XCTAssertNotNil(testMessage.packetData, "%@", testArgs); + + // when + NSData *packetData = testMessage.packetData; // encodes strings for OSC + F53OSCMessage *parsedMessage = [F53OSCParser parseOscMessageData:packetData]; // decodes data + + // then + XCTAssertNotNil(parsedMessage, "%@", testArgs); + XCTAssertEqualObjects(parsedMessage.arguments, testArgs, "%@", testArgs); + XCTAssertEqualObjects(testMessage, parsedMessage, "%@", testArgs); + XCTAssertEqualObjects(testMessage.addressPattern, parsedMessage.addressPattern, "%@", testArgs); + XCTAssertEqual(testMessage.arguments.count, parsedMessage.arguments.count, "%@", testArgs); + XCTAssertTrue([testMessage.arguments isEqualToArray:parsedMessage.arguments], "%@", testArgs); + } +} + +@end + +NS_ASSUME_NONNULL_END