@@ -1066,3 +1066,291 @@ describe("textSimilarity", () => {
10661066 expect ( textSimilarity ( "abc" , "xyz" ) ) . toBe ( 0.0 )
10671067 } )
10681068} )
1069+
1070+ describe ( "writable section types require editable inputs" , ( ) => {
1071+ it ( "fails activity_open_ended_answer with no textarea/input/blank marker" , ( ) => {
1072+ const html = `
1073+ <section data-section-type="activity_open_ended_answer" data-section-id="pg001_section">
1074+ <p data-id="pg001_gp001">Write your answer below:</p>
1075+ <hr class="border-b border-gray-400" />
1076+ <hr class="border-b border-gray-400" />
1077+ </section>
1078+ `
1079+ const result = validateSectionHtml ( html , [ "pg001_gp001" ] , [ ] )
1080+ expect ( result . valid ) . toBe ( false )
1081+ expect ( result . errors ) . toContainEqual (
1082+ expect . stringContaining (
1083+ 'Section type "activity_open_ended_answer" requires at least one editable element'
1084+ )
1085+ )
1086+ } )
1087+
1088+ it ( "passes activity_open_ended_answer when a <textarea> is present" , ( ) => {
1089+ const html = `
1090+ <section data-section-type="activity_open_ended_answer" data-section-id="pg001_section">
1091+ <p data-id="pg001_gp001">Write your answer below:</p>
1092+ <textarea class="w-full" aria-label="answer"></textarea>
1093+ </section>
1094+ `
1095+ const result = validateSectionHtml ( html , [ "pg001_gp001" ] , [ ] )
1096+ expect ( result . valid ) . toBe ( true )
1097+ } )
1098+
1099+ it ( "passes activity_fill_in_the_blank when a [[blank:item-N]] marker is present" , ( ) => {
1100+ const html = `
1101+ <section data-section-type="activity_fill_in_the_blank" data-section-id="pg001_section">
1102+ <p class="fitb-sentence" data-id="pg001_gp001">The capital of France is [[blank:item-1]].</p>
1103+ </section>
1104+ `
1105+ const result = validateSectionHtml (
1106+ html ,
1107+ [ "pg001_gp001" ] ,
1108+ [ ] ,
1109+ undefined ,
1110+ {
1111+ expectedTexts : new Map ( [
1112+ [ "pg001_gp001" , "The capital of France is ___." ] ,
1113+ ] ) ,
1114+ }
1115+ )
1116+ expect ( result . valid ) . toBe ( true )
1117+ } )
1118+
1119+ it ( "fails activity_fill_in_a_table when no <input> elements are present" , ( ) => {
1120+ const html = `
1121+ <section data-section-type="activity_fill_in_a_table" data-section-id="pg001_section">
1122+ <table>
1123+ <tr>
1124+ <td data-id="pg001_gp001">Name</td>
1125+ <td><div class="border-b"></div></td>
1126+ </tr>
1127+ </table>
1128+ </section>
1129+ `
1130+ const result = validateSectionHtml ( html , [ "pg001_gp001" ] , [ ] )
1131+ expect ( result . valid ) . toBe ( false )
1132+ expect ( result . errors ) . toContainEqual (
1133+ expect . stringContaining (
1134+ 'Section type "activity_fill_in_a_table" requires at least one editable element'
1135+ )
1136+ )
1137+ } )
1138+
1139+ it ( "does not require editable elements for non-writable section types" , ( ) => {
1140+ const html = `
1141+ <section data-section-type="text_only" data-section-id="pg001_section">
1142+ <p data-id="pg001_gp001">Just some prose.</p>
1143+ </section>
1144+ `
1145+ const result = validateSectionHtml ( html , [ "pg001_gp001" ] , [ ] )
1146+ expect ( result . valid ) . toBe ( true )
1147+ } )
1148+ } )
1149+
1150+ describe ( "textbook blank placeholders may be omitted when editable element is provided" , ( ) => {
1151+ it ( "accepts label text with underscores stripped when a <textarea> is added next to it" , ( ) => {
1152+ const html = `
1153+ <section data-section-type="activity_open_ended_answer" data-section-id="pg010_section">
1154+ <div>
1155+ <p data-id="pg010_gp001_tx001">Nombre:</p>
1156+ <textarea aria-label="nombre"></textarea>
1157+ </div>
1158+ </section>
1159+ `
1160+ const result = validateSectionHtml (
1161+ html ,
1162+ [ "pg010_gp001_tx001" ] ,
1163+ [ ] ,
1164+ undefined ,
1165+ {
1166+ expectedTexts : new Map ( [ [ "pg010_gp001_tx001" , "Nombre: ___" ] ] ) ,
1167+ }
1168+ )
1169+ expect ( result . valid ) . toBe ( true )
1170+ expect ( result . errors ) . toEqual ( [ ] )
1171+ } )
1172+
1173+ it ( "accepts label with multiple stripped underscore runs (e.g. dates)" , ( ) => {
1174+ const html = `
1175+ <section data-section-type="activity_open_ended_answer" data-section-id="pg011_section">
1176+ <div>
1177+ <p data-id="pg011_gp002_tx003">Fecha de nacimiento:</p>
1178+ <textarea aria-label="fecha"></textarea>
1179+ </div>
1180+ </section>
1181+ `
1182+ const result = validateSectionHtml (
1183+ html ,
1184+ [ "pg011_gp002_tx003" ] ,
1185+ [ ] ,
1186+ undefined ,
1187+ {
1188+ expectedTexts : new Map ( [
1189+ [ "pg011_gp002_tx003" , "Fecha de nacimiento: ___ / ___ / ___" ] ,
1190+ ] ) ,
1191+ }
1192+ )
1193+ expect ( result . valid ) . toBe ( true )
1194+ expect ( result . errors ) . toEqual ( [ ] )
1195+ } )
1196+
1197+ it ( "preserves the rendered (stripped) text when underscores were dropped" , ( ) => {
1198+ const html = `
1199+ <section data-section-type="activity_open_ended_answer" data-section-id="pg010_section">
1200+ <p data-id="pg010_gp009_tx003">¡Soy !</p>
1201+ <textarea aria-label="soy"></textarea>
1202+ </section>
1203+ `
1204+ const result = validateSectionHtml (
1205+ html ,
1206+ [ "pg010_gp009_tx003" ] ,
1207+ [ ] ,
1208+ undefined ,
1209+ {
1210+ expectedTexts : new Map ( [ [ "pg010_gp009_tx003" , "¡Soy ___ !" ] ] ) ,
1211+ }
1212+ )
1213+ expect ( result . valid ) . toBe ( true )
1214+ // Rendered HTML should keep the stripped version, not put the underscores back.
1215+ expect ( result . sectionHtml ) . not . toContain ( "___" )
1216+ } )
1217+
1218+ it ( "still rejects truly different text even with underscore stripping" , ( ) => {
1219+ const html = `
1220+ <section data-section-type="activity_open_ended_answer" data-section-id="pg010_section">
1221+ <p data-id="pg010_gp001_tx001">Completely unrelated text here</p>
1222+ <textarea aria-label="x"></textarea>
1223+ </section>
1224+ `
1225+ const result = validateSectionHtml (
1226+ html ,
1227+ [ "pg010_gp001_tx001" ] ,
1228+ [ ] ,
1229+ undefined ,
1230+ {
1231+ expectedTexts : new Map ( [ [ "pg010_gp001_tx001" , "Nombre: ___" ] ] ) ,
1232+ }
1233+ )
1234+ expect ( result . valid ) . toBe ( false )
1235+ expect ( result . errors ) . toContainEqual (
1236+ expect . stringContaining ( 'Text mismatch for data-id "pg010_gp001_tx001"' )
1237+ )
1238+ } )
1239+ } )
1240+
1241+ describe ( "hasEditableElement — input type filtering" , ( ) => {
1242+ it ( "does not count <input type=\"radio\"> as a writable input" , ( ) => {
1243+ const html = `
1244+ <section data-section-type="activity_open_ended_answer" data-section-id="pg001_section">
1245+ <p data-id="pg001_gp001">Question?</p>
1246+ <input type="radio" value="a" />
1247+ <input type="radio" value="b" />
1248+ </section>
1249+ `
1250+ const result = validateSectionHtml ( html , [ "pg001_gp001" ] , [ ] )
1251+ expect ( result . valid ) . toBe ( false )
1252+ expect ( result . errors ) . toContainEqual (
1253+ expect . stringContaining (
1254+ 'Section type "activity_open_ended_answer" requires at least one editable element'
1255+ )
1256+ )
1257+ } )
1258+
1259+ it ( "does not count <input type=\"checkbox\">, \"submit\", \"hidden\" as writable" , ( ) => {
1260+ const html = `
1261+ <section data-section-type="activity_fill_in_the_blank" data-section-id="pg001_section">
1262+ <p data-id="pg001_gp001">Label</p>
1263+ <input type="checkbox" />
1264+ <input type="submit" value="Go" />
1265+ <input type="hidden" name="x" value="1" />
1266+ </section>
1267+ `
1268+ const result = validateSectionHtml ( html , [ "pg001_gp001" ] , [ ] )
1269+ expect ( result . valid ) . toBe ( false )
1270+ expect ( result . errors ) . toContainEqual (
1271+ expect . stringContaining (
1272+ 'Section type "activity_fill_in_the_blank" requires at least one editable element'
1273+ )
1274+ )
1275+ } )
1276+
1277+ it ( "counts <input> with no type attribute as writable (defaults to text)" , ( ) => {
1278+ const html = `
1279+ <section data-section-type="activity_fill_in_the_blank" data-section-id="pg001_section">
1280+ <p data-id="pg001_gp001">Name:</p>
1281+ <input data-activity-item="item-1" aria-label="name" />
1282+ </section>
1283+ `
1284+ const result = validateSectionHtml ( html , [ "pg001_gp001" ] , [ ] )
1285+ expect ( result . valid ) . toBe ( true )
1286+ } )
1287+
1288+ it ( "counts <input type=\"text\"> as writable" , ( ) => {
1289+ const html = `
1290+ <section data-section-type="activity_fill_in_the_blank" data-section-id="pg001_section">
1291+ <p data-id="pg001_gp001">Name:</p>
1292+ <input type="text" data-activity-item="item-1" aria-label="name" />
1293+ </section>
1294+ `
1295+ const result = validateSectionHtml ( html , [ "pg001_gp001" ] , [ ] )
1296+ expect ( result . valid ) . toBe ( true )
1297+ } )
1298+
1299+ it ( "ignores [[blank:item-N]] markers inside <style> blocks" , ( ) => {
1300+ const html = `
1301+ <section data-section-type="activity_fill_in_the_blank" data-section-id="pg001_section">
1302+ <style>/* [[blank:item-1]] */</style>
1303+ <p data-id="pg001_gp001">Label</p>
1304+ </section>
1305+ `
1306+ const result = validateSectionHtml ( html , [ "pg001_gp001" ] , [ ] )
1307+ expect ( result . valid ) . toBe ( false )
1308+ expect ( result . errors ) . toContainEqual (
1309+ expect . stringContaining (
1310+ 'Section type "activity_fill_in_the_blank" requires at least one editable element'
1311+ )
1312+ )
1313+ } )
1314+ } )
1315+
1316+ describe ( "text replacement preserves nested editables" , ( ) => {
1317+ it ( "does not destroy a <textarea> nested inside a data-id element" , ( ) => {
1318+ const html = `
1319+ <section data-section-type="activity_open_ended_answer" data-section-id="pg001_section">
1320+ <p data-id="pg001_gp001">La capital es <textarea aria-label="capital"></textarea></p>
1321+ </section>
1322+ `
1323+ const result = validateSectionHtml (
1324+ html ,
1325+ [ "pg001_gp001" ] ,
1326+ [ ] ,
1327+ undefined ,
1328+ {
1329+ expectedTexts : new Map ( [ [ "pg001_gp001" , "La capital es ___" ] ] ) ,
1330+ }
1331+ )
1332+ expect ( result . valid ) . toBe ( true )
1333+ // The nested textarea must survive — otherwise the page renders as
1334+ // static text and the learner can no longer write an answer.
1335+ expect ( result . sectionHtml ) . toContain ( "<textarea" )
1336+ } )
1337+
1338+ it ( "does not destroy a nested <input> writable element" , ( ) => {
1339+ const html = `
1340+ <section data-section-type="activity_fill_in_the_blank" data-section-id="pg001_section">
1341+ <p data-id="pg001_gp001">Nombre: <input type="text" data-activity-item="item-1" aria-label="nombre" /></p>
1342+ </section>
1343+ `
1344+ const result = validateSectionHtml (
1345+ html ,
1346+ [ "pg001_gp001" ] ,
1347+ [ ] ,
1348+ undefined ,
1349+ {
1350+ expectedTexts : new Map ( [ [ "pg001_gp001" , "Nombre: ___" ] ] ) ,
1351+ }
1352+ )
1353+ expect ( result . valid ) . toBe ( true )
1354+ expect ( result . sectionHtml ) . toContain ( "<input" )
1355+ } )
1356+ } )
0 commit comments